meowpress 0.3.0-alpha.5 → 0.3.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,140 @@ owns `dist/`, set `outputDirectory` to a separate directory and reserve it for g
70
70
  to choose the root for those skills. Set `notFound: false` to omit the built-in `404.html`, or
71
71
  supply `notFound: { title, message, homeLabel }` to change its copy.
72
72
 
73
+ ## Site URL and time zone
74
+
75
+ `site.url` is the public origin of the site, without a path, such as `https://example.com`. It is
76
+ validated and stored without a trailing slash; the build does not use it for output yet. `site.timeZone`
77
+ is an IANA time zone, `Asia/Shanghai` by default. It decides the day key and the `HH:mm` label of a
78
+ post or thought whose date has no calendar date in its text (a date from the file's modified time,
79
+ or text such as `September 1, 2026 23:30 UTC`). A date written as `2026-09-01` or as an ISO
80
+ timestamp keeps its written day. An invalid value fails when the config is resolved.
81
+
82
+ ## Languages
83
+
84
+ Without `locales`, a site has one locale, English, unprefixed routes and no catalogs. Setting
85
+ `locales` publishes the theme and the content in more languages.
86
+
87
+ ```ts
88
+ export default meowSite({
89
+ locales: {
90
+ source: "en",
91
+ targets: { fr: { label: "Français" } },
92
+ untranslated: "link",
93
+ stale: "notice",
94
+ disclosure: true,
95
+ },
96
+ });
97
+ ```
98
+
99
+ - `source` is the language the content and the site-owned copy are written in. It keeps today's
100
+ unprefixed routes. Each `targets` tag is published under `/<tag>/`, with the tag in lower case
101
+ (`zh-Hant` becomes `/zh-hant/`); `<html lang>` keeps the canonical tag, and `dir="rtl"` is added
102
+ for right-to-left scripts. `label` is the language's own name. A target equal to the source, an
103
+ invalid tag, or two tags that are the same after canonicalisation fail when the config is
104
+ resolved.
105
+ - The theme's own sentences are written in English and declared in one table, exported as
106
+ `themeMessages`. Every locale other than English takes them from `locales/<tag>.json`, a flat
107
+ object from the English sentence to its translation (a sentence with a `context` maps to an
108
+ object from context to translation). `localesDirectory` changes the directory, which resolves
109
+ against the project directory. `locales.catalogs.<tag>` supplies the same format inline and wins
110
+ over the file. A missing file is an empty catalog; invalid JSON, or a file that is not an object
111
+ of strings, fails the build and names the file. The source locale needs a catalog too when it is
112
+ not English.
113
+ - A sentence with no translation shows its English text. After the build, one line per locale
114
+ reports how many sentences fell back and which file to fill. A site without `locales` prints
115
+ nothing.
116
+ - Site-owned copy is translated through the same catalogs, keyed by the text as written in
117
+ `source`: `site.name`, `site.description`, `site.about.heading`, `paragraphs`, `linksHeading` and
118
+ each link `label`, and the three `notFound` strings. The source tree prints them as written.
119
+ `agentGuide` is not translated.
120
+ - Each target gets `/<tag>/` (home), `/<tag>/about/`, `/<tag>/posts/`, `/<tag>/thoughts/`,
121
+ `/<tag>/404.html` (a host decides whether it serves the file) and a page for every content item
122
+ written in that language. Dates follow the page locale. Every internal link goes through one
123
+ helper that points a route that exists only in the source tree, such as skills, to its source
124
+ URL. Skills pages exist once, in the source locale.
125
+ - With more than one locale the header shows a script-free language switch that points to the same
126
+ page where the translation exists and to that locale's home otherwise. With `site.url`, pages
127
+ also carry `<link rel="canonical">`, one `<link rel="alternate" hreflang>` per version and
128
+ `x-default` for the source; without `site.url` neither is emitted and the build says so once.
129
+ - A target's tag may not equal a top-level route, content asset directory, slug prefix or
130
+ consumer page path; the build fails and names the tag. A `pages` provider still owns the whole
131
+ site map: no locale tree is written for it, and it receives the resolved `locales` in its
132
+ context.
133
+
134
+ ### Translated content
135
+
136
+ A file name may carry a locale suffix. `content/posts/first.fr.md` is the French translation of
137
+ `content/posts/first.md`; `only-fr.fr.md` without a `only-fr.md` is an original written in French.
138
+ The suffix is read only for a configured locale, so `notes.v2.md` keeps its slug, and it uses the
139
+ canonical tag in lower case. Files that share a kind and slug are one translation set; the member
140
+ in the source locale is the original, and a set without one is a set of originals, one per
141
+ language. `first.md` next to `first.en.md` is an error, because both claim the same slug in the
142
+ source locale.
143
+
144
+ A translation records what it was translated from:
145
+
146
+ ```yaml
147
+ ---
148
+ title: Premier billet
149
+ translation:
150
+ source: 3f9a1c2e
151
+ reviewed: false
152
+ ---
153
+ ```
154
+
155
+ `translation.source` is the first 12 hex characters of SHA-256 over the original's resolved title,
156
+ a newline and its body with line endings normalised to LF. `reviewed: true` marks a translation a
157
+ person has checked. Both fields are optional; a translation without a recorded hash counts as
158
+ stale. From that the build knows three states without anyone maintaining them: **current** (the
159
+ recorded hash matches the original), **stale** (the original changed since) and **missing**.
160
+
161
+ - A current or stale translation is published under `/<tag>/<kind>s/<slug>/`.
162
+ - A stale translation is still published, with a notice that the original has changed and a link
163
+ to it. `stale: "fail"` makes it a build error instead, for a site that would rather not publish
164
+ it.
165
+ - A translation without `reviewed: true` carries a quiet line saying it is a translation and
166
+ linking the original; `disclosure: false` removes that line.
167
+ - A missing translation produces no page. In the target's timeline and archives the original is
168
+ listed with its language marked and `lang` on the entry, linking the original's URL;
169
+ `untranslated: "omit"` leaves it out instead.
170
+ - A translation inherits `date`, `tags` and `category` from its original unless it sets them. A
171
+ file whose name used to be its slug (`notes.fr.md` used to publish at `/posts/notes.fr/`) now
172
+ reads as a suffix, and the build reports the URL that changed.
173
+
174
+ ### The work list
175
+
176
+ ```sh
177
+ meowpress locales # one line per missing or stale item
178
+ meowpress locales --json # the same list as a contract
179
+ meowpress locales stamp content/posts/first.fr.md
180
+ ```
181
+
182
+ The report holds, for each target locale, the interface messages the audit finds missing, unused
183
+ or invalid (theme sentences and site copy), and the content items that are missing or stale with
184
+ the original's file, URL and current hash, the target file and, for a stale item, the hash it was
185
+ translated from. `stamp` writes the original's current hash into a translation's frontmatter after
186
+ it has been written or revised, and adds frontmatter to a file that has none. The loop for "add
187
+ French to this blog" is: configure the locale, run `locales`, write what it names, stamp, run
188
+ `locales` again until it is empty, then build. A site without `locales` refuses both commands.
189
+
190
+ ### Agents as readers
191
+
192
+ `/translations.json` is published when `locales` is configured and is reserved like `agents.md`:
193
+ it is the same report the work list prints, so an agent can read one file to know what exists and
194
+ what to trust. The root `/llms.txt`, `/llm.txt` and `/agents.md` index the originals, each in its
195
+ own language with that language marked, then link each target's index. A target tree gets
196
+ `/<tag>/llms.txt`, `/<tag>/llm.txt` and `/<tag>/agents.md` next to its pages; they list that tree's
197
+ pages and the originals it shows untranslated, marked with their language and linked to the source
198
+ URL. A `pages` provider that sets `locale` and `original` on a `SitePage` enters the manifest and
199
+ the per-locale index; its Markdown copy starts with `<!-- lang: fr; original: /guides/ -->`.
200
+
201
+ A right-to-left target is laid out in that direction: the theme's chrome and Meowtail's prose
202
+ mirror through logical properties, dates follow the page locale, and code blocks stay
203
+ left-to-right. Inline code inside right-to-left prose is still subject to the Unicode bidi
204
+ algorithm, as on any site. The default faces cover Latin, Cyrillic and Greek; other scripts use
205
+ system fonts unless the site lists faces in `fonts`.
206
+
73
207
  ## Build from code
