Files
notesnook/docs/help/.vitepress/seo.mts
2026-08-14 12:34:07 +05:00

207 lines
6.7 KiB
TypeScript

/**
* Per-page SEO: canonical URL, Open Graph, Twitter cards and JSON-LD.
*
* The help site ranks #1 for high-intent queries like "import enex", so every
* page needs to be individually addressable, individually described, and
* eligible for rich results. Driven from each page's frontmatter:
*
* ---
* title: Import from Evernote # sidebar label
* description: One sentence… # meta description + search snippet
* pageTitle: How to import Evernote… # optional: overrides the <title> only
* keywords: [import enex, evernote…] # optional
* schema: howto | faq | article # optional, default article
* faqs: # required when schema: faq
* - q: …
* a: …
* ---
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { HeadConfig, TransformPageContext, PageData } from "vitepress";
import { resolveString } from "./strings.mjs";
const SITE = "https://notesnook.com/help";
const OG_IMAGE = `${SITE}/logo.png`;
const url = (relativePath: string) =>
`${SITE}/${relativePath
.replace(/(index)?\.md$/, "")
.replace(/\/$/, "")}`.replace(/\/$/, "") || SITE;
/** "organizing-notes/archive-notes.md" -> ["Organizing notes", "Archive notes"] */
function breadcrumbs(relativePath: string, title: string) {
const parts = relativePath.split("/").slice(0, -1);
const crumbs = [{ name: "Notesnook Help", item: SITE }];
let path = "";
for (const part of parts) {
path += `/${part}`;
crumbs.push({
name: part.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase()),
item: `${SITE}${path}`
});
}
crumbs.push({ name: title, item: url(relativePath) });
return crumbs;
}
/**
* The page's markdown. `transformPageData`'s context does not carry the source,
* so it is read back off disk.
*/
function pageSource(relativePath: string) {
try {
return readFileSync(join(process.cwd(), "contents", relativePath), "utf8");
} catch {
return "";
}
}
/** Numbered list items in the first tab of a page become HowTo steps. */
const STRING_TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)(?::(\d+))?\s*\}\}/g;
function howToSteps(src: string) {
const steps: { name: string; text: string }[] = [];
for (const line of src.split("\n")) {
const m = line.match(/^\s*\d+\.\s+(.*\S)\s*$/);
if (!m) continue;
const text = m[1]
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// These steps come from the raw markdown, before the markdown-it plugin
// has swapped `{{key}}` for the app's label — resolve them here too, or
// the structured data Google reads ships the raw tokens.
.replace(STRING_TOKEN, (_m, key: string, count?: string) =>
resolveString(key, count ? Number(count) : undefined)
)
.replace(/[`*_]/g, "")
.trim();
if (text.length > 3) steps.push({ name: text.slice(0, 110), text });
if (steps.length >= 12) break;
}
return steps;
}
function jsonLd(pageData: PageData, ctx: TransformPageContext) {
const fm = pageData.frontmatter;
const title = (fm.pageTitle || fm.title || pageData.title) as string;
const description = (fm.description || "") as string;
const pageUrl = url(pageData.relativePath);
const graph: Record<string, unknown>[] = [];
graph.push({
"@type": "BreadcrumbList",
itemListElement: breadcrumbs(pageData.relativePath, title).map((c, i) => ({
"@type": "ListItem",
position: i + 1,
name: c.name,
item: c.item
}))
});
const publisher = {
"@type": "Organization",
name: "Notesnook",
url: "https://notesnook.com",
logo: OG_IMAGE
};
if (fm.schema === "faq" && Array.isArray(fm.faqs) && fm.faqs.length) {
graph.push({
"@type": "FAQPage",
mainEntity: fm.faqs.map((f: { q: string; a: string }) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a }
}))
});
} else if (fm.schema === "howto") {
const steps = howToSteps(pageSource(pageData.relativePath));
if (steps.length)
graph.push({
"@type": "HowTo",
name: title,
description,
url: pageUrl,
step: steps.map((s, i) => ({
"@type": "HowToStep",
position: i + 1,
name: s.name,
text: s.text,
url: `${pageUrl}#${i + 1}`
})),
tool: [{ "@type": "HowToTool", name: "Notesnook" }],
totalTime: fm.totalTime || undefined
});
}
graph.push({
"@type": "TechArticle",
headline: title,
description,
url: pageUrl,
inLanguage: "en",
isPartOf: {
"@type": "WebSite",
name: "Notesnook Help",
url: SITE
},
about: {
"@type": "SoftwareApplication",
name: "Notesnook",
applicationCategory: "ProductivityApplication",
operatingSystem: "Windows, macOS, Linux, Android, iOS, Web"
},
author: publisher,
publisher,
dateModified: pageData.lastUpdated
? new Date(pageData.lastUpdated).toISOString()
: undefined
});
return JSON.stringify({ "@context": "https://schema.org", "@graph": graph });
}
/**
* Head tags for one page. Returned as frontmatter `head` entries so VitePress
* merges them into the rendered <head>.
*/
export function seoHead(
pageData: PageData,
ctx: TransformPageContext
): HeadConfig[] {
const fm = pageData.frontmatter;
if (fm.layout === "home" && !fm.description) return [];
const title = (fm.pageTitle || fm.title || pageData.title) as string;
const description = (fm.description || "") as string;
const pageUrl = url(pageData.relativePath);
const fullTitle = fm.pageTitle
? `${fm.pageTitle} | Notesnook Help`
: `${title} | Notesnook Help`;
const head: HeadConfig[] = [
["link", { rel: "canonical", href: pageUrl }],
["meta", { property: "og:title", content: fullTitle }],
["meta", { property: "og:description", content: description }],
["meta", { property: "og:url", content: pageUrl }],
["meta", { property: "og:image", content: OG_IMAGE }],
["meta", { name: "twitter:card", content: "summary" }],
["meta", { name: "twitter:title", content: fullTitle }],
["meta", { name: "twitter:description", content: description }]
];
if (Array.isArray(fm.keywords) && fm.keywords.length)
head.push(["meta", { name: "keywords", content: fm.keywords.join(", ") }]);
head.push(["script", { type: "application/ld+json" }, jsonLd(pageData, ctx)]);
return head;
}
/** The <title> tag: prefer an SEO-shaped `pageTitle` when the page defines one. */
export function seoTitle(pageData: PageData) {
if (pageData.frontmatter.pageTitle)
pageData.title = pageData.frontmatter.pageTitle as string;
}