From 36c11a2bd04cbb16c87f296336626f9084085b01 Mon Sep 17 00:00:00 2001 From: Styler Date: Sat, 18 Jul 2026 14:05:49 +0200 Subject: [PATCH] Technical SEO: breadcrumb JSON-LD, git-based sitemap lastmod, homepage title, duplicate-title fixes, search-intent retitles --- astro.config.mjs | 54 +++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 ++ src/components/overrides/Head.astro | 44 +++++++++++++-- src/content/docs/chatbot/filters/index.mdx | 2 +- src/content/docs/chatbot/getting-started.mdx | 2 +- src/content/docs/chatbot/variables/args.mdx | 3 +- src/content/docs/chatbot/variables/user.mdx | 5 +- src/content/docs/index.mdx | 4 ++ src/content/docs/overlays/getting-started.mdx | 2 +- src/content/docs/overlays/widget-structure.md | 6 ++- .../topics/chatbot-modules-emotecombo.md | 2 +- .../topics/chatbot-modules-pyramid.md | 2 +- 13 files changed, 117 insertions(+), 13 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 7ae8546..3b03f74 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,6 +1,8 @@ // @ts-check import fs from 'node:fs'; +import { execSync } from 'node:child_process'; import { defineConfig } from 'astro/config'; +import sitemap from '@astrojs/sitemap'; import starlight from '@astrojs/starlight'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightLinksValidator from 'starlight-links-validator'; @@ -39,6 +41,50 @@ const redirects = { '/websockets/topics/chatbot/timeout': '/websockets/topics/chatbot-timeout', }; +/** Most recent git commit date per content file, for sitemap . + One `git log` pass; the first time a file appears is its newest commit. */ +function buildLastmodMap() { + const map = new Map(); + const out = execSync('git log --format=%x00%cI --name-only -- src/content', { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + let current = null; + for (const raw of out.split('\n')) { + if (raw.startsWith('\0')) { + current = raw.slice(1).trim(); + } else { + const line = raw.trim(); + if (line && current && !map.has(line)) map.set(line, current); + } + } + return map; +} +const lastmodMap = buildLastmodMap(); + +/** Maps a built page URL back to its source file's last commit date. */ +function lastmodFor(url) { + const path = new URL(url).pathname.replace(/\/$/, '').replace(/^\//, ''); + const candidates = []; + if (path === '') { + candidates.push('src/content/docs/index.mdx'); + } else if (path.startsWith('changelog/post/')) { + const slug = path.slice('changelog/post/'.length); + for (const key of lastmodMap.keys()) { + if (key.startsWith('src/content/changelog/') && key.endsWith(`/${slug}.mdx`)) candidates.push(key); + } + } else { + for (const ext of ['md', 'mdx']) { + candidates.push(`src/content/docs/${path}.${ext}`, `src/content/docs/${path}/index.${ext}`); + } + } + for (const c of candidates) { + const date = lastmodMap.get(c); + if (date) return date; + } + return undefined; +} + /** Writes dist/_redirects (Cloudflare redirects file) from the map above. Each path is emitted with and without a trailing slash so both forms 301. */ function emitRedirectsFile() { @@ -73,6 +119,14 @@ export default defineConfig({ integrations: [ emitRedirectsFile(), + // Starlight skips its own sitemap integration when one is already present. + // This one adds from each page's last git commit date. + sitemap({ + serialize(item) { + const lastmod = lastmodFor(item.url); + return lastmod ? { ...item, lastmod } : item; + }, + }), mermaid({ autoTheme: true }), starlight({ title: 'StreamElements Docs', diff --git a/package.json b/package.json index df2127b..cf876b4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@astrojs/rss": "^4.0.19", + "@astrojs/sitemap": "^3.7.3", "@astrojs/starlight": "^0.41.3", "@fontsource-variable/inter": "^5.2.8", "astro": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97cdd25..a66aad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@astrojs/rss': specifier: ^4.0.19 version: 4.0.19 + '@astrojs/sitemap': + specifier: ^3.7.3 + version: 3.7.3 '@astrojs/starlight': specifier: ^0.41.3 version: 0.41.3(@astrojs/markdown-remark@7.2.1)(astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(rollup@4.61.1)(yaml@2.9.0)) diff --git a/src/components/overrides/Head.astro b/src/components/overrides/Head.astro index 8f7e284..36d9443 100644 --- a/src/components/overrides/Head.astro +++ b/src/components/overrides/Head.astro @@ -1,12 +1,50 @@ --- /** - * Head override: emits a tag for pages that - * declare `keywords` in their frontmatter (carried over from Docusaurus). + * Head override: + * - emits a tag for pages that declare `keywords` + * in their frontmatter (carried over from Docusaurus) + * - emits BreadcrumbList JSON-LD derived from the URL path, so search + * results show a breadcrumb trail instead of a bare URL */ import Default from '@astrojs/starlight/components/Head.astro'; -const { keywords } = Astro.locals.starlightRoute.entry.data; +const { keywords, title } = Astro.locals.starlightRoute.entry.data; + +// Labels for path segments that appear between the site root and the page. +const SECTION_LABELS: Record = { + chatbot: 'Chatbot', + overlays: 'Overlays', + websockets: 'WebSockets', + commands: 'Commands', + default: 'Default Commands', + variables: 'Variables', + modules: 'Modules', + filters: 'Spam Filters', + topics: 'Topics', +}; + +const site = (Astro.site?.href ?? 'https://docs.streamelements.com/').replace(/\/$/, ''); +const segments = Astro.url.pathname.split('/').filter(Boolean); + +let breadcrumbs: object | null = null; +if (segments.length > 0 && segments[0] !== '404') { + const items = [{ '@type': 'ListItem', position: 1, name: 'StreamElements Docs', item: site }]; + segments.forEach((seg, i) => { + const isLast = i === segments.length - 1; + const name = isLast + ? title + : (SECTION_LABELS[seg] ?? seg.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')); + items.push({ + '@type': 'ListItem', + position: i + 2, + name, + item: `${site}/${segments.slice(0, i + 1).join('/')}`, + }); + }); + breadcrumbs = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items }; +} --- {keywords && keywords.length > 0 && } +{breadcrumbs &&