74
208
 
75
209
  `meowSite()` resolves configuration and `buildSite()` writes the site. Both are exported from
@@ -100,6 +234,9 @@ export default meowSite({
100
234
 
101
235
  - `pages` is a provider of complete HTML documents. A path that resolves above the site root is
102
236
  refused rather than clamped.
237
+ - `SitePageContext` carries the resolved `locales` and `pageLocale(tag)`, the locale instance the
238
+ built-in pages use, with the site's catalogs, time zone and missing-message reporting. A page
239
+ that sets `locale` and `original` enters `/translations.json` and the per-locale index.
103
240
  - A page's optional `markdown` is the same page for agents. It is published next to the route
104
241
  (`components/button.md` for `/components/button/`, `index.md` for the root), and `llms.txt`,
105
242
  `llm.txt` and `agents.md` link it instead of the HTML. `llms.txt` follows the
@@ -1,6 +1,7 @@
1
1
  import { type ThemeDefinition } from "meowcss";
2
2
  import { type SiteFont } from "./fonts.js";
3
3
  import { type SiteIdentity, type SiteNotFound } from "./identity.js";
4
+ import { type ResolvedLocales, type SiteLocales } from "./locales.js";
4
5
  import { type ContentSourceInput, type MdxComponent } from "./content.js";
5
6
  import { type SitePageProvider } from "./pages.js";
6
7
  export { sitePageMarkdownFile } from "./pages.js";
@@ -8,6 +9,7 @@ export type { SitePage, SitePageContext, SitePageProvider } from "./pages.js";
8
9
  export type { ContentItem, ContentSource, ContentSourceInput } from "./content.js";
9
10
  export type { SiteFont } from "./fonts.js";
10
11
  export type { SiteIdentity, SiteNotFound } from "./identity.js";
12
+ export type { ResolvedLocaleTarget, ResolvedLocales, SiteLocales } from "./locales.js";
11
13
  export interface SiteConfig {
12
14
  /** Roots containing posts, thoughts and optional assets. Defaults to `content/`. */
13
15
  content?: ContentSourceInput | readonly ContentSourceInput[];
@@ -41,6 +43,17 @@ export interface SiteConfig {
41
43
  * for the default Blog theme and to none for any other theme.
42
44
  */
43
45
  fonts?: readonly SiteFont[];
46
+ /**
47
+ * Languages the site publishes. The source locale keeps today's unprefixed routes and each
48
+ * target is published under `/<tag>/`. Without it the site has one locale, English, and its
49
+ * output does not change.
50
+ */
51
+ locales?: SiteLocales | ResolvedLocales;
52
+ /**
53
+ * Directory of `<tag>.json` interface catalogs, one per configured locale, resolved against the
54
+ * project directory. Defaults to `locales/`.
55
+ */
56
+ localesDirectory?: string;
44
57
  }
45
58
  export interface ResolvedSiteConfig {
46
59
  content: readonly ContentSourceInput[];
@@ -56,6 +69,8 @@ export interface ResolvedSiteConfig {
56
69
  mdxComponents: Record<string, MdxComponent>;
57
70
  themeStylesheet: string;
58
71
  fonts: readonly SiteFont[];
72
+ locales?: ResolvedLocales;
73
+ localesDirectory: string;
59
74
  }
60
75
  /** Define a site config in the app package without taking on the build pipeline. */
61
76
  export declare function meowSite(config?: SiteConfig): ResolvedSiteConfig;
package/dist/src/build.js CHANGED
@@ -1,16 +1,49 @@
1
1
  import { removeLegacyIsFallbacks } from "meowcss/bundle";
2
2
  import { blogFonts, blogTheme } from "./theme.js";
3
3
  import { FONT_EXTERNAL, publishFonts } from "./fonts.js";
4
- import { DEFAULT_SITE } from "./identity.js";
4
+ import { DEFAULT_SITE, DEFAULT_TIME_ZONE } from "./identity.js";
5
+ import { createPageLocale, localePath } from "./locale.js";
6
+ import { assertNoRouteCollisions, firstSegment, loadLocaleCatalogs, localeSegment, RESERVED_SEGMENTS, resolveLocales, } from "./locales.js";
5
7
  import { constants } from "node:fs";
6
8
  import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7
9
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
- import { collectContentAssets, isDraftAsset, loadSection, normalizeContentSources, } from "./content.js";
10
+ import { collectContentAssets, isDraftAsset, loadSection, localizedItems, normalizeContentSources, treeItems, } from "./content.js";
9
11
  import { aboutPage, agentMarkdown, DEFAULT_THEME_STYLESHEET, homePage, listPage, llmFullText, llmText, notFoundPage, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
10
12
  import { loadSkills } from "./skills.js";
13
+ import { contentRoute, reportedPath, translationReport } from "./translations.js";
11
14
  import { sitePageFile, sitePageMarkdownFile } from "./pages.js";
12
15
  import { publishSite, validateBuildDestination, validateOutputDirectory } from "./output.js";
13
16
  export { sitePageMarkdownFile } from "./pages.js";
17
+ /** Validate the origin of `site.url` and store it without a trailing slash. */
18
+ function resolveSiteUrl(value) {
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ }
23
+ catch {
24
+ throw new Error(`site.url must be an origin such as https://example.com: ${value}`);
25
+ }
26
+ if ((url.protocol !== "https:" && url.protocol !== "http:") ||
27
+ url.pathname !== "/" ||
28
+ url.search !== "" ||
29
+ url.hash !== "" ||
30
+ url.username !== "" ||
31
+ url.password !== "" ||
32
+ value.includes("#") ||
33
+ value.includes("?")) {
34
+ throw new Error(`site.url must be an origin without a path, such as https://example.com: ${value}`);
35
+ }
36
+ return url.origin;
37
+ }
38
+ function validateTimeZone(timeZone) {
39
+ try {
40
+ new Intl.DateTimeFormat("en", { timeZone });
41
+ }
42
+ catch {
43
+ throw new Error(`site.timeZone must be an IANA time zone such as Asia/Shanghai: ${timeZone}`);
44
+ }
45
+ return timeZone;
46
+ }
14
47
  /** Define a site config in the app package without taking on the build pipeline. */
15
48
  export function meowSite(config = {}) {
16
49
  const themeStylesheet = config.themeStylesheet ?? DEFAULT_THEME_STYLESHEET;
@@ -25,15 +58,26 @@ export function meowSite(config = {}) {
25
58
  projectRootDirectory: config.projectRootDirectory
26
59
  ? resolve(config.projectRootDirectory)
27
60
  : undefined,
28
- site: { ...DEFAULT_SITE, ...config.site },
61
+ site: resolveSite(config.site),
29
62
  pages: config.pages,
30
63
  notFound: config.notFound,
31
64
  agentGuide: config.agentGuide,
32
65
  mdxComponents: config.mdxComponents ?? {},
33
66
  themeStylesheet,
34
67
  fonts: config.fonts ?? (config.theme === undefined || config.theme === blogTheme ? blogFonts : []),
68
+ locales: resolveLocales(config.locales),
69
+ localesDirectory: resolve(config.localesDirectory ?? "locales"),
35
70
  };
36
71
  }
72
+ function resolveSite(site) {
73
+ const resolved = { ...DEFAULT_SITE, ...site };
74
+ if (site?.url !== undefined)
75
+ resolved.url = resolveSiteUrl(site.url);
76
+ else
77
+ delete resolved.url;
78
+ resolved.timeZone = validateTimeZone(site?.timeZone ?? DEFAULT_TIME_ZONE);
79
+ return resolved;
80
+ }
37
81
  async function writePage(generatedDirectory, pathname, html) {
38
82
  const target = join(generatedDirectory, pathname);
39
83
  await mkdir(resolve(target, ".."), { recursive: true });
@@ -95,6 +139,15 @@ async function writeConsumerPages(generatedDirectory, pages) {
95
139
  }
96
140
  /** Files the build writes itself; a page's Markdown copy may not take their place. */
97
141
  const RESERVED_MARKDOWN = new Set(["agents.md"]);
142
+ /** A Markdown copy names its language, and a translation the original it comes from. */
143
+ function pageMarkdownCopy(page) {
144
+ if (page.locale === undefined)
145
+ return page.markdown;
146
+ const markers = [`lang: ${page.locale}`];
147
+ if (page.original !== undefined)
148
+ markers.push(`original: ${page.original}`);
149
+ return `<!-- ${markers.join("; ")} -->\n\n${page.markdown.trim()}\n`;
150
+ }
98
151
  /** Each page's Markdown copy, written straight into the output next to its route. */
99
152
  async function writeConsumerMarkdown(outputDirectory, pages) {
100
153
  for (const page of pages) {
@@ -102,16 +155,17 @@ async function writeConsumerMarkdown(outputDirectory, pages) {
102
155
  continue;
103
156
  if (RESERVED_MARKDOWN.has(sitePageMarkdownFile(page.path)))
104
157
  throw new Error(`Site page Markdown would replace the agent index: ${page.path}`);
105
- await writePage(outputDirectory, consumerPageFile(outputDirectory, page.path, sitePageMarkdownFile), page.markdown);
158
+ await writePage(outputDirectory, consumerPageFile(outputDirectory, page.path, sitePageMarkdownFile), pageMarkdownCopy(page));
106
159
  }
107
160
  }
108
- async function writeContentPages(generatedDirectory, posts, thoughts, theme, site, stylesheet) {
161
+ async function writeContentPages(generatedDirectory, posts, thoughts, theme, site, stylesheet, locale) {
109
162
  const entrypoints = [];
163
+ const prefix = locale.segment === "" ? "" : `${locale.segment}/`;
110
164
  for (const post of posts) {
111
- entrypoints.push(await writePage(generatedDirectory, `posts/${post.slug}/index.html`, postPage(post, posts, thoughts, theme, site, stylesheet)));
165
+ entrypoints.push(await writePage(generatedDirectory, `${prefix}posts/${post.slug}/index.html`, postPage(post, posts, thoughts, theme, site, stylesheet, locale)));
112
166
  }
113
167
  for (const thought of thoughts) {
114
- entrypoints.push(await writePage(generatedDirectory, `thoughts/${thought.slug}/index.html`, thoughtPage(thought, theme, site, stylesheet)));
168
+ entrypoints.push(await writePage(generatedDirectory, `${prefix}thoughts/${thought.slug}/index.html`, thoughtPage(thought, theme, site, stylesheet, locale)));
115
169
  }
116
170
  return entrypoints;
117
171
  }
@@ -133,48 +187,195 @@ export async function buildSite(input = {}) {
133
187
  await rm(workspace, { recursive: true, force: true });
134
188
  }
135
189
  }
190
+ /** Collects, per locale, the theme and site sentences that had no translation. */
191
+ function createMissingLog() {
192
+ const byLocale = new Map();
193
+ return {
194
+ record(miss) {
195
+ const keys = byLocale.get(miss.locale) ?? new Set();
196
+ keys.add(JSON.stringify([miss.context ?? "", miss.source]));
197
+ byLocale.set(miss.locale, keys);
198
+ },
199
+ report(directory, segmentOf) {
200
+ for (const [tag, keys] of byLocale) {
201
+ const path = join(directory, `${segmentOf(tag)}.json`);
202
+ const local = relative(process.cwd(), path);
203
+ const file = local.startsWith("..") || isAbsolute(local) ? path : local;
204
+ console.log(`Locale ${tag}: ${keys.size} messages have no translation and fall back to the source text. Fill ${file}.`);
205
+ }
206
+ },
207
+ };
208
+ }
209
+ /** Top-level names of the output that a locale's URL segment may not take. */
210
+ async function takenSegments(config, pages) {
211
+ const taken = new Set(RESERVED_SEGMENTS);
212
+ for (const asset of await collectContentAssets(config.content, { directory: "assets" })) {
213
+ const segment = firstSegment(asset.relativePath);
214
+ if (segment)
215
+ taken.add(segment);
216
+ }
217
+ for (const source of normalizeContentSources(config.content)) {
218
+ const segment = source.slugPrefix ? firstSegment(source.slugPrefix) : undefined;
219
+ if (segment)
220
+ taken.add(segment);
221
+ }
222
+ for (const page of pages ?? []) {
223
+ const segment = firstSegment(page.path);
224
+ if (segment)
225
+ taken.add(segment);
226
+ }
227
+ return taken;
228
+ }
136
229
  async function writeSite(config, generatedDirectory) {
137
- const [posts, thoughts, skills] = await Promise.all([
138
- loadSection(config.content, "post", { components: config.mdxComponents }),
139
- loadSection(config.content, "thought", { components: config.mdxComponents }),
230
+ const timeZone = config.site.timeZone ?? DEFAULT_TIME_ZONE;
231
+ const locales = config.locales;
232
+ // A locale suffix is read only for a configured locale, so pairing follows the site's config.
233
+ const contentLocales = locales
234
+ ? { source: locales.source, targets: locales.targets.map((target) => target.tag) }
235
+ : undefined;
236
+ const [posts, thoughts, skills, catalogs] = await Promise.all([
237
+ loadSection(config.content, "post", {
238
+ components: config.mdxComponents,
239
+ timeZone,
240
+ locales: contentLocales,
241
+ }),
242
+ loadSection(config.content, "thought", {
243
+ components: config.mdxComponents,
244
+ timeZone,
245
+ locales: contentLocales,
246
+ }),
140
247
  config.pages
141
248
  ? Promise.resolve([])
142
249
  : loadSkills(config.skillsDirectory, {
143
250
  includeProjectSkills: config.includeProjectSkills,
144
251
  projectRootDirectory: config.projectRootDirectory,
145
252
  }),
253
+ config.locales
254
+ ? loadLocaleCatalogs(config.locales, config.localesDirectory)
255
+ : Promise.resolve({}),
146
256
  ]);
147
- const timeline = [...posts, ...thoughts].sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0));
257
+ const source = locales?.source ?? "en";
258
+ // A site that would rather not publish an outdated translation fails the build instead.
259
+ if (locales?.stale === "fail") {
260
+ for (const translation of [...posts, ...thoughts].flatMap((item) => item.translations)) {
261
+ if (translation.translationState !== "stale")
262
+ continue;
263
+ throw new Error(`Stale translation ${reportedPath(translation.sourcePath)}: its original changed since it was translated. Revise it and run meowpress locales stamp, or set locales.stale to "notice".`);
264
+ }
265
+ }
266
+ // The source tree lists the originals written in its own language; originals in a target
267
+ // language live in that language's tree.
268
+ const sourcePosts = posts.filter((post) => post.locale === source);
269
+ const sourceThoughts = thoughts.filter((thought) => thought.locale === source);
270
+ const timeline = [...sourcePosts, ...sourceThoughts].sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0));
148
271
  await mkdir(config.outputDirectory, { recursive: true });
149
272
  await mkdir(generatedDirectory, { recursive: true });
150
- // A consumer that supplies its own pages owns the whole site map; the package's
151
- // own blog routes are not published alongside them.
273
+ const missing = createMissingLog();
274
+ const entries = locales
275
+ ? [
276
+ { tag: locales.source, segment: "", label: locales.source, isSource: true },
277
+ ...locales.targets.map((target) => ({
278
+ tag: target.tag,
279
+ segment: target.segment,
280
+ label: target.label,
281
+ isSource: false,
282
+ })),
283
+ ]
284
+ : undefined;
285
+ const localeFor = (tag, segment) => createPageLocale({
286
+ tag,
287
+ source,
288
+ segment,
289
+ catalogs,
290
+ timeZone,
291
+ entries,
292
+ disclosure: locales?.disclosure ?? true,
293
+ onMissing: (miss) => missing.record(miss),
294
+ });
295
+ const rootLocale = localeFor(source, "");
296
+ const targetLocales = (locales?.targets ?? []).map((target) => ({
297
+ target,
298
+ locale: localeFor(target.tag, target.segment),
299
+ }));
300
+ /** The locale a consumer page is written in, with the site's catalogs and time zone. */
301
+ const pageLocaleFor = (tag) => {
302
+ if (tag === source)
303
+ return rootLocale;
304
+ const target = locales?.targets.find((candidate) => candidate.tag === tag);
305
+ if (!target)
306
+ throw new Error(`Page locale "${tag}" is not configured: add it to locales.targets or use "${source}".`);
307
+ return localeFor(target.tag, target.segment);
308
+ };
309
+ // A consumer that supplies its own pages owns the whole site map; the package's own blog
310
+ // routes are not published alongside them.
152
311
  const pages = config.pages
153
312
  ? await config.pages({
154
313
  posts,
155
314
  thoughts,
156
315
  site: config.site,
157
316
  theme: config.theme,
317
+ locales: config.locales,
318
+ pageLocale: pageLocaleFor,
158
319
  })
159
320
  : undefined;
321
+ // Everything that decides what a locale's URL segment may be is known here.
322
+ if (locales)
323
+ assertNoRouteCollisions(locales, await takenSegments(config, pages));
324
+ // What each target tree shows and publishes. `treeItems` lists an original where no
325
+ // translation exists; `localizedItems` is the set of pages that tree owns.
326
+ const targetTrees = targetLocales.map(({ target, locale }) => {
327
+ const treePosts = treeItems(posts, target.tag, {
328
+ source,
329
+ untranslated: locales?.untranslated ?? "link",
330
+ });
331
+ const treeThoughts = treeItems(thoughts, target.tag, {
332
+ source,
333
+ untranslated: locales?.untranslated ?? "link",
334
+ });
335
+ return {
336
+ target,
337
+ locale,
338
+ posts: treePosts,
339
+ thoughts: treeThoughts,
340
+ timeline: [...treePosts, ...treeThoughts].sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0)),
341
+ };
342
+ });
160
343
  const entrypoints = [];
161
344
  if (pages) {
162
345
  entrypoints.push(...(await writeConsumerPages(generatedDirectory, pages)));
163
346
  }
164
347
  else {
165
348
  entrypoints.push(...(await Promise.all([
166
- writePage(generatedDirectory, "index.html", homePage(timeline, config.theme, config.site, config.themeStylesheet)),
167
- writePage(generatedDirectory, "about/index.html", aboutPage(config.theme, config.site, config.themeStylesheet)),
168
- writePage(generatedDirectory, "posts/index.html", listPage("post", posts, config.theme, config.site, config.themeStylesheet)),
169
- writePage(generatedDirectory, "thoughts/index.html", listPage("thought", thoughts, config.theme, config.site, config.themeStylesheet)),
170
- writePage(generatedDirectory, "skills/index.html", skillsIndexPage(skills, config.theme, config.site, config.themeStylesheet)),
171
- ])), ...(await writeContentPages(generatedDirectory, posts, thoughts, config.theme, config.site, config.themeStylesheet)));
349
+ writePage(generatedDirectory, "index.html", homePage(timeline, config.theme, config.site, config.themeStylesheet, rootLocale)),
350
+ writePage(generatedDirectory, "about/index.html", aboutPage(config.theme, config.site, config.themeStylesheet, rootLocale)),
351
+ writePage(generatedDirectory, "posts/index.html", listPage("post", sourcePosts, config.theme, config.site, config.themeStylesheet, rootLocale)),
352
+ writePage(generatedDirectory, "thoughts/index.html", listPage("thought", sourceThoughts, config.theme, config.site, config.themeStylesheet, rootLocale)),
353
+ writePage(generatedDirectory, "skills/index.html", skillsIndexPage(skills, config.theme, config.site, config.themeStylesheet, rootLocale)),
354
+ ])), ...(await writeContentPages(generatedDirectory, sourcePosts, sourceThoughts, config.theme, config.site, config.themeStylesheet, rootLocale)));
172
355
  if (config.notFound !== false) {
173
- entrypoints.push(await writePage(generatedDirectory, "404.html", notFoundPage(config.notFound ?? {}, config.theme, config.site, config.themeStylesheet)));
356
+ entrypoints.push(await writePage(generatedDirectory, "404.html", notFoundPage(config.notFound ?? {}, config.theme, config.site, config.themeStylesheet, rootLocale, targetLocales.map(({ target, locale }) => ({
357
+ tag: target.tag,
358
+ label: target.label,
359
+ href: localePath(locale, "/"),
360
+ })))));
174
361
  }
