#159 - SEO enhancements

This commit is contained in:
Elio Struyf
2021-10-26 14:42:01 +02:00
parent cbf434f741
commit b473431eae
15 changed files with 197 additions and 31 deletions
+1
View File
@@ -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
+20 -1
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;
+12
View File
@@ -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,
-1
View File
@@ -20,7 +20,6 @@ export class StatusListener {
const draft = ContentType.getDraftField();
if (!draft || draft.type !== "boolean") {
frontMatterSB.hide();
return;
}
let editor = vscode.window.activeTextEditor;
+3
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";
@@ -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
*/
+46 -5
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, 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<string>(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;
+1 -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, () => {
-2
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}`);
+1
View File
@@ -45,6 +45,7 @@ export interface DateInfo {
export interface SEO {
title: number;
slug: number;
description: number;
content: number;
descriptionField: string;
@@ -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>
+1 -1
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>
);
+58 -12
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>
);
+13 -6
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>
);
};
+8 -1
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} />
+3 -1
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> }
</>
);
};