diff --git a/CHANGELOG.md b/CHANGELOG.md index 1131ede9..90a58029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 🎨 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 diff --git a/assets/media/styles.css b/assets/media/styles.css index 40ec8a0f..d9cb4f9f 100644 --- a/assets/media/styles.css +++ b/assets/media/styles.css @@ -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; diff --git a/package.json b/package.json index 8ae47c11..1783d177 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,12 @@ "configuration": { "title": "Front Matter: use frontmatter.json for shared team settings", "properties": { + "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.content.autoUpdateDate": { "type": "boolean", "default": false, @@ -494,6 +500,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, diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 7b11e678..e77b783e 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -20,7 +20,6 @@ export class StatusListener { const draft = ContentType.getDraftField(); if (!draft || draft.type !== "boolean") { frontMatterSB.hide(); - return; } let editor = vscode.window.activeTextEditor; diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 55a4dd12..63259245 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -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"; @@ -45,6 +46,8 @@ export const SETTINGS_DASHBOARD_MEDIA_SNIPPET = "dashboard.mediaSnippet"; export const SETTINGS_FRAMEWORK_ID = "framework.id"; +export const SETTING_SITE_BASEURL = "site.baseURL"; + /** * @deprecated */ diff --git a/src/explorerView/ExplorerView.ts b/src/explorerView/ExplorerView.ts index 07033d73..ff073a22 100644 --- a/src/explorerView/ExplorerView.ts +++ b/src/explorerView/ExplorerView.ts @@ -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, SETTINGS_CONTENT_DRAFT_FIELD } 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"; @@ -12,7 +12,7 @@ import { TagType } from '../panelWebView/TagType'; import { DraftField, TaxonomyType } from '../models'; import { exec } from 'child_process'; import { fromMarkdown } from 'mdast-util-from-markdown'; -import { Content } from 'mdast'; +import { Content, Root } from 'mdast'; import { COMMAND_NAME } from '../constants/Extension'; import { Folders } from '../commands/Folders'; import { Preview } from '../commands/Preview'; @@ -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, Text } from 'mdast-util-from-markdown/lib'; const FILE_LIMIT = 10; @@ -380,6 +381,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 @@ -476,6 +478,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable { * Get article details */ private getArticleDetails() { + const baseUrl = Settings.get(SETTING_SITE_BASEURL); const editor = window.activeTextEditor; if (!editor) { return null; @@ -492,13 +495,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 }; @@ -507,6 +533,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; diff --git a/src/extension.ts b/src/extension.ts index ad613b9b..6d416d32 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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, () => { diff --git a/src/helpers/CustomScript.ts b/src/helpers/CustomScript.ts index 477add62..f12912e7 100644 --- a/src/helpers/CustomScript.ts +++ b/src/helpers/CustomScript.ts @@ -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}`); diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 88254121..86c52ac3 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -45,6 +45,7 @@ export interface DateInfo { export interface SEO { title: number; + slug: number; description: number; content: number; descriptionField: string; diff --git a/src/panelWebView/components/ArticleDetails.tsx b/src/panelWebView/components/ArticleDetails.tsx index 3969cc69..12499096 100644 --- a/src/panelWebView/components/ArticleDetails.tsx +++ b/src/panelWebView/components/ArticleDetails.tsx @@ -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 = ({details} ) } + + { + details?.internalLinks !== undefined && ( + + Internal links + {details.internalLinks} + + ) + } + + { + details?.externalLinks !== undefined && ( + + External links + {details.externalLinks} + + ) + } + + { + details?.images !== undefined && ( + + Images + {details.images} + + ) + } diff --git a/src/panelWebView/components/SeoFieldInfo.tsx b/src/panelWebView/components/SeoFieldInfo.tsx index d61cf4f5..28d95fe6 100644 --- a/src/panelWebView/components/SeoFieldInfo.tsx +++ b/src/panelWebView/components/SeoFieldInfo.tsx @@ -15,7 +15,7 @@ const SeoFieldInfo: React.FunctionComponent = ({ title, valu {title} {value}/{recommendation} - { isValid !== undefined ? : - } + { isValid !== undefined ? : - } ); diff --git a/src/panelWebView/components/SeoKeywordInfo.tsx b/src/panelWebView/components/SeoKeywordInfo.tsx index bdf0b451..1b27b641 100644 --- a/src/panelWebView/components/SeoKeywordInfo.tsx +++ b/src/panelWebView/components/SeoKeywordInfo.tsx @@ -8,9 +8,39 @@ export interface ISeoKeywordInfoProps { description: string; slug: string; content: string; + wordCount?: number; + headings?: string[]; } -const SeoKeywordInfo: React.FunctionComponent = ({keyword, title, description, slug, content}: React.PropsWithChildren) => { +const SeoKeywordInfo: React.FunctionComponent = ({keyword, title, description, slug, content, wordCount, headings}: React.PropsWithChildren) => { + + 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 + } else if (density >= 0.75 && density < 1.5) { + return + } else { + return + } + }; + + const checkHeadings = () => { + if (!headings || headings.length === 0) { + return null; + } + + const exists = headings.filter(heading => heading.split(' ').findIndex(word => word.toLowerCase() === keyword.toLowerCase()) !== -1); + return 0} />; + }; if (!keyword) { return null; @@ -19,17 +49,33 @@ const SeoKeywordInfo: React.FunctionComponent = ({keyword, return ( {keyword} - - - - - - - - - - - + +
+ +
+
+ +
+
+ +
+
+ +
+ { + headings && headings.length > 0 && ( +
+ {checkHeadings()} +
+ ) + } + { + wordCount && ( +
+ {density()} +
+ ) + }
); diff --git a/src/panelWebView/components/SeoKeywords.tsx b/src/panelWebView/components/SeoKeywords.tsx index 177804d5..282e9a83 100644 --- a/src/panelWebView/components/SeoKeywords.tsx +++ b/src/panelWebView/components/SeoKeywords.tsx @@ -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 = ({keywords, ...data}: React.PropsWithChildren) => { @@ -37,13 +39,10 @@ const SeoKeywords: React.FunctionComponent = ({keywords, ...d

Keywords

- + Keyword - Title - Description - Slug - Content + Details { @@ -55,6 +54,14 @@ const SeoKeywords: React.FunctionComponent = ({keywords, ...d } + + { + data.wordCount && ( +
+ * A keyword density of 1-1.5% is sufficient in most cases. +
+ ) + }
); }; diff --git a/src/panelWebView/components/SeoStatus.tsx b/src/panelWebView/components/SeoStatus.tsx index 357a8568..dea3200b 100644 --- a/src/panelWebView/components/SeoStatus.tsx +++ b/src/panelWebView/components/SeoStatus.tsx @@ -13,7 +13,7 @@ export interface ISeoStatusProps { const SeoStatus: React.FunctionComponent = (props: React.PropsWithChildren) => { const { data, seo } = props; - const { title } = data; + const { title, slug } = data; const [ isOpen, setIsOpen ] = React.useState(true); const tableRef = React.useRef(); const pushUpdate = React.useRef((value: boolean) => { @@ -65,6 +65,11 @@ const SeoStatus: React.FunctionComponent = (props: React.PropsW ) } + { + (slug && seo.slug > 0) && ( + + ) + } { (data[descriptionField] && seo.description > 0) && ( @@ -85,6 +90,8 @@ const SeoStatus: React.FunctionComponent = (props: React.PropsW title={title} description={data[descriptionField]} slug={data.slug} + headings={data?.articleDetails?.headingsText} + wordCount={data?.articleDetails?.wordCount} content={data?.articleDetails?.content} /> diff --git a/src/panelWebView/components/ValidInfo.tsx b/src/panelWebView/components/ValidInfo.tsx index 431f923a..2bc2877b 100644 --- a/src/panelWebView/components/ValidInfo.tsx +++ b/src/panelWebView/components/ValidInfo.tsx @@ -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 = ({isValid}: React.PropsWithChildren) => { +const ValidInfo: React.FunctionComponent = ({label, isValid}: React.PropsWithChildren) => { return ( <> { @@ -16,6 +17,7 @@ const ValidInfo: React.FunctionComponent = ({isValid}: React.Pr ) } + { label && {label} } ); };