175
362
  for (const skill of skills) {
176
363
  const skillPath = skill.scope === "public" ? `skills/${skill.slug}` : `skills/project/${skill.slug}`;
177
- entrypoints.push(await writePage(generatedDirectory, `${skillPath}/index.html`, skillPage(skill, config.theme, config.site, config.themeStylesheet)));
364
+ entrypoints.push(await writePage(generatedDirectory, `${skillPath}/index.html`, skillPage(skill, config.theme, config.site, config.themeStylesheet, rootLocale)));
365
+ }
366
+ // Skills pages exist once, in the source tree. Every target tree has its own home, About,
367
+ // the two archives, a 404 and a page for each content item written in its language.
368
+ for (const tree of targetTrees) {
369
+ const prefix = `${tree.target.segment}/`;
370
+ entrypoints.push(...(await Promise.all([
371
+ writePage(generatedDirectory, `${prefix}index.html`, homePage(tree.timeline, config.theme, config.site, config.themeStylesheet, tree.locale)),
372
+ writePage(generatedDirectory, `${prefix}about/index.html`, aboutPage(config.theme, config.site, config.themeStylesheet, tree.locale)),
373
+ writePage(generatedDirectory, `${prefix}posts/index.html`, listPage("post", tree.posts, config.theme, config.site, config.themeStylesheet, tree.locale)),
374
+ writePage(generatedDirectory, `${prefix}thoughts/index.html`, listPage("thought", tree.thoughts, config.theme, config.site, config.themeStylesheet, tree.locale)),
375
+ ])), ...(await writeContentPages(generatedDirectory, localizedItems(posts, tree.target.tag), localizedItems(thoughts, tree.target.tag), config.theme, config.site, config.themeStylesheet, tree.locale)));
376
+ if (config.notFound !== false) {
377
+ entrypoints.push(await writePage(generatedDirectory, `${prefix}404.html`, notFoundPage(config.notFound ?? {}, config.theme, config.site, config.themeStylesheet, tree.locale)));
378
+ }
178
379
  }
