Skip to content

Commit f1b5907

Browse files
montfortclaude
andauthored
feat(website): /features index page + multilingual sitemap (hreflang + lastmod + sitemap-index + robots.txt) (#202)
SEO improvement so crawlers understand the site's information tree (features / docs / blog) and how each section's content branches into the three shipped locales (en / es / zh-CN). Changes: - **New /features index page** (website/src/pages/features/{index.tsx, index.module.css}). Renders FeatureGrid wrapped in a thin header (eyebrow + H1 + intro) so the nine /features/<slug> pages now have a parent aggregator, matching the pattern /blog and /docs already have. i18n-ready via translate() with localized OG metadata. - **FeaturesLayout sidebar gains an "Overview" link** back to the new /features index, with active-state detection that doesn't false-match on /features/<slug> subpaths. - **Sitemap plugin enriched** via createSitemapItems: every URL in each per-locale sitemap now carries `xhtml:link rel="alternate" hreflang="..."` entries for all three locales + an x-default — Google uses these to cluster the per-locale URLs as translations rather than duplicates. 80 URLs × 4 alternates × 3 sitemaps = 960 hreflang annotations emitted. - **lastmod populated** for docs and blog posts via showLastUpdateTime on both content plugins + experimental_vcs: 'git-eager' on the future config so git history reads are eager-cached at build start. Aggregator pages (blog tag indexes, archive, author pages) correctly have no lastmod — they don't map to a single source file. - **sitemap-index.xml** (static, website/static/) aggregates the three per-locale sitemaps so crawlers discover all locales from one entry point. - **robots.txt** (static, website/static/) declares `Sitemap:` pointing at sitemap-index.xml. Without this, the multi-sitemap setup is not auto-discoverable. - **i18n keys added** for the /features page (eyebrow, H1, intro, OG title, OG description, sidebar Overview label) in es + zh-CN code.json. Build verified locally: 80 URLs × 3 locales, hreflang on every URL, lastmod on 23 EN / 16 ES / 16 zh-CN URLs (all content; aggregators correctly skipped). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b3b8fd commit f1b5907

8 files changed

Lines changed: 268 additions & 1 deletion

File tree

website/docusaurus.config.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {themes as prismThemes} from 'prism-react-renderer';
2-
import type {Config} from '@docusaurus/types';
2+
import type {Config, I18n} from '@docusaurus/types';
33
import type * as Preset from '@docusaurus/preset-classic';
44

55
const GITHUB_REPO = 'https://github.com/StrangeDaysTech/straymark';
@@ -126,6 +126,12 @@ const config: Config = {
126126

127127
future: {
128128
v4: true,
129+
// Required for sitemap <lastmod> to be populated from git: the sitemap
130+
// plugin reads each route's last update timestamp via this VCS layer.
131+
// 'git-eager' pre-reads the whole repo once at build start, then answers
132+
// per-file queries in memory — faster than 'git-ad-hoc' for a build that
133+
// covers ~80 routes × 3 locales. See VcsPreset in @docusaurus/types.
134+
experimental_vcs: 'git-eager',
129135
},
130136

131137
url: 'https://straymark.dev',
@@ -169,6 +175,9 @@ const config: Config = {
169175
sidebarPath: './sidebars.ts',
170176
include: ['intro.md', 'adopters/**', 'contributors/**'],
171177
exclude: ['**/i18n/**', '**/proposals/**', '**/decisions/**'],
178+
// Populates each route's metadata.lastUpdatedAt from git so the
179+
// sitemap plugin can emit <lastmod> per doc URL.
180+
showLastUpdateTime: true,
172181
},
173182
blog: {
174183
path: 'blog',
@@ -178,6 +187,10 @@ const config: Config = {
178187
blogSidebarCount: 'ALL',
179188
blogSidebarTitle: 'All posts',
180189
showReadingTime: true,
190+
// Populates metadata.lastUpdatedAt from git for every post so the
191+
// sitemap plugin can emit <lastmod> per post URL. Same lever the
192+
// docs plugin uses above.
193+
showLastUpdateTime: true,
181194
feedOptions: {
182195
type: ['rss', 'atom'],
183196
title: 'StrayMark Blog',
@@ -194,6 +207,79 @@ const config: Config = {
194207
theme: {
195208
customCss: './src/css/custom.css',
196209
},
210+
// Sitemap config. The classic preset bundles @docusaurus/plugin-sitemap;
211+
// by default it emits one sitemap.xml per locale build (en at root,
212+
// /es/sitemap.xml, /zh-CN/sitemap.xml) with no lastmod and no
213+
// hreflang alternates — meaning search engines treat each locale's
214+
// copy of the same content as unrelated documents.
215+
//
216+
// We enrich the default emitter via createSitemapItems:
217+
// - `lastmod: 'date'` makes the plugin attach <lastmod> per URL,
218+
// pulled from git for files with a known sourceFilePath.
219+
// - createSitemapItems wraps defaultCreateSitemapItems and adds an
220+
// `xhtml:link rel="alternate" hreflang="..."` entry per item for
221+
// every locale we ship, plus an x-default pointing at the EN
222+
// canonical. Google uses this to cluster the per-locale URLs as
223+
// translations of the same page rather than as duplicates.
224+
//
225+
// The static counterparts (/sitemap-index.xml that aggregates the
226+
// three per-locale sitemaps, and /robots.txt that points crawlers at
227+
// the index) live under website/static/ — Docusaurus copies them
228+
// verbatim to the build root.
229+
sitemap: {
230+
lastmod: 'date',
231+
createSitemapItems: async (params) => {
232+
const {defaultCreateSitemapItems, ...rest} = params;
233+
const items = await defaultCreateSitemapItems(rest);
234+
const {siteConfig} = params;
235+
const siteUrl = siteConfig.url;
236+
// Docusaurus types `siteConfig.i18n` as I18nConfig (the input
237+
// shape), but at build time the loader hydrates it into the full
238+
// I18n shape, which carries `currentLocale`. Cast accordingly.
239+
const i18n = siteConfig.i18n as unknown as I18n;
240+
const {defaultLocale, locales, currentLocale} = i18n;
241+
242+
// URL prefix for the current build. Default locale has no prefix
243+
// (URLs live at root); other locales are namespaced as
244+
// /<locale>/... — matches Docusaurus's i18n routing.
245+
const prefixFor = (locale: string): string =>
246+
locale === defaultLocale ? '' : `/${locale}`;
247+
const currentPrefix = prefixFor(currentLocale);
248+
249+
// Strip the current-locale prefix from a path to recover the
250+
// locale-agnostic canonical path. Used to build the per-locale
251+
// alternate URLs below.
252+
const stripCurrentPrefix = (path: string): string => {
253+
if (!currentPrefix) return path;
254+
if (path === currentPrefix) return '/';
255+
if (path.startsWith(`${currentPrefix}/`)) {
256+
return path.slice(currentPrefix.length);
257+
}
258+
return path;
259+
};
260+
261+
return items.map((item) => {
262+
const path = item.url.replace(siteUrl, '');
263+
const canonicalPath = stripCurrentPrefix(path) || '/';
264+
265+
// One hreflang entry per shipped locale + x-default. The lib
266+
// (sitemap@7.x) emits each as
267+
// <xhtml:link rel="alternate" hreflang="<lang>" href="<url>" />
268+
const links = [
269+
...locales.map((locale) => ({
270+
lang: locale,
271+
url: `${siteUrl}${prefixFor(locale)}${canonicalPath}`,
272+
})),
273+
{
274+
lang: 'x-default',
275+
url: `${siteUrl}${canonicalPath}`,
276+
},
277+
];
278+
279+
return {...item, links};
280+
});
281+
},
282+
},
197283
// Note: we do NOT use the preset's `gtag` option. The plugin injects
198284
// gtag.js + `gtag('config', ...)` BEFORE user-config headTags render,
199285
// which means `gtag('consent', 'default', 'denied')` arrives after

website/i18n/es/code.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@
8383
"message": "Qué trae StrayMark",
8484
"description": "Title for the left sidebar shared across feature pages"
8585
},
86+
"features.sidebar.overview": {
87+
"message": "Resumen",
88+
"description": "Sidebar link back to the /features index page from any feature subpage"
89+
},
90+
"features.index.eyebrow": {
91+
"message": "Características",
92+
"description": "Small eyebrow label above the /features H1"
93+
},
94+
"features.index.title": {
95+
"message": "Qué trae StrayMark",
96+
"description": "H1 of the /features index page"
97+
},
98+
"features.index.intro": {
99+
"message": "Nueve capacidades que componen StrayMark — desde la disciplina cognitiva y la gobernanza repo-native hasta la auditoría multi-modelo y la observación emergente. Elegí la que mapea al problema que estás resolviendo; cada una enlaza a una página dedicada.",
100+
"description": "Intro paragraph above the FeatureGrid on the /features index page"
101+
},
102+
"meta.features.title": {
103+
"message": "Qué trae StrayMark — todas las características",
104+
"description": "OG/Twitter title and browser tab for the /features index (the \" | StrayMark\" suffix is appended by Docusaurus automatically)"
105+
},
106+
"meta.features.description": {
107+
"message": "Todas las capacidades de StrayMark en una página: charters, gobernanza, auditoría multi-modelo, detección de drift TDE, el CLI, skills para agentes y observación emergente.",
108+
"description": "OG/Twitter description and meta description for the /features index"
109+
},
86110
"features.discipline.title": {
87111
"message": "Disciplina cognitiva estructurada",
88112
"description": "Feature: structured cognitive discipline (title)"

website/i18n/zh-CN/code.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@
1111
"message": "StrayMark 提供什么",
1212
"description": "Title for the left sidebar shared across feature pages"
1313
},
14+
"features.sidebar.overview": {
15+
"message": "概览",
16+
"description": "Sidebar link back to the /features index page from any feature subpage"
17+
},
18+
"features.index.eyebrow": {
19+
"message": "功能",
20+
"description": "Small eyebrow label above the /features H1"
21+
},
22+
"features.index.title": {
23+
"message": "StrayMark 提供什么",
24+
"description": "H1 of the /features index page"
25+
},
26+
"features.index.intro": {
27+
"message": "构成 StrayMark 的九项能力 —— 从认知纪律与原生于仓库的治理,到多模型审计与涌现观察。选择映射到你正在解决的问题的那一项;每一项都链接到一个专门的页面。",
28+
"description": "Intro paragraph above the FeatureGrid on the /features index page"
29+
},
30+
"meta.features.title": {
31+
"message": "StrayMark 提供什么 —— 全部功能一览",
32+
"description": "OG/Twitter title and browser tab for the /features index (the \" | StrayMark\" suffix is appended by Docusaurus automatically)"
33+
},
34+
"meta.features.description": {
35+
"message": "StrayMark 的所有能力汇集在一页:charters、治理、多模型审计、TDE 漂移检测、CLI、智能体 skills 与涌现观察。",
36+
"description": "OG/Twitter description and meta description for the /features index"
37+
},
1438
"features.discipline.title": {
1539
"message": "结构化的认知纪律",
1640
"description": "Feature: structured cognitive discipline (title)"

website/src/components/FeaturesLayout/index.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,39 @@ function isActive(pathname: string, slug: string): boolean {
6363
);
6464
}
6565

66+
function isOverviewActive(pathname: string): boolean {
67+
// True only on the /features index — must NOT match /features/<slug>.
68+
return /\/features\/?$/.test(pathname);
69+
}
70+
6671
export default function FeaturesLayout({children}: Props): ReactNode {
6772
const {pathname} = useLocation();
6873
const sidebarTitle = translate({
6974
id: 'features.sidebar.title',
7075
message: "What's in the box",
7176
description: 'Title for the left sidebar shared across feature pages',
7277
});
78+
const overviewLabel = translate({
79+
id: 'features.sidebar.overview',
80+
message: 'Overview',
81+
description: 'Sidebar link back to the /features index page from any feature subpage',
82+
});
83+
const overviewActive = isOverviewActive(pathname);
7384

7485
return (
7586
<div className={styles.container}>
7687
<aside className={styles.sidebar} aria-label={sidebarTitle}>
7788
<h3 className={styles.sidebarTitle}>{sidebarTitle}</h3>
7889
<ul className={styles.sidebarList}>
90+
<li>
91+
<Link
92+
to="/features"
93+
className={`${styles.sidebarLink} ${overviewActive ? styles.sidebarLinkActive : ''}`}
94+
aria-current={overviewActive ? 'page' : undefined}
95+
>
96+
{overviewLabel}
97+
</Link>
98+
</li>
7999
{FEATURES.map((f) => {
80100
const active = isActive(pathname, f.slug);
81101
return (
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.header {
2+
padding: 4rem 0 2rem;
3+
border-bottom: 1px solid var(--ifm-color-emphasis-200);
4+
}
5+
6+
.headerInner {
7+
max-width: 880px;
8+
margin: 0 auto;
9+
padding: 0 1.5rem;
10+
}
11+
12+
.eyebrow {
13+
margin: 0 0 0.5rem;
14+
font-size: 0.875rem;
15+
font-weight: 600;
16+
letter-spacing: 0.08em;
17+
text-transform: uppercase;
18+
color: var(--ifm-color-primary);
19+
}
20+
21+
.title {
22+
margin: 0 0 1rem;
23+
font-size: clamp(2rem, 4vw + 1rem, 3rem);
24+
font-weight: 700;
25+
line-height: 1.15;
26+
}
27+
28+
.intro {
29+
margin: 0;
30+
font-size: 1.125rem;
31+
line-height: 1.6;
32+
color: var(--ifm-color-emphasis-700);
33+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type {ReactNode} from 'react';
2+
import Layout from '@theme/Layout';
3+
import Translate, {translate} from '@docusaurus/Translate';
4+
import FeatureGrid from '@site/src/components/FeatureGrid';
5+
import styles from './index.module.css';
6+
7+
export default function FeaturesIndex(): ReactNode {
8+
const metaTitle = translate({
9+
id: 'meta.features.title',
10+
message: "What's in the box — all StrayMark features",
11+
description:
12+
'OG/Twitter title and browser tab for the /features index (the " | StrayMark" suffix is appended by Docusaurus automatically)',
13+
});
14+
const metaDescription = translate({
15+
id: 'meta.features.description',
16+
message:
17+
'Every StrayMark capability on one page: charters, governance, multi-model audit, TDE drift detection, the CLI, agent skills, and emergent observation.',
18+
description: 'OG/Twitter description and meta description for the /features index',
19+
});
20+
21+
return (
22+
<Layout title={metaTitle} description={metaDescription}>
23+
<main>
24+
<header className={styles.header}>
25+
<div className={styles.headerInner}>
26+
<p className={styles.eyebrow}>
27+
<Translate
28+
id="features.index.eyebrow"
29+
description="Small eyebrow label above the /features H1"
30+
>
31+
Features
32+
</Translate>
33+
</p>
34+
<h1 className={styles.title}>
35+
<Translate
36+
id="features.index.title"
37+
description="H1 of the /features index page"
38+
>
39+
What's in the box
40+
</Translate>
41+
</h1>
42+
<p className={styles.intro}>
43+
<Translate
44+
id="features.index.intro"
45+
description="Intro paragraph above the FeatureGrid on the /features index page"
46+
>
47+
Nine capabilities that compose StrayMark — from cognitive
48+
discipline and repo-native governance to multi-model audit
49+
and emergent observation. Pick the one that maps to the
50+
problem you're solving; each links to a focused page.
51+
</Translate>
52+
</p>
53+
</div>
54+
</header>
55+
<FeatureGrid />
56+
</main>
57+
</Layout>
58+
);
59+
}

website/static/robots.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# StrayMark robots.txt
2+
# Crawl policy: open. The site is public marketing + docs + blog.
3+
# Sitemap directive points crawlers at the locale-aware index, which
4+
# in turn references the three per-locale sitemaps (en, es, zh-CN).
5+
6+
User-agent: *
7+
Allow: /
8+
9+
Sitemap: https://straymark.dev/sitemap-index.xml

website/static/sitemap-index.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3+
<sitemap>
4+
<loc>https://straymark.dev/sitemap.xml</loc>
5+
</sitemap>
6+
<sitemap>
7+
<loc>https://straymark.dev/es/sitemap.xml</loc>
8+
</sitemap>
9+
<sitemap>
10+
<loc>https://straymark.dev/zh-CN/sitemap.xml</loc>
11+
</sitemap>
12+
</sitemapindex>

0 commit comments

Comments
 (0)