meowpress 0.3.0-alpha.4 → 0.3.0-alpha.6
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 +142 -0
- package/dist/src/build.d.ts +15 -0
- package/dist/src/build.js +273 -31
- package/dist/src/cli.js +112 -14
- package/dist/src/content.d.ts +61 -0
- package/dist/src/content.js +162 -21
- package/dist/src/identity.d.ts +12 -0
- package/dist/src/identity.js +2 -0
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +5 -2
- package/dist/src/locale.d.ts +71 -0
- package/dist/src/locale.js +105 -0
- package/dist/src/locales.d.ts +53 -0
- package/dist/src/locales.js +149 -0
- package/dist/src/messages.d.ts +402 -0
- package/dist/src/messages.js +331 -0
- package/dist/src/output.js +2 -0
- package/dist/src/pages.d.ts +13 -0
- package/dist/src/site.d.ts +27 -11
- package/dist/src/site.js +412 -232
- package/dist/src/theme.js +42 -8
- package/dist/src/translations.d.ts +104 -0
- package/dist/src/translations.js +212 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Meowpress builds a static blog from Markdown and MDX, with a built-in Journal-based theme for posts, short notes
|
|
4
4
|
and public skills. A project can supply its own theme or page map through `meowpress/build`.
|
|
5
5
|
|
|
6
|
+
The built-in theme renders functional icons as inline SVG through Meowtail's Lucide-backed
|
|
7
|
+
`Icon`, not font glyphs. Custom pages can use `Icon` or `IconSource` from `meowtail/icons`
|
|
8
|
+
and the full Lucide set from `meowtail/icons/lucide`; brand artwork such as `GithubIcon`
|
|
9
|
+
stays separate. Icons render at build time and need no browser script or icon font.
|
|
10
|
+
|
|
6
11
|
## Build and preview a site
|
|
7
12
|
|
|
8
13
|
Use Bun and install this version of `meowpress` with compatible `meowcss` and `meowjsx` peers.
|
|
@@ -65,6 +70,140 @@ owns `dist/`, set `outputDirectory` to a separate directory and reserve it for g
|
|
|
65
70
|
to choose the root for those skills. Set `notFound: false` to omit the built-in `404.html`, or
|
|
66
71
|
supply `notFound: { title, message, homeLabel }` to change its copy.
|
|
67
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
|
+
|
|
68
207
|
## Build from code
|
|
69
208
|
|
|
70
209
|
`meowSite()` resolves configuration and `buildSite()` writes the site. Both are exported from
|
|
@@ -95,6 +234,9 @@ export default meowSite({
|
|
|
95
234
|
|
|
96
235
|
- `pages` is a provider of complete HTML documents. A path that resolves above the site root is
|
|
97
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.
|
|
98
240
|
- A page's optional `markdown` is the same page for agents. It is published next to the route
|
|
99
241
|
(`components/button.md` for `/components/button/`, `index.md` for the root), and `llms.txt`,
|
|
100
242
|
`llm.txt` and `agents.md` link it instead of the HTML. `llms.txt` follows the
|
package/dist/src/build.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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",
|
|
169
|
-
writePage(generatedDirectory, "thoughts/index.html", listPage("thought",
|
|
170
|
-
writePage(generatedDirectory, "skills/index.html", skillsIndexPage(skills, config.theme, config.site, config.themeStylesheet)),
|
|
171
|
-
])), ...(await writeContentPages(generatedDirectory,
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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`;
|