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/dist/src/cli.js
CHANGED
|
@@ -3,21 +3,38 @@ import { stat } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { buildSite, meowSite } from "./build.js";
|
|
6
|
+
import { loadSection } from "./content.js";
|
|
7
|
+
import { loadLocaleCatalogs } from "./locales.js";
|
|
6
8
|
import { hasBuiltSite, validateOutputDirectory } from "./output.js";
|
|
7
9
|
import { serveSite } from "./preview.js";
|
|
10
|
+
import { formatReport, stampTranslation, translationReport, } from "./translations.js";
|
|
8
11
|
const USAGE = [
|
|
9
|
-
"Usage: meowpress [--config <file>]",
|
|
12
|
+
"Usage: meowpress [--config <file>] [--root <dir>]",
|
|
10
13
|
" meowpress build [root] [--config <file>]",
|
|
11
14
|
" meowpress preview [root] [--config <file>] [--port <port>]",
|
|
15
|
+
" meowpress locales [--json] [--root <dir>] [--config <file>]",
|
|
16
|
+
" meowpress locales stamp <file> [--root <dir>] [--config <file>]",
|
|
12
17
|
].join("\n");
|
|
13
18
|
const CONFIG_FILES = ["meowpress.config.ts", "site.config.ts"];
|
|
14
19
|
function parseArgs(args) {
|
|
15
|
-
const
|
|
16
|
-
const hasCommand =
|
|
20
|
+
const first = args[0];
|
|
21
|
+
const hasCommand = first === "build" || first === "preview" || first === "locales";
|
|
22
|
+
let mode = hasCommand ? first : "build";
|
|
23
|
+
let index = hasCommand ? 1 : 0;
|
|
24
|
+
let file;
|
|
25
|
+
if (mode === "locales" && args[index] === "stamp") {
|
|
26
|
+
const value = args[++index];
|
|
27
|
+
if (!value || value.startsWith("-"))
|
|
28
|
+
throw new Error("Missing file for locales stamp");
|
|
29
|
+
file = value;
|
|
30
|
+
mode = "stamp";
|
|
31
|
+
index++;
|
|
32
|
+
}
|
|
17
33
|
let root;
|
|
18
34
|
let config;
|
|
19
35
|
let port;
|
|
20
|
-
|
|
36
|
+
let json = false;
|
|
37
|
+
for (; index < args.length; index++) {
|
|
21
38
|
const arg = args[index];
|
|
22
39
|
if (arg === "--config" && config === undefined) {
|
|
23
40
|
const value = args[++index];
|
|
@@ -25,20 +42,37 @@ function parseArgs(args) {
|
|
|
25
42
|
throw new Error("Missing file for --config");
|
|
26
43
|
config = value;
|
|
27
44
|
}
|
|
28
|
-
else if (arg === "--
|
|
45
|
+
else if (arg === "--root" && root === undefined) {
|
|
46
|
+
const value = args[++index];
|
|
47
|
+
if (!value || value.startsWith("-"))
|
|
48
|
+
throw new Error("Missing directory for --root");
|
|
49
|
+
root = value;
|
|
50
|
+
}
|
|
51
|
+
else if (arg === "--port" && mode === "preview" && port === undefined) {
|
|
29
52
|
const value = args[++index];
|
|
30
53
|
port = Number(value);
|
|
31
54
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
32
55
|
throw new Error("Port must be an integer between 1 and 65535");
|
|
33
56
|
}
|
|
34
|
-
else if (
|
|
57
|
+
else if (arg === "--json" && mode === "locales" && !json) {
|
|
58
|
+
json = true;
|
|
59
|
+
}
|
|
60
|
+
else if ((mode === "build" || mode === "preview") &&
|
|
61
|
+
!arg.startsWith("-") &&
|
|
62
|
+
root === undefined) {
|
|
35
63
|
root = arg;
|
|
36
64
|
}
|
|
37
65
|
else {
|
|
38
66
|
throw new Error(`Unexpected argument: ${arg}\n${USAGE}`);
|
|
39
67
|
}
|
|
40
68
|
}
|
|
41
|
-
return {
|
|
69
|
+
return { mode, root: resolve(root ?? "."), config, port, json, file };
|
|
70
|
+
}
|
|
71
|
+
/** The locale configuration the content loader needs, or undefined on a single-locale site. */
|
|
72
|
+
function contentLocales(config) {
|
|
73
|
+
return config.locales
|
|
74
|
+
? { source: config.locales.source, targets: config.locales.targets.map((target) => target.tag) }
|
|
75
|
+
: undefined;
|
|
42
76
|
}
|
|
43
77
|
async function loadConfig(config) {
|
|
44
78
|
for (const candidate of config === undefined ? CONFIG_FILES : [config]) {
|
|
@@ -62,6 +96,60 @@ async function loadConfig(config) {
|
|
|
62
96
|
}
|
|
63
97
|
return {};
|
|
64
98
|
}
|
|
99
|
+
/** Load the content and catalogs a work-list command needs, without building the site. */
|
|
100
|
+
async function loadTranslationInput(config) {
|
|
101
|
+
const locales = config.locales;
|
|
102
|
+
if (!locales)
|
|
103
|
+
throw new Error("This site configures no locales. Add `locales` to meowpress.config.ts, then run meowpress locales again.");
|
|
104
|
+
const options = {
|
|
105
|
+
components: config.mdxComponents,
|
|
106
|
+
timeZone: config.site.timeZone,
|
|
107
|
+
locales: contentLocales(config),
|
|
108
|
+
};
|
|
109
|
+
const [posts, thoughts, catalogs] = await Promise.all([
|
|
110
|
+
loadSection(config.content, "post", options),
|
|
111
|
+
loadSection(config.content, "thought", options),
|
|
112
|
+
loadLocaleCatalogs(locales, config.localesDirectory),
|
|
113
|
+
]);
|
|
114
|
+
const report = translationReport({
|
|
115
|
+
locales,
|
|
116
|
+
catalogs,
|
|
117
|
+
site: config.site,
|
|
118
|
+
notFound: config.notFound,
|
|
119
|
+
posts,
|
|
120
|
+
thoughts,
|
|
121
|
+
});
|
|
122
|
+
return { posts, thoughts, report };
|
|
123
|
+
}
|
|
124
|
+
async function runLocales(options) {
|
|
125
|
+
const config = meowSite(await loadConfig(options.config));
|
|
126
|
+
if (options.mode === "stamp") {
|
|
127
|
+
const locales = config.locales;
|
|
128
|
+
if (!locales)
|
|
129
|
+
throw new Error("This site configures no locales. Add `locales` to meowpress.config.ts before stamping a translation.");
|
|
130
|
+
const stampOptions = {
|
|
131
|
+
components: config.mdxComponents,
|
|
132
|
+
timeZone: config.site.timeZone,
|
|
133
|
+
locales: contentLocales(config),
|
|
134
|
+
};
|
|
135
|
+
const [posts, thoughts] = await Promise.all([
|
|
136
|
+
loadSection(config.content, "post", stampOptions),
|
|
137
|
+
loadSection(config.content, "thought", stampOptions),
|
|
138
|
+
]);
|
|
139
|
+
const file = resolve(options.file);
|
|
140
|
+
const stamped = await stampTranslation(file, [...posts, ...thoughts]);
|
|
141
|
+
console.log(`Stamped ${stamped.file} with ${stamped.hash}.`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { report } = await loadTranslationInput(config);
|
|
145
|
+
if (options.json) {
|
|
146
|
+
console.log(JSON.stringify(report, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const text = formatReport(report);
|
|
150
|
+
if (text)
|
|
151
|
+
console.log(text);
|
|
152
|
+
}
|
|
65
153
|
export async function runSiteCli(args = Bun.argv.slice(2)) {
|
|
66
154
|
if (args.includes("--help") || args.includes("-h")) {
|
|
67
155
|
console.log(USAGE);
|
|
@@ -71,14 +159,20 @@ export async function runSiteCli(args = Bun.argv.slice(2)) {
|
|
|
71
159
|
if (options.root !== process.cwd()) {
|
|
72
160
|
if (!(await stat(options.root).catch(() => undefined))?.isDirectory())
|
|
73
161
|
throw new Error(`Project root is not a directory: ${options.root}`);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
options.command,
|
|
162
|
+
const childArgs = [
|
|
163
|
+
options.mode === "stamp" ? "locales" : options.mode,
|
|
164
|
+
...(options.mode === "stamp" ? ["stamp", options.file] : []),
|
|
165
|
+
...(options.json ? ["--json"] : []),
|
|
79
166
|
...(options.config ? ["--config", options.config] : []),
|
|
80
167
|
...(options.port ? ["--port", String(options.port)] : []),
|
|
81
|
-
]
|
|
168
|
+
];
|
|
169
|
+
// Trusted config can call meowSite while loading, so it and its imports need the selected cwd.
|
|
170
|
+
const child = Bun.spawn([process.execPath, import.meta.path, ...childArgs], {
|
|
171
|
+
cwd: options.root,
|
|
172
|
+
stdin: "inherit",
|
|
173
|
+
stdout: "inherit",
|
|
174
|
+
stderr: "inherit",
|
|
175
|
+
});
|
|
82
176
|
const interrupt = () => child.kill("SIGINT");
|
|
83
177
|
const terminate = () => child.kill("SIGTERM");
|
|
84
178
|
process.once("SIGINT", interrupt);
|
|
@@ -92,8 +186,12 @@ export async function runSiteCli(args = Bun.argv.slice(2)) {
|
|
|
92
186
|
}
|
|
93
187
|
return;
|
|
94
188
|
}
|
|
189
|
+
if (options.mode === "locales" || options.mode === "stamp") {
|
|
190
|
+
await runLocales(options);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
95
193
|
const config = await loadConfig(options.config);
|
|
96
|
-
if (options.
|
|
194
|
+
if (options.mode === "preview") {
|
|
97
195
|
const resolved = meowSite(config);
|
|
98
196
|
await validateOutputDirectory(resolved);
|
|
99
197
|
if (!(await hasBuiltSite(resolved.outputDirectory))) {
|
package/dist/src/content.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { type Child, type Props } from "meowjsx/runtime";
|
|
2
2
|
export type ContentKind = "post" | "thought";
|
|
3
3
|
export type Frontmatter = Record<string, unknown>;
|
|
4
|
+
/** Whether a translation still matches the original it records. */
|
|
5
|
+
export type TranslationState = "current" | "stale";
|
|
6
|
+
/**
|
|
7
|
+
* The locales a content tree may carry, so a file-name suffix is read only when it names a
|
|
8
|
+
* configured locale: `notes.v2.md` keeps its slug, `notes.fr.md` is a French translation.
|
|
9
|
+
*/
|
|
10
|
+
export interface ContentLocales {
|
|
11
|
+
/** The language of an unsuffixed file, and the one whose file in a set is the original. */
|
|
12
|
+
source: string;
|
|
13
|
+
/** Other canonical tags, written as a lower-case file-name suffix. */
|
|
14
|
+
targets?: readonly string[];
|
|
15
|
+
}
|
|
4
16
|
/** A content root containing `posts/`, `thoughts/`, and optional asset folders. */
|
|
5
17
|
export interface ContentSource {
|
|
6
18
|
directory: string;
|
|
@@ -22,6 +34,10 @@ export interface LoadSectionOptions {
|
|
|
22
34
|
duplicate?: DuplicateContentPolicy;
|
|
23
35
|
/** Extra components available to MDX content pages. */
|
|
24
36
|
components?: Record<string, MdxComponent>;
|
|
37
|
+
/** IANA time zone for day keys and time labels. Defaults to "Asia/Shanghai". */
|
|
38
|
+
timeZone?: string;
|
|
39
|
+
/** Locales a file-name suffix may name. Without it every item is written in English. */
|
|
40
|
+
locales?: ContentLocales;
|
|
25
41
|
}
|
|
26
42
|
/** A component an MDX content page may use by name. */
|
|
27
43
|
export type MdxComponent = (props: Props) => Child;
|
|
@@ -40,6 +56,28 @@ export interface ContentItem {
|
|
|
40
56
|
tags: string[];
|
|
41
57
|
readingMinutes: number;
|
|
42
58
|
characterCount: number;
|
|
59
|
+
/** Canonical tag of the language the item is written in. */
|
|
60
|
+
locale: string;
|
|
61
|
+
/**
|
|
62
|
+
* The slug the file name would have without locale suffixes, set only when a suffix was read.
|
|
63
|
+
* `notes.fr.md` reports `notes.fr` here and resolves to `notes`.
|
|
64
|
+
*/
|
|
65
|
+
previousSlug?: string;
|
|
66
|
+
/** On an original: the translations that share its slug, sorted by language. */
|
|
67
|
+
translations: ContentItem[];
|
|
68
|
+
/** On a translation: whether it still matches the original it records. */
|
|
69
|
+
translationState?: TranslationState;
|
|
70
|
+
/** On a translation: the original it was translated from. */
|
|
71
|
+
original?: ContentItem;
|
|
72
|
+
/** On a translation: the source hash recorded in its frontmatter, when it has one. */
|
|
73
|
+
translatedFrom?: string;
|
|
74
|
+
}
|
|
75
|
+
/** The `translation` frontmatter block of a translation. */
|
|
76
|
+
export interface TranslationRecord {
|
|
77
|
+
/** The provenance hash of the original when this file was translated. */
|
|
78
|
+
source?: string;
|
|
79
|
+
/** A person marked the translation as reviewed; unset means it was not. */
|
|
80
|
+
reviewed?: boolean;
|
|
43
81
|
}
|
|
44
82
|
export interface ParsedDocument {
|
|
45
83
|
metadata: Frontmatter;
|
|
@@ -55,8 +93,31 @@ export interface CollectContentAssetsOptions {
|
|
|
55
93
|
directory?: string;
|
|
56
94
|
duplicate?: DuplicateContentPolicy;
|
|
57
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The provenance hash of an original: the first 12 hex characters of SHA-256 over its resolved
|
|
98
|
+
* title, a newline, and its body with line endings normalised to LF.
|
|
99
|
+
*/
|
|
100
|
+
export declare function translationHash(item: {
|
|
101
|
+
title: string;
|
|
102
|
+
body: string;
|
|
103
|
+
}): string;
|
|
104
|
+
/** The `translation` frontmatter block, when it has the expected shape. */
|
|
105
|
+
export declare function translationRecord(metadata: Frontmatter): TranslationRecord;
|
|
58
106
|
export declare function normalizeContentSources(input: ContentSourceInput | readonly ContentSourceInput[], baseDirectory?: string): ContentSource[];
|
|
59
107
|
export declare function loadSection(sources: ContentSourceInput | readonly ContentSourceInput[], kind: ContentKind, options?: LoadSectionOptions): Promise<ContentItem[]>;
|
|
108
|
+
/**
|
|
109
|
+
* Every item written in one locale: the originals in that language and the translations of
|
|
110
|
+
* originals written in another one. These are the pages a locale's tree publishes.
|
|
111
|
+
*/
|
|
112
|
+
export declare function localizedItems(originals: readonly ContentItem[], locale: string): ContentItem[];
|
|
113
|
+
/**
|
|
114
|
+
* The entries a locale's tree lists: its own pages, and the original of anything not translated
|
|
115
|
+
* into it — marked with the original's language and linked to where it is published.
|
|
116
|
+
*/
|
|
117
|
+
export declare function treeItems(originals: readonly ContentItem[], locale: string, options: {
|
|
118
|
+
source: string;
|
|
119
|
+
untranslated: "link" | "omit";
|
|
120
|
+
}): ContentItem[];
|
|
60
121
|
export declare function withoutLeadingHeading(html: string): string;
|
|
61
122
|
export declare function collectAssetFiles(directory: string): Promise<string[]>;
|
|
62
123
|
export declare function collectContentAssets(sources: ContentSourceInput | readonly ContentSourceInput[], options?: CollectContentAssetsOptions): Promise<ContentAsset[]>;
|
package/dist/src/content.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { compile, run } from "@mdx-js/mdx";
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
4
5
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { parse as parseYaml } from "yaml";
|
|
6
7
|
import { Fragment, h } from "meowjsx/runtime";
|
|
7
8
|
import { renderToHtml } from "meowjsx/web";
|
|
9
|
+
import { DEFAULT_TIME_ZONE } from "./identity.js";
|
|
8
10
|
const CONTENT_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
11
|
+
/** Frontmatter keys a date may be written under, in precedence order. */
|
|
12
|
+
const DATE_KEYS = [
|
|
13
|
+
"published_at",
|
|
14
|
+
"publishedAt",
|
|
15
|
+
"date",
|
|
16
|
+
"created_at",
|
|
17
|
+
"createdAt",
|
|
18
|
+
"updated_at",
|
|
19
|
+
"updatedAt",
|
|
20
|
+
];
|
|
9
21
|
function isRecord(value) {
|
|
10
22
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
23
|
}
|
|
@@ -39,7 +51,7 @@ function parseDate(value) {
|
|
|
39
51
|
const date = new Date(text);
|
|
40
52
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
41
53
|
}
|
|
42
|
-
function makeDateKey(value, date) {
|
|
54
|
+
function makeDateKey(value, date, timeZone) {
|
|
43
55
|
const text = asText(value);
|
|
44
56
|
const explicitDate = text?.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
|
45
57
|
if (explicitDate) {
|
|
@@ -50,8 +62,9 @@ function makeDateKey(value, date) {
|
|
|
50
62
|
}
|
|
51
63
|
if (!date)
|
|
52
64
|
return "undated";
|
|
65
|
+
// The "en-US" formatter only produces the numeric parts that are read back below.
|
|
53
66
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
54
|
-
timeZone
|
|
67
|
+
timeZone,
|
|
55
68
|
year: "numeric",
|
|
56
69
|
month: "2-digit",
|
|
57
70
|
day: "2-digit",
|
|
@@ -61,15 +74,16 @@ function makeDateKey(value, date) {
|
|
|
61
74
|
const day = parts.find((part) => part.type === "day").value;
|
|
62
75
|
return `${year.padStart(4, "0")}-${month}-${day}`;
|
|
63
76
|
}
|
|
64
|
-
function makeTimeLabel(value, date) {
|
|
77
|
+
function makeTimeLabel(value, date, timeZone) {
|
|
65
78
|
const text = asText(value);
|
|
66
79
|
if (text) {
|
|
67
80
|
return text.match(/(?:T|\s)(\d{2}:\d{2})/)?.[1] ?? "";
|
|
68
81
|
}
|
|
69
82
|
if (!date)
|
|
70
83
|
return "";
|
|
84
|
+
// "zh-CN" with hour12 false is only the way to print a 24-hour HH:mm; it is not a display locale.
|
|
71
85
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
72
|
-
timeZone
|
|
86
|
+
timeZone,
|
|
73
87
|
hour: "2-digit",
|
|
74
88
|
minute: "2-digit",
|
|
75
89
|
hour12: false,
|
|
@@ -174,6 +188,99 @@ function makeSlug(sourcePath, sectionDirectory) {
|
|
|
174
188
|
.split(sep)
|
|
175
189
|
.join("/");
|
|
176
190
|
}
|
|
191
|
+
/** Canonical tag by lower-cased file-name suffix, for the locales a content tree may carry. */
|
|
192
|
+
function localeSuffixes(locales) {
|
|
193
|
+
const suffixes = new Map();
|
|
194
|
+
if (!locales)
|
|
195
|
+
return suffixes;
|
|
196
|
+
for (const tag of [locales.source, ...(locales.targets ?? [])]) {
|
|
197
|
+
suffixes.set(tag.toLowerCase(), tag);
|
|
198
|
+
}
|
|
199
|
+
return suffixes;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Split a configured locale off the last segment of a slug. The suffix is read only when it
|
|
203
|
+
* names a configured locale, so `notes.v2.md` keeps its slug and `notes.fr.md` is French.
|
|
204
|
+
*/
|
|
205
|
+
function splitLocaleSuffix(slug, suffixes) {
|
|
206
|
+
const slash = slug.lastIndexOf("/");
|
|
207
|
+
const name = slug.slice(slash + 1);
|
|
208
|
+
const dot = name.lastIndexOf(".");
|
|
209
|
+
if (dot <= 0)
|
|
210
|
+
return { slug };
|
|
211
|
+
const locale = suffixes.get(name.slice(dot + 1).toLowerCase());
|
|
212
|
+
if (locale === undefined)
|
|
213
|
+
return { slug };
|
|
214
|
+
return { slug: `${slug.slice(0, slash + 1)}${name.slice(0, dot)}`, locale };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The provenance hash of an original: the first 12 hex characters of SHA-256 over its resolved
|
|
218
|
+
* title, a newline, and its body with line endings normalised to LF.
|
|
219
|
+
*/
|
|
220
|
+
export function translationHash(item) {
|
|
221
|
+
return createHash("sha256")
|
|
222
|
+
.update(`${item.title}\n${item.body.replaceAll("\r\n", "\n")}`)
|
|
223
|
+
.digest("hex")
|
|
224
|
+
.slice(0, 12);
|
|
225
|
+
}
|
|
226
|
+
/** The `translation` frontmatter block, when it has the expected shape. */
|
|
227
|
+
export function translationRecord(metadata) {
|
|
228
|
+
const value = metadata.translation;
|
|
229
|
+
if (!isRecord(value))
|
|
230
|
+
return {};
|
|
231
|
+
return {
|
|
232
|
+
source: typeof value.source === "string" ? value.source : undefined,
|
|
233
|
+
reviewed: value.reviewed === true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** A translation inherits the date, tags and category of its original unless it sets them. */
|
|
237
|
+
function inheritFromOriginal(item, original) {
|
|
238
|
+
if (metadataValue(item.metadata, DATE_KEYS) === undefined) {
|
|
239
|
+
item.date = original.date;
|
|
240
|
+
item.dateKey = original.dateKey;
|
|
241
|
+
item.timeLabel = original.timeLabel;
|
|
242
|
+
}
|
|
243
|
+
if (metadataValue(item.metadata, ["tags"]) === undefined)
|
|
244
|
+
item.tags = [...original.tags];
|
|
245
|
+
if (item.metadata.category === undefined && original.metadata.category !== undefined) {
|
|
246
|
+
item.metadata.category = original.metadata.category;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Group items that share a slug into translation sets. With a source-locale member it is the
|
|
251
|
+
* original and the others record provenance against it; without one every member is an original
|
|
252
|
+
* in its own language and none is stale.
|
|
253
|
+
*/
|
|
254
|
+
function pairTranslations(items, source) {
|
|
255
|
+
const sets = new Map();
|
|
256
|
+
for (const item of items) {
|
|
257
|
+
const key = `${item.kind}\u0000${item.slug}`;
|
|
258
|
+
const members = sets.get(key) ?? [];
|
|
259
|
+
members.push(item);
|
|
260
|
+
sets.set(key, members);
|
|
261
|
+
}
|
|
262
|
+
const originals = [];
|
|
263
|
+
for (const members of sets.values()) {
|
|
264
|
+
members.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
265
|
+
const original = members.find((member) => member.locale === source);
|
|
266
|
+
if (!original) {
|
|
267
|
+
originals.push(...members);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
original.translations = members.filter((member) => member !== original);
|
|
271
|
+
for (const translation of original.translations) {
|
|
272
|
+
translation.original = original;
|
|
273
|
+
translation.translatedFrom = translationRecord(translation.metadata).source;
|
|
274
|
+
translation.translationState =
|
|
275
|
+
translation.translatedFrom === translationHash(original) ? "current" : "stale";
|
|
276
|
+
inheritFromOriginal(translation, original);
|
|
277
|
+
}
|
|
278
|
+
originals.push(original);
|
|
279
|
+
}
|
|
280
|
+
return originals.sort((left, right) => {
|
|
281
|
+
return (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
177
284
|
function normalizeSlugPrefix(prefix) {
|
|
178
285
|
const normalized = (prefix ?? "")
|
|
179
286
|
.replaceAll("\\", "/")
|
|
@@ -259,22 +366,26 @@ async function loadSectionFromSource(source, kind, options) {
|
|
|
259
366
|
if (assetRoots.has(sectionDirectory))
|
|
260
367
|
return [];
|
|
261
368
|
const sourceFiles = await collectFiles(sectionDirectory, assetRoots, mapping !== undefined);
|
|
369
|
+
const timeZone = options.timeZone ?? DEFAULT_TIME_ZONE;
|
|
370
|
+
const sourceLocale = options.locales?.source ?? "en";
|
|
371
|
+
const suffixes = localeSuffixes(options.locales);
|
|
262
372
|
const items = [];
|
|
373
|
+
const seen = new Map();
|
|
263
374
|
for (const sourcePath of sourceFiles) {
|
|
264
375
|
if (isDraft(sourcePath, sectionDirectory, kind))
|
|
265
376
|
continue;
|
|
266
377
|
const sourceText = await readFile(sourcePath, "utf8");
|
|
267
378
|
const document = parseFrontmatter(sourceText);
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
379
|
+
const fileSlug = withSlugPrefix(source.slugPrefix, makeSlug(sourcePath, sectionDirectory));
|
|
380
|
+
const { slug, locale: suffixLocale } = splitLocaleSuffix(fileSlug, suffixes);
|
|
381
|
+
const locale = suffixLocale ?? sourceLocale;
|
|
382
|
+
const key = `${locale}\u0000${slug}`;
|
|
383
|
+
const clash = seen.get(key);
|
|
384
|
+
if (clash !== undefined) {
|
|
385
|
+
throw new Error(`Duplicate ${kind} "${slug}" for locale "${locale}": ${clash} and ${sourcePath}`);
|
|
386
|
+
}
|
|
387
|
+
seen.set(key, sourcePath);
|
|
388
|
+
const dateValue = metadataValue(document.metadata, DATE_KEYS);
|
|
278
389
|
const date = parseDate(dateValue) ?? (await stat(sourcePath)).mtime;
|
|
279
390
|
const bodyWithoutLeadingHeading = document.body.replace(/^\s*#\s+.+?(?:\r?\n|$)+/, "");
|
|
280
391
|
const plainText = stripMarkdown(bodyWithoutLeadingHeading);
|
|
@@ -290,11 +401,14 @@ async function loadSectionFromSource(source, kind, options) {
|
|
|
290
401
|
html,
|
|
291
402
|
metadata: document.metadata,
|
|
292
403
|
date,
|
|
293
|
-
dateKey: makeDateKey(dateValue, date),
|
|
294
|
-
timeLabel: makeTimeLabel(dateValue, date),
|
|
404
|
+
dateKey: makeDateKey(dateValue, date, timeZone),
|
|
405
|
+
timeLabel: makeTimeLabel(dateValue, date, timeZone),
|
|
295
406
|
tags: getTags(document.metadata),
|
|
296
407
|
readingMinutes: getReadingMinutes(plainText),
|
|
297
408
|
characterCount: plainText.length,
|
|
409
|
+
locale,
|
|
410
|
+
...(suffixLocale === undefined ? {} : { previousSlug: fileSlug }),
|
|
411
|
+
translations: [],
|
|
298
412
|
});
|
|
299
413
|
}
|
|
300
414
|
return items;
|
|
@@ -305,17 +419,44 @@ export async function loadSection(sources, kind, options = {}) {
|
|
|
305
419
|
for (const source of normalizeContentSources(sources)) {
|
|
306
420
|
const items = await loadSectionFromSource(source, kind, options);
|
|
307
421
|
for (const item of items) {
|
|
308
|
-
|
|
422
|
+
// Pairing happens after the overlay, so a translation and its original are different keys.
|
|
423
|
+
const key = `${item.locale}\u0000${item.slug}`;
|
|
424
|
+
if (merged.has(key)) {
|
|
309
425
|
if (duplicatePolicy === "error")
|
|
310
426
|
throw duplicateContentError(kind, item.slug);
|
|
311
427
|
continue;
|
|
312
428
|
}
|
|
313
|
-
merged.set(
|
|
429
|
+
merged.set(key, item);
|
|
314
430
|
}
|
|
315
431
|
}
|
|
316
|
-
return [...merged.values()]
|
|
317
|
-
|
|
318
|
-
|
|
432
|
+
return pairTranslations([...merged.values()], options.locales?.source ?? "en");
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Every item written in one locale: the originals in that language and the translations of
|
|
436
|
+
* originals written in another one. These are the pages a locale's tree publishes.
|
|
437
|
+
*/
|
|
438
|
+
export function localizedItems(originals, locale) {
|
|
439
|
+
return originals
|
|
440
|
+
.flatMap((original) => [original, ...original.translations])
|
|
441
|
+
.filter((item) => item.locale === locale)
|
|
442
|
+
.sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The entries a locale's tree lists: its own pages, and the original of anything not translated
|
|
446
|
+
* into it — marked with the original's language and linked to where it is published.
|
|
447
|
+
*/
|
|
448
|
+
export function treeItems(originals, locale, options) {
|
|
449
|
+
const items = [];
|
|
450
|
+
for (const original of originals) {
|
|
451
|
+
const translation = original.translations.find((item) => item.locale === locale);
|
|
452
|
+
if (original.locale === locale)
|
|
453
|
+
items.push(original);
|
|
454
|
+
else if (translation)
|
|
455
|
+
items.push(translation);
|
|
456
|
+
else if (options.untranslated === "link")
|
|
457
|
+
items.push(original);
|
|
458
|
+
}
|
|
459
|
+
return items.sort((left, right) => (right.date?.getTime() ?? 0) - (left.date?.getTime() ?? 0));
|
|
319
460
|
}
|
|
320
461
|
export function withoutLeadingHeading(html) {
|
|
321
462
|
return html.replace(/^\s*<h1>.*?<\/h1>\s*/s, "");
|
package/dist/src/identity.d.ts
CHANGED
|
@@ -40,5 +40,17 @@ export interface SiteIdentity {
|
|
|
40
40
|
about?: string;
|
|
41
41
|
};
|
|
42
42
|
about?: SiteAbout;
|
|
43
|
+
/**
|
|
44
|
+
* Public origin of the site, without a path: `https://example.com`. It is validated and stored
|
|
45
|
+
* without a trailing slash; later releases use it for absolute alternate and canonical links.
|
|
46
|
+
*/
|
|
47
|
+
url?: string;
|
|
48
|
+
/**
|
|
49
|
+
* IANA time zone that turns a date with a time into a day key and an `HH:mm` label. Defaults to
|
|
50
|
+
* `Asia/Shanghai`, the value earlier releases hard-coded, so existing day keys do not move.
|
|
51
|
+
*/
|
|
52
|
+
timeZone?: string;
|
|
43
53
|
}
|
|
54
|
+
/** The time zone date keys and time labels use when a site sets none. */
|
|
55
|
+
export declare const DEFAULT_TIME_ZONE = "Asia/Shanghai";
|
|
44
56
|
export declare const DEFAULT_SITE: SiteIdentity;
|
package/dist/src/identity.js
CHANGED
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, normalizeContentSources, type ContentItem, type ContentAsset, type ContentKind, type ContentSource, type ContentSourceInput, type DuplicateContentPolicy, type LoadSectionOptions, } from "./content.js";
|
|
2
|
-
export { buildSite, meowSite, type ResolvedSiteConfig, type SiteConfig } from "./build.js";
|
|
1
|
+
export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, localizedItems, normalizeContentSources, translationHash, treeItems, type ContentItem, type ContentAsset, type ContentKind, type ContentLocales, type ContentSource, type ContentSourceInput, type DuplicateContentPolicy, type LoadSectionOptions, type TranslationRecord, type TranslationState, } from "./content.js";
|
|
2
|
+
export { buildSite, meowSite, type ResolvedLocaleTarget, type ResolvedLocales, type ResolvedSiteConfig, type SiteConfig, type SiteLocales, } from "./build.js";
|
|
3
|
+
export { contentUrl, createPageLocale, localePath, type LocaleEntry, type PageLocale, } from "./locale.js";
|
|
4
|
+
export { themeMessages } from "./messages.js";
|
|
5
|
+
export { contentRoute, formatReport, siteCopyMessages, stampTranslation, translationReport, type ContentWorkItem, type LocaleWork, type ManifestPage, type ManifestSitePage, type TranslationReport, } from "./translations.js";
|
|
3
6
|
export { aboutPage, agentMarkdown, homePage, listPage, llmText, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
|
|
4
7
|
export { DEFAULT_SITE, type SiteAbout, type SiteIdentity, type SiteProfileLink } from "./identity.js";
|
|
5
8
|
export { blogFonts, blogTheme } from "./theme.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, normalizeContentSources, } from "./content.js";
|
|
2
|
-
export { buildSite, meowSite } from "./build.js";
|
|
1
|
+
export { collectContentAssets, collectAssetFiles, isDraftAsset, loadSection, localizedItems, normalizeContentSources, translationHash, treeItems, } from "./content.js";
|
|
2
|
+
export { buildSite, meowSite, } from "./build.js";
|
|
3
|
+
export { contentUrl, createPageLocale, localePath, } from "./locale.js";
|
|
4
|
+
export { themeMessages } from "./messages.js";
|
|
5
|
+
export { contentRoute, formatReport, siteCopyMessages, stampTranslation, translationReport, } from "./translations.js";
|
|
3
6
|
export { aboutPage, agentMarkdown, homePage, listPage, llmText, postPage, skillPage, skillsIndexPage, thoughtPage, } from "./site.js";
|
|
4
7
|
export { DEFAULT_SITE } from "./identity.js";
|
|
5
8
|
export { blogFonts, blogTheme } from "./theme.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Child } from "meowjsx";
|
|
2
|
+
import { type Catalog, type CreateI18nOptions, type I18n, type MessageDescriptor, type MessageParams, type MissingMessage, type RichPart } from "meowjsx/i18n";
|
|
3
|
+
/**
|
|
4
|
+
* An instance for the built-in theme: the same as `createI18n`, except that `t` and `t.rich`
|
|
5
|
+
* refuse any source that is not declared in `themeMessages`. The theme and any component it
|
|
6
|
+
* renders through `I18nProvider` therefore cannot carry a sentence the work list does not know.
|
|
7
|
+
* `lookup` is not checked: it translates site-owned copy that no table can list.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createThemeI18n(options: CreateI18nOptions): I18n;
|
|
10
|
+
/** A locale the site publishes, as a page sees it for the language switch and alternate links. */
|
|
11
|
+
export interface LocaleEntry {
|
|
12
|
+
/** Canonical tag, written to `lang` and `hreflang`. */
|
|
13
|
+
tag: string;
|
|
14
|
+
/** URL segment, "" for the source tree. */
|
|
15
|
+
segment: string;
|
|
16
|
+
/** The language's own name, shown by the language switch. */
|
|
17
|
+
label: string;
|
|
18
|
+
isSource: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What a page needs to know about the locale it is rendered for. Page functions receive it
|
|
22
|
+
* explicitly because they are plain functions: the ambient `t` works only in component bodies.
|
|
23
|
+
*/
|
|
24
|
+
export interface PageLocale {
|
|
25
|
+
/** Canonical tag, written to `<html lang>`. */
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
readonly direction: "ltr" | "rtl";
|
|
28
|
+
/** URL segment of a target locale (the lower-cased tag), or "" for the source locale. */
|
|
29
|
+
readonly segment: string;
|
|
30
|
+
/** The language site-owned copy and content are written in. */
|
|
31
|
+
readonly source: string;
|
|
32
|
+
/** The tree that keeps the unprefixed routes. */
|
|
33
|
+
readonly isSource: boolean;
|
|
34
|
+
/** Every locale the site publishes, the source first; one entry on a single-locale site. */
|
|
35
|
+
readonly entries: readonly LocaleEntry[];
|
|
36
|
+
/** Whether an unreviewed translation says so on the page. */
|
|
37
|
+
readonly disclosure: boolean;
|
|
38
|
+
/** Theme sentences. Its source language is always English. */
|
|
39
|
+
readonly i18n: I18n;
|
|
40
|
+
/** Site-owned copy (name, description, About, 404) in this locale, written in `source`. */
|
|
41
|
+
copy(text: string): string;
|
|
42
|
+
}
|
|
43
|
+
export interface PageLocaleOptions {
|
|
44
|
+
tag: string;
|
|
45
|
+
source: string;
|
|
46
|
+
segment: string;
|
|
47
|
+
catalogs: Readonly<Record<string, Catalog>>;
|
|
48
|
+
timeZone?: string;
|
|
49
|
+
onMissing?: (miss: MissingMessage) => void;
|
|
50
|
+
entries?: readonly LocaleEntry[];
|
|
51
|
+
disclosure?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare function createPageLocale(options: PageLocaleOptions): PageLocale;
|
|
54
|
+
/** The locale every page function uses when the caller passes none. */
|
|
55
|
+
export declare function defaultPageLocale(): PageLocale;
|
|
56
|
+
/** The URL of an internal route for a locale. Every internal link of the theme goes through
|
|
57
|
+
* it. A route that exists only in the source tree (skills) keeps its source URL. */
|
|
58
|
+
export declare function localePath(locale: PageLocale, path: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* The URL of a content item, in the tree of the language it is written in. An original in the
|
|
61
|
+
* source locale keeps the unprefixed route; anything else lives under its locale's segment.
|
|
62
|
+
*/
|
|
63
|
+
export declare function contentUrl(locale: PageLocale, item: {
|
|
64
|
+
kind: string;
|
|
65
|
+
slug: string;
|
|
66
|
+
locale: string;
|
|
67
|
+
}): string;
|
|
68
|
+
/** The text of a message that carries named tags, with the parts supplied by the page. */
|
|
69
|
+
export declare function richMessage(locale: PageLocale, message: MessageDescriptor, parts: {
|
|
70
|
+
readonly [name: string]: RichPart;
|
|
71
|
+
}, params?: MessageParams): Child;
|