Compare commits

...

23 Commits

Author SHA1 Message Date
Elio Struyf
9618a89528 Merge pull request #169 from estruyf/dev 2021-11-05 09:25:09 +01:00
Elio Struyf
14f0af2754 Updated changelog 2021-11-05 09:24:26 +01:00
Elio Struyf
ebe248670d 5.4.0 2021-11-03 12:00:56 +01:00
Elio Struyf
511960c4a9 #167 - Allow to set a preview path per content type 2021-11-03 12:00:49 +01:00
Elio Struyf
31fd1f93ce Url joining 2021-11-02 16:32:36 +01:00
Elio Struyf
6625b69170 #166 - Filename and folder logic for slug 2021-11-02 13:48:52 +01:00
Elio Struyf
9e8533fbb8 Updated changelog 2021-11-02 11:58:43 +01:00
Elio Struyf
9c9cbb7dcb #166 - Add preview button to panel when no markdown file is opened 2021-11-02 11:56:40 +01:00
Elio Struyf
079a13e161 Merge pull request #164 from estruyf/dev
Merge for 5.3.1
2021-10-29 10:25:17 +02:00
Elio Struyf
69c1e587d0 5.3.1 2021-10-29 10:18:31 +02:00
Elio Struyf
3996252531 #163 - Set workspace state instead of global 2021-10-29 10:18:20 +02:00
Elio Struyf
4fddda65e6 Merge pull request #162 from estruyf/dev
5.3.0 merge
2021-10-28 15:33:06 +02:00
Elio Struyf
5916344092 added release notes 2021-10-28 12:00:24 +02:00
Elio Struyf
b96722dd69 #158 - Update boolean field check 2021-10-27 11:42:57 +02:00
Elio Struyf
263ccab311 5.3.0 2021-10-26 15:59:45 +02:00
Elio Struyf
3571af82c7 Updated readme 2021-10-26 15:59:31 +02:00
Elio Struyf
c60520c0ff Fix replace in action button 2021-10-26 15:28:01 +02:00
Elio Struyf
b473431eae #159 - SEO enhancements 2021-10-26 14:42:01 +02:00
Elio Struyf
cbf434f741 Updated changelog 2021-10-25 12:54:19 +02:00
Elio Struyf
04c401207f #158 - New draft field setting + choice implementation 2021-10-25 12:53:04 +02:00
Elio Struyf
7291e6aac6 Fix tag replacement 2021-10-25 10:24:08 +02:00
Elio Struyf
a7aab96f0e Fix time formatting 2021-10-25 10:11:31 +02:00
Elio Struyf
f500749644 Fix slug punctuation 2021-10-20 15:21:27 +02:00
42 changed files with 590 additions and 100 deletions

View File