179
380
  }
180
381
  // Font faces come first in the one theme stylesheet; the bundled stylesheet sits at the output
@@ -186,16 +387,57 @@ async function writeSite(config, generatedDirectory) {
186
387
  if (pages)
187
388
  await writeConsumerMarkdown(config.outputDirectory, pages);
188
389
  await writeAssets(config.outputDirectory, config.content, "posts/assets");
189
- const full = pages ? llmFullText(config.site, pages, config.agentGuide) : undefined;
190
- await Promise.all([
191
- writeFile(join(config.outputDirectory, "llm.txt"), llmText(posts, thoughts, config.site, pages, config.agentGuide), "utf8"),
192
- writeFile(join(config.outputDirectory, "llms.txt"), llmText(posts, thoughts, config.site, pages, config.agentGuide), "utf8"),
193
- ...(full === undefined
194
- ? []
195
- : [writeFile(join(config.outputDirectory, "llms-full.txt"), full, "utf8")]),
196
- writeFile(join(config.outputDirectory, "agents.md"), agentMarkdown(posts, thoughts, config.site, pages, config.content.some((source) => typeof source !== "string" &&
197
- (source.postsDirectory !== undefined || source.thoughtsDirectory !== undefined))), "utf8"),
198
- ]);
390
+ const full = pages ? llmFullText(config.site, pages, config.agentGuide, rootLocale) : undefined;
391
+ const mappedSections = config.content.some((input) => typeof input !== "string" &&
392
+ (input.postsDirectory !== undefined || input.thoughtsDirectory !== undefined));
393
+ const indexes = [
394
+ ["llm.txt", llmText(posts, thoughts, config.site, pages, config.agentGuide, rootLocale)],
395
+ ["llms.txt", llmText(posts, thoughts, config.site, pages, config.agentGuide, rootLocale)],
396
+ ...(full === undefined ? [] : [["llms-full.txt", full]]),
397
+ ["agents.md", agentMarkdown(posts, thoughts, config.site, pages, mappedSections, rootLocale)],
398
+ ];
399
+ // A target locale gets its own agent indexes next to the pages it publishes. A consumer that
400
+ // owns the site map owns its indexes too, so no locale tree is written for it.
401
+ if (!pages) {
402
+ for (const tree of targetTrees) {
403
+ indexes.push([
404
+ `${tree.target.segment}/llm.txt`,
405
+ llmText(tree.posts, tree.thoughts, config.site, undefined, config.agentGuide, tree.locale),
406
+ ], [
407
+ `${tree.target.segment}/llms.txt`,
408
+ llmText(tree.posts, tree.thoughts, config.site, undefined, config.agentGuide, tree.locale),
409
+ ], [
410
+ `${tree.target.segment}/agents.md`,
411
+ agentMarkdown(tree.posts, tree.thoughts, config.site, undefined, mappedSections, tree.locale),
412
+ ]);
413
+ }
414
+ }
415
+ await Promise.all(indexes.map(([path, text]) => writeFile(join(config.outputDirectory, path), text, "utf8")));
416
+ // The manifest is published only when locales are configured: it is the same work list the
417
+ // `locales` command prints, for an agent that reads the site instead of its source.
418
+ if (locales) {
419
+ const report = translationReport({
420
+ locales,
421
+ catalogs,
422
+ site: config.site,
423
+ notFound: config.notFound,
424
+ posts,
425
+ thoughts,
426
+ pages,
427
+ });
428
+ await writeFile(join(config.outputDirectory, "translations.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
429
+ if (!config.site.url && locales.targets.length > 0)
430
+ console.log("Site url is not set, so pages carry no alternate or canonical links. Set site.url to the public origin.");
431
+ }
432
+ // A file that used to carry its locale in its slug now reads it as a suffix; say which URL
433
+ // changed rather than leaving it to be discovered.
434
+ const allItems = [...posts, ...thoughts].flatMap((item) => [item, ...item.translations]);
435
+ for (const item of allItems) {
436
+ if (item.previousSlug === undefined)
437
+ continue;
438
+ console.log(`Locale ${item.locale}: ${reportedPath(item.sourcePath)} publishes at ${contentRoute(item.kind, item.slug, item.locale, source)}; the file name used to make /${item.kind}s/${item.previousSlug}/.`);
439
+ }
440
+ missing.report(config.localesDirectory, (tag) => localeSegment(tag));
199
441
  return pages
200
442
  ? `${posts.length} content items and ${pages.length} site pages`
201
443
  : `${posts.length} posts and ${thoughts.length} thoughts`;