scribe-cms 0.0.1
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 +150 -0
- package/dist/cli/index.cjs +2303 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2289 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +2268 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2238 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.cjs +1216 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.js +1207 -0
- package/dist/runtime.js.map +1 -0
- package/dist/src/config/load-config.d.ts +10 -0
- package/dist/src/config/load-config.d.ts.map +1 -0
- package/dist/src/config/load-env.d.ts +3 -0
- package/dist/src/config/load-env.d.ts.map +1 -0
- package/dist/src/config/resolve-config.d.ts +13 -0
- package/dist/src/config/resolve-config.d.ts.map +1 -0
- package/dist/src/core/alias-helpers.d.ts +7 -0
- package/dist/src/core/alias-helpers.d.ts.map +1 -0
- package/dist/src/core/builtin-fields.d.ts +38 -0
- package/dist/src/core/builtin-fields.d.ts.map +1 -0
- package/dist/src/core/field.d.ts +60 -0
- package/dist/src/core/field.d.ts.map +1 -0
- package/dist/src/core/introspect-schema.d.ts +20 -0
- package/dist/src/core/introspect-schema.d.ts.map +1 -0
- package/dist/src/core/slug-aliases.d.ts +27 -0
- package/dist/src/core/slug-aliases.d.ts.map +1 -0
- package/dist/src/core/types.d.ts +261 -0
- package/dist/src/core/types.d.ts.map +1 -0
- package/dist/src/create-project.d.ts +10 -0
- package/dist/src/create-project.d.ts.map +1 -0
- package/dist/src/create-scribe.d.ts +11 -0
- package/dist/src/create-scribe.d.ts.map +1 -0
- package/dist/src/hash/page-hash.d.ts +7 -0
- package/dist/src/hash/page-hash.d.ts.map +1 -0
- package/dist/src/history/record-revision.d.ts +12 -0
- package/dist/src/history/record-revision.d.ts.map +1 -0
- package/dist/src/i18n/build-url.d.ts +14 -0
- package/dist/src/i18n/build-url.d.ts.map +1 -0
- package/dist/src/i18n/resolve-document.d.ts +12 -0
- package/dist/src/i18n/resolve-document.d.ts.map +1 -0
- package/dist/src/i18n/translation-index.d.ts +7 -0
- package/dist/src/i18n/translation-index.d.ts.map +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/loader/create-loader.d.ts +17 -0
- package/dist/src/loader/create-loader.d.ts.map +1 -0
- package/dist/src/loader/normalize-en.d.ts +3 -0
- package/dist/src/loader/normalize-en.d.ts.map +1 -0
- package/dist/src/loader/parse-mdx.d.ts +7 -0
- package/dist/src/loader/parse-mdx.d.ts.map +1 -0
- package/dist/src/redirects/build-redirects.d.ts +17 -0
- package/dist/src/redirects/build-redirects.d.ts.map +1 -0
- package/dist/src/redirects/translation-index.d.ts +4 -0
- package/dist/src/redirects/translation-index.d.ts.map +1 -0
- package/dist/src/redirects/types.d.ts +6 -0
- package/dist/src/redirects/types.d.ts.map +1 -0
- package/dist/src/runtime.d.ts +5 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/sitemap/generate-sitemap.d.ts +5 -0
- package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -0
- package/dist/src/sitemap/join-base-url.d.ts +3 -0
- package/dist/src/sitemap/join-base-url.d.ts.map +1 -0
- package/dist/src/sitemap/types.d.ts +39 -0
- package/dist/src/sitemap/types.d.ts.map +1 -0
- package/dist/src/storage/sqlite.d.ts +9 -0
- package/dist/src/storage/sqlite.d.ts.map +1 -0
- package/dist/src/storage/translations.d.ts +58 -0
- package/dist/src/storage/translations.d.ts.map +1 -0
- package/dist/src/translate/gemini-client.d.ts +15 -0
- package/dist/src/translate/gemini-client.d.ts.map +1 -0
- package/dist/src/translate/page-translator.d.ts +24 -0
- package/dist/src/translate/page-translator.d.ts.map +1 -0
- package/dist/src/translate/prompts/translation-prompt.d.ts +14 -0
- package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -0
- package/dist/src/translate/resolve-translate-config.d.ts +10 -0
- package/dist/src/translate/resolve-translate-config.d.ts.map +1 -0
- package/dist/src/translate/worklist.d.ts +19 -0
- package/dist/src/translate/worklist.d.ts.map +1 -0
- package/dist/src/validate/validate-project.d.ts +16 -0
- package/dist/src/validate/validate-project.d.ts.map +1 -0
- package/dist/src/validate/validate-relations.d.ts +19 -0
- package/dist/src/validate/validate-relations.d.ts.map +1 -0
- package/dist/src/validate/validate-slug-suffix.d.ts +12 -0
- package/dist/src/validate/validate-slug-suffix.d.ts.map +1 -0
- package/dist/studio/server.cjs +673 -0
- package/dist/studio/server.cjs.map +1 -0
- package/dist/studio/server.d.ts +7 -0
- package/dist/studio/server.d.ts.map +1 -0
- package/dist/studio/server.js +664 -0
- package/dist/studio/server.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,2303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var fs2 = require('fs');
|
|
5
|
+
var path3 = require('path');
|
|
6
|
+
var Database = require('better-sqlite3');
|
|
7
|
+
var zod = require('zod');
|
|
8
|
+
var matter = require('gray-matter');
|
|
9
|
+
var jiti = require('jiti');
|
|
10
|
+
var crypto = require('crypto');
|
|
11
|
+
var genai = require('@google/genai');
|
|
12
|
+
var dotenv = require('dotenv');
|
|
13
|
+
var nodeServer = require('@hono/node-server');
|
|
14
|
+
var hono = require('hono');
|
|
15
|
+
|
|
16
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
17
|
+
|
|
18
|
+
var fs2__default = /*#__PURE__*/_interopDefault(fs2);
|
|
19
|
+
var path3__default = /*#__PURE__*/_interopDefault(path3);
|
|
20
|
+
var Database__default = /*#__PURE__*/_interopDefault(Database);
|
|
21
|
+
var matter__default = /*#__PURE__*/_interopDefault(matter);
|
|
22
|
+
var dotenv__default = /*#__PURE__*/_interopDefault(dotenv);
|
|
23
|
+
|
|
24
|
+
var __defProp = Object.defineProperty;
|
|
25
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
26
|
+
var __esm = (fn, res) => function __init() {
|
|
27
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
28
|
+
};
|
|
29
|
+
var __export = (target, all) => {
|
|
30
|
+
for (var name in all)
|
|
31
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ../../node_modules/tsup/assets/cjs_shims.js
|
|
35
|
+
var getImportMetaUrl, importMetaUrl;
|
|
36
|
+
var init_cjs_shims = __esm({
|
|
37
|
+
"../../node_modules/tsup/assets/cjs_shims.js"() {
|
|
38
|
+
getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
39
|
+
importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// src/storage/sqlite.ts
|
|
44
|
+
var sqlite_exports = {};
|
|
45
|
+
__export(sqlite_exports, {
|
|
46
|
+
closeStore: () => closeStore,
|
|
47
|
+
openStore: () => openStore,
|
|
48
|
+
resolveStorePath: () => resolveStorePath
|
|
49
|
+
});
|
|
50
|
+
function resolveStorePath(config) {
|
|
51
|
+
return config.storePath;
|
|
52
|
+
}
|
|
53
|
+
function openStore(config, mode = "readwrite") {
|
|
54
|
+
const storePath = resolveStorePath(config);
|
|
55
|
+
fs2__default.default.mkdirSync(path3__default.default.dirname(storePath), { recursive: true });
|
|
56
|
+
const db = new Database__default.default(storePath, { readonly: mode === "readonly" });
|
|
57
|
+
if (mode === "readwrite") {
|
|
58
|
+
migrate(db);
|
|
59
|
+
}
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
function migrate(db) {
|
|
63
|
+
for (const sql of MIGRATIONS) {
|
|
64
|
+
db.exec(sql);
|
|
65
|
+
}
|
|
66
|
+
db.prepare(
|
|
67
|
+
`INSERT INTO meta(key, value) VALUES('schema_version', ?)
|
|
68
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
69
|
+
).run(String(SCHEMA_VERSION));
|
|
70
|
+
}
|
|
71
|
+
function closeStore(db) {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
74
|
+
var SCHEMA_VERSION, MIGRATIONS;
|
|
75
|
+
var init_sqlite = __esm({
|
|
76
|
+
"src/storage/sqlite.ts"() {
|
|
77
|
+
init_cjs_shims();
|
|
78
|
+
SCHEMA_VERSION = 2;
|
|
79
|
+
MIGRATIONS = [
|
|
80
|
+
`CREATE TABLE IF NOT EXISTS meta (
|
|
81
|
+
key TEXT PRIMARY KEY,
|
|
82
|
+
value TEXT NOT NULL
|
|
83
|
+
)`,
|
|
84
|
+
`CREATE TABLE IF NOT EXISTS translations (
|
|
85
|
+
content_type TEXT NOT NULL,
|
|
86
|
+
en_slug TEXT NOT NULL,
|
|
87
|
+
locale TEXT NOT NULL,
|
|
88
|
+
slug TEXT NOT NULL,
|
|
89
|
+
frontmatter_json TEXT NOT NULL,
|
|
90
|
+
body TEXT NOT NULL,
|
|
91
|
+
en_hash TEXT NOT NULL,
|
|
92
|
+
translated_at TEXT NOT NULL,
|
|
93
|
+
model TEXT NOT NULL,
|
|
94
|
+
PRIMARY KEY (content_type, en_slug, locale)
|
|
95
|
+
)`,
|
|
96
|
+
`CREATE INDEX IF NOT EXISTS idx_translations_type_locale
|
|
97
|
+
ON translations(content_type, locale)`,
|
|
98
|
+
`CREATE TABLE IF NOT EXISTS revisions (
|
|
99
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
content_type TEXT NOT NULL,
|
|
101
|
+
en_slug TEXT NOT NULL,
|
|
102
|
+
locale TEXT,
|
|
103
|
+
revision_kind TEXT NOT NULL,
|
|
104
|
+
en_hash TEXT NOT NULL,
|
|
105
|
+
body_hash TEXT NOT NULL,
|
|
106
|
+
created_at TEXT NOT NULL,
|
|
107
|
+
model TEXT,
|
|
108
|
+
body_preview TEXT
|
|
109
|
+
)`,
|
|
110
|
+
`CREATE INDEX IF NOT EXISTS idx_revisions_lookup
|
|
111
|
+
ON revisions(content_type, en_slug, locale, created_at DESC)`,
|
|
112
|
+
`CREATE TABLE IF NOT EXISTS slug_aliases (
|
|
113
|
+
content_type TEXT NOT NULL,
|
|
114
|
+
canonical_en_slug TEXT NOT NULL,
|
|
115
|
+
alias_en_slug TEXT NOT NULL,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
PRIMARY KEY (content_type, alias_en_slug)
|
|
118
|
+
)`,
|
|
119
|
+
`CREATE INDEX IF NOT EXISTS idx_slug_aliases_canonical
|
|
120
|
+
ON slug_aliases(content_type, canonical_en_slug)`,
|
|
121
|
+
`CREATE TABLE IF NOT EXISTS alias_locale_slugs (
|
|
122
|
+
content_type TEXT NOT NULL,
|
|
123
|
+
alias_en_slug TEXT NOT NULL,
|
|
124
|
+
locale TEXT NOT NULL,
|
|
125
|
+
locale_slug TEXT NOT NULL,
|
|
126
|
+
captured_at TEXT NOT NULL,
|
|
127
|
+
PRIMARY KEY (content_type, alias_en_slug, locale)
|
|
128
|
+
)`
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// cli/index.ts
|
|
134
|
+
init_cjs_shims();
|
|
135
|
+
|
|
136
|
+
// src/index.ts
|
|
137
|
+
init_cjs_shims();
|
|
138
|
+
|
|
139
|
+
// src/core/field.ts
|
|
140
|
+
init_cjs_shims();
|
|
141
|
+
var FIELD_KIND = /* @__PURE__ */ Symbol.for("@genlook/scribe/fieldKind");
|
|
142
|
+
var RELATION_META = /* @__PURE__ */ Symbol.for("@genlook/scribe/relationMeta");
|
|
143
|
+
function getFieldKind(schema) {
|
|
144
|
+
const tagged = schema;
|
|
145
|
+
return tagged[FIELD_KIND] ?? "structural";
|
|
146
|
+
}
|
|
147
|
+
function getRelationTarget(schema) {
|
|
148
|
+
let current = schema;
|
|
149
|
+
for (let i = 0; i < 8; i++) {
|
|
150
|
+
const tagged = current;
|
|
151
|
+
if (tagged[RELATION_META]) {
|
|
152
|
+
return tagged[RELATION_META];
|
|
153
|
+
}
|
|
154
|
+
if (typeof tagged.unwrap === "function") {
|
|
155
|
+
current = tagged.unwrap();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (typeof tagged.removeDefault === "function") {
|
|
159
|
+
current = tagged.removeDefault();
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (tagged._def?.innerType) {
|
|
163
|
+
current = tagged._def.innerType;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function unwrapSchema(schema) {
|
|
171
|
+
let current = schema;
|
|
172
|
+
for (let i = 0; i < 8; i++) {
|
|
173
|
+
const anySchema = current;
|
|
174
|
+
if (typeof anySchema.unwrap === "function") {
|
|
175
|
+
current = anySchema.unwrap();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (typeof anySchema.removeDefault === "function") {
|
|
179
|
+
current = anySchema.removeDefault();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (anySchema._def?.innerType) {
|
|
183
|
+
current = anySchema._def.innerType;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/core/types.ts
|
|
192
|
+
init_cjs_shims();
|
|
193
|
+
|
|
194
|
+
// src/create-scribe.ts
|
|
195
|
+
init_cjs_shims();
|
|
196
|
+
|
|
197
|
+
// src/config/resolve-config.ts
|
|
198
|
+
init_cjs_shims();
|
|
199
|
+
|
|
200
|
+
// src/i18n/build-url.ts
|
|
201
|
+
init_cjs_shims();
|
|
202
|
+
var SLUG_PLACEHOLDER = "{slug}";
|
|
203
|
+
function isRoutableType(type) {
|
|
204
|
+
return typeof type.path === "string" && type.path.length > 0;
|
|
205
|
+
}
|
|
206
|
+
function assertValidPathTemplate(path10, typeId) {
|
|
207
|
+
if (!path10.startsWith("/")) {
|
|
208
|
+
throw new Error(`Content type "${typeId}": path must start with / (got "${path10}")`);
|
|
209
|
+
}
|
|
210
|
+
const count = (path10.match(/\{slug\}/g) ?? []).length;
|
|
211
|
+
if (count !== 1) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Content type "${typeId}": path must contain exactly one {slug} (got "${path10}")`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function pathPrefix(template) {
|
|
218
|
+
const idx = template.indexOf(SLUG_PLACEHOLDER);
|
|
219
|
+
if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
|
|
220
|
+
return template.slice(0, idx);
|
|
221
|
+
}
|
|
222
|
+
function pathSuffix(template) {
|
|
223
|
+
const idx = template.indexOf(SLUG_PLACEHOLDER);
|
|
224
|
+
if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
|
|
225
|
+
return template.slice(idx + SLUG_PLACEHOLDER.length);
|
|
226
|
+
}
|
|
227
|
+
function resolvePath(template, slug, locale, defaultLocale) {
|
|
228
|
+
const relative = template.replace(SLUG_PLACEHOLDER, slug);
|
|
229
|
+
if (locale === defaultLocale) return relative;
|
|
230
|
+
return `/${locale}${relative}`;
|
|
231
|
+
}
|
|
232
|
+
function extractSlugFromResolvedPath(template, resolvedPath) {
|
|
233
|
+
const prefix = pathPrefix(template);
|
|
234
|
+
const suffix = pathSuffix(template);
|
|
235
|
+
if (!resolvedPath.startsWith(prefix)) return null;
|
|
236
|
+
if (suffix && !resolvedPath.endsWith(suffix)) return null;
|
|
237
|
+
const slugEnd = suffix ? resolvedPath.length - suffix.length : resolvedPath.length;
|
|
238
|
+
const slug = resolvedPath.slice(prefix.length, slugEnd);
|
|
239
|
+
return slug.length > 0 ? slug : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/config/resolve-config.ts
|
|
243
|
+
var RESOLVED = /* @__PURE__ */ Symbol.for("@genlook/scribe/resolvedConfig");
|
|
244
|
+
function isResolvedConfig(config) {
|
|
245
|
+
return typeof config === "object" && config !== null && config[RESOLVED] === true;
|
|
246
|
+
}
|
|
247
|
+
function resolveConfig(input, baseDir) {
|
|
248
|
+
if (isResolvedConfig(input)) return input;
|
|
249
|
+
const raw = input;
|
|
250
|
+
if (!raw.rootDir) {
|
|
251
|
+
throw new Error("scribe config: rootDir is required");
|
|
252
|
+
}
|
|
253
|
+
if (!raw.locales || raw.locales.length === 0) {
|
|
254
|
+
throw new Error("scribe config: locales must be a non-empty array");
|
|
255
|
+
}
|
|
256
|
+
const defaultLocale = raw.defaultLocale ?? "en";
|
|
257
|
+
if (!raw.locales.includes(defaultLocale)) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const projectRoot = path3__default.default.resolve(baseDir ?? process.cwd(), raw.rootDir);
|
|
263
|
+
const contentRoot = path3__default.default.resolve(projectRoot, raw.contentDir ?? "content");
|
|
264
|
+
const storePath = path3__default.default.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
|
|
265
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
266
|
+
const types = raw.types.map((type) => {
|
|
267
|
+
if (seenIds.has(type.id)) {
|
|
268
|
+
throw new Error(`scribe config: duplicate content type id "${type.id}"`);
|
|
269
|
+
}
|
|
270
|
+
seenIds.add(type.id);
|
|
271
|
+
if (type.path) {
|
|
272
|
+
assertValidPathTemplate(type.path, type.id);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
...type,
|
|
276
|
+
contentDir: type.contentDir ?? type.id,
|
|
277
|
+
label: type.label ?? type.id.charAt(0).toUpperCase() + type.id.slice(1),
|
|
278
|
+
slugStrategy: type.slugStrategy ?? "fixed",
|
|
279
|
+
indexFallback: type.indexFallback ?? (type.path ? "en" : "none")
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
const config = {
|
|
283
|
+
rootDir: contentRoot,
|
|
284
|
+
storePath,
|
|
285
|
+
locales: [...raw.locales],
|
|
286
|
+
defaultLocale,
|
|
287
|
+
localePresets: raw.localePresets,
|
|
288
|
+
translate: raw.translate,
|
|
289
|
+
types
|
|
290
|
+
};
|
|
291
|
+
Object.defineProperty(config, RESOLVED, {
|
|
292
|
+
value: true,
|
|
293
|
+
enumerable: false
|
|
294
|
+
});
|
|
295
|
+
return config;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/create-project.ts
|
|
299
|
+
init_cjs_shims();
|
|
300
|
+
|
|
301
|
+
// src/core/introspect-schema.ts
|
|
302
|
+
init_cjs_shims();
|
|
303
|
+
function introspectSchema(schema, prefix = []) {
|
|
304
|
+
const relation = getRelationTarget(schema);
|
|
305
|
+
if (relation && prefix.length > 0) {
|
|
306
|
+
return [
|
|
307
|
+
{
|
|
308
|
+
path: prefix,
|
|
309
|
+
kind: "relation",
|
|
310
|
+
relationTarget: relation.typeId,
|
|
311
|
+
relationMultiple: relation.multiple,
|
|
312
|
+
relationOptional: relation.optional
|
|
313
|
+
}
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
const base = unwrapSchema(schema);
|
|
317
|
+
if (base instanceof Object && "shape" in base) {
|
|
318
|
+
const shape = base.shape;
|
|
319
|
+
const fields = [];
|
|
320
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
321
|
+
fields.push(...introspectSchema(child, [...prefix, key]));
|
|
322
|
+
}
|
|
323
|
+
return fields;
|
|
324
|
+
}
|
|
325
|
+
if (base instanceof Object && "element" in base) {
|
|
326
|
+
const element = base.element;
|
|
327
|
+
const elementBase = unwrapSchema(element);
|
|
328
|
+
if (elementBase instanceof Object && "shape" in elementBase) {
|
|
329
|
+
const shape = elementBase.shape;
|
|
330
|
+
const fields = [];
|
|
331
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
332
|
+
fields.push(...introspectSchema(child, [...prefix, "*", key]));
|
|
333
|
+
}
|
|
334
|
+
return fields;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return [{ path: prefix, kind: getFieldKind(schema) }];
|
|
338
|
+
}
|
|
339
|
+
function extractByPaths(data, paths) {
|
|
340
|
+
const out = {};
|
|
341
|
+
for (const path10 of paths) {
|
|
342
|
+
const value = getAtPath(data, path10);
|
|
343
|
+
if (value !== void 0) {
|
|
344
|
+
setAtPath(out, path10.filter((p) => p !== "*"), value);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
function pickTranslatable(data, schema) {
|
|
350
|
+
const translatablePaths = introspectSchema(schema).filter((f) => f.kind === "translatable").map((f) => f.path);
|
|
351
|
+
return extractByPaths(data, translatablePaths);
|
|
352
|
+
}
|
|
353
|
+
function pickStructural(data, schema) {
|
|
354
|
+
const structuralPaths = introspectSchema(schema).filter((f) => f.kind === "structural" || f.kind === "relation").map((f) => f.path);
|
|
355
|
+
return extractByPaths(data, structuralPaths);
|
|
356
|
+
}
|
|
357
|
+
function mergeStructuralOntoLocale(localeData, enData, schema) {
|
|
358
|
+
const structural = pickStructural(enData, schema);
|
|
359
|
+
const translatable = pickTranslatable(localeData, schema);
|
|
360
|
+
return deepMerge(structural, translatable);
|
|
361
|
+
}
|
|
362
|
+
function getAtPath(obj, path10) {
|
|
363
|
+
let current = obj;
|
|
364
|
+
for (const segment of path10) {
|
|
365
|
+
if (segment === "*") {
|
|
366
|
+
if (!Array.isArray(current)) return void 0;
|
|
367
|
+
return current.map(
|
|
368
|
+
(item) => typeof item === "object" && item !== null ? getAtPath(item, path10.slice(path10.indexOf("*") + 1)) : void 0
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
372
|
+
current = current[segment];
|
|
373
|
+
}
|
|
374
|
+
return current;
|
|
375
|
+
}
|
|
376
|
+
function setAtPath(obj, path10, value) {
|
|
377
|
+
if (path10.length === 0) return;
|
|
378
|
+
if (path10.length === 1) {
|
|
379
|
+
obj[path10[0]] = value;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const [head, ...rest] = path10;
|
|
383
|
+
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
384
|
+
obj[head] = {};
|
|
385
|
+
}
|
|
386
|
+
setAtPath(obj[head], rest, value);
|
|
387
|
+
}
|
|
388
|
+
function deepMerge(base, overlay) {
|
|
389
|
+
const out = { ...base };
|
|
390
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
391
|
+
if (value && typeof value === "object" && !Array.isArray(value) && out[key] && typeof out[key] === "object" && !Array.isArray(out[key])) {
|
|
392
|
+
out[key] = deepMerge(out[key], value);
|
|
393
|
+
} else if (Array.isArray(value) && Array.isArray(out[key])) {
|
|
394
|
+
out[key] = mergeArrayOverlay(out[key], value);
|
|
395
|
+
} else {
|
|
396
|
+
out[key] = value;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
function mergeArrayOverlay(base, overlay) {
|
|
402
|
+
return base.map((item, index) => {
|
|
403
|
+
const overlayItem = overlay[index];
|
|
404
|
+
if (item && typeof item === "object" && !Array.isArray(item) && overlayItem && typeof overlayItem === "object" && !Array.isArray(overlayItem)) {
|
|
405
|
+
return deepMerge(item, overlayItem);
|
|
406
|
+
}
|
|
407
|
+
return overlayItem ?? item;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function listRelationFields(schema) {
|
|
411
|
+
return introspectSchema(schema).filter((f) => f.kind === "relation");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/loader/create-loader.ts
|
|
415
|
+
init_cjs_shims();
|
|
416
|
+
|
|
417
|
+
// src/core/builtin-fields.ts
|
|
418
|
+
init_cjs_shims();
|
|
419
|
+
var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
420
|
+
var slugPatternSchema = zod.z.string().regex(SLUG_PATTERN, "slug must be lowercase-kebab-case");
|
|
421
|
+
var isoDateSchema = zod.z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/, "Use ISO date YYYY-MM-DD or full ISO 8601");
|
|
422
|
+
var canonicalPathSchema = zod.z.string().regex(/^\//, "canonicalPath must start with /");
|
|
423
|
+
var LOCALE_BUILTIN_KEYS = [
|
|
424
|
+
["publishedAt", "publishedAt is EN-only; inherited from the EN parent at load time"],
|
|
425
|
+
["updatedAt", "updatedAt is EN-only; inherited from the EN parent at load time"],
|
|
426
|
+
["noindex", "noindex is EN-only; inherited from the EN parent at load time"],
|
|
427
|
+
["canonicalPath", "canonicalPath is EN-only; inherited from the EN parent at load time"],
|
|
428
|
+
["aliases", "aliases is EN-only; remove from locale translation"],
|
|
429
|
+
["redirect_to", "redirect_to is EN-only; remove from locale translation"],
|
|
430
|
+
["translationOf", "translationOf is internal; remove from locale translation"],
|
|
431
|
+
["enSlug", "enSlug is internal; remove from locale translation"]
|
|
432
|
+
];
|
|
433
|
+
function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
|
|
434
|
+
const issues = [];
|
|
435
|
+
const rest = { ...data };
|
|
436
|
+
const aliasesResult = zod.z.array(slugPatternSchema).max(20).optional().default([]).safeParse(rest.aliases ?? []);
|
|
437
|
+
delete rest.aliases;
|
|
438
|
+
const redirectRaw = rest.redirect_to;
|
|
439
|
+
delete rest.redirect_to;
|
|
440
|
+
let redirectTo;
|
|
441
|
+
if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
|
|
442
|
+
if (typeof redirectRaw !== "string") {
|
|
443
|
+
issues.push({
|
|
444
|
+
field: "redirect_to",
|
|
445
|
+
message: "redirect_to must be a string path",
|
|
446
|
+
level: "error"
|
|
447
|
+
});
|
|
448
|
+
} else if (!pathTemplate) {
|
|
449
|
+
issues.push({
|
|
450
|
+
field: "redirect_to",
|
|
451
|
+
message: "redirect_to is not allowed on reference-only content types",
|
|
452
|
+
level: "error"
|
|
453
|
+
});
|
|
454
|
+
} else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
|
|
455
|
+
issues.push({
|
|
456
|
+
field: "redirect_to",
|
|
457
|
+
message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
|
|
458
|
+
level: "error"
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
redirectTo = redirectRaw;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const aliases = aliasesResult.success ? aliasesResult.data : [];
|
|
465
|
+
if (!aliasesResult.success) {
|
|
466
|
+
for (const issue of aliasesResult.error.issues) {
|
|
467
|
+
issues.push({
|
|
468
|
+
field: `aliases.${issue.path.join(".")}`,
|
|
469
|
+
message: issue.message,
|
|
470
|
+
level: "error"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
let publishedAt;
|
|
475
|
+
if (rest.publishedAt !== void 0 && rest.publishedAt !== null && rest.publishedAt !== "") {
|
|
476
|
+
const parsed = isoDateSchema.safeParse(rest.publishedAt);
|
|
477
|
+
if (parsed.success) {
|
|
478
|
+
publishedAt = parsed.data;
|
|
479
|
+
} else {
|
|
480
|
+
for (const issue of parsed.error.issues) {
|
|
481
|
+
issues.push({ field: "publishedAt", message: issue.message, level: "error" });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
delete rest.publishedAt;
|
|
485
|
+
}
|
|
486
|
+
let updatedAt;
|
|
487
|
+
if (rest.updatedAt !== void 0 && rest.updatedAt !== null && rest.updatedAt !== "") {
|
|
488
|
+
const parsed = isoDateSchema.safeParse(rest.updatedAt);
|
|
489
|
+
if (parsed.success) {
|
|
490
|
+
updatedAt = parsed.data;
|
|
491
|
+
} else {
|
|
492
|
+
for (const issue of parsed.error.issues) {
|
|
493
|
+
issues.push({ field: "updatedAt", message: issue.message, level: "error" });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
delete rest.updatedAt;
|
|
497
|
+
} else if (publishedAt) {
|
|
498
|
+
updatedAt = publishedAt;
|
|
499
|
+
}
|
|
500
|
+
let noindex = false;
|
|
501
|
+
if (rest.noindex !== void 0 && rest.noindex !== null && rest.noindex !== "") {
|
|
502
|
+
if (typeof rest.noindex === "boolean") {
|
|
503
|
+
noindex = rest.noindex;
|
|
504
|
+
} else {
|
|
505
|
+
issues.push({ field: "noindex", message: "noindex must be a boolean", level: "error" });
|
|
506
|
+
}
|
|
507
|
+
delete rest.noindex;
|
|
508
|
+
}
|
|
509
|
+
let canonicalPathOverride;
|
|
510
|
+
if (rest.canonicalPath !== void 0 && rest.canonicalPath !== null && rest.canonicalPath !== "") {
|
|
511
|
+
const parsed = canonicalPathSchema.safeParse(rest.canonicalPath);
|
|
512
|
+
if (parsed.success) {
|
|
513
|
+
canonicalPathOverride = parsed.data;
|
|
514
|
+
} else {
|
|
515
|
+
for (const issue of parsed.error.issues) {
|
|
516
|
+
issues.push({ field: "canonicalPath", message: issue.message, level: "error" });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
delete rest.canonicalPath;
|
|
520
|
+
}
|
|
521
|
+
for (const internalKey of ["translationOf", "enSlug"]) {
|
|
522
|
+
if (rest[internalKey] !== void 0) {
|
|
523
|
+
issues.push({
|
|
524
|
+
field: internalKey,
|
|
525
|
+
message: `${internalKey} is managed internally by Scribe; remove from frontmatter`,
|
|
526
|
+
level: "warning"
|
|
527
|
+
});
|
|
528
|
+
delete rest[internalKey];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
builtin: {
|
|
533
|
+
aliases,
|
|
534
|
+
redirectTo,
|
|
535
|
+
publishedAt,
|
|
536
|
+
updatedAt,
|
|
537
|
+
noindex,
|
|
538
|
+
canonicalPathOverride
|
|
539
|
+
},
|
|
540
|
+
rest,
|
|
541
|
+
issues
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function validateLocaleBuiltinFields(data) {
|
|
545
|
+
const issues = [];
|
|
546
|
+
for (const [key, message] of LOCALE_BUILTIN_KEYS) {
|
|
547
|
+
if (data[key] !== void 0) {
|
|
548
|
+
issues.push({ field: key, message, level: "error" });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return issues;
|
|
552
|
+
}
|
|
553
|
+
function resolveCanonicalPathname(type, doc, defaultLocale) {
|
|
554
|
+
if (doc.canonicalPathOverride) {
|
|
555
|
+
if (doc.locale === defaultLocale) return doc.canonicalPathOverride;
|
|
556
|
+
return `/${doc.locale}${doc.canonicalPathOverride}`;
|
|
557
|
+
}
|
|
558
|
+
if (!type.path) return `/${doc.slug}`;
|
|
559
|
+
return resolvePath(type.path, doc.slug, doc.locale, defaultLocale);
|
|
560
|
+
}
|
|
561
|
+
function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
|
|
562
|
+
const out = { ...frontmatter };
|
|
563
|
+
if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
|
|
564
|
+
if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
|
|
565
|
+
out.noindex = doc.noindex;
|
|
566
|
+
out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
function seoFieldsFromEn(enDoc) {
|
|
570
|
+
return {
|
|
571
|
+
publishedAt: enDoc.publishedAt,
|
|
572
|
+
updatedAt: enDoc.updatedAt,
|
|
573
|
+
noindex: enDoc.noindex,
|
|
574
|
+
canonicalPathOverride: enDoc.canonicalPathOverride,
|
|
575
|
+
redirectTo: enDoc.redirectTo
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/loader/create-loader.ts
|
|
580
|
+
init_sqlite();
|
|
581
|
+
|
|
582
|
+
// src/storage/translations.ts
|
|
583
|
+
init_cjs_shims();
|
|
584
|
+
function upsertTranslation(db, input) {
|
|
585
|
+
db.prepare(
|
|
586
|
+
`INSERT INTO translations (
|
|
587
|
+
content_type, en_slug, locale, slug, frontmatter_json, body, en_hash, translated_at, model
|
|
588
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
589
|
+
ON CONFLICT(content_type, en_slug, locale) DO UPDATE SET
|
|
590
|
+
slug = excluded.slug,
|
|
591
|
+
frontmatter_json = excluded.frontmatter_json,
|
|
592
|
+
body = excluded.body,
|
|
593
|
+
en_hash = excluded.en_hash,
|
|
594
|
+
translated_at = excluded.translated_at,
|
|
595
|
+
model = excluded.model`
|
|
596
|
+
).run(
|
|
597
|
+
input.contentType,
|
|
598
|
+
input.enSlug,
|
|
599
|
+
input.locale,
|
|
600
|
+
input.slug,
|
|
601
|
+
JSON.stringify(input.frontmatter),
|
|
602
|
+
input.body,
|
|
603
|
+
input.enHash,
|
|
604
|
+
input.translatedAt,
|
|
605
|
+
input.model
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
function getTranslation(db, contentType, enSlug, locale) {
|
|
609
|
+
return db.prepare(
|
|
610
|
+
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
|
|
611
|
+
).get(contentType, enSlug, locale);
|
|
612
|
+
}
|
|
613
|
+
function listTranslationsForType(db, contentType) {
|
|
614
|
+
return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
|
|
615
|
+
}
|
|
616
|
+
function listTranslationsForEnSlug(db, contentType, enSlug) {
|
|
617
|
+
return db.prepare(
|
|
618
|
+
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
|
|
619
|
+
).all(contentType, enSlug);
|
|
620
|
+
}
|
|
621
|
+
function bulkLoadTranslations(db) {
|
|
622
|
+
return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
|
|
623
|
+
}
|
|
624
|
+
function appendRevision(db, input) {
|
|
625
|
+
const result = db.prepare(
|
|
626
|
+
`INSERT INTO revisions (
|
|
627
|
+
content_type, en_slug, locale, revision_kind, en_hash, body_hash, created_at, model, body_preview
|
|
628
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
629
|
+
).run(
|
|
630
|
+
input.contentType,
|
|
631
|
+
input.enSlug,
|
|
632
|
+
input.locale,
|
|
633
|
+
input.revisionKind,
|
|
634
|
+
input.enHash,
|
|
635
|
+
input.bodyHash,
|
|
636
|
+
input.createdAt,
|
|
637
|
+
input.model ?? null,
|
|
638
|
+
input.bodyPreview ?? null
|
|
639
|
+
);
|
|
640
|
+
return Number(result.lastInsertRowid);
|
|
641
|
+
}
|
|
642
|
+
function listRevisions(db, contentType, enSlug, locale) {
|
|
643
|
+
if (locale) {
|
|
644
|
+
return db.prepare(
|
|
645
|
+
`SELECT * FROM revisions
|
|
646
|
+
WHERE content_type = ? AND en_slug = ? AND locale = ?
|
|
647
|
+
ORDER BY created_at DESC, id DESC`
|
|
648
|
+
).all(contentType, enSlug, locale);
|
|
649
|
+
}
|
|
650
|
+
return db.prepare(
|
|
651
|
+
`SELECT * FROM revisions
|
|
652
|
+
WHERE content_type = ? AND en_slug = ?
|
|
653
|
+
ORDER BY created_at DESC, id DESC`
|
|
654
|
+
).all(contentType, enSlug);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/loader/normalize-en.ts
|
|
658
|
+
init_cjs_shims();
|
|
659
|
+
function normalizeEnFrontmatter(data) {
|
|
660
|
+
const out = { ...data };
|
|
661
|
+
if (!out.publishedAt && typeof out.date === "string") {
|
|
662
|
+
out.publishedAt = out.date;
|
|
663
|
+
}
|
|
664
|
+
if (!out.heroImage && typeof out.image === "string" && out.image.startsWith("/")) {
|
|
665
|
+
out.heroImage = out.image;
|
|
666
|
+
}
|
|
667
|
+
if (!out.updatedAt && typeof out.publishedAt === "string") {
|
|
668
|
+
out.updatedAt = out.publishedAt;
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
function isPublishableContentFile(name) {
|
|
673
|
+
if (!name.endsWith(".md") && !name.endsWith(".mdx")) return false;
|
|
674
|
+
const first = name.charAt(0);
|
|
675
|
+
return first === first.toLowerCase() && first !== "_";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/loader/create-loader.ts
|
|
679
|
+
function isPostFile(name) {
|
|
680
|
+
return isPublishableContentFile(name);
|
|
681
|
+
}
|
|
682
|
+
function listEnFiles(contentDir) {
|
|
683
|
+
if (!fs2__default.default.existsSync(contentDir)) return [];
|
|
684
|
+
return fs2__default.default.readdirSync(contentDir).filter(isPostFile).map((f) => path3__default.default.join(contentDir, f));
|
|
685
|
+
}
|
|
686
|
+
function slugFromPath(filePath) {
|
|
687
|
+
return path3__default.default.basename(filePath).replace(/\.(md|mdx)$/, "");
|
|
688
|
+
}
|
|
689
|
+
function parseEnMdx(filePath, config, type) {
|
|
690
|
+
const raw = fs2__default.default.readFileSync(filePath, "utf8");
|
|
691
|
+
const parsed = matter__default.default(raw);
|
|
692
|
+
const slug = slugFromPath(filePath);
|
|
693
|
+
const normalized = normalizeEnFrontmatter(parsed.data);
|
|
694
|
+
const { builtin, rest, issues: builtinIssues } = extractBuiltinEnFields(
|
|
695
|
+
normalized,
|
|
696
|
+
type.path,
|
|
697
|
+
slug,
|
|
698
|
+
config.defaultLocale
|
|
699
|
+
);
|
|
700
|
+
const issues = builtinIssues.map((issue) => ({
|
|
701
|
+
field: issue.field,
|
|
702
|
+
message: issue.message,
|
|
703
|
+
level: issue.level
|
|
704
|
+
}));
|
|
705
|
+
const result = type.schema.safeParse(rest);
|
|
706
|
+
if (!result.success) {
|
|
707
|
+
return {
|
|
708
|
+
document: null,
|
|
709
|
+
issues: [
|
|
710
|
+
...issues,
|
|
711
|
+
...result.error.issues.map((issue) => ({
|
|
712
|
+
field: issue.path.join("."),
|
|
713
|
+
message: issue.message,
|
|
714
|
+
level: "error"
|
|
715
|
+
}))
|
|
716
|
+
]
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (issues.some((i) => i.level === "error")) {
|
|
720
|
+
return { document: null, issues };
|
|
721
|
+
}
|
|
722
|
+
const frontmatter = mergeBuiltinsIntoFrontmatter(
|
|
723
|
+
result.data,
|
|
724
|
+
{
|
|
725
|
+
publishedAt: builtin.publishedAt,
|
|
726
|
+
updatedAt: builtin.updatedAt,
|
|
727
|
+
noindex: builtin.noindex,
|
|
728
|
+
canonicalPathOverride: builtin.canonicalPathOverride,
|
|
729
|
+
slug,
|
|
730
|
+
locale: config.defaultLocale
|
|
731
|
+
},
|
|
732
|
+
type,
|
|
733
|
+
config.defaultLocale
|
|
734
|
+
);
|
|
735
|
+
const crossIssues = type.crossValidate?.(result.data, {
|
|
736
|
+
locale: config.defaultLocale,
|
|
737
|
+
defaultLocale: config.defaultLocale,
|
|
738
|
+
slug,
|
|
739
|
+
enSlug: slug,
|
|
740
|
+
knownLocales: config.locales
|
|
741
|
+
}) ?? [];
|
|
742
|
+
issues.push(...crossIssues);
|
|
743
|
+
const document2 = {
|
|
744
|
+
slug,
|
|
745
|
+
enSlug: slug,
|
|
746
|
+
locale: config.defaultLocale,
|
|
747
|
+
aliases: builtin.aliases,
|
|
748
|
+
redirectTo: builtin.redirectTo,
|
|
749
|
+
publishedAt: builtin.publishedAt,
|
|
750
|
+
updatedAt: builtin.updatedAt,
|
|
751
|
+
noindex: builtin.noindex,
|
|
752
|
+
canonicalPathOverride: builtin.canonicalPathOverride,
|
|
753
|
+
frontmatter,
|
|
754
|
+
content: parsed.content,
|
|
755
|
+
filePath
|
|
756
|
+
};
|
|
757
|
+
return { document: document2, issues };
|
|
758
|
+
}
|
|
759
|
+
function buildDocumentFromTranslation(row, enDoc, type) {
|
|
760
|
+
const localeFm = JSON.parse(row.frontmatter_json);
|
|
761
|
+
const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
|
|
762
|
+
const seo = seoFieldsFromEn(enDoc);
|
|
763
|
+
const frontmatter = mergeBuiltinsIntoFrontmatter(
|
|
764
|
+
merged,
|
|
765
|
+
{ ...seo, slug: row.slug, locale: row.locale },
|
|
766
|
+
type,
|
|
767
|
+
enDoc.locale
|
|
768
|
+
);
|
|
769
|
+
return {
|
|
770
|
+
slug: row.slug,
|
|
771
|
+
enSlug: row.en_slug,
|
|
772
|
+
locale: row.locale,
|
|
773
|
+
aliases: [],
|
|
774
|
+
redirectTo: seo.redirectTo,
|
|
775
|
+
publishedAt: seo.publishedAt,
|
|
776
|
+
updatedAt: seo.updatedAt,
|
|
777
|
+
noindex: seo.noindex,
|
|
778
|
+
canonicalPathOverride: seo.canonicalPathOverride,
|
|
779
|
+
frontmatter,
|
|
780
|
+
content: row.body
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
var DEV_REVALIDATE_MS = 1500;
|
|
784
|
+
function createContentLoader(config, type) {
|
|
785
|
+
let cached = null;
|
|
786
|
+
let signature = "";
|
|
787
|
+
let lastCheck = 0;
|
|
788
|
+
const contentDir = path3__default.default.join(
|
|
789
|
+
/* turbopackIgnore: true */
|
|
790
|
+
config.rootDir,
|
|
791
|
+
type.contentDir
|
|
792
|
+
);
|
|
793
|
+
const storePath = resolveStorePath(config);
|
|
794
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
795
|
+
function computeSignature() {
|
|
796
|
+
const files = listEnFiles(contentDir);
|
|
797
|
+
let newest = 0;
|
|
798
|
+
for (const f of files) {
|
|
799
|
+
try {
|
|
800
|
+
newest = Math.max(newest, fs2__default.default.statSync(f).mtimeMs);
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
let store = 0;
|
|
805
|
+
try {
|
|
806
|
+
store = fs2__default.default.statSync(storePath).mtimeMs;
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
return `${files.length}:${newest}:${store}`;
|
|
810
|
+
}
|
|
811
|
+
function build() {
|
|
812
|
+
const out = /* @__PURE__ */ new Map();
|
|
813
|
+
const englishBySlug = /* @__PURE__ */ new Map();
|
|
814
|
+
for (const file of listEnFiles(contentDir)) {
|
|
815
|
+
const { document: document2, issues } = parseEnMdx(file, config, type);
|
|
816
|
+
if (document2) {
|
|
817
|
+
englishBySlug.set(document2.slug, document2);
|
|
818
|
+
if (issues.length > 0) {
|
|
819
|
+
for (const issue of issues) {
|
|
820
|
+
console.warn(
|
|
821
|
+
`[scribe:${type.id}] ${file} ${issue.level}: ${issue.field}: ${issue.message}`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
console.warn(
|
|
827
|
+
`[scribe:${type.id}] Skipping ${file} \u2014 validation failed:
|
|
828
|
+
` + issues.map((i) => ` - ${i.field}: ${i.message}`).join("\n")
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
out.set(config.defaultLocale, {
|
|
833
|
+
bySlug: englishBySlug,
|
|
834
|
+
byEnSlug: englishBySlug
|
|
835
|
+
});
|
|
836
|
+
const db = openStore(config, "readonly");
|
|
837
|
+
const rows = listTranslationsForType(db, type.id);
|
|
838
|
+
db.close();
|
|
839
|
+
const rowsByLocale = /* @__PURE__ */ new Map();
|
|
840
|
+
for (const row of rows) {
|
|
841
|
+
const list = rowsByLocale.get(row.locale) ?? [];
|
|
842
|
+
list.push(row);
|
|
843
|
+
rowsByLocale.set(row.locale, list);
|
|
844
|
+
}
|
|
845
|
+
for (const locale of config.locales) {
|
|
846
|
+
if (locale === config.defaultLocale) continue;
|
|
847
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
848
|
+
const byEnSlug = /* @__PURE__ */ new Map();
|
|
849
|
+
for (const row of rowsByLocale.get(locale) ?? []) {
|
|
850
|
+
const enDoc = englishBySlug.get(row.en_slug);
|
|
851
|
+
if (!enDoc) continue;
|
|
852
|
+
const doc = buildDocumentFromTranslation(row, enDoc, type);
|
|
853
|
+
bySlug.set(doc.slug, doc);
|
|
854
|
+
byEnSlug.set(row.en_slug, doc);
|
|
855
|
+
}
|
|
856
|
+
out.set(locale, { bySlug, byEnSlug });
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
return () => {
|
|
861
|
+
if (cached) {
|
|
862
|
+
if (isProd) return cached;
|
|
863
|
+
const now = Date.now();
|
|
864
|
+
if (now - lastCheck < DEV_REVALIDATE_MS) return cached;
|
|
865
|
+
lastCheck = now;
|
|
866
|
+
const sig = computeSignature();
|
|
867
|
+
if (sig === signature) return cached;
|
|
868
|
+
cached = build();
|
|
869
|
+
signature = sig;
|
|
870
|
+
return cached;
|
|
871
|
+
}
|
|
872
|
+
lastCheck = Date.now();
|
|
873
|
+
cached = build();
|
|
874
|
+
signature = isProd ? "" : computeSignature();
|
|
875
|
+
return cached;
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function readEnDocument(config, type, enSlug) {
|
|
879
|
+
const contentDir = path3__default.default.join(
|
|
880
|
+
/* turbopackIgnore: true */
|
|
881
|
+
config.rootDir,
|
|
882
|
+
type.contentDir
|
|
883
|
+
);
|
|
884
|
+
for (const ext of [".mdx", ".md"]) {
|
|
885
|
+
const filePath = path3__default.default.join(contentDir, `${enSlug}${ext}`);
|
|
886
|
+
if (!fs2__default.default.existsSync(filePath)) continue;
|
|
887
|
+
const { document: document2 } = parseEnMdx(filePath, config, type);
|
|
888
|
+
return document2;
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
function getTranslatablePayload(doc, type) {
|
|
893
|
+
return {
|
|
894
|
+
frontmatter: pickTranslatable(doc.frontmatter, type.schema),
|
|
895
|
+
body: doc.content
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/i18n/resolve-document.ts
|
|
900
|
+
init_cjs_shims();
|
|
901
|
+
function findEnDocByAlias(slug, allDocs, defaultLocale) {
|
|
902
|
+
const enIdx = allDocs.get(defaultLocale);
|
|
903
|
+
if (!enIdx) return null;
|
|
904
|
+
for (const doc of enIdx.bySlug.values()) {
|
|
905
|
+
if (doc.aliases.includes(slug)) return doc;
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
function enCanonicalDoc(doc, allDocs, defaultLocale) {
|
|
910
|
+
if (doc.locale === defaultLocale) return doc;
|
|
911
|
+
return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
|
|
912
|
+
}
|
|
913
|
+
function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
|
|
914
|
+
if (!type.path) return void 0;
|
|
915
|
+
const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
|
|
916
|
+
if (!enDoc.redirectTo) return void 0;
|
|
917
|
+
const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
|
|
918
|
+
if (!targetEnSlug) return void 0;
|
|
919
|
+
const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
|
|
920
|
+
const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
|
|
921
|
+
return resolvePath(type.path, targetSlug, locale, defaultLocale);
|
|
922
|
+
}
|
|
923
|
+
function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
|
|
924
|
+
if (result.shouldRedirectTo || !result.document) return result;
|
|
925
|
+
const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
|
|
926
|
+
if (!redirect) return result;
|
|
927
|
+
return {
|
|
928
|
+
document: null,
|
|
929
|
+
actualLocale: result.actualLocale,
|
|
930
|
+
shouldRedirectTo: redirect
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
|
|
934
|
+
const idx = allDocs.get(locale);
|
|
935
|
+
const direct = idx?.bySlug.get(slug);
|
|
936
|
+
if (direct) {
|
|
937
|
+
return withRedirectTo({ document: direct, actualLocale: locale }, locale, defaultLocale, allDocs, type);
|
|
938
|
+
}
|
|
939
|
+
const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
|
|
940
|
+
if (aliasDoc && type.path) {
|
|
941
|
+
const canonicalSlug = aliasDoc.slug;
|
|
942
|
+
const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
|
|
943
|
+
const targetSlug = localizedSlug ?? canonicalSlug;
|
|
944
|
+
if (targetSlug !== slug) {
|
|
945
|
+
return {
|
|
946
|
+
document: null,
|
|
947
|
+
actualLocale: locale,
|
|
948
|
+
shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
for (const [docLocale, docIdx] of allDocs) {
|
|
953
|
+
const found = docIdx.bySlug.get(slug);
|
|
954
|
+
if (!found) continue;
|
|
955
|
+
const correctSlug = getSlugForLocale(found, docLocale, locale, allDocs, defaultLocale);
|
|
956
|
+
if (correctSlug && correctSlug !== slug) {
|
|
957
|
+
if (!type.path) {
|
|
958
|
+
return { document: null, actualLocale: locale };
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
document: null,
|
|
962
|
+
actualLocale: locale,
|
|
963
|
+
shouldRedirectTo: resolvePath(type.path, correctSlug, locale, defaultLocale)
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (docLocale === defaultLocale) {
|
|
967
|
+
return withRedirectTo(
|
|
968
|
+
{ document: found, actualLocale: defaultLocale },
|
|
969
|
+
locale,
|
|
970
|
+
defaultLocale,
|
|
971
|
+
allDocs,
|
|
972
|
+
type
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (!type.path) {
|
|
976
|
+
return { document: null, actualLocale: locale };
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
document: null,
|
|
980
|
+
actualLocale: locale,
|
|
981
|
+
shouldRedirectTo: resolvePath(type.path, found.enSlug, locale, defaultLocale)
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
if (locale !== defaultLocale) {
|
|
985
|
+
const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
|
|
986
|
+
if (enDoc && type.indexFallback === "en") {
|
|
987
|
+
return withRedirectTo(
|
|
988
|
+
{ document: enDoc, actualLocale: defaultLocale },
|
|
989
|
+
locale,
|
|
990
|
+
defaultLocale,
|
|
991
|
+
allDocs,
|
|
992
|
+
type
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const byEn = idx?.byEnSlug.get(slug);
|
|
996
|
+
if (byEn) {
|
|
997
|
+
return withRedirectTo(
|
|
998
|
+
{ document: byEn, actualLocale: locale },
|
|
999
|
+
locale,
|
|
1000
|
+
defaultLocale,
|
|
1001
|
+
allDocs,
|
|
1002
|
+
type
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
|
|
1006
|
+
if (fallback && type.indexFallback === "en") {
|
|
1007
|
+
return withRedirectTo(
|
|
1008
|
+
{ document: fallback, actualLocale: defaultLocale },
|
|
1009
|
+
locale,
|
|
1010
|
+
defaultLocale,
|
|
1011
|
+
allDocs,
|
|
1012
|
+
type
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return { document: null, actualLocale: locale };
|
|
1017
|
+
}
|
|
1018
|
+
function getSlugForLocale(document2, sourceLocale, targetLocale, allDocs, defaultLocale) {
|
|
1019
|
+
if (sourceLocale === targetLocale) return document2.slug;
|
|
1020
|
+
const englishSlug = sourceLocale === defaultLocale ? document2.slug : document2.enSlug;
|
|
1021
|
+
if (!englishSlug) return null;
|
|
1022
|
+
if (targetLocale === defaultLocale) return englishSlug;
|
|
1023
|
+
return allDocs.get(targetLocale)?.byEnSlug.get(englishSlug)?.slug ?? null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/create-project.ts
|
|
1027
|
+
function comparatorFor(orderBy) {
|
|
1028
|
+
if (typeof orderBy === "function") return orderBy;
|
|
1029
|
+
switch (orderBy) {
|
|
1030
|
+
case "publishedAt":
|
|
1031
|
+
return (a, b) => (a.publishedAt ?? "").localeCompare(b.publishedAt ?? "");
|
|
1032
|
+
case "-publishedAt":
|
|
1033
|
+
return (a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "");
|
|
1034
|
+
case "updatedAt":
|
|
1035
|
+
return (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? "");
|
|
1036
|
+
case "-updatedAt":
|
|
1037
|
+
return (a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
|
1038
|
+
default:
|
|
1039
|
+
return (a, b) => a.slug.localeCompare(b.slug);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function buildRuntime(config, type, getRuntime) {
|
|
1043
|
+
const load = createContentLoader(config, type);
|
|
1044
|
+
const relationFields = new Map(
|
|
1045
|
+
listRelationFields(type.schema).filter((f) => f.path.length === 1).map((f) => [f.path[0], f])
|
|
1046
|
+
);
|
|
1047
|
+
function assertRoutable(method) {
|
|
1048
|
+
if (!type.path) {
|
|
1049
|
+
throw new Error(
|
|
1050
|
+
`Content type "${type.id}" has no path template \u2014 ${method}() requires one`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
return type.path;
|
|
1054
|
+
}
|
|
1055
|
+
const runtime = {
|
|
1056
|
+
id: type.id,
|
|
1057
|
+
config: type,
|
|
1058
|
+
load,
|
|
1059
|
+
list(locale = config.defaultLocale, options = {}) {
|
|
1060
|
+
const idx = load().get(locale);
|
|
1061
|
+
if (!idx) return [];
|
|
1062
|
+
const docs = Array.from(idx.bySlug.values());
|
|
1063
|
+
docs.sort(comparatorFor(options.orderBy ?? type.orderBy ?? "slug"));
|
|
1064
|
+
return options.limit !== void 0 ? docs.slice(0, options.limit) : docs;
|
|
1065
|
+
},
|
|
1066
|
+
get(slug, locale = config.defaultLocale) {
|
|
1067
|
+
return load().get(locale)?.bySlug.get(slug) ?? null;
|
|
1068
|
+
},
|
|
1069
|
+
resolve(slug, locale) {
|
|
1070
|
+
const result = resolveLocalizedDocument(slug, locale, config.defaultLocale, load(), type);
|
|
1071
|
+
if (result.document && type.path) {
|
|
1072
|
+
return {
|
|
1073
|
+
...result,
|
|
1074
|
+
canonicalPath: resolvePath(
|
|
1075
|
+
type.path,
|
|
1076
|
+
result.document.slug,
|
|
1077
|
+
result.actualLocale,
|
|
1078
|
+
config.defaultLocale
|
|
1079
|
+
)
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
return result;
|
|
1083
|
+
},
|
|
1084
|
+
staticParams(options = {}) {
|
|
1085
|
+
assertRoutable("staticParams");
|
|
1086
|
+
const all = load();
|
|
1087
|
+
const enIdx = all.get(config.defaultLocale);
|
|
1088
|
+
if (!enIdx) return [];
|
|
1089
|
+
const params = [];
|
|
1090
|
+
for (const locale of options.locales ?? config.locales) {
|
|
1091
|
+
const localeIdx = all.get(locale);
|
|
1092
|
+
for (const doc of enIdx.bySlug.values()) {
|
|
1093
|
+
const slug = locale === config.defaultLocale ? doc.slug : localeIdx?.byEnSlug.get(doc.slug)?.slug ?? doc.slug;
|
|
1094
|
+
params.push({ locale, slug });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return params;
|
|
1098
|
+
},
|
|
1099
|
+
alternates(doc) {
|
|
1100
|
+
const pathTemplate = assertRoutable("alternates");
|
|
1101
|
+
const out = {
|
|
1102
|
+
[config.defaultLocale]: resolvePath(
|
|
1103
|
+
pathTemplate,
|
|
1104
|
+
doc.enSlug,
|
|
1105
|
+
config.defaultLocale,
|
|
1106
|
+
config.defaultLocale
|
|
1107
|
+
)
|
|
1108
|
+
};
|
|
1109
|
+
const all = load();
|
|
1110
|
+
for (const locale of config.locales) {
|
|
1111
|
+
if (locale === config.defaultLocale) continue;
|
|
1112
|
+
const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
|
|
1113
|
+
if (translated) {
|
|
1114
|
+
out[locale] = resolvePath(pathTemplate, translated.slug, locale, config.defaultLocale);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return out;
|
|
1118
|
+
},
|
|
1119
|
+
translation(doc, targetLocale) {
|
|
1120
|
+
if (targetLocale === doc.locale) return doc;
|
|
1121
|
+
const all = load();
|
|
1122
|
+
if (targetLocale === config.defaultLocale) {
|
|
1123
|
+
return all.get(config.defaultLocale)?.bySlug.get(doc.enSlug) ?? null;
|
|
1124
|
+
}
|
|
1125
|
+
return all.get(targetLocale)?.byEnSlug.get(doc.enSlug) ?? null;
|
|
1126
|
+
},
|
|
1127
|
+
url(slug, locale) {
|
|
1128
|
+
const pathTemplate = assertRoutable("url");
|
|
1129
|
+
return resolvePath(pathTemplate, slug, locale, config.defaultLocale);
|
|
1130
|
+
},
|
|
1131
|
+
related(doc, fieldName, locale) {
|
|
1132
|
+
const meta = relationFields.get(fieldName);
|
|
1133
|
+
if (!meta) {
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
`Content type "${type.id}" has no top-level relation field "${fieldName}"`
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
const target = getRuntime(meta.relationTarget);
|
|
1139
|
+
const lookupLocale = locale ?? doc.locale;
|
|
1140
|
+
const value = doc.frontmatter[fieldName];
|
|
1141
|
+
const deref = (enSlug) => {
|
|
1142
|
+
const all = target.load();
|
|
1143
|
+
return all.get(lookupLocale)?.byEnSlug.get(enSlug) ?? all.get(config.defaultLocale)?.bySlug.get(enSlug) ?? null;
|
|
1144
|
+
};
|
|
1145
|
+
if (meta.relationMultiple) {
|
|
1146
|
+
if (!Array.isArray(value)) return [];
|
|
1147
|
+
return value.filter((slug) => typeof slug === "string" && slug.length > 0).map(deref).filter((d) => d !== null);
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1150
|
+
if (meta.relationOptional) return null;
|
|
1151
|
+
throw new Error(
|
|
1152
|
+
`${type.id} "${doc.enSlug}": required relation "${fieldName}" is missing`
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
const resolved = deref(value);
|
|
1156
|
+
if (!resolved && !meta.relationOptional) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`${type.id} "${doc.enSlug}": relation "${fieldName}" references missing ${meta.relationTarget} "${value}" \u2014 run \`scribe validate\``
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
return resolved;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
return runtime;
|
|
1165
|
+
}
|
|
1166
|
+
function createProject(config) {
|
|
1167
|
+
const runtimes = /* @__PURE__ */ new Map();
|
|
1168
|
+
const getRuntime = (id) => {
|
|
1169
|
+
const runtime = runtimes.get(id);
|
|
1170
|
+
if (!runtime) {
|
|
1171
|
+
throw new Error(`Unknown content type "${id}"`);
|
|
1172
|
+
}
|
|
1173
|
+
return runtime;
|
|
1174
|
+
};
|
|
1175
|
+
for (const type of config.types) {
|
|
1176
|
+
runtimes.set(type.id, buildRuntime(config, type, getRuntime));
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
config,
|
|
1180
|
+
rootDir: config.rootDir,
|
|
1181
|
+
storePath: config.storePath,
|
|
1182
|
+
getType: getRuntime,
|
|
1183
|
+
listTypes() {
|
|
1184
|
+
return Array.from(runtimes.values());
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/sitemap/generate-sitemap.ts
|
|
1190
|
+
init_cjs_shims();
|
|
1191
|
+
|
|
1192
|
+
// src/redirects/build-redirects.ts
|
|
1193
|
+
init_cjs_shims();
|
|
1194
|
+
|
|
1195
|
+
// src/core/slug-aliases.ts
|
|
1196
|
+
init_cjs_shims();
|
|
1197
|
+
|
|
1198
|
+
// src/core/alias-helpers.ts
|
|
1199
|
+
init_cjs_shims();
|
|
1200
|
+
function enFileExists(config, type, enSlug) {
|
|
1201
|
+
const dir = path3__default.default.join(config.rootDir, type.contentDir);
|
|
1202
|
+
return fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.mdx`)) || fs2__default.default.existsSync(path3__default.default.join(dir, `${enSlug}.md`));
|
|
1203
|
+
}
|
|
1204
|
+
function sqliteHasTranslations(db, contentTypeId, enSlug) {
|
|
1205
|
+
return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
|
|
1206
|
+
}
|
|
1207
|
+
function isAliasKnown(config, type, aliasEnSlug, db) {
|
|
1208
|
+
return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
|
|
1209
|
+
}
|
|
1210
|
+
function listEnSlugs(rootDir, contentDir) {
|
|
1211
|
+
const dir = path3__default.default.join(rootDir, contentDir);
|
|
1212
|
+
if (!fs2__default.default.existsSync(dir)) return [];
|
|
1213
|
+
return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/core/slug-aliases.ts
|
|
1217
|
+
init_sqlite();
|
|
1218
|
+
function buildGlobalAliasIndex(project) {
|
|
1219
|
+
const { config } = project;
|
|
1220
|
+
const aliasToTarget = /* @__PURE__ */ new Map();
|
|
1221
|
+
const allAliasSlugs = /* @__PURE__ */ new Set();
|
|
1222
|
+
const issues = [];
|
|
1223
|
+
const canonicalSlugsByType = /* @__PURE__ */ new Map();
|
|
1224
|
+
for (const type of config.types) {
|
|
1225
|
+
const slugs = listEnSlugs(config.rootDir, type.contentDir);
|
|
1226
|
+
canonicalSlugsByType.set(type.id, new Set(slugs));
|
|
1227
|
+
}
|
|
1228
|
+
for (const type of config.types) {
|
|
1229
|
+
if (!type.path) continue;
|
|
1230
|
+
const canonicalSlugs = canonicalSlugsByType.get(type.id) ?? /* @__PURE__ */ new Set();
|
|
1231
|
+
for (const enSlug of canonicalSlugs) {
|
|
1232
|
+
const doc = readEnDocument(config, type, enSlug);
|
|
1233
|
+
if (!doc) continue;
|
|
1234
|
+
for (const alias of doc.aliases) {
|
|
1235
|
+
if (alias === enSlug) {
|
|
1236
|
+
issues.push({
|
|
1237
|
+
contentTypeId: type.id,
|
|
1238
|
+
enSlug,
|
|
1239
|
+
field: "aliases",
|
|
1240
|
+
message: `Alias "${alias}" must not equal the canonical slug`,
|
|
1241
|
+
level: "error"
|
|
1242
|
+
});
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (canonicalSlugs.has(alias)) {
|
|
1246
|
+
issues.push({
|
|
1247
|
+
contentTypeId: type.id,
|
|
1248
|
+
enSlug,
|
|
1249
|
+
field: "aliases",
|
|
1250
|
+
message: `Alias "${alias}" collides with another document's canonical slug`,
|
|
1251
|
+
level: "error"
|
|
1252
|
+
});
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const existing = aliasToTarget.get(alias);
|
|
1256
|
+
if (existing) {
|
|
1257
|
+
issues.push({
|
|
1258
|
+
contentTypeId: type.id,
|
|
1259
|
+
enSlug,
|
|
1260
|
+
field: "aliases",
|
|
1261
|
+
message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
|
|
1262
|
+
level: "error"
|
|
1263
|
+
});
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
aliasToTarget.set(alias, {
|
|
1267
|
+
contentTypeId: type.id,
|
|
1268
|
+
canonicalSlug: enSlug,
|
|
1269
|
+
path: type.path
|
|
1270
|
+
});
|
|
1271
|
+
allAliasSlugs.add(alias);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return { aliasToTarget, allAliasSlugs, issues };
|
|
1276
|
+
}
|
|
1277
|
+
function validateAliasCoverage(project) {
|
|
1278
|
+
const { config } = project;
|
|
1279
|
+
const { aliasToTarget } = buildGlobalAliasIndex(project);
|
|
1280
|
+
const issues = [];
|
|
1281
|
+
const db = openStore(config, "readonly");
|
|
1282
|
+
try {
|
|
1283
|
+
for (const [alias, target] of aliasToTarget) {
|
|
1284
|
+
const type = config.types.find((t) => t.id === target.contentTypeId);
|
|
1285
|
+
if (!type) continue;
|
|
1286
|
+
const known = isAliasKnown(config, type, alias, db);
|
|
1287
|
+
if (!known) {
|
|
1288
|
+
issues.push({
|
|
1289
|
+
contentTypeId: type.id,
|
|
1290
|
+
enSlug: target.canonicalSlug,
|
|
1291
|
+
field: "aliases",
|
|
1292
|
+
message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
|
|
1293
|
+
level: "warning"
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
if (enFileExists(config, type, alias)) {
|
|
1297
|
+
issues.push({
|
|
1298
|
+
contentTypeId: type.id,
|
|
1299
|
+
enSlug: target.canonicalSlug,
|
|
1300
|
+
field: "aliases",
|
|
1301
|
+
message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
|
|
1302
|
+
level: "warning"
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
|
|
1306
|
+
if (sqliteRows.length > 0) {
|
|
1307
|
+
issues.push({
|
|
1308
|
+
contentTypeId: type.id,
|
|
1309
|
+
enSlug: target.canonicalSlug,
|
|
1310
|
+
field: "aliases",
|
|
1311
|
+
message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
|
|
1312
|
+
level: "info"
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
for (const locale of config.locales) {
|
|
1316
|
+
if (locale === config.defaultLocale) continue;
|
|
1317
|
+
const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
|
|
1318
|
+
(r) => r.locale === locale
|
|
1319
|
+
);
|
|
1320
|
+
if (!canonicalRow) continue;
|
|
1321
|
+
const aliasRow = sqliteRows.find((r) => r.locale === locale);
|
|
1322
|
+
if (!aliasRow && known) {
|
|
1323
|
+
issues.push({
|
|
1324
|
+
contentTypeId: type.id,
|
|
1325
|
+
enSlug: target.canonicalSlug,
|
|
1326
|
+
field: "aliases",
|
|
1327
|
+
message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
|
|
1328
|
+
level: "warning"
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
} finally {
|
|
1334
|
+
db.close();
|
|
1335
|
+
}
|
|
1336
|
+
return issues;
|
|
1337
|
+
}
|
|
1338
|
+
function validateAliasRedirectChains(project) {
|
|
1339
|
+
const { config } = project;
|
|
1340
|
+
const { aliasToTarget, allAliasSlugs } = buildGlobalAliasIndex(project);
|
|
1341
|
+
const issues = [];
|
|
1342
|
+
for (const [alias, target] of aliasToTarget) {
|
|
1343
|
+
const doc = readEnDocument(
|
|
1344
|
+
config,
|
|
1345
|
+
project.getType(target.contentTypeId).config,
|
|
1346
|
+
target.canonicalSlug
|
|
1347
|
+
);
|
|
1348
|
+
if (doc?.redirectTo) {
|
|
1349
|
+
issues.push({
|
|
1350
|
+
contentTypeId: target.contentTypeId,
|
|
1351
|
+
enSlug: target.canonicalSlug,
|
|
1352
|
+
field: "aliases",
|
|
1353
|
+
message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
|
|
1354
|
+
level: "error"
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
for (const type of config.types) {
|
|
1359
|
+
if (!type.path) continue;
|
|
1360
|
+
for (const enSlug of listEnSlugs(config.rootDir, type.contentDir)) {
|
|
1361
|
+
const doc = readEnDocument(config, type, enSlug);
|
|
1362
|
+
if (!doc?.redirectTo) continue;
|
|
1363
|
+
const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
|
|
1364
|
+
if (targetSlug && allAliasSlugs.has(targetSlug)) {
|
|
1365
|
+
issues.push({
|
|
1366
|
+
contentTypeId: type.id,
|
|
1367
|
+
enSlug,
|
|
1368
|
+
field: "redirect_to",
|
|
1369
|
+
message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
|
|
1370
|
+
level: "error"
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
|
|
1374
|
+
if (targetDoc?.redirectTo) {
|
|
1375
|
+
issues.push({
|
|
1376
|
+
contentTypeId: type.id,
|
|
1377
|
+
enSlug,
|
|
1378
|
+
field: "redirect_to",
|
|
1379
|
+
message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
|
|
1380
|
+
level: "error"
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return issues;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// src/redirects/translation-index.ts
|
|
1389
|
+
init_cjs_shims();
|
|
1390
|
+
|
|
1391
|
+
// src/i18n/translation-index.ts
|
|
1392
|
+
init_cjs_shims();
|
|
1393
|
+
|
|
1394
|
+
// src/redirects/translation-index.ts
|
|
1395
|
+
init_sqlite();
|
|
1396
|
+
|
|
1397
|
+
// src/sitemap/join-base-url.ts
|
|
1398
|
+
init_cjs_shims();
|
|
1399
|
+
|
|
1400
|
+
// src/config/load-config.ts
|
|
1401
|
+
init_cjs_shims();
|
|
1402
|
+
function findConfigPath(cwd) {
|
|
1403
|
+
const candidates = [
|
|
1404
|
+
path3__default.default.join(
|
|
1405
|
+
/* turbopackIgnore: true */
|
|
1406
|
+
cwd,
|
|
1407
|
+
"scribe.config.ts"
|
|
1408
|
+
),
|
|
1409
|
+
path3__default.default.join(
|
|
1410
|
+
/* turbopackIgnore: true */
|
|
1411
|
+
cwd,
|
|
1412
|
+
"scribe.config.mts"
|
|
1413
|
+
),
|
|
1414
|
+
path3__default.default.join(
|
|
1415
|
+
/* turbopackIgnore: true */
|
|
1416
|
+
cwd,
|
|
1417
|
+
"scribe.config.js"
|
|
1418
|
+
)
|
|
1419
|
+
];
|
|
1420
|
+
for (const candidate of candidates) {
|
|
1421
|
+
if (fs2__default.default.existsSync(candidate) && fs2__default.default.statSync(candidate).isFile()) {
|
|
1422
|
+
return candidate;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
function loadConfigSync(options = {}) {
|
|
1428
|
+
const cwd = options.cwd ?? /* turbopackIgnore: true */
|
|
1429
|
+
process.cwd();
|
|
1430
|
+
const configPath = options.configPath ?? findConfigPath(cwd);
|
|
1431
|
+
if (!configPath) {
|
|
1432
|
+
throw new Error(`No scribe.config.ts found in ${cwd} (pass --config)`);
|
|
1433
|
+
}
|
|
1434
|
+
const jiti$1 = jiti.createJiti(importMetaUrl, {
|
|
1435
|
+
interopDefault: true,
|
|
1436
|
+
moduleCache: false
|
|
1437
|
+
});
|
|
1438
|
+
const loaded = jiti$1(configPath);
|
|
1439
|
+
const config = "default" in loaded ? loaded.default : loaded;
|
|
1440
|
+
if (!config?.rootDir) {
|
|
1441
|
+
throw new Error(`Invalid scribe config at ${configPath}: missing rootDir`);
|
|
1442
|
+
}
|
|
1443
|
+
return resolveConfig(config, path3__default.default.dirname(configPath));
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/validate/validate-project.ts
|
|
1447
|
+
init_cjs_shims();
|
|
1448
|
+
|
|
1449
|
+
// src/hash/page-hash.ts
|
|
1450
|
+
init_cjs_shims();
|
|
1451
|
+
function sha256(input) {
|
|
1452
|
+
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
1453
|
+
}
|
|
1454
|
+
function computePageEnHash(translatableFrontmatter, body) {
|
|
1455
|
+
const payload = JSON.stringify({ frontmatter: translatableFrontmatter, body });
|
|
1456
|
+
return sha256(payload);
|
|
1457
|
+
}
|
|
1458
|
+
function computeBodyHash(body) {
|
|
1459
|
+
return sha256(body);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/history/record-revision.ts
|
|
1463
|
+
init_cjs_shims();
|
|
1464
|
+
init_sqlite();
|
|
1465
|
+
function recordRevision(config, input) {
|
|
1466
|
+
const db = openStore(config, "readwrite");
|
|
1467
|
+
const id = appendRevision(db, {
|
|
1468
|
+
contentType: input.contentType,
|
|
1469
|
+
enSlug: input.enSlug,
|
|
1470
|
+
locale: input.locale,
|
|
1471
|
+
revisionKind: input.revisionKind,
|
|
1472
|
+
enHash: input.enHash,
|
|
1473
|
+
bodyHash: computeBodyHash(input.body),
|
|
1474
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1475
|
+
model: input.model,
|
|
1476
|
+
bodyPreview: input.body.slice(0, 200)
|
|
1477
|
+
});
|
|
1478
|
+
db.close();
|
|
1479
|
+
return id;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/validate/validate-project.ts
|
|
1483
|
+
init_sqlite();
|
|
1484
|
+
|
|
1485
|
+
// src/validate/validate-relations.ts
|
|
1486
|
+
init_cjs_shims();
|
|
1487
|
+
function getAtPath2(obj, fieldPath) {
|
|
1488
|
+
let current = obj;
|
|
1489
|
+
for (const segment of fieldPath) {
|
|
1490
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
1491
|
+
current = current[segment];
|
|
1492
|
+
}
|
|
1493
|
+
return current;
|
|
1494
|
+
}
|
|
1495
|
+
function buildEnSlugIndex(config) {
|
|
1496
|
+
const index = /* @__PURE__ */ new Map();
|
|
1497
|
+
for (const type of config.types) {
|
|
1498
|
+
const slugs = new Set(listEnSlugs(config.rootDir, type.contentDir));
|
|
1499
|
+
index.set(type.id, slugs);
|
|
1500
|
+
}
|
|
1501
|
+
return index;
|
|
1502
|
+
}
|
|
1503
|
+
function listEnSlugsForType(rootDir, contentDir) {
|
|
1504
|
+
const dir = path3__default.default.join(rootDir, contentDir);
|
|
1505
|
+
if (!fs2__default.default.existsSync(dir)) return [];
|
|
1506
|
+
return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
1507
|
+
}
|
|
1508
|
+
function validateRelations(project) {
|
|
1509
|
+
const { config } = project;
|
|
1510
|
+
const slugIndex = buildEnSlugIndex(config);
|
|
1511
|
+
const issues = [];
|
|
1512
|
+
const typeIds = new Set(config.types.map((t) => t.id));
|
|
1513
|
+
for (const type of config.types) {
|
|
1514
|
+
const relationFields = listRelationFields(type.schema);
|
|
1515
|
+
if (relationFields.length === 0) continue;
|
|
1516
|
+
for (const enSlug of listEnSlugsForType(config.rootDir, type.contentDir)) {
|
|
1517
|
+
const doc = readEnDocument(config, type, enSlug);
|
|
1518
|
+
if (!doc) continue;
|
|
1519
|
+
for (const fieldMeta of relationFields) {
|
|
1520
|
+
const targetTypeId = fieldMeta.relationTarget;
|
|
1521
|
+
const danglingLevel = fieldMeta.relationOptional || fieldMeta.relationMultiple ? "warning" : "error";
|
|
1522
|
+
if (!typeIds.has(targetTypeId)) {
|
|
1523
|
+
issues.push({
|
|
1524
|
+
contentTypeId: type.id,
|
|
1525
|
+
enSlug,
|
|
1526
|
+
field: fieldMeta.path.join("."),
|
|
1527
|
+
message: `Relation target type "${targetTypeId}" is not registered`,
|
|
1528
|
+
level: "error"
|
|
1529
|
+
});
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
const value = getAtPath2(doc.frontmatter, fieldMeta.path);
|
|
1533
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
1534
|
+
const targetSlugs = slugIndex.get(targetTypeId) ?? /* @__PURE__ */ new Set();
|
|
1535
|
+
const fieldLabel = fieldMeta.path.join(".");
|
|
1536
|
+
if (fieldMeta.relationMultiple) {
|
|
1537
|
+
if (!Array.isArray(value)) continue;
|
|
1538
|
+
for (const slug of value) {
|
|
1539
|
+
if (typeof slug !== "string" || !slug) continue;
|
|
1540
|
+
if (!targetSlugs.has(slug)) {
|
|
1541
|
+
issues.push({
|
|
1542
|
+
contentTypeId: type.id,
|
|
1543
|
+
enSlug,
|
|
1544
|
+
field: fieldLabel,
|
|
1545
|
+
message: `${fieldLabel} references "${slug}" \u2014 no ${targetTypeId} doc with that slug`,
|
|
1546
|
+
level: danglingLevel
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
} else if (typeof value === "string") {
|
|
1551
|
+
if (!targetSlugs.has(value)) {
|
|
1552
|
+
issues.push({
|
|
1553
|
+
contentTypeId: type.id,
|
|
1554
|
+
enSlug,
|
|
1555
|
+
field: fieldLabel,
|
|
1556
|
+
message: `${fieldLabel} references "${value}" \u2014 no ${targetTypeId} doc with slug "${value}"`,
|
|
1557
|
+
level: danglingLevel
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
return issues;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/validate/validate-slug-suffix.ts
|
|
1568
|
+
init_cjs_shims();
|
|
1569
|
+
function validateTranslationSlugSuffixes(config, db) {
|
|
1570
|
+
const issues = [];
|
|
1571
|
+
const localeCodes = config.locales.filter((l) => l !== config.defaultLocale);
|
|
1572
|
+
for (const row of bulkLoadTranslations(db)) {
|
|
1573
|
+
if (row.locale === config.defaultLocale) continue;
|
|
1574
|
+
for (const code of localeCodes) {
|
|
1575
|
+
if (row.slug.endsWith(`-${code}`)) {
|
|
1576
|
+
issues.push({
|
|
1577
|
+
contentTypeId: row.content_type,
|
|
1578
|
+
enSlug: row.en_slug,
|
|
1579
|
+
locale: row.locale,
|
|
1580
|
+
slug: row.slug,
|
|
1581
|
+
message: `Translation slug "${row.slug}" ends with locale code "-${code}"`
|
|
1582
|
+
});
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return issues;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/validate/validate-project.ts
|
|
1591
|
+
function listEnSlugs2(rootDir, contentDir) {
|
|
1592
|
+
const dir = path3__default.default.join(rootDir, contentDir);
|
|
1593
|
+
if (!fs2__default.default.existsSync(dir)) return [];
|
|
1594
|
+
return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
1595
|
+
}
|
|
1596
|
+
function validateProject(config) {
|
|
1597
|
+
const issues = [];
|
|
1598
|
+
const project = createProject(config);
|
|
1599
|
+
const storePath = project.storePath;
|
|
1600
|
+
if (!fs2__default.default.existsSync(storePath)) {
|
|
1601
|
+
issues.push({
|
|
1602
|
+
level: "error",
|
|
1603
|
+
message: `Missing store.sqlite at ${storePath}`
|
|
1604
|
+
});
|
|
1605
|
+
return { ok: false, issues };
|
|
1606
|
+
}
|
|
1607
|
+
const db = openStore(config, "readonly");
|
|
1608
|
+
for (const type of config.types) {
|
|
1609
|
+
const enSlugs = listEnSlugs2(config.rootDir, type.contentDir);
|
|
1610
|
+
const englishSlugs = new Set(enSlugs);
|
|
1611
|
+
if (!type.translate && isRoutableType(type)) {
|
|
1612
|
+
issues.push({
|
|
1613
|
+
level: "warning",
|
|
1614
|
+
contentType: type.id,
|
|
1615
|
+
message: `No translate config on content type "${type.id}" \u2014 using Scribe defaults`
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
for (const enSlug of enSlugs) {
|
|
1619
|
+
const enDoc = readEnDocument(config, type, enSlug);
|
|
1620
|
+
if (!enDoc) {
|
|
1621
|
+
issues.push({
|
|
1622
|
+
level: "error",
|
|
1623
|
+
contentType: type.id,
|
|
1624
|
+
enSlug,
|
|
1625
|
+
message: "Failed to parse EN document"
|
|
1626
|
+
});
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const payload = getTranslatablePayload(enDoc, type);
|
|
1630
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
1631
|
+
const crossIssues = type.crossValidate?.(enDoc.frontmatter, {
|
|
1632
|
+
locale: config.defaultLocale,
|
|
1633
|
+
defaultLocale: config.defaultLocale,
|
|
1634
|
+
slug: enSlug,
|
|
1635
|
+
enSlug,
|
|
1636
|
+
knownLocales: config.locales,
|
|
1637
|
+
englishSlugs
|
|
1638
|
+
}) ?? [];
|
|
1639
|
+
for (const issue of crossIssues) {
|
|
1640
|
+
issues.push({
|
|
1641
|
+
level: issue.level,
|
|
1642
|
+
contentType: type.id,
|
|
1643
|
+
enSlug,
|
|
1644
|
+
field: issue.field,
|
|
1645
|
+
message: issue.message
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
for (const locale of config.locales) {
|
|
1649
|
+
if (locale === config.defaultLocale) continue;
|
|
1650
|
+
const row = getTranslation(db, type.id, enSlug, locale);
|
|
1651
|
+
if (!row) continue;
|
|
1652
|
+
if (row.en_hash !== currentEnHash) {
|
|
1653
|
+
issues.push({
|
|
1654
|
+
level: "warning",
|
|
1655
|
+
contentType: type.id,
|
|
1656
|
+
enSlug,
|
|
1657
|
+
locale,
|
|
1658
|
+
field: "en_hash",
|
|
1659
|
+
message: "Translation is stale (en_hash mismatch)"
|
|
1660
|
+
});
|
|
1661
|
+
recordRevision(config, {
|
|
1662
|
+
contentType: type.id,
|
|
1663
|
+
enSlug,
|
|
1664
|
+
locale,
|
|
1665
|
+
revisionKind: "en_edit_detected",
|
|
1666
|
+
enHash: currentEnHash,
|
|
1667
|
+
body: row.body
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
const localeFm = JSON.parse(row.frontmatter_json);
|
|
1671
|
+
for (const issue of validateLocaleBuiltinFields(localeFm)) {
|
|
1672
|
+
issues.push({
|
|
1673
|
+
level: issue.level,
|
|
1674
|
+
contentType: type.id,
|
|
1675
|
+
enSlug,
|
|
1676
|
+
locale,
|
|
1677
|
+
field: issue.field,
|
|
1678
|
+
message: issue.message
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
project.getType(type.id).load();
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
issues.push({
|
|
1687
|
+
level: "error",
|
|
1688
|
+
contentType: type.id,
|
|
1689
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
db.close();
|
|
1694
|
+
const aliasIndex = buildGlobalAliasIndex(project);
|
|
1695
|
+
for (const issue of aliasIndex.issues) {
|
|
1696
|
+
issues.push({
|
|
1697
|
+
level: issue.level,
|
|
1698
|
+
contentType: issue.contentTypeId,
|
|
1699
|
+
enSlug: issue.enSlug,
|
|
1700
|
+
field: issue.field,
|
|
1701
|
+
message: issue.message
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
for (const issue of validateAliasRedirectChains(project)) {
|
|
1705
|
+
issues.push({
|
|
1706
|
+
level: issue.level,
|
|
1707
|
+
contentType: issue.contentTypeId,
|
|
1708
|
+
enSlug: issue.enSlug,
|
|
1709
|
+
field: issue.field,
|
|
1710
|
+
message: issue.message
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
for (const issue of validateAliasCoverage(project)) {
|
|
1714
|
+
issues.push({
|
|
1715
|
+
level: issue.level,
|
|
1716
|
+
contentType: issue.contentTypeId,
|
|
1717
|
+
enSlug: issue.enSlug,
|
|
1718
|
+
field: issue.field,
|
|
1719
|
+
message: issue.message
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
for (const issue of validateRelations(project)) {
|
|
1723
|
+
issues.push({
|
|
1724
|
+
level: issue.level,
|
|
1725
|
+
contentType: issue.contentTypeId,
|
|
1726
|
+
enSlug: issue.enSlug,
|
|
1727
|
+
field: issue.field,
|
|
1728
|
+
message: issue.message
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const dbForSuffix = openStore(config, "readonly");
|
|
1732
|
+
try {
|
|
1733
|
+
for (const issue of validateTranslationSlugSuffixes(config, dbForSuffix)) {
|
|
1734
|
+
issues.push({
|
|
1735
|
+
level: "error",
|
|
1736
|
+
contentType: issue.contentTypeId,
|
|
1737
|
+
enSlug: issue.enSlug,
|
|
1738
|
+
locale: issue.locale,
|
|
1739
|
+
field: "slug",
|
|
1740
|
+
message: issue.message
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
} finally {
|
|
1744
|
+
dbForSuffix.close();
|
|
1745
|
+
}
|
|
1746
|
+
return { ok: issues.every((i) => i.level !== "error"), issues };
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/translate/worklist.ts
|
|
1750
|
+
init_cjs_shims();
|
|
1751
|
+
init_sqlite();
|
|
1752
|
+
function listEnSlugs3(rootDir, contentDir) {
|
|
1753
|
+
const dir = path3__default.default.join(rootDir, contentDir);
|
|
1754
|
+
if (!fs2__default.default.existsSync(dir)) return [];
|
|
1755
|
+
return fs2__default.default.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
1756
|
+
}
|
|
1757
|
+
function buildWorklist(config, options = {}) {
|
|
1758
|
+
const db = openStore(config, "readonly");
|
|
1759
|
+
const items = [];
|
|
1760
|
+
const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
|
|
1761
|
+
for (const type of config.types) {
|
|
1762
|
+
if (options.contentType && type.id !== options.contentType) continue;
|
|
1763
|
+
const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs3(config.rootDir, type.contentDir);
|
|
1764
|
+
for (const enSlug of enSlugs) {
|
|
1765
|
+
const enDoc = readEnDocument(config, type, enSlug);
|
|
1766
|
+
if (!enDoc) continue;
|
|
1767
|
+
const payload = getTranslatablePayload(enDoc, type);
|
|
1768
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
1769
|
+
for (const locale of locales) {
|
|
1770
|
+
if (locale === config.defaultLocale) continue;
|
|
1771
|
+
const existing = getTranslation(db, type.id, enSlug, locale);
|
|
1772
|
+
if (!existing) {
|
|
1773
|
+
items.push({
|
|
1774
|
+
contentType: type.id,
|
|
1775
|
+
enSlug,
|
|
1776
|
+
locale,
|
|
1777
|
+
reason: "missing",
|
|
1778
|
+
currentEnHash
|
|
1779
|
+
});
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
if (existing.en_hash !== currentEnHash) {
|
|
1783
|
+
items.push({
|
|
1784
|
+
contentType: type.id,
|
|
1785
|
+
enSlug,
|
|
1786
|
+
locale,
|
|
1787
|
+
reason: "stale",
|
|
1788
|
+
currentEnHash,
|
|
1789
|
+
storedEnHash: existing.en_hash
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
db.close();
|
|
1796
|
+
return items;
|
|
1797
|
+
}
|
|
1798
|
+
function resolveLocalesFromPreset(config, preset, explicitLocales) {
|
|
1799
|
+
if (explicitLocales?.length) return explicitLocales;
|
|
1800
|
+
if (preset && config.localePresets?.[preset]) {
|
|
1801
|
+
return config.localePresets[preset].filter((l) => l !== config.defaultLocale);
|
|
1802
|
+
}
|
|
1803
|
+
return config.locales.filter((l) => l !== config.defaultLocale);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/translate/page-translator.ts
|
|
1807
|
+
init_cjs_shims();
|
|
1808
|
+
init_sqlite();
|
|
1809
|
+
|
|
1810
|
+
// src/translate/gemini-client.ts
|
|
1811
|
+
init_cjs_shims();
|
|
1812
|
+
var DEFAULT_MODEL = "gemini-2.5-pro";
|
|
1813
|
+
function extractJson(text) {
|
|
1814
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1815
|
+
if (fenced?.[1]) return fenced[1].trim();
|
|
1816
|
+
const start = text.indexOf("{");
|
|
1817
|
+
const end = text.lastIndexOf("}");
|
|
1818
|
+
if (start >= 0 && end > start) return text.slice(start, end + 1);
|
|
1819
|
+
return text.trim();
|
|
1820
|
+
}
|
|
1821
|
+
async function translatePageWithGemini(input) {
|
|
1822
|
+
const apiKey = input.apiKey ?? process.env.GEMINI_API_KEY;
|
|
1823
|
+
if (!apiKey) {
|
|
1824
|
+
throw new Error("GEMINI_API_KEY is required for scribe translate");
|
|
1825
|
+
}
|
|
1826
|
+
const model = input.model ?? process.env.PROSE_GEMINI_MODEL ?? DEFAULT_MODEL;
|
|
1827
|
+
const ai = new genai.GoogleGenAI({ apiKey });
|
|
1828
|
+
const response = await ai.models.generateContent({
|
|
1829
|
+
model,
|
|
1830
|
+
contents: input.prompt,
|
|
1831
|
+
config: {
|
|
1832
|
+
responseMimeType: "application/json"
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
const raw = response.text ?? "";
|
|
1836
|
+
const parsed = JSON.parse(extractJson(raw));
|
|
1837
|
+
if (!parsed.frontmatter || typeof parsed.body !== "string") {
|
|
1838
|
+
throw new Error("Gemini response missing frontmatter/body");
|
|
1839
|
+
}
|
|
1840
|
+
return { model, raw, parsed };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// src/translate/prompts/translation-prompt.ts
|
|
1844
|
+
init_cjs_shims();
|
|
1845
|
+
var LOCALE_NAMES = {
|
|
1846
|
+
fr: "French",
|
|
1847
|
+
es: "Spanish",
|
|
1848
|
+
de: "German",
|
|
1849
|
+
nl: "Dutch",
|
|
1850
|
+
uk: "Ukrainian",
|
|
1851
|
+
tr: "Turkish",
|
|
1852
|
+
da: "Danish",
|
|
1853
|
+
he: "Hebrew",
|
|
1854
|
+
"zh-CN": "Simplified Chinese",
|
|
1855
|
+
"zh-TW": "Traditional Chinese",
|
|
1856
|
+
pt: "Portuguese",
|
|
1857
|
+
ja: "Japanese",
|
|
1858
|
+
ru: "Russian",
|
|
1859
|
+
it: "Italian",
|
|
1860
|
+
ar: "Arabic"
|
|
1861
|
+
};
|
|
1862
|
+
function defaultLocalizationPrompt(localeName, locale) {
|
|
1863
|
+
return [
|
|
1864
|
+
`Localize the content below into natural ${localeName} (${locale}).`,
|
|
1865
|
+
"Do not translate word-for-word.",
|
|
1866
|
+
"Preserve the source tone and brand voice.",
|
|
1867
|
+
"Write as if a native speaker authored it for the target market."
|
|
1868
|
+
].join(" ");
|
|
1869
|
+
}
|
|
1870
|
+
function buildPageTranslationPrompt(input) {
|
|
1871
|
+
const localeName = LOCALE_NAMES[input.targetLocale] ?? input.targetLocale;
|
|
1872
|
+
const prompt = input.resolved.promptOverride ?? defaultLocalizationPrompt(localeName, input.targetLocale);
|
|
1873
|
+
const lines = [
|
|
1874
|
+
prompt,
|
|
1875
|
+
"",
|
|
1876
|
+
...input.resolved.context ? ["## Context", input.resolved.context, ""] : [],
|
|
1877
|
+
"## Rules",
|
|
1878
|
+
...input.resolved.rules.map((rule) => `- ${rule}`),
|
|
1879
|
+
"",
|
|
1880
|
+
"## Output format",
|
|
1881
|
+
"Return ONLY valid JSON with keys:",
|
|
1882
|
+
input.slugStrategy === "localized" ? "`frontmatter` (object with translated frontmatter fields), `body` (string, full MDX body), `slug` (string)." : "`frontmatter` (object), `body` (string).",
|
|
1883
|
+
"",
|
|
1884
|
+
"## EN metadata",
|
|
1885
|
+
`title: ${input.enTitle}`,
|
|
1886
|
+
`description: ${input.enDescription}`,
|
|
1887
|
+
"",
|
|
1888
|
+
"## EN translatable frontmatter (JSON)",
|
|
1889
|
+
JSON.stringify(input.translatableFrontmatter, null, 2),
|
|
1890
|
+
"",
|
|
1891
|
+
"## EN body (MDX)",
|
|
1892
|
+
input.enBody
|
|
1893
|
+
];
|
|
1894
|
+
return lines.join("\n");
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// src/translate/resolve-translate-config.ts
|
|
1898
|
+
init_cjs_shims();
|
|
1899
|
+
function slugStrategyRules(slugStrategy, preserveTerms) {
|
|
1900
|
+
if (slugStrategy === "localized") {
|
|
1901
|
+
const rules = [
|
|
1902
|
+
"Provide a localized URL slug in JSON field `slug`.",
|
|
1903
|
+
"Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
|
|
1904
|
+
"For non-Latin locales, transliterate into Latin script."
|
|
1905
|
+
];
|
|
1906
|
+
if (preserveTerms?.length) {
|
|
1907
|
+
rules.push(
|
|
1908
|
+
`Preserve these terms verbatim in slugs (lowercase): ${preserveTerms.join(", ")}.`
|
|
1909
|
+
);
|
|
1910
|
+
} else {
|
|
1911
|
+
rules.push("Preserve 4-digit years and proper nouns in slugs.");
|
|
1912
|
+
}
|
|
1913
|
+
return rules;
|
|
1914
|
+
}
|
|
1915
|
+
return ["Do NOT output a slug \u2014 slugStrategy is fixed."];
|
|
1916
|
+
}
|
|
1917
|
+
function mergeContext(project, type) {
|
|
1918
|
+
const parts = [project?.context, type?.context].filter(Boolean);
|
|
1919
|
+
return parts.length > 0 ? parts.join("\n\n") : void 0;
|
|
1920
|
+
}
|
|
1921
|
+
function mergeTranslateConfig(project, type, slugStrategy) {
|
|
1922
|
+
return {
|
|
1923
|
+
promptOverride: type?.prompt ?? project?.prompt,
|
|
1924
|
+
context: mergeContext(project, type),
|
|
1925
|
+
rules: [
|
|
1926
|
+
...project?.rules ?? [],
|
|
1927
|
+
...type?.rules ?? [],
|
|
1928
|
+
...slugStrategyRules(slugStrategy, project?.slugPreserveTerms)
|
|
1929
|
+
],
|
|
1930
|
+
model: type?.model ?? project?.defaultModel
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
function resolveTranslateConfig(config, type) {
|
|
1934
|
+
return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// src/translate/page-translator.ts
|
|
1938
|
+
async function translatePage(config, item, options = {}) {
|
|
1939
|
+
const type = config.types.find((t) => t.id === item.contentType);
|
|
1940
|
+
if (!type) throw new Error(`Unknown content type ${item.contentType}`);
|
|
1941
|
+
const enDoc = readEnDocument(config, type, item.enSlug);
|
|
1942
|
+
if (!enDoc) throw new Error(`EN document not found: ${item.enSlug}`);
|
|
1943
|
+
const payload = getTranslatablePayload(enDoc, type);
|
|
1944
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
1945
|
+
const db = openStore(config, "readonly");
|
|
1946
|
+
const existing = getTranslation(db, type.id, item.enSlug, item.locale);
|
|
1947
|
+
db.close();
|
|
1948
|
+
if (!options.force && existing && existing.en_hash === currentEnHash) {
|
|
1949
|
+
return {
|
|
1950
|
+
contentType: item.contentType,
|
|
1951
|
+
enSlug: item.enSlug,
|
|
1952
|
+
locale: item.locale,
|
|
1953
|
+
skipped: true,
|
|
1954
|
+
reason: "fresh"
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
const resolvedTranslate = resolveTranslateConfig(config, type);
|
|
1958
|
+
const prompt = buildPageTranslationPrompt({
|
|
1959
|
+
resolved: resolvedTranslate,
|
|
1960
|
+
targetLocale: item.locale,
|
|
1961
|
+
enTitle: String(enDoc.frontmatter.title ?? item.enSlug),
|
|
1962
|
+
enDescription: String(enDoc.frontmatter.description ?? ""),
|
|
1963
|
+
translatableFrontmatter: payload.frontmatter,
|
|
1964
|
+
enBody: payload.body,
|
|
1965
|
+
slugStrategy: type.slugStrategy
|
|
1966
|
+
});
|
|
1967
|
+
if (options.dryRun) {
|
|
1968
|
+
return {
|
|
1969
|
+
contentType: item.contentType,
|
|
1970
|
+
enSlug: item.enSlug,
|
|
1971
|
+
locale: item.locale,
|
|
1972
|
+
skipped: false,
|
|
1973
|
+
model: options.model
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
const result = await translatePageWithGemini({
|
|
1977
|
+
prompt,
|
|
1978
|
+
model: options.model ?? resolvedTranslate.model
|
|
1979
|
+
});
|
|
1980
|
+
const slug = type.slugStrategy === "localized" ? result.parsed.slug ?? existing?.slug ?? item.enSlug : item.enSlug;
|
|
1981
|
+
const writeDb = openStore(config, "readwrite");
|
|
1982
|
+
upsertTranslation(writeDb, {
|
|
1983
|
+
contentType: type.id,
|
|
1984
|
+
enSlug: item.enSlug,
|
|
1985
|
+
locale: item.locale,
|
|
1986
|
+
slug,
|
|
1987
|
+
frontmatter: result.parsed.frontmatter,
|
|
1988
|
+
body: result.parsed.body,
|
|
1989
|
+
enHash: currentEnHash,
|
|
1990
|
+
translatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1991
|
+
model: result.model
|
|
1992
|
+
});
|
|
1993
|
+
writeDb.close();
|
|
1994
|
+
recordRevision(config, {
|
|
1995
|
+
contentType: type.id,
|
|
1996
|
+
enSlug: item.enSlug,
|
|
1997
|
+
locale: item.locale,
|
|
1998
|
+
revisionKind: "translation",
|
|
1999
|
+
enHash: currentEnHash,
|
|
2000
|
+
body: result.parsed.body,
|
|
2001
|
+
model: result.model
|
|
2002
|
+
});
|
|
2003
|
+
return {
|
|
2004
|
+
contentType: item.contentType,
|
|
2005
|
+
enSlug: item.enSlug,
|
|
2006
|
+
locale: item.locale,
|
|
2007
|
+
skipped: false,
|
|
2008
|
+
model: result.model
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
async function translateWorklist(config, items, options = {}) {
|
|
2012
|
+
const results = [];
|
|
2013
|
+
for (const item of items) {
|
|
2014
|
+
results.push(await translatePage(config, item, options));
|
|
2015
|
+
}
|
|
2016
|
+
return results;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// src/config/load-env.ts
|
|
2020
|
+
init_cjs_shims();
|
|
2021
|
+
var ENV_FILES = [".env", ".env.local"];
|
|
2022
|
+
function loadEnvFromCwd(cwd) {
|
|
2023
|
+
for (const file of ENV_FILES) {
|
|
2024
|
+
const envPath = path3__default.default.join(
|
|
2025
|
+
/* turbopackIgnore: true */
|
|
2026
|
+
cwd,
|
|
2027
|
+
file
|
|
2028
|
+
);
|
|
2029
|
+
if (!fs2__default.default.existsSync(envPath)) continue;
|
|
2030
|
+
dotenv__default.default.config({ path: envPath, override: false });
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// studio/server.ts
|
|
2035
|
+
init_cjs_shims();
|
|
2036
|
+
init_sqlite();
|
|
2037
|
+
function renderLayout(title, body) {
|
|
2038
|
+
return `<!doctype html>
|
|
2039
|
+
<html lang="en">
|
|
2040
|
+
<head>
|
|
2041
|
+
<meta charset="utf-8" />
|
|
2042
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2043
|
+
<title>${title} \u2014 Scribe Studio</title>
|
|
2044
|
+
<style>
|
|
2045
|
+
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b0d10; color: #e8eaed; }
|
|
2046
|
+
header { padding: 16px 24px; border-bottom: 1px solid #222; display: flex; gap: 16px; }
|
|
2047
|
+
header a { color: #9ecbff; text-decoration: none; }
|
|
2048
|
+
main { padding: 24px; }
|
|
2049
|
+
table { border-collapse: collapse; width: 100%; }
|
|
2050
|
+
th, td { border: 1px solid #333; padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
2051
|
+
th { background: #151922; }
|
|
2052
|
+
.muted { color: #9aa0a6; }
|
|
2053
|
+
.stale { color: #ffb4a2; }
|
|
2054
|
+
.ok { color: #8ce99a; }
|
|
2055
|
+
.card { background: #151922; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
|
2056
|
+
</style>
|
|
2057
|
+
</head>
|
|
2058
|
+
<body>
|
|
2059
|
+
<header>
|
|
2060
|
+
<strong>Scribe Studio</strong>
|
|
2061
|
+
<a href="/">Browser</a>
|
|
2062
|
+
<a href="/staleness">Staleness</a>
|
|
2063
|
+
<a href="/history">History</a>
|
|
2064
|
+
</header>
|
|
2065
|
+
<main>${body}</main>
|
|
2066
|
+
</body>
|
|
2067
|
+
</html>`;
|
|
2068
|
+
}
|
|
2069
|
+
async function startStudio(project, options = {}) {
|
|
2070
|
+
const app = new hono.Hono();
|
|
2071
|
+
const config = project.config;
|
|
2072
|
+
app.get("/", (c) => {
|
|
2073
|
+
const cards = project.listTypes().map((type) => {
|
|
2074
|
+
const localeRows = config.locales.map((locale) => {
|
|
2075
|
+
const count = type.load().get(locale)?.bySlug.size ?? 0;
|
|
2076
|
+
return `<li>${locale}: ${count}</li>`;
|
|
2077
|
+
}).join("");
|
|
2078
|
+
return `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
|
|
2079
|
+
});
|
|
2080
|
+
return c.html(renderLayout("Browser", cards.join("")));
|
|
2081
|
+
});
|
|
2082
|
+
app.get("/staleness", (c) => {
|
|
2083
|
+
const worklist = buildWorklist(config);
|
|
2084
|
+
const rows = worklist.slice(0, 500).map(
|
|
2085
|
+
(item) => `<tr><td>${item.contentType}</td><td>${item.enSlug}</td><td>${item.locale}</td><td class="stale">${item.reason}</td></tr>`
|
|
2086
|
+
).join("");
|
|
2087
|
+
const html = `<h1>Translation staleness</h1><p class="muted">${worklist.length} stale/missing entries (showing up to 500)</p>
|
|
2088
|
+
<table><thead><tr><th>Type</th><th>EN slug</th><th>Locale</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
2089
|
+
return c.html(renderLayout("Staleness", html));
|
|
2090
|
+
});
|
|
2091
|
+
app.get("/history", (c) => {
|
|
2092
|
+
const typeId = c.req.query("type") ?? project.listTypes()[0]?.id;
|
|
2093
|
+
const enSlug = c.req.query("slug");
|
|
2094
|
+
if (!typeId) return c.html(renderLayout("History", "<p>No content types.</p>"));
|
|
2095
|
+
const db = openStore(config, "readonly");
|
|
2096
|
+
let body = `<h1>History</h1><form><label>Type <select name="type">${project.listTypes().map((t) => `<option value="${t.id}" ${t.id === typeId ? "selected" : ""}>${t.id}</option>`).join("")}</select></label> <label>EN slug <input name="slug" value="${enSlug ?? ""}" /></label> <button>View</button></form>`;
|
|
2097
|
+
if (enSlug) {
|
|
2098
|
+
const rows = listRevisions(db, typeId, enSlug).slice(0, 100).map(
|
|
2099
|
+
(row) => `<tr><td>${row.created_at}</td><td>${row.revision_kind}</td><td>${row.locale ?? "en"}</td><td>${row.en_hash.slice(0, 8)}</td><td class="muted">${row.body_preview ?? ""}</td></tr>`
|
|
2100
|
+
).join("");
|
|
2101
|
+
body += `<table><thead><tr><th>When</th><th>Kind</th><th>Locale</th><th>EN hash</th><th>Preview</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
2102
|
+
} else {
|
|
2103
|
+
const translations = listTranslationsForType(db, typeId).slice(0, 100);
|
|
2104
|
+
body += `<ul>${translations.map((row) => `<li><a href="/history?type=${typeId}&slug=${row.en_slug}">${row.en_slug}</a> (${row.locale})</li>`).join("")}</ul>`;
|
|
2105
|
+
}
|
|
2106
|
+
db.close();
|
|
2107
|
+
return c.html(renderLayout("History", body));
|
|
2108
|
+
});
|
|
2109
|
+
app.get("/api/staleness-matrix", (c) => {
|
|
2110
|
+
const matrix = {};
|
|
2111
|
+
for (const type of project.listTypes()) {
|
|
2112
|
+
matrix[type.id] = {};
|
|
2113
|
+
for (const locale of config.locales) {
|
|
2114
|
+
if (locale === config.defaultLocale) continue;
|
|
2115
|
+
const enSlugs = type.list().map((doc) => doc.slug);
|
|
2116
|
+
let stale = 0;
|
|
2117
|
+
for (const enSlug of enSlugs) {
|
|
2118
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
2119
|
+
if (!enDoc) continue;
|
|
2120
|
+
const payload = getTranslatablePayload(enDoc, type.config);
|
|
2121
|
+
const hash = computePageEnHash(payload.frontmatter, payload.body);
|
|
2122
|
+
const db = openStore(config, "readonly");
|
|
2123
|
+
const row = db.prepare(
|
|
2124
|
+
`SELECT en_hash FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
|
|
2125
|
+
).get(type.id, enSlug, locale);
|
|
2126
|
+
db.close();
|
|
2127
|
+
if (!row || row.en_hash !== hash) stale++;
|
|
2128
|
+
}
|
|
2129
|
+
matrix[type.id][locale] = stale;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
return c.json(matrix);
|
|
2133
|
+
});
|
|
2134
|
+
const port = options.port ?? 3600;
|
|
2135
|
+
const host = options.host ?? "127.0.0.1";
|
|
2136
|
+
nodeServer.serve({ fetch: app.fetch, port, hostname: host }, () => {
|
|
2137
|
+
console.log(`Scribe studio listening on http://${host}:${port}`);
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// cli/index.ts
|
|
2142
|
+
function parseArgs(argv) {
|
|
2143
|
+
const options = { cwd: process.cwd() };
|
|
2144
|
+
const positional = [];
|
|
2145
|
+
let i = 0;
|
|
2146
|
+
while (i < argv.length) {
|
|
2147
|
+
const arg = argv[i];
|
|
2148
|
+
if (arg === "--config") {
|
|
2149
|
+
options.config = argv[++i];
|
|
2150
|
+
i++;
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
if (arg === "--locale") {
|
|
2154
|
+
const locales = [];
|
|
2155
|
+
while (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
2156
|
+
locales.push(argv[++i]);
|
|
2157
|
+
}
|
|
2158
|
+
options.locale = locales;
|
|
2159
|
+
i++;
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
if (arg === "--preset") {
|
|
2163
|
+
options.preset = argv[++i];
|
|
2164
|
+
i++;
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
if (arg === "--type") {
|
|
2168
|
+
options.type = argv[++i];
|
|
2169
|
+
i++;
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
if (arg === "--slug") {
|
|
2173
|
+
options.slug = argv[++i];
|
|
2174
|
+
i++;
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
if (arg === "--model") {
|
|
2178
|
+
options.model = argv[++i];
|
|
2179
|
+
i++;
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
if (arg === "--port") {
|
|
2183
|
+
options.port = Number(argv[++i]);
|
|
2184
|
+
i++;
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
if (arg === "--dry-run") {
|
|
2188
|
+
options.dryRun = true;
|
|
2189
|
+
i++;
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (arg === "--force") {
|
|
2193
|
+
options.force = true;
|
|
2194
|
+
i++;
|
|
2195
|
+
continue;
|
|
2196
|
+
}
|
|
2197
|
+
if (arg.startsWith("--")) {
|
|
2198
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
2199
|
+
}
|
|
2200
|
+
positional.push(arg);
|
|
2201
|
+
i++;
|
|
2202
|
+
}
|
|
2203
|
+
const [command = "help", ...rest] = positional;
|
|
2204
|
+
return { command, options, rest };
|
|
2205
|
+
}
|
|
2206
|
+
function loadProject(options) {
|
|
2207
|
+
const config = loadConfigSync({
|
|
2208
|
+
cwd: options.cwd,
|
|
2209
|
+
configPath: options.config
|
|
2210
|
+
});
|
|
2211
|
+
return createProject(config);
|
|
2212
|
+
}
|
|
2213
|
+
async function main() {
|
|
2214
|
+
const { command, options, rest } = parseArgs(process.argv.slice(2));
|
|
2215
|
+
loadEnvFromCwd(options.cwd);
|
|
2216
|
+
if (command === "help" || command === "--help") {
|
|
2217
|
+
console.log(`Usage: scribe <command>
|
|
2218
|
+
|
|
2219
|
+
Commands:
|
|
2220
|
+
status Show EN docs + translation counts
|
|
2221
|
+
validate Validate EN files and sqlite consistency
|
|
2222
|
+
translate Translate stale/missing locale pages
|
|
2223
|
+
history <type> <slug> Show revision timeline
|
|
2224
|
+
studio Start read-only local studio
|
|
2225
|
+
`);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const project = loadProject(options);
|
|
2229
|
+
const config = project.config;
|
|
2230
|
+
switch (command) {
|
|
2231
|
+
case "status": {
|
|
2232
|
+
for (const type of project.listTypes()) {
|
|
2233
|
+
const enCount = type.list().length;
|
|
2234
|
+
console.log(`${type.id}: ${enCount} EN docs`);
|
|
2235
|
+
for (const locale of config.locales) {
|
|
2236
|
+
if (locale === config.defaultLocale) continue;
|
|
2237
|
+
const count = type.load().get(locale)?.bySlug.size ?? 0;
|
|
2238
|
+
console.log(` ${locale}: ${count}`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
console.log(`store: ${project.storePath}`);
|
|
2242
|
+
break;
|
|
2243
|
+
}
|
|
2244
|
+
case "validate": {
|
|
2245
|
+
const result = validateProject(config);
|
|
2246
|
+
for (const issue of result.issues) {
|
|
2247
|
+
console.log(
|
|
2248
|
+
`[${issue.level}] ${issue.contentType ?? ""} ${issue.enSlug ?? ""} ${issue.locale ?? ""} ${issue.message}`
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
if (!result.ok) process.exitCode = 1;
|
|
2252
|
+
break;
|
|
2253
|
+
}
|
|
2254
|
+
case "translate": {
|
|
2255
|
+
const locales = resolveLocalesFromPreset(config, options.preset, options.locale);
|
|
2256
|
+
const worklist = buildWorklist(config, {
|
|
2257
|
+
contentType: options.type,
|
|
2258
|
+
locales,
|
|
2259
|
+
enSlug: options.slug
|
|
2260
|
+
});
|
|
2261
|
+
console.log(`Translating ${worklist.length} page(s)...`);
|
|
2262
|
+
const results = await translateWorklist(config, worklist, {
|
|
2263
|
+
model: options.model,
|
|
2264
|
+
dryRun: options.dryRun,
|
|
2265
|
+
force: options.force
|
|
2266
|
+
});
|
|
2267
|
+
for (const result of results) {
|
|
2268
|
+
console.log(
|
|
2269
|
+
`${result.contentType}/${result.enSlug}@${result.locale}: ${result.skipped ? "skipped" : "translated"}${result.model ? ` (${result.model})` : ""}`
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
break;
|
|
2273
|
+
}
|
|
2274
|
+
case "history": {
|
|
2275
|
+
const [typeId, enSlug, locale] = rest;
|
|
2276
|
+
if (!typeId || !enSlug) {
|
|
2277
|
+
throw new Error("Usage: scribe history <type> <en-slug> [locale]");
|
|
2278
|
+
}
|
|
2279
|
+
const { openStore: openStore2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
|
|
2280
|
+
const db = openStore2(config, "readonly");
|
|
2281
|
+
const rows = listRevisions(db, typeId, enSlug, locale);
|
|
2282
|
+
db.close();
|
|
2283
|
+
for (const row of rows) {
|
|
2284
|
+
console.log(
|
|
2285
|
+
`${row.created_at} ${row.revision_kind} locale=${row.locale ?? "en"} en_hash=${row.en_hash.slice(0, 8)} body_hash=${row.body_hash.slice(0, 8)}`
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
break;
|
|
2289
|
+
}
|
|
2290
|
+
case "studio": {
|
|
2291
|
+
await startStudio(project, { port: options.port ?? 3600 });
|
|
2292
|
+
break;
|
|
2293
|
+
}
|
|
2294
|
+
default:
|
|
2295
|
+
throw new Error(`Unknown command: ${command}`);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
main().catch((error) => {
|
|
2299
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2300
|
+
process.exit(1);
|
|
2301
|
+
});
|
|
2302
|
+
//# sourceMappingURL=index.cjs.map
|
|
2303
|
+
//# sourceMappingURL=index.cjs.map
|