@@ -1,5 +1,31 @@
# Change Log
## [5.4.0] - 2021-11-05
### 🎨 Enhancements
- [#166](https://github.com/estruyf/vscode-front-matter/issues/166): Added preview button to the panel base view
- [#167](https://github.com/estruyf/vscode-front-matter/issues/167): Allow to set the preview path per content type
## [5.3.1] - 2021-10-29
### 🐞 Fixes
- [#163](https://github.com/estruyf/vscode-front-matter/issues/163): Setting workspace state instead of global state for the media view
## [5.3.0] - 2021-10-28 - [Release Notes](https://beta.frontmatter.codes/updates/v5.3.0)
### 🎨 Enhancements
- [#158](https://github.com/estruyf/vscode-front-matter/issues/158): Add support for non-boolean draft/publish status fields
- [#159](https://github.com/estruyf/vscode-front-matter/issues/159): Enhancements to SEO checks: Slug check, keyword details, more article information
### 🐞 Fixes
- Value check when generating slug from title
- Fix for date time formatting with `DD` and `YYYY` tokens
- Fix in tag space replacing when object is passed
## [5.2.0] - 2021-10-19
### 🎨 Enhancements

View File

@@ -48,6 +48,10 @@ Our main extension features are:
> If you see something missing in your article creation flow, please feel free to reach out.
**Version 5**
The new media dashboard redesign got introduced + support for setting metadata on media files [v5.0.0 release notes](https://frontmatter.codes/updates/v5.0.0).
**Version 4**
Support for Team level settings, content-types, and image support. Get to know more at: [v4.0.0 release notes](https://frontmatter.codes/updates/v4_0_0).

View File

@@ -46,6 +46,10 @@ Our main extension features are:
> If you see something missing in your article creation flow, please feel free to reach out.
**Version 5**
The new media dashboard redesign got introduced + support for setting metadata on media files [v5.0.0 release notes](https://frontmatter.codes/updates/v5.0.0).
**Version 4**
Support for Team level settings, content-types, and image support. Get to know more at: [v4.0.0 release notes](https://frontmatter.codes/updates/v4_0_0).

View File

@@ -356,8 +356,18 @@
text-transform: capitalize;
}
.table__cell__seo_details {
padding: 10px;
}
.table__cell__validation {
text-align: center;
text-align: left;
}
.table__cell__validation div {
display: flex;
align-items: center;
padding: 2px 0;
}
.table__cell__validation .valid {
@@ -368,6 +378,15 @@
color: #E6AF2E;
}
.table__cell__validation div span + span {
margin-left: .5rem;
}
.seo__status__note {
font-size: 10px;
padding: 3px 0;
}
/* Fields */
.field__toggle {
position: relative;

8
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "vscode-front-matter-beta",
"version": "5.2.0",
"version": "5.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -6386,6 +6386,12 @@
}
}
},
"url-join-ts": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/url-join-ts/-/url-join-ts-1.0.5.tgz",
"integrity": "sha512-u+5gi7JyOwhj58ZKwkmkzFGHuepTpmwjqfUDGVjsJJstsCz63CJAINixgJaDcMbmuyWPJIxbtBpIfaDgOQ9KMQ==",
"dev": true
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View File

@@ -3,7 +3,7 @@
"displayName": "Front Matter",
"description": "An essential Visual Studio Code extension when you want to manage the markdown pages of your static site like: Hugo, Jekyll, Hexo, NextJs, Gatsby, and many more...",
"icon": "assets/frontmatter-teal-128x128.png",
"version": "5.2.0",
"version": "5.4.0",
"preview": false,
"publisher": "eliostruyf",
"galleryBanner": {
@@ -97,6 +97,43 @@
"markdownDescription": "Specify if you want to automatically update the modified date of your article/page. [Check in the docs](https://frontmatter.codes/docs/settings#frontmatter.content.autoupdatedate)",
"scope": "Content"
},
"frontMatter.content.draftField": {
"type": "object",
"markdownDescription": "Define the draft field you want to use to manage your content. [Check in the docs](https://frontmatter.codes/docs/settings#frontMatter.content.draftField)",
"default": {
"name": "draft",
"type": "boolean"
},
"properties": {
"type": {
"type": "string",
"enum": [
"boolean",
"choice"
],
"description": ""
},
"name": {
"type": "string",
"description": "Name of the field to use"
},
"choices": {
"type": "array",
"description": "List of choices for the field",
"items": {
"type": [
"string"
]
}
}
},
"additionalProperties": false,
"required": [
"type",
"name"
],
"scope": "Content"
},
"frontMatter.content.fmHighlight": {
"type": "boolean",
"default": true,
@@ -219,6 +256,12 @@
"markdownDescription": "Specify the path you want to add after the host and before your slug. This can be used for instance to include the year/month like: `yyyy/MM`. The date will be generated based on the article its date field value. [Check in the docs](https://frontmatter.codes/docs/settings#frontmatter.preview.pathname)",
"scope": "Site preview"
},
"frontMatter.site.baseURL": {
"type": "string",
"default": "",
"markdownDescription": "Specify the base URL of your site, this will be used for SEO checks. [Check in the docs](https://frontmatter.codes/docs/settings#frontmatter.site.baseURL)",
"scope": "Site"
},
"frontMatter.taxonomy.alignFilename": {
"type": "boolean",
"default": false,
@@ -273,7 +316,8 @@
"image",
"choice",
"tags",
"categories"
"categories",
"draft"
],
"description": "Define the type of field"
},
@@ -340,6 +384,14 @@
"type": "boolean",
"default": false,
"description": "Specify if you want to create a folder when creating new content."
},
"previewPath": {
"type": [
"null",
"string"
],
"default": null,
"description": "Defines a custom preview path for the content type."
}
},
"additionalProperties": false,
@@ -456,6 +508,12 @@
"markdownDescription": "Specifies the optimal description length for SEO (set to `-1` to turn it off). [Check in the docs](https://frontmatter.codes/docs/settings#frontmatter.taxonomy.seodescriptionlength)",
"scope": "Taxonomy"
},
"frontMatter.taxonomy.seoSlugLength": {
"type": "number",
"default": 75,
"markdownDescription": "Specifies the optimal slug length for SEO (set to `-1` to turn it off). [Check in the docs](https://frontmatter.codes/docs/settings#frontmatter.taxonomy.seoSlugLength)",
"scope": "Taxonomy"
},
"frontMatter.taxonomy.seoTitleLength": {
"type": "number",
"default": 60,
@@ -726,6 +784,7 @@
"ts-loader": "8.0.3",
"tslint": "6.1.3",
"typescript": "4.0.2",
"url-join-ts": "^1.0.5",
"wc-react": "github:estruyf/wc-react",
"webpack": "4.44.2",
"webpack-cli": "3.3.12"

View File

@@ -5,11 +5,12 @@ import { format } from "date-fns";
import { ArticleHelper, Settings, SlugHelper } from '../helpers';
import matter = require('gray-matter');
import { Notifications } from '../helpers/Notifications';
import { extname, basename } from 'path';
import { extname, basename, parse, dirname } from 'path';
import { COMMAND_NAME, DefaultFields } from '../constants';
import { DashboardData } from '../models/DashboardData';
import { ExplorerView } from '../explorerView/ExplorerView';
import { DateHelper } from '../helpers/DateHelper';
import { parseWinPath } from '../helpers/parseWinPath';
export class Article {
@@ -172,7 +173,7 @@ export class Article {
let newFileName = `${slugName}${ext}`;
if (filePrefix && typeof filePrefix === "string") {
newFileName = `${format(new Date(), DateHelper.formatUpdate(filePrefix))}-${newFileName}`;
newFileName = `${format(new Date(), DateHelper.formatUpdate(filePrefix) as string)}-${newFileName}`;
}
const newPath = editor.document.uri.fsPath.replace(fileName, newFileName);
@@ -183,7 +184,7 @@ export class Article {
await vscode.workspace.fs.rename(editor.document.uri, vscode.Uri.file(newPath), {
overwrite: false
});
} catch (e) {
} catch (e: any) {
Notifications.error(`Failed to rename file: ${e?.message || e}`);
}
}
@@ -191,6 +192,31 @@ export class Article {
}
}
/**
* Retrieve the slug from the front matter
*/
public static getSlug() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const file = parseWinPath(editor.document.fileName);
if (!file.endsWith(`.md`) && !file.endsWith(`.mdx`)) {
return;
}
const parsedFile = parse(file);
if (parsedFile.name.toLowerCase() !== "index") {
return parsedFile.name;
}
const folderName = basename(dirname(file));
return folderName;
}
/**
* Toggle the page its draft mode
*/
@@ -244,7 +270,7 @@ export class Article {
const dateFormat = Settings.get(SETTING_DATE_FORMAT) as string;
if (dateFormat && typeof dateFormat === "string") {
return format(dateValue, DateHelper.formatUpdate(dateFormat));
return format(dateValue, DateHelper.formatUpdate(dateFormat) as string);
} else {
return typeof dateValue.toISOString === 'function' ? dateValue.toISOString() : dateValue?.toString();
}

View File

@@ -1,10 +1,10 @@
import { SETTINGS_CONTENT_STATIC_FOLDER, SETTING_DATE_FIELD, SETTING_SEO_DESCRIPTION_FIELD, SETTINGS_DASHBOARD_OPENONSTART, SETTINGS_DASHBOARD_MEDIA_SNIPPET, SETTING_TAXONOMY_CONTENT_TYPES, DefaultFields, HOME_PAGE_NAVIGATION_ID, ExtensionState, COMMAND_NAME, SETTINGS_FRAMEWORK_ID } from '../constants';
import { SETTINGS_CONTENT_STATIC_FOLDER, SETTING_DATE_FIELD, SETTING_SEO_DESCRIPTION_FIELD, SETTINGS_DASHBOARD_OPENONSTART, SETTINGS_DASHBOARD_MEDIA_SNIPPET, SETTING_TAXONOMY_CONTENT_TYPES, DefaultFields, HOME_PAGE_NAVIGATION_ID, ExtensionState, COMMAND_NAME, SETTINGS_FRAMEWORK_ID, SETTINGS_CONTENT_DRAFT_FIELD } from '../constants';
import { ArticleHelper } from './../helpers/ArticleHelper';
import { basename, dirname, extname, join, parse } from "path";
import { existsSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
import { commands, Uri, ViewColumn, Webview, WebviewPanel, window, workspace, env, Position } from "vscode";
import { Settings as SettingsHelper } from '../helpers';
import { Framework, TaxonomyType } from '../models';
import { DraftField, Framework, TaxonomyType } from '../models';
import { Folders } from './Folders';
import { DashboardCommand } from '../dashboardWebView/DashboardCommand';
import { DashboardMessage } from '../dashboardWebView/DashboardMessage';
@@ -25,6 +25,7 @@ import imageSize from 'image-size';
import { parseWinPath } from '../helpers/parseWinPath';
import { DateHelper } from '../helpers/DateHelper';
import { FrameworkDetector } from '../helpers/FrameworkDetector';
import { ContentType } from '../helpers/ContentType';
export class Dashboard {
private static webview: WebviewPanel | null = null;
@@ -161,7 +162,7 @@ export class Dashboard {
}
break;
case DashboardMessage.setPageViewType:
Extension.getInstance().setState(ExtensionState.PagesView, msg.data);
Extension.getInstance().setState(ExtensionState.PagesView, msg.data, "workspace");
break;
case DashboardMessage.getMedia:
Dashboard.getMedia(msg?.data?.page, msg?.data?.folder);
@@ -275,9 +276,10 @@ export class Dashboard {
categories: SettingsHelper.getTaxonomy(TaxonomyType.Category),
openOnStart: SettingsHelper.get(SETTINGS_DASHBOARD_OPENONSTART),
versionInfo: ext.getVersion(),
pageViewType: await ext.getState<ViewType | undefined>(ExtensionState.PagesView),
pageViewType: await ext.getState<ViewType | undefined>(ExtensionState.PagesView, "workspace"),
mediaSnippet: SettingsHelper.get<string[]>(SETTINGS_DASHBOARD_MEDIA_SNIPPET) || [],
contentTypes: SettingsHelper.get(SETTING_TAXONOMY_CONTENT_TYPES) || [],
draftField: SettingsHelper.get<DraftField>(SETTINGS_CONTENT_DRAFT_FIELD),
contentFolders: Folders.get().map(f => f.path),
crntFramework: SettingsHelper.get<string>(SETTINGS_FRAMEWORK_ID),
framework: (!isInitialized && wsFolder) ? FrameworkDetector.get(wsFolder.fsPath) : null,
@@ -323,7 +325,7 @@ export class Dashboard {
// If the static folder is not set, retreive the last opened location
if (!selectedFolder) {
const stateValue = await Extension.getInstance().getState<string | undefined>(ExtensionState.SelectedFolder);
const stateValue = await Extension.getInstance().getState<string | undefined>(ExtensionState.SelectedFolder, "workspace");
if (stateValue !== HOME_PAGE_NAVIGATION_ID) {
// Support for page bundles
@@ -438,7 +440,7 @@ export class Dashboard {
}
// Store the last opened folder
await Extension.getInstance().setState(ExtensionState.SelectedFolder, requestedFolder === HOME_PAGE_NAVIGATION_ID ? HOME_PAGE_NAVIGATION_ID : selectedFolder);
await Extension.getInstance().setState(ExtensionState.SelectedFolder, requestedFolder === HOME_PAGE_NAVIGATION_ID ? HOME_PAGE_NAVIGATION_ID : selectedFolder, "workspace");
Dashboard.postWebviewMessage({
command: DashboardCommand.media,
@@ -479,7 +481,7 @@ export class Dashboard {
fmModified: file.mtime,
fmFilePath: file.filePath,
fmFileName: file.fileName,
fmDraft: article?.data.draft ? "Draft" : "Published",
fmDraft: ContentType.getDraftStatus(article?.data),
fmYear: article?.data[dateField] ? DateHelper.tryParse(article?.data[dateField])?.getFullYear() : null,
// Make sure these are always set
title: article?.data.title,

View File

@@ -6,6 +6,8 @@ import { Settings } from '../helpers';
import { PreviewSettings } from '../models';
import { format } from 'date-fns';
import { DateHelper } from '../helpers/DateHelper';
import { Article } from '.';
import { urlJoin } from 'url-join-ts';
export class Preview {
@@ -32,12 +34,25 @@ export class Preview {
const article = editor ? ArticleHelper.getFrontMatter(editor) : null;
let slug = article?.data ? article.data.slug : "";
if (settings.pathname) {
let pathname = settings.pathname;
if (article?.data) {
const contentType = ArticleHelper.getContentType(article.data);
if (contentType && contentType.previewPath) {
pathname = contentType.previewPath;
}
}
if (!slug) {
slug = Article.getSlug();
}
if (pathname) {
const articleDate = ArticleHelper.getDate(article);
try {
slug = join(format(articleDate || new Date(), DateHelper.formatUpdate(settings.pathname)), slug);
slug = join(format(articleDate || new Date(), DateHelper.formatUpdate(pathname) as string), slug);
} catch (error) {
slug = join(settings.pathname, slug);
slug = join(pathname, slug);
}
}
@@ -113,9 +128,9 @@ export class Preview {
</head>
<body>
<div class="slug">
<input type="text" value="${join(localhostUrl.toString(), slug)}" disabled />
<input type="text" value="${urlJoin(localhostUrl.toString(), slug || '')}" disabled />
</div>
<iframe src="${join(localhostUrl.toString(), slug)}" >
<iframe src="${urlJoin(localhostUrl.toString(), slug || '')}" >
</body>
</html>`;
}
@@ -132,4 +147,4 @@ export class Preview {
pathname
};
}
}
}

View File

@@ -3,6 +3,7 @@ import * as vscode from 'vscode';
import { ArticleHelper, SeoHelper, Settings } from '../helpers';
import { ExplorerView } from '../explorerView/ExplorerView';
import { DefaultFields } from '../constants';
import { ContentType } from '../helpers/ContentType';
export class StatusListener {
@@ -15,6 +16,11 @@ export class StatusListener {
public static async verify(frontMatterSB: vscode.StatusBarItem, collection: vscode.DiagnosticCollection) {
const draftMsg = "in draft";
const publishMsg = "to publish";
const draft = ContentType.getDraftField();
if (!draft || draft.type !== "boolean") {
frontMatterSB.hide();
}
let editor = vscode.window.activeTextEditor;
if (editor && ArticleHelper.isMarkdownFile()) {

View File

@@ -5,6 +5,7 @@ export const DEFAULT_CONTENT_TYPE_NAME = 'default';
export const DEFAULT_CONTENT_TYPE: ContentType = {
"name": "default",
"pageBundle": false,
"previewPath": null,
"fields": [
{
"title": "Title",
@@ -29,7 +30,7 @@ export const DEFAULT_CONTENT_TYPE: ContentType = {
{
"title": "Is in draft",
"name": "draft",
"type": "boolean"
"type": "draft"
},
{
"title": "Tags",

View File

@@ -2,5 +2,6 @@
export const DefaultFields = {
PublishingDate: `date`,
LastModified: `lastmod`,
Description: `description`
Description: `description`,
Slug: `slug`
};

View File

@@ -20,6 +20,7 @@ export const SETTING_REMOVE_QUOTES = "taxonomy.noPropertyValueQuotes";
export const SETTING_FRONTMATTER_TYPE = "taxonomy.frontMatterType";
export const SETTING_SEO_TITLE_LENGTH = "taxonomy.seoTitleLength";
export const SETTING_SEO_SLUG_LENGTH = "taxonomy.seoSlugLength";
export const SETTING_SEO_DESCRIPTION_LENGTH = "taxonomy.seoDescriptionLength";
export const SETTING_SEO_CONTENT_MIN_LENGTH = "taxonomy.seoContentLengh";
export const SETTING_SEO_DESCRIPTION_FIELD = "taxonomy.seoDescriptionField";
@@ -38,12 +39,15 @@ export const SETTING_AUTO_UPDATE_DATE = "content.autoUpdateDate";
export const SETTINGS_CONTENT_PAGE_FOLDERS = "content.pageFolders";
export const SETTINGS_CONTENT_STATIC_FOLDER = "content.publicFolder";
export const SETTINGS_CONTENT_FRONTMATTER_HIGHLIGHT = "content.fmHighlight";
export const SETTINGS_CONTENT_DRAFT_FIELD = "content.draftField";
export const SETTINGS_DASHBOARD_OPENONSTART = "dashboard.openOnStart";
export const SETTINGS_DASHBOARD_MEDIA_SNIPPET = "dashboard.mediaSnippet";
export const SETTINGS_FRAMEWORK_ID = "framework.id";
export const SETTING_SITE_BASEURL = "site.baseURL";
/**
* @deprecated
*/

View File

@@ -41,7 +41,7 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
<div className="p-4 w-full">
<div className={`flex justify-between items-center`}>
<Status draft={!!draft} />
<Status draft={draft} />
<DateField value={date} />
</div>
@@ -64,7 +64,7 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
<DateField value={date} />
</div>
<div className="col-span-2">
<Status draft={!!draft} />
<Status draft={draft} />
</div>
</button>
</li>

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Tab } from '../constants/Tab';
import { TabAtom } from '../state';
import { SettingsAtom, TabAtom } from '../state';
export interface INavigationProps {
totalPages: number;
@@ -15,19 +15,47 @@ export const tabs = [
export const Navigation: React.FunctionComponent<INavigationProps> = ({totalPages}: React.PropsWithChildren<INavigationProps>) => {
const [ crntTab, setCrntTab ] = useRecoilState(TabAtom);
const settings = useRecoilValue(SettingsAtom);
return (
<nav className="flex-1 -mb-px flex space-x-6 xl:space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.name}
className={`${tab.id === crntTab ? `border-teal-900 dark:border-teal-300 text-teal-900 dark:text-teal-300` : `border-transparent text-gray-500 dark:text-whisper-600 hover:text-gray-700 dark:hover:text-whisper-700 hover:border-gray-300 dark:hover:border-whisper-500`} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
aria-current={tab.id === crntTab ? 'page' : undefined}
onClick={() => setCrntTab(tab.id)}
>
{tab.name}{(tab.id === crntTab && totalPages) ? ` (${totalPages})` : ''}
</button>
))}
{
settings?.draftField?.type === "boolean" ? (
tabs.map((tab) => (
<button
key={tab.name}
className={`${tab.id === crntTab ? `border-teal-900 dark:border-teal-300 text-teal-900 dark:text-teal-300` : `border-transparent text-gray-500 dark:text-whisper-600 hover:text-gray-700 dark:hover:text-whisper-700 hover:border-gray-300 dark:hover:border-whisper-500`} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
aria-current={tab.id === crntTab ? 'page' : undefined}
onClick={() => setCrntTab(tab.id)}
>
{tab.name}{(tab.id === crntTab && totalPages) ? ` (${totalPages})` : ''}
</button>
))
) : (
<>
<button
className={`${tabs[0].id === crntTab ? `border-teal-900 dark:border-teal-300 text-teal-900 dark:text-teal-300` : `border-transparent text-gray-500 dark:text-whisper-600 hover:text-gray-700 dark:hover:text-whisper-700 hover:border-gray-300 dark:hover:border-whisper-500`} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
aria-current={tabs[0].id === crntTab ? 'page' : undefined}
onClick={() => setCrntTab(tabs[0].id)}
>
{tabs[0].name}{(tabs[0].id === crntTab && totalPages) ? ` (${totalPages})` : ''}
</button>
{
settings?.draftField?.choices?.map((value, idx) => (
<button
key={`${value}-${idx}`}
className={`${value === crntTab ? `border-teal-900 dark:border-teal-300 text-teal-900 dark:text-teal-300` : `border-transparent text-gray-500 dark:text-whisper-600 hover:text-gray-700 dark:hover:text-whisper-700 hover:border-gray-300 dark:hover:border-whisper-500`} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm first-letter:uppercase`}
aria-current={value === crntTab ? 'page' : undefined}
onClick={() => setCrntTab(value)}
>
{value}{(value === crntTab && totalPages) ? ` (${totalPages})` : ''}
</button>
))
}
</>
)
}
</nav>
);
};

View File

@@ -1,10 +1,22 @@
import * as React from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsAtom } from '../state';
export interface IStatusProps {
draft: boolean;
draft: boolean | string;
}
export const Status: React.FunctionComponent<IStatusProps> = ({draft}: React.PropsWithChildren<IStatusProps>) => {
const settings = useRecoilValue(SettingsAtom);
if (settings?.draftField && settings.draftField.type === "choice") {
if (draft) {
return <span className={`inline-block px-2 py-1 leading-none rounded-full font-semibold uppercase tracking-wide text-xs text-whisper-200 dark:text-vulcan-500 bg-teal-500`}>{draft}</span>;
} else {
return null;
}
}
return (
<span className={`inline-block px-2 py-1 leading-none rounded-full font-semibold uppercase tracking-wide text-xs text-whisper-200 dark:text-vulcan-500 ${draft ? "bg-red-500" : "bg-teal-500"}`}>{draft ? "Draft" : "Published"}</span>
);

View File

@@ -4,7 +4,7 @@ import { Tab } from '../constants/Tab';
import { Page } from '../models/Page';
import Fuse from 'fuse.js';
import { useRecoilValue } from 'recoil';
import { CategorySelector, FolderSelector, SearchSelector, SortingSelector, TabSelector, TagSelector } from '../state';
import { CategorySelector, FolderSelector, SearchSelector, SettingsSelector, SortingSelector, TabSelector, TagSelector } from '../state';
const fuseOptions: Fuse.IFuseOptions<Page> = {
keys: [
@@ -16,6 +16,7 @@ const fuseOptions: Fuse.IFuseOptions<Page> = {
export default function usePages(pages: Page[]) {
const [ pageItems, setPageItems ] = useState<Page[]>([]);
const settings = useRecoilValue(SettingsSelector);
const tab = useRecoilValue(TabSelector);
const sorting = useRecoilValue(SortingSelector);
const folder = useRecoilValue(FolderSelector);
@@ -24,6 +25,8 @@ export default function usePages(pages: Page[]) {
const category = useRecoilValue(CategorySelector);
useEffect(() => {
const draftField = settings?.draftField;
// Check if search needs to be performed
let searchedPages = pages;
if (search) {
@@ -34,12 +37,22 @@ export default function usePages(pages: Page[]) {
// Filter the pages
let pagesToShow: Page[] = Object.assign([], searchedPages);
if (tab === Tab.Published) {
pagesToShow = searchedPages.filter(page => !page.draft);
} else if (tab === Tab.Draft) {
pagesToShow = searchedPages.filter(page => !!page.draft);
if (draftField && draftField.type === 'choice') {
if (tab !== Tab.All) {
pagesToShow = pagesToShow.filter(page => page.fmDraft === tab);
} else {
pagesToShow = searchedPages;
}
} else {
pagesToShow = searchedPages;
const draftFieldName = draftField?.name || "draft";
if (tab === Tab.Published) {
pagesToShow = searchedPages.filter(page => !page[draftFieldName]);
} else if (tab === Tab.Draft) {
pagesToShow = searchedPages.filter(page => !!page[draftFieldName]);
} else {
pagesToShow = searchedPages;
}
}
// Sort the pages
@@ -69,7 +82,7 @@ export default function usePages(pages: Page[]) {
}
setPageItems(pagesSorted);
}, [ pages, tab, sorting, folder, search, tag, category ]);
}, [ settings?.draftField, pages, tab, sorting, folder, search, tag, category ]);
return {
pageItems

View File

@@ -1,7 +1,7 @@
import { VersionInfo } from '../../models/VersionInfo';
import { ViewType } from '../state';
import { ContentFolder } from '../../models/ContentFolder';
import { ContentType, Framework } from '../../models';
import { ContentType, DraftField, Framework } from '../../models';
export interface Settings {
beta: boolean;
@@ -19,4 +19,5 @@ export interface Settings {
contentFolders: string[];
crntFramework: string;
framework: Framework | null | undefined;
draftField: DraftField | null | undefined;
}

View File

@@ -1,7 +1,7 @@
import { atom } from 'recoil';
import { Tab } from '../../constants/Tab';
export const TabAtom = atom<Tab>({
export const TabAtom = atom<Tab | string>({
key: 'TabAtom',
default: Tab.All
});

View File

@@ -1,6 +1,6 @@
import { DashboardData } from '../models/DashboardData';
import { Template } from '../commands/Template';
import { DefaultFields, SETTINGS_CONTENT_FRONTMATTER_HIGHLIGHT, SETTING_AUTO_UPDATE_DATE, SETTING_CUSTOM_SCRIPTS, SETTING_SEO_CONTENT_MIN_LENGTH, SETTING_SEO_DESCRIPTION_FIELD, SETTING_SLUG_UPDATE_FILE_NAME, SETTING_PREVIEW_HOST, SETTING_DATE_FORMAT, SETTING_COMMA_SEPARATED_FIELDS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_PANEL_FREEFORM, SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH, SETTING_SLUG_PREFIX, SETTING_SLUG_SUFFIX, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_TAGS } from '../constants';
import { DefaultFields, SETTINGS_CONTENT_FRONTMATTER_HIGHLIGHT, SETTING_AUTO_UPDATE_DATE, SETTING_CUSTOM_SCRIPTS, SETTING_SEO_CONTENT_MIN_LENGTH, SETTING_SEO_DESCRIPTION_FIELD, SETTING_SLUG_UPDATE_FILE_NAME, SETTING_PREVIEW_HOST, SETTING_DATE_FORMAT, SETTING_COMMA_SEPARATED_FIELDS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_PANEL_FREEFORM, SETTING_SEO_DESCRIPTION_LENGTH, SETTING_SEO_TITLE_LENGTH, SETTING_SLUG_PREFIX, SETTING_SLUG_SUFFIX, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_TAGS, SETTINGS_CONTENT_DRAFT_FIELD, SETTING_SEO_SLUG_LENGTH, SETTING_SITE_BASEURL } from '../constants';
import * as os from 'os';
import { PanelSettings, CustomScript as ICustomScript } from '../models/PanelSettings';
import { CancellationToken, Disposable, Uri, Webview, WebviewView, WebviewViewProvider, WebviewViewResolveContext, window, workspace, commands, env as vscodeEnv } from "vscode";
@@ -9,7 +9,7 @@ import { Command } from "../panelWebView/Command";
import { CommandToCode } from '../panelWebView/CommandToCode';
import { Article } from '../commands';
import { TagType } from '../panelWebView/TagType';
import { TaxonomyType } from '../models';
import { DraftField, TaxonomyType } from '../models';
import { exec } from 'child_process';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { Content } from 'mdast';
@@ -22,6 +22,7 @@ import { Extension } from '../helpers/Extension';
import { Dashboard } from '../commands/Dashboard';
import { ImageHelper } from '../helpers/ImageHelper';
import { CustomScript } from '../helpers/CustomScript';
import { Link, Parent } from 'mdast-util-from-markdown/lib';
const FILE_LIMIT = 10;
@@ -269,6 +270,15 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
}
}
}
// Check slug
if (!updatedMetadata[DefaultFields.Slug]) {
const slug = Article.getSlug();
if (slug) {
updatedMetadata[DefaultFields.Slug] = slug;
}
}
this.postWebviewMessage({ command: Command.metadata, data: {
...updatedMetadata
@@ -380,6 +390,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
data: {
seo: {
title: Settings.get(SETTING_SEO_TITLE_LENGTH) as number || -1,
slug: Settings.get(SETTING_SEO_SLUG_LENGTH) as number || -1,
description: Settings.get(SETTING_SEO_DESCRIPTION_LENGTH) as number || -1,
content: Settings.get(SETTING_SEO_CONTENT_MIN_LENGTH) as number || -1,
descriptionField: Settings.get(SETTING_SEO_DESCRIPTION_FIELD) as string || DefaultFields.Description
@@ -403,7 +414,8 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
preview: Preview.getSettings(),
commaSeparatedFields: Settings.get(SETTING_COMMA_SEPARATED_FIELDS) || [],
contentTypes: Settings.get(SETTING_TAXONOMY_CONTENT_TYPES) || [],
dashboardViewData: Dashboard.viewData
dashboardViewData: Dashboard.viewData,
draftField: Settings.get<DraftField>(SETTINGS_CONTENT_DRAFT_FIELD)
} as PanelSettings
});
}
@@ -475,6 +487,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
* Get article details
*/
private getArticleDetails() {
const baseUrl = Settings.get<string>(SETTING_SITE_BASEURL);
const editor = window.activeTextEditor;
if (!editor) {
return null;
@@ -491,13 +504,36 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
content = content.replace(/({{(.*?)}})/g, ''); // remove hugo shortcodes
const mdTree = fromMarkdown(content);
const headings = mdTree.children.filter(node => node.type === 'heading').length;
const paragraphs = mdTree.children.filter(node => node.type === 'paragraph').length;
const elms: Parent[] | Link[] = this.getAllElms(mdTree);
const headings = elms.filter(node => node.type === 'heading');
const paragraphs = elms.filter(node => node.type === 'paragraph').length;
const images = elms.filter(node => node.type === 'image').length;
const links: string[] = elms.filter(node => node.type === 'link').map(node => (node as Link).url);
const internalLinks = links.filter(link => !link.startsWith('http') || (baseUrl && link.toLowerCase().includes((baseUrl || "").toLowerCase()))).length;
let externalLinks = links.filter(link => link.startsWith('http'));
if (baseUrl) {
externalLinks = externalLinks.filter(link => !link.toLowerCase().includes(baseUrl.toLowerCase()));
}
const headers = [];
for (const header of headings) {
const text = header?.children?.filter((node: any) => node.type === 'text').map((node: any) => node.value).join(" ");
if (text) {
headers.push(text);
}
}
const wordCount = this.wordCount(0, mdTree);
return {
headings,
headings: headings.length,
headingsText: headers,
paragraphs,
images,
internalLinks,
externalLinks: externalLinks.length,
wordCount,
content: article.content
};
@@ -506,6 +542,21 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
return null;
}
private getAllElms(node: Content | any, allElms?: any[]): any[] {
if (!allElms) {
allElms = [];
}
if (node.children?.length > 0) {
for (const child of node.children) {
allElms.push(Object.assign({}, child));
this.getAllElms(child, allElms);
}
}
return allElms;
}
private counts(acc: any, node: any) {
// add 1 to an initial or existing value
acc[node.type] = (acc[node.type] || 0) + 1;

View File

@@ -126,7 +126,7 @@ export async function activate(context: vscode.ExtensionContext) {
});
// Settings promotion command
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.promote, () => { console.log('promote'); SettingsHelper.promote(); }));
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.promote, SettingsHelper.promote ));
// Collapse all sections in the webview
const collapseAll = vscode.commands.registerCommand(COMMAND_NAME.collapseSections, () => {

View File

@@ -204,7 +204,7 @@ export class ArticleHelper {
let newFileName = `${sanitizedName}.md`;
if (prefix && typeof prefix === "string") {
newFileName = `${format(new Date(), DateHelper.formatUpdate(prefix))}-${newFileName}`;
newFileName = `${format(new Date(), DateHelper.formatUpdate(prefix) as string)}-${newFileName}`;
}
newFilePath = join(folderPath, newFileName);
@@ -251,7 +251,6 @@ export class ArticleHelper {
const items = [{
title: "Check file",
action: async () => {
console.log(fileName);
await EditorHelper.showFile(fileName)
}
}];

View File

@@ -1,18 +1,59 @@
import { ArticleHelper, Settings } from ".";
import { SETTING_TAXONOMY_CONTENT_TYPES, SETTING_TEMPLATES_PREFIX } from "../constants";
import { ContentType as IContentType } from '../models';
import { SETTINGS_CONTENT_DRAFT_FIELD, SETTING_TAXONOMY_CONTENT_TYPES } from "../constants";
import { ContentType as IContentType, DraftField } from '../models';
import { Uri, workspace, window } from 'vscode';
import { Folders } from "../commands/Folders";
import { Questions } from "./Questions";
import { format } from "date-fns";
import { join } from "path";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { writeFileSync } from "fs";
import { Notifications } from "./Notifications";
import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType";
export class ContentType {
/**
* Retrieve the draft field
* @returns
*/
public static getDraftField() {
const draftField = Settings.get<DraftField | null | undefined>(SETTINGS_CONTENT_DRAFT_FIELD);
if (draftField) {
return draftField;
}
return null;
}
/**
* Retrieve the field its status
* @param data
* @returns
*/
public static getDraftStatus(data: { [field: string]: any }) {
const contentType = ArticleHelper.getContentType(data);
const draftSetting = ContentType.getDraftField();
const draftField = contentType.fields.find(f => f.type === "draft");
let fieldValue = null;
if (draftField) {
fieldValue = data[draftField.name];
} else if (draftSetting && data && data[draftSetting.name]) {
fieldValue = data[draftSetting.name];
}
if (draftSetting && fieldValue) {
if (draftSetting.type === "boolean") {
return fieldValue ? "Draft" : "Published";
} else {
return fieldValue;
}
}
return null;
}
/**
* Create content based on content types
* @returns

View File

@@ -89,8 +89,6 @@ export class CustomScript {
articleData = `'${articleData}'`;
}
console.log(articleData);
exec(`${script.nodeBin || "node"} ${join(wsPath, script.script)} "${wsPath}" "${contentPath}" ${articleData}`, (error, stdout) => {
if (error) {
Notifications.error(`${script.title}: ${error.message}`);

View File

@@ -3,7 +3,11 @@ import { parse, parseISO, parseJSON } from "date-fns";
export class DateHelper {
public static formatUpdate(value: string) {
public static formatUpdate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
value = value.replace(/YYYY/g, 'yyyy');
value = value.replace(/DD/g, 'dd');
return value;

View File

@@ -38,7 +38,7 @@ export class Settings {
* Check if the setting is present in the workspace and ask to promote them to the global settings
*/
public static async checkToPromote() {
const isPromoted = await Extension.getInstance().getState<boolean | undefined>(ExtensionState.SettingPromoted);
const isPromoted = await Extension.getInstance().getState<boolean | undefined>(ExtensionState.SettingPromoted, "workspace");
if (!isPromoted) {
if (Settings.hasSettings()) {
window.showInformationMessage(`You have local settings. Would you like to promote them to the global settings ("frontmatter.json")?`, 'Yes', 'No').then(async (result) => {
@@ -47,7 +47,7 @@ export class Settings {
}
if (result === "No" || result === "Yes") {
Extension.getInstance().setState(ExtensionState.SettingPromoted, true);
Extension.getInstance().setState(ExtensionState.SettingPromoted, true, "workspace");
}
});
}

View File

@@ -14,14 +14,18 @@ export class SlugHelper {
// Remove punctuation from input string, and split it into words.
let cleanTitle = this.removePunctuation(articleTitle);
cleanTitle = cleanTitle.toLowerCase();
// Split into words
let words = cleanTitle.split(/\s/);
// Removing stop words
words = this.removeStopWords(words);
cleanTitle = words.join("-");
cleanTitle = this.replaceCharacters(cleanTitle);
return cleanTitle;
if (cleanTitle) {
cleanTitle = cleanTitle.toLowerCase();
// Split into words
let words = cleanTitle.split(/\s/);
// Removing stop words
words = this.removeStopWords(words);
cleanTitle = words.join("-");
cleanTitle = this.replaceCharacters(cleanTitle);
return cleanTitle;
}
return null;
}
/**
@@ -30,6 +34,10 @@ export class SlugHelper {
* @param value
*/
private static removePunctuation(value: string): string {
if (typeof value !== "string") {
return "";
}
const punctuationless = value?.replace(/[\.,-\/#!$@%\^&\*;:{}=\-_`'"~()+\?<>]/g, " ");
// Remove double spaces
return punctuationless?.replace(/\s{2,}/g," ");

5
src/models/DraftField.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface DraftField {
name: string;
type: "boolean" | "choice";
choices?: string[];
}

View File

@@ -1,4 +1,5 @@
import { FileType } from "vscode";
import { DraftField } from ".";
import { Choice } from "./Choice";
import { DashboardData } from "./DashboardData";
@@ -17,19 +18,21 @@ export interface PanelSettings {
preview: PreviewSettings;
contentTypes: ContentType[];
dashboardViewData: DashboardData | undefined;
draftField: DraftField;
}
export interface ContentType {
name: string;
fields: Field[];
previewPath?: string | null;
pageBundle?: boolean;
}
export interface Field {
title?: string;
name: string;
type: "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories";
type: "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft";
choices?: string[] | Choice[];
single?: boolean;
multiple?: boolean;
@@ -43,6 +46,7 @@ export interface DateInfo {
export interface SEO {
title: number;
slug: number;
description: number;
content: number;
descriptionField: string;

View File

@@ -1,6 +1,7 @@
export * from './Choice';
export * from './ContentFolder';
export * from './DashboardData';
export * from './DraftField';
export * from './Framework';
export * from './MediaPaths';
export * from './PanelSettings';

View File

@@ -27,8 +27,8 @@ const Actions: React.FunctionComponent<IActionsProps> = (props: React.PropsWithC
{
(settings && settings.scripts && settings.scripts.length > 0) && (
settings.scripts.map((value) => (
<CustomScript key={value.title.replace(/ /g, '')} {...value} />
settings.scripts.map((value, idx) => (
<CustomScript key={value?.title?.replace(/ /g, '') || idx} {...value} />
))
)
}

View File

@@ -6,6 +6,9 @@ export interface IArticleDetailsProps {
headings: number;
paragraphs: number;
wordCount: number;
internalLinks: number;
externalLinks: number;
images: number;
}
}
@@ -42,6 +45,33 @@ const ArticleDetails: React.FunctionComponent<IArticleDetailsProps> = ({details}
</VsTableRow>
)
}
{
details?.internalLinks !== undefined && (
<VsTableRow>
<VsTableCell>Internal links</VsTableCell>
<VsTableCell>{details.internalLinks}</VsTableCell>
</VsTableRow>
)
}
{
details?.externalLinks !== undefined && (
<VsTableRow>
<VsTableCell>External links</VsTableCell>
<VsTableCell>{details.externalLinks}</VsTableCell>
</VsTableRow>
)
}
{
details?.images !== undefined && (
<VsTableRow>
<VsTableCell>Images</VsTableCell>
<VsTableCell>{details.images}</VsTableCell>
</VsTableRow>
)
}
</VsTableBody>
</VsTable>
</div>

View File

@@ -27,6 +27,10 @@ const BaseView: React.FunctionComponent<IBaseViewProps> = ({settings, folderAndF
MessageHelper.sendMessage(CommandToCode.createContent);
};
const openPreview = () => {
MessageHelper.sendMessage(CommandToCode.openPreview);
};
return (
<div className="frontmatter">
<div className={`ext_actions`}>
@@ -37,6 +41,7 @@ const BaseView: React.FunctionComponent<IBaseViewProps> = ({settings, folderAndF
<button onClick={openDashboard}>Open dashboard</button>
<button onClick={initProject} disabled={settings?.isInitialized}>Initialize project</button>
<button onClick={createContent} disabled={!settings?.isInitialized}>Create new content</button>
<button onClick={openPreview} disabled={!settings?.preview?.host}>Open site preview</button>
</div>
</Collapsible>

View File

@@ -52,7 +52,7 @@ export const DateTimeField: React.FunctionComponent<IDateTimeFieldProps> = ({lab
selected={dateValue as Date || new Date()}
onChange={onDateChange}
timeInputLabel="Time:"
dateFormat={format || "MM/dd/yyyy HH:mm"}
dateFormat={DateHelper.formatUpdate(format) || "MM/dd/yyyy HH:mm"}
customInput={(<CustomInput />)}
showTimeInput
/>

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { RocketIcon } from '../Icons/RocketIcon';
import { VsLabel } from '../VscodeComponents';
import { ChoiceField } from './ChoiceField';
import { Toggle } from './Toggle';
export interface IDraftFieldProps {
label: string;
type: "boolean" | "choice";
value: boolean | string | null | undefined;
choices?: string[];
onChanged: (value: string | boolean) => void;
}
export const DraftField: React.FunctionComponent<IDraftFieldProps> = ({ label, type, value, choices, onChanged }: React.PropsWithChildren<IDraftFieldProps>) => {
if (type === "boolean") {
return (
<Toggle
label={label}
checked={!!value}
onChanged={(checked) => onChanged(checked)} />
);
}
if (type === "choice") {
return (
<ChoiceField
label={label}
selected={value as string}
choices={choices as string[]}
multiSelect={false}
onChange={value => onChanged(value as string)} />
);
}
return null;
};

View File

@@ -18,6 +18,7 @@ import { ChoiceField } from './Fields/ChoiceField';
import useContentType from '../../hooks/useContentType';
import { DateHelper } from '../../helpers/DateHelper';
import FieldBoundary from './ErrorBoundary/FieldBoundary';
import { DraftField } from './Fields/DraftField';
export interface IMetadataProps {
settings: PanelSettings | undefined;
@@ -172,6 +173,20 @@ const Metadata: React.FunctionComponent<IMetadataProps> = ({settings, metadata,
unsetFocus={unsetFocus} />
</FieldBoundary>
);
} else if (field.type === 'draft') {
const draftField = settings?.draftField;
const value = metadata[field.name];
return (
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
<DraftField
label={field.title || field.name}
type={draftField.type}
choices={draftField.choices || []}
value={value as boolean | string | null | undefined}
onChanged={(value: boolean | string) => sendUpdate(field.name, value)} />
</FieldBoundary>
);
} else {
return null;
}

View File

@@ -15,7 +15,7 @@ const SeoFieldInfo: React.FunctionComponent<ISeoFieldInfoProps> = ({ title, valu
<VsTableCell className={`table__cell table__title`}>{title}</VsTableCell>
<VsTableCell className={`table__cell`}>{value}/{recommendation}</VsTableCell>
<VsTableCell className={`table__cell table__cell__validation`}>
{ isValid !== undefined ? <ValidInfo isValid={isValid} /> : <span>-</span> }
{ isValid !== undefined ? <ValidInfo label={undefined} isValid={isValid} /> : <span>-</span> }
</VsTableCell>
</VsTableRow>
);

View File

@@ -8,9 +8,39 @@ export interface ISeoKeywordInfoProps {
description: string;
slug: string;
content: string;
wordCount?: number;
headings?: string[];
}
const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({keyword, title, description, slug, content}: React.PropsWithChildren<ISeoKeywordInfoProps>) => {
const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({keyword, title, description, slug, content, wordCount, headings}: React.PropsWithChildren<ISeoKeywordInfoProps>) => {
const density = () => {
if (!wordCount) {
return null;
}
const pattern = new RegExp('\\b' + keyword.toLowerCase() + '\\b', 'ig');
const count = (content.match(pattern) || []).length;
const density = (count / wordCount) * 100;
const densityTitle = `Keyword usage ${density.toFixed(2)}% *`;
if (density < 0.75) {
return <ValidInfo label={densityTitle} isValid={false} />
} else if (density >= 0.75 && density < 1.5) {
return <ValidInfo label={densityTitle} isValid={true} />
} else {
return <ValidInfo label={densityTitle} isValid={false} />
}
};
const checkHeadings = () => {
if (!headings || headings.length === 0) {
return null;
}
const exists = headings.filter(heading => heading.split(' ').findIndex(word => word.toLowerCase() === keyword.toLowerCase()) !== -1);
return <ValidInfo label={`Used in heading(s)`} isValid={exists.length > 0} />;
};
if (!keyword) {
return null;
@@ -19,17 +49,33 @@ const SeoKeywordInfo: React.FunctionComponent<ISeoKeywordInfoProps> = ({keyword,
return (
<VsTableRow>
<VsTableCell className={`table__cell`}>{keyword}</VsTableCell>
<VsTableCell className={`table__cell table__cell__validation`}>
<ValidInfo isValid={!!title && title.toLowerCase().includes(keyword.toLowerCase())} />
</VsTableCell>
<VsTableCell className={`table__cell table__cell__validation`}>
<ValidInfo isValid={!!description && description.toLowerCase().includes(keyword.toLowerCase())} />
</VsTableCell>
<VsTableCell className={`table__cell table__cell__validation`}>
<ValidInfo isValid={!!slug && (slug.toLowerCase().includes(keyword.toLowerCase()) || slug.toLowerCase().includes(keyword.replace(/ /g, '-').toLowerCase()))} />
</VsTableCell>
<VsTableCell className={`table__cell table__cell__validation`}>
<ValidInfo isValid={!!content && content.toLowerCase().includes(keyword.toLowerCase())} />
<VsTableCell className={`table__cell table__cell__validation table__cell__seo_details`}>
<div>
<ValidInfo label={`Title`} isValid={!!title && title.toLowerCase().includes(keyword.toLowerCase())} />
</div>
<div>
<ValidInfo label={`Description`} isValid={!!description && description.toLowerCase().includes(keyword.toLowerCase())} />
</div>
<div>
<ValidInfo label={`Slug`} isValid={!!slug && (slug.toLowerCase().includes(keyword.toLowerCase()) || slug.toLowerCase().includes(keyword.replace(/ /g, '-').toLowerCase()))} />
</div>
<div>
<ValidInfo label={`Content`} isValid={!!content && content.toLowerCase().includes(keyword.toLowerCase())} />
</div>
{
headings && headings.length > 0 && (
<div>
{checkHeadings()}
</div>
)
}
{
wordCount && (
<div>
{density()}
</div>
)
}
</VsTableCell>
</VsTableRow>
);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { SeoKeywordInfo } from './SeoKeywordInfo';
import { VsTable, VsTableBody, VsTableCell, VsTableHeader, VsTableHeaderCell, VsTableRow } from './VscodeComponents';
import { VsTable, VsTableBody, VsTableHeader, VsTableHeaderCell } from './VscodeComponents';
export interface ISeoKeywordsProps {
keywords: string[] | null;
@@ -9,6 +9,8 @@ export interface ISeoKeywordsProps {
description: string;
slug: string;
content: string;
headings?: string[];
wordCount?: number;
}
const SeoKeywords: React.FunctionComponent<ISeoKeywordsProps> = ({keywords, ...data}: React.PropsWithChildren<ISeoKeywordsProps>) => {
@@ -37,13 +39,10 @@ const SeoKeywords: React.FunctionComponent<ISeoKeywordsProps> = ({keywords, ...d
<div className={`seo__status__keywords`}>
<h4>Keywords</h4>
<VsTable bordered>
<VsTable bordered columns={["30%", "auto"]}>
<VsTableHeader slot="header">
<VsTableHeaderCell className={`table__cell`}>Keyword</VsTableHeaderCell>
<VsTableHeaderCell className={`table__cell`}>Title</VsTableHeaderCell>
<VsTableHeaderCell className={`table__cell`}>Description</VsTableHeaderCell>
<VsTableHeaderCell className={`table__cell`}>Slug</VsTableHeaderCell>
<VsTableHeaderCell className={`table__cell`}>Content</VsTableHeaderCell>
<VsTableHeaderCell className={`table__cell`}>Details</VsTableHeaderCell>
</VsTableHeader>
<VsTableBody slot="body">
{
@@ -55,6 +54,14 @@ const SeoKeywords: React.FunctionComponent<ISeoKeywordsProps> = ({keywords, ...d
}
</VsTableBody>
</VsTable>
{
data.wordCount && (
<div className={`seo__status__note`}>
* A keyword density of 1-1.5% is sufficient in most cases.
</div>
)
}
</div>
);
};

View File

@@ -13,7 +13,7 @@ export interface ISeoStatusProps {
const SeoStatus: React.FunctionComponent<ISeoStatusProps> = (props: React.PropsWithChildren<ISeoStatusProps>) => {
const { data, seo } = props;
const { title } = data;
const { title, slug } = data;
const [ isOpen, setIsOpen ] = React.useState(true);
const tableRef = React.useRef<HTMLElement>();
const pushUpdate = React.useRef((value: boolean) => {
@@ -65,6 +65,11 @@ const SeoStatus: React.FunctionComponent<ISeoStatusProps> = (props: React.PropsW
<SeoFieldInfo title={`title`} value={title.length} recommendation={`${seo.title} chars`} isValid={title.length <= seo.title} />
)
}
{
(slug && seo.slug > 0) && (
<SeoFieldInfo title={`slug`} value={slug.length} recommendation={`${seo.slug} chars`} isValid={slug.length <= seo.slug} />
)
}
{
(data[descriptionField] && seo.description > 0) && (
@@ -85,6 +90,8 @@ const SeoStatus: React.FunctionComponent<ISeoStatusProps> = (props: React.PropsW
title={title}
description={data[descriptionField]}
slug={data.slug}
headings={data?.articleDetails?.headingsText}
wordCount={data?.articleDetails?.wordCount}
content={data?.articleDetails?.content} />
<ArticleDetails details={data.articleDetails} />

View File

@@ -18,7 +18,7 @@ const Tags: React.FunctionComponent<ITagsProps> = (props: React.PropsWithChildre
const unknownTags = values.filter(v => !options.includes(v));
const generateKey = (tag: string, idx: number) => {
if (tag) {
if (tag && typeof tag === 'string') {
return `${tag.replace(/ /g, "_")}-${idx}`;
}
return `tag-${idx}`;

View File

@@ -3,10 +3,11 @@ import { CheckIcon } from './Icons/CheckIcon';
import { WarningIcon } from './Icons/WarningIcon';
export interface IValidInfoProps {
label?: string;
isValid: boolean;
}
const ValidInfo: React.FunctionComponent<IValidInfoProps> = ({isValid}: React.PropsWithChildren<IValidInfoProps>) => {
const ValidInfo: React.FunctionComponent<IValidInfoProps> = ({label, isValid}: React.PropsWithChildren<IValidInfoProps>) => {
return (
<>
{
@@ -16,6 +17,7 @@ const ValidInfo: React.FunctionComponent<IValidInfoProps> = ({isValid}: React.Pr
<span className="warning"><WarningIcon /></span>
)
}
{ label && <span>{label}</span> }
</>
);
};