scribe-cms 0.0.2 → 0.0.5
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 +2 -3
- package/bin/scribe.js +2 -11
- package/dist/cli/index.cjs +1437 -225
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +1437 -226
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/prompt-translate.d.ts +16 -0
- package/dist/cli/prompt-translate.d.ts.map +1 -0
- package/dist/cli/translate-progress.d.ts +11 -0
- package/dist/cli/translate-progress.d.ts.map +1 -0
- package/dist/index.cjs +591 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +587 -148
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +14 -2
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +14 -2
- package/dist/runtime.js.map +1 -1
- package/dist/src/config/resolve-config.d.ts.map +1 -1
- package/dist/src/core/field.d.ts +2 -0
- package/dist/src/core/field.d.ts.map +1 -1
- package/dist/src/core/types.d.ts +5 -1
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/export/build-static-raw-exports.d.ts +37 -0
- package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
- package/dist/src/export/write-static-raw-exports.d.ts +12 -0
- package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
- package/dist/src/history/record-revision.d.ts +2 -0
- package/dist/src/history/record-revision.d.ts.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/storage/sqlite.d.ts.map +1 -1
- package/dist/src/storage/translations.d.ts +6 -0
- package/dist/src/storage/translations.d.ts.map +1 -1
- package/dist/src/translate/gemini-client.d.ts +7 -0
- package/dist/src/translate/gemini-client.d.ts.map +1 -1
- package/dist/src/translate/gemini-models.d.ts +8 -0
- package/dist/src/translate/gemini-models.d.ts.map +1 -0
- package/dist/src/translate/gemini-pricing.d.ts +11 -0
- package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
- package/dist/src/translate/page-translator.d.ts +38 -1
- package/dist/src/translate/page-translator.d.ts.map +1 -1
- package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
- package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
- package/dist/src/translate/response-schema.d.ts +7 -0
- package/dist/src/translate/response-schema.d.ts.map +1 -0
- package/dist/src/translate/validate-translation.d.ts +14 -0
- package/dist/src/translate/validate-translation.d.ts.map +1 -0
- package/dist/src/translate/worklist.d.ts +3 -0
- package/dist/src/translate/worklist.d.ts.map +1 -1
- package/dist/src/validate/validate-assets.d.ts +20 -0
- package/dist/src/validate/validate-assets.d.ts.map +1 -0
- package/dist/src/validate/validate-project.d.ts +1 -1
- package/dist/src/validate/validate-project.d.ts.map +1 -1
- package/dist/src/version.d.ts +4 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/studio/server.cjs +611 -194
- package/dist/studio/server.cjs.map +1 -1
- package/dist/studio/server.d.ts +1 -1
- package/dist/studio/server.d.ts.map +1 -1
- package/dist/studio/server.js +611 -194
- package/dist/studio/server.js.map +1 -1
- package/package.json +16 -5
- package/bin/ensure-built.mjs +0 -31
package/dist/studio/server.cjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
var nodeServer = require('@hono/node-server');
|
|
4
4
|
var hono = require('hono');
|
|
5
|
+
var zod = require('zod');
|
|
5
6
|
var crypto = require('crypto');
|
|
6
7
|
var fs2 = require('fs');
|
|
7
8
|
var path2 = require('path');
|
|
8
9
|
var matter = require('gray-matter');
|
|
9
|
-
var zod = require('zod');
|
|
10
10
|
var Database = require('better-sqlite3');
|
|
11
11
|
|
|
12
12
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -17,6 +17,164 @@ var matter__default = /*#__PURE__*/_interopDefault(matter);
|
|
|
17
17
|
var Database__default = /*#__PURE__*/_interopDefault(Database);
|
|
18
18
|
|
|
19
19
|
// studio/server.ts
|
|
20
|
+
var FIELD_KIND = /* @__PURE__ */ Symbol.for("@genlook/scribe/fieldKind");
|
|
21
|
+
var RELATION_META = /* @__PURE__ */ Symbol.for("@genlook/scribe/relationMeta");
|
|
22
|
+
function getFieldKind(schema) {
|
|
23
|
+
const tagged = schema;
|
|
24
|
+
return tagged[FIELD_KIND] ?? "structural";
|
|
25
|
+
}
|
|
26
|
+
function getRelationTarget(schema) {
|
|
27
|
+
let current = schema;
|
|
28
|
+
for (let i = 0; i < 8; i++) {
|
|
29
|
+
const tagged = current;
|
|
30
|
+
if (tagged[RELATION_META]) {
|
|
31
|
+
return tagged[RELATION_META];
|
|
32
|
+
}
|
|
33
|
+
if (typeof tagged.unwrap === "function") {
|
|
34
|
+
current = tagged.unwrap();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (typeof tagged.removeDefault === "function") {
|
|
38
|
+
current = tagged.removeDefault();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (tagged._def?.innerType) {
|
|
42
|
+
current = tagged._def.innerType;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function unwrapSchema(schema) {
|
|
50
|
+
let current = schema;
|
|
51
|
+
for (let i = 0; i < 8; i++) {
|
|
52
|
+
const anySchema = current;
|
|
53
|
+
if (typeof anySchema.unwrap === "function") {
|
|
54
|
+
current = anySchema.unwrap();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (typeof anySchema.removeDefault === "function") {
|
|
58
|
+
current = anySchema.removeDefault();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (anySchema._def?.innerType) {
|
|
62
|
+
current = anySchema._def.innerType;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/core/introspect-schema.ts
|
|
71
|
+
function introspectSchema(schema, prefix = []) {
|
|
72
|
+
const relation = getRelationTarget(schema);
|
|
73
|
+
if (relation && prefix.length > 0) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
path: prefix,
|
|
77
|
+
kind: "relation",
|
|
78
|
+
relationTarget: relation.typeId,
|
|
79
|
+
relationMultiple: relation.multiple,
|
|
80
|
+
relationOptional: relation.optional
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
const base = unwrapSchema(schema);
|
|
85
|
+
if (base instanceof Object && "shape" in base) {
|
|
86
|
+
const shape = base.shape;
|
|
87
|
+
const fields = [];
|
|
88
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
89
|
+
fields.push(...introspectSchema(child, [...prefix, key]));
|
|
90
|
+
}
|
|
91
|
+
return fields;
|
|
92
|
+
}
|
|
93
|
+
if (base instanceof Object && "element" in base) {
|
|
94
|
+
const element = base.element;
|
|
95
|
+
const elementBase = unwrapSchema(element);
|
|
96
|
+
if (elementBase instanceof Object && "shape" in elementBase) {
|
|
97
|
+
const shape = elementBase.shape;
|
|
98
|
+
const fields = [];
|
|
99
|
+
for (const [key, child] of Object.entries(shape)) {
|
|
100
|
+
fields.push(...introspectSchema(child, [...prefix, "*", key]));
|
|
101
|
+
}
|
|
102
|
+
return fields;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [{ path: prefix, kind: getFieldKind(schema) }];
|
|
106
|
+
}
|
|
107
|
+
function extractByPaths(data, paths) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const path4 of paths) {
|
|
110
|
+
const value = getAtPath(data, path4);
|
|
111
|
+
if (value !== void 0) {
|
|
112
|
+
setAtPath(out, path4.filter((p) => p !== "*"), value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function pickTranslatable(data, schema) {
|
|
118
|
+
const translatablePaths = introspectSchema(schema).filter((f) => f.kind === "translatable").map((f) => f.path);
|
|
119
|
+
return extractByPaths(data, translatablePaths);
|
|
120
|
+
}
|
|
121
|
+
function pickStructural(data, schema) {
|
|
122
|
+
const structuralPaths = introspectSchema(schema).filter((f) => f.kind === "structural" || f.kind === "relation").map((f) => f.path);
|
|
123
|
+
return extractByPaths(data, structuralPaths);
|
|
124
|
+
}
|
|
125
|
+
function mergeStructuralOntoLocale(localeData, enData, schema) {
|
|
126
|
+
const structural = pickStructural(enData, schema);
|
|
127
|
+
const translatable = pickTranslatable(localeData, schema);
|
|
128
|
+
return deepMerge(structural, translatable);
|
|
129
|
+
}
|
|
130
|
+
function getAtPath(obj, path4) {
|
|
131
|
+
let current = obj;
|
|
132
|
+
for (const segment of path4) {
|
|
133
|
+
if (segment === "*") {
|
|
134
|
+
if (!Array.isArray(current)) return void 0;
|
|
135
|
+
return current.map(
|
|
136
|
+
(item) => typeof item === "object" && item !== null ? getAtPath(item, path4.slice(path4.indexOf("*") + 1)) : void 0
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
140
|
+
current = current[segment];
|
|
141
|
+
}
|
|
142
|
+
return current;
|
|
143
|
+
}
|
|
144
|
+
function setAtPath(obj, path4, value) {
|
|
145
|
+
if (path4.length === 0) return;
|
|
146
|
+
if (path4.length === 1) {
|
|
147
|
+
obj[path4[0]] = value;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const [head, ...rest] = path4;
|
|
151
|
+
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
152
|
+
obj[head] = {};
|
|
153
|
+
}
|
|
154
|
+
setAtPath(obj[head], rest, value);
|
|
155
|
+
}
|
|
156
|
+
function deepMerge(base, overlay) {
|
|
157
|
+
const out = { ...base };
|
|
158
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
159
|
+
if (value && typeof value === "object" && !Array.isArray(value) && out[key] && typeof out[key] === "object" && !Array.isArray(out[key])) {
|
|
160
|
+
out[key] = deepMerge(out[key], value);
|
|
161
|
+
} else if (Array.isArray(value) && Array.isArray(out[key])) {
|
|
162
|
+
out[key] = mergeArrayOverlay(out[key], value);
|
|
163
|
+
} else {
|
|
164
|
+
out[key] = value;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function mergeArrayOverlay(base, overlay) {
|
|
170
|
+
return base.map((item, index) => {
|
|
171
|
+
const overlayItem = overlay[index];
|
|
172
|
+
if (item && typeof item === "object" && !Array.isArray(item) && overlayItem && typeof overlayItem === "object" && !Array.isArray(overlayItem)) {
|
|
173
|
+
return deepMerge(item, overlayItem);
|
|
174
|
+
}
|
|
175
|
+
return overlayItem ?? item;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
20
178
|
function sha256(input) {
|
|
21
179
|
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
22
180
|
}
|
|
@@ -184,134 +342,7 @@ function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
|
|
|
184
342
|
out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
|
|
185
343
|
return out;
|
|
186
344
|
}
|
|
187
|
-
var
|
|
188
|
-
var RELATION_META = /* @__PURE__ */ Symbol.for("@genlook/scribe/relationMeta");
|
|
189
|
-
function getFieldKind(schema) {
|
|
190
|
-
const tagged = schema;
|
|
191
|
-
return tagged[FIELD_KIND] ?? "structural";
|
|
192
|
-
}
|
|
193
|
-
function getRelationTarget(schema) {
|
|
194
|
-
let current = schema;
|
|
195
|
-
for (let i = 0; i < 8; i++) {
|
|
196
|
-
const tagged = current;
|
|
197
|
-
if (tagged[RELATION_META]) {
|
|
198
|
-
return tagged[RELATION_META];
|
|
199
|
-
}
|
|
200
|
-
if (typeof tagged.unwrap === "function") {
|
|
201
|
-
current = tagged.unwrap();
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (typeof tagged.removeDefault === "function") {
|
|
205
|
-
current = tagged.removeDefault();
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (tagged._def?.innerType) {
|
|
209
|
-
current = tagged._def.innerType;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
function unwrapSchema(schema) {
|
|
217
|
-
let current = schema;
|
|
218
|
-
for (let i = 0; i < 8; i++) {
|
|
219
|
-
const anySchema = current;
|
|
220
|
-
if (typeof anySchema.unwrap === "function") {
|
|
221
|
-
current = anySchema.unwrap();
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (typeof anySchema.removeDefault === "function") {
|
|
225
|
-
current = anySchema.removeDefault();
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (anySchema._def?.innerType) {
|
|
229
|
-
current = anySchema._def.innerType;
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
return current;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// src/core/introspect-schema.ts
|
|
238
|
-
function introspectSchema(schema, prefix = []) {
|
|
239
|
-
const relation = getRelationTarget(schema);
|
|
240
|
-
if (relation && prefix.length > 0) {
|
|
241
|
-
return [
|
|
242
|
-
{
|
|
243
|
-
path: prefix,
|
|
244
|
-
kind: "relation",
|
|
245
|
-
relationTarget: relation.typeId,
|
|
246
|
-
relationMultiple: relation.multiple,
|
|
247
|
-
relationOptional: relation.optional
|
|
248
|
-
}
|
|
249
|
-
];
|
|
250
|
-
}
|
|
251
|
-
const base = unwrapSchema(schema);
|
|
252
|
-
if (base instanceof Object && "shape" in base) {
|
|
253
|
-
const shape = base.shape;
|
|
254
|
-
const fields = [];
|
|
255
|
-
for (const [key, child] of Object.entries(shape)) {
|
|
256
|
-
fields.push(...introspectSchema(child, [...prefix, key]));
|
|
257
|
-
}
|
|
258
|
-
return fields;
|
|
259
|
-
}
|
|
260
|
-
if (base instanceof Object && "element" in base) {
|
|
261
|
-
const element = base.element;
|
|
262
|
-
const elementBase = unwrapSchema(element);
|
|
263
|
-
if (elementBase instanceof Object && "shape" in elementBase) {
|
|
264
|
-
const shape = elementBase.shape;
|
|
265
|
-
const fields = [];
|
|
266
|
-
for (const [key, child] of Object.entries(shape)) {
|
|
267
|
-
fields.push(...introspectSchema(child, [...prefix, "*", key]));
|
|
268
|
-
}
|
|
269
|
-
return fields;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return [{ path: prefix, kind: getFieldKind(schema) }];
|
|
273
|
-
}
|
|
274
|
-
function extractByPaths(data, paths) {
|
|
275
|
-
const out = {};
|
|
276
|
-
for (const path4 of paths) {
|
|
277
|
-
const value = getAtPath(data, path4);
|
|
278
|
-
if (value !== void 0) {
|
|
279
|
-
setAtPath(out, path4.filter((p) => p !== "*"), value);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return out;
|
|
283
|
-
}
|
|
284
|
-
function pickTranslatable(data, schema) {
|
|
285
|
-
const translatablePaths = introspectSchema(schema).filter((f) => f.kind === "translatable").map((f) => f.path);
|
|
286
|
-
return extractByPaths(data, translatablePaths);
|
|
287
|
-
}
|
|
288
|
-
function getAtPath(obj, path4) {
|
|
289
|
-
let current = obj;
|
|
290
|
-
for (const segment of path4) {
|
|
291
|
-
if (segment === "*") {
|
|
292
|
-
if (!Array.isArray(current)) return void 0;
|
|
293
|
-
return current.map(
|
|
294
|
-
(item) => typeof item === "object" && item !== null ? getAtPath(item, path4.slice(path4.indexOf("*") + 1)) : void 0
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
298
|
-
current = current[segment];
|
|
299
|
-
}
|
|
300
|
-
return current;
|
|
301
|
-
}
|
|
302
|
-
function setAtPath(obj, path4, value) {
|
|
303
|
-
if (path4.length === 0) return;
|
|
304
|
-
if (path4.length === 1) {
|
|
305
|
-
obj[path4[0]] = value;
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
const [head, ...rest] = path4;
|
|
309
|
-
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
310
|
-
obj[head] = {};
|
|
311
|
-
}
|
|
312
|
-
setAtPath(obj[head], rest, value);
|
|
313
|
-
}
|
|
314
|
-
var SCHEMA_VERSION = 2;
|
|
345
|
+
var SCHEMA_VERSION = 3;
|
|
315
346
|
var MIGRATIONS = [
|
|
316
347
|
`CREATE TABLE IF NOT EXISTS meta (
|
|
317
348
|
key TEXT PRIMARY KEY,
|
|
@@ -368,17 +399,27 @@ function resolveStorePath(config) {
|
|
|
368
399
|
}
|
|
369
400
|
function openStore(config, mode = "readwrite") {
|
|
370
401
|
const storePath = resolveStorePath(config);
|
|
371
|
-
|
|
402
|
+
if (mode === "readwrite") {
|
|
403
|
+
fs2__default.default.mkdirSync(path2__default.default.dirname(storePath), { recursive: true });
|
|
404
|
+
}
|
|
372
405
|
const db = new Database__default.default(storePath, { readonly: mode === "readonly" });
|
|
373
406
|
if (mode === "readwrite") {
|
|
374
407
|
migrate(db);
|
|
375
408
|
}
|
|
376
409
|
return db;
|
|
377
410
|
}
|
|
411
|
+
function addColumnIfMissing(db, table, column, ddlType) {
|
|
412
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
413
|
+
if (!columns.some((c) => c.name === column)) {
|
|
414
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddlType}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
378
417
|
function migrate(db) {
|
|
379
418
|
for (const sql of MIGRATIONS) {
|
|
380
419
|
db.exec(sql);
|
|
381
420
|
}
|
|
421
|
+
addColumnIfMissing(db, "revisions", "frontmatter_json", "TEXT");
|
|
422
|
+
addColumnIfMissing(db, "revisions", "body", "TEXT");
|
|
382
423
|
db.prepare(
|
|
383
424
|
`INSERT INTO meta(key, value) VALUES('schema_version', ?)
|
|
384
425
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
@@ -391,10 +432,17 @@ function getTranslation(db, contentType, enSlug, locale) {
|
|
|
391
432
|
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
|
|
392
433
|
).get(contentType, enSlug, locale);
|
|
393
434
|
}
|
|
394
|
-
function
|
|
395
|
-
return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug
|
|
435
|
+
function listTranslationsForLocale(db, contentType, locale) {
|
|
436
|
+
return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
|
|
396
437
|
}
|
|
397
438
|
function listRevisions(db, contentType, enSlug, locale) {
|
|
439
|
+
if (locale) {
|
|
440
|
+
return db.prepare(
|
|
441
|
+
`SELECT * FROM revisions
|
|
442
|
+
WHERE content_type = ? AND en_slug = ? AND locale = ?
|
|
443
|
+
ORDER BY created_at DESC, id DESC`
|
|
444
|
+
).all(contentType, enSlug, locale);
|
|
445
|
+
}
|
|
398
446
|
return db.prepare(
|
|
399
447
|
`SELECT * FROM revisions
|
|
400
448
|
WHERE content_type = ? AND en_slug = ?
|
|
@@ -560,103 +608,472 @@ function buildWorklist(config, options = {}) {
|
|
|
560
608
|
}
|
|
561
609
|
}
|
|
562
610
|
db.close();
|
|
611
|
+
const strategy = options.strategy ?? "all";
|
|
612
|
+
if (strategy === "missing-only") {
|
|
613
|
+
return items.filter((item) => item.reason === "missing");
|
|
614
|
+
}
|
|
563
615
|
return items;
|
|
564
616
|
}
|
|
565
617
|
|
|
566
618
|
// studio/server.ts
|
|
567
|
-
function
|
|
619
|
+
function escapeHtml(value) {
|
|
620
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
621
|
+
}
|
|
622
|
+
function encodePathSegment(value) {
|
|
623
|
+
return encodeURIComponent(value);
|
|
624
|
+
}
|
|
625
|
+
function statusDot(status) {
|
|
626
|
+
const labels = {
|
|
627
|
+
source: "en",
|
|
628
|
+
"up-to-date": "ok",
|
|
629
|
+
stale: "stale",
|
|
630
|
+
missing: "\u2014"
|
|
631
|
+
};
|
|
632
|
+
return `<span class="status" title="${status}"><span class="dot dot-${status}"></span>${labels[status]}</span>`;
|
|
633
|
+
}
|
|
634
|
+
function documentStatus(config, db, type, enSlug, locale) {
|
|
635
|
+
if (locale === config.defaultLocale) {
|
|
636
|
+
return { status: "source" };
|
|
637
|
+
}
|
|
638
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
639
|
+
if (!enDoc) {
|
|
640
|
+
return { status: "missing" };
|
|
641
|
+
}
|
|
642
|
+
const payload = getTranslatablePayload(enDoc, type.config);
|
|
643
|
+
const currentEnHash = computePageEnHash(payload.frontmatter, payload.body);
|
|
644
|
+
const row = getTranslation(db, type.id, enSlug, locale);
|
|
645
|
+
if (!row) {
|
|
646
|
+
return { status: "missing", currentEnHash };
|
|
647
|
+
}
|
|
648
|
+
if (row.en_hash !== currentEnHash) {
|
|
649
|
+
return { status: "stale", currentEnHash, storedEnHash: row.en_hash };
|
|
650
|
+
}
|
|
651
|
+
return { status: "up-to-date", currentEnHash, storedEnHash: row.en_hash };
|
|
652
|
+
}
|
|
653
|
+
function flattenFrontmatter(data, prefix = "") {
|
|
654
|
+
const rows = [];
|
|
655
|
+
for (const [key, value] of Object.entries(data)) {
|
|
656
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
657
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
658
|
+
rows.push(...flattenFrontmatter(value, fullKey));
|
|
659
|
+
} else {
|
|
660
|
+
const display = typeof value === "string" ? value : value === void 0 ? "" : JSON.stringify(value, null, 2);
|
|
661
|
+
rows.push({ key: fullKey, value: display });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return rows;
|
|
665
|
+
}
|
|
666
|
+
function translatableKeySet(schema) {
|
|
667
|
+
return new Set(
|
|
668
|
+
introspectSchema(schema).filter((field) => field.kind === "translatable").map((field) => field.path.join("."))
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
function renderFrontmatterTable(frontmatter, schema) {
|
|
672
|
+
const translatable = translatableKeySet(schema);
|
|
673
|
+
const rows = flattenFrontmatter(frontmatter).map((row) => {
|
|
674
|
+
const flag = translatable.has(row.key) ? `<span class="flag t" title="Translatable">T</span>` : `<span class="flag s" title="Structural">S</span>`;
|
|
675
|
+
return `<tr>
|
|
676
|
+
<td class="k">${flag}${escapeHtml(row.key)}</td>
|
|
677
|
+
<td class="v">${escapeHtml(row.value)}</td>
|
|
678
|
+
</tr>`;
|
|
679
|
+
}).join("");
|
|
680
|
+
return `<table class="kv">
|
|
681
|
+
<tbody>${rows || `<tr><td colspan="2" class="dim">\u2014</td></tr>`}</tbody>
|
|
682
|
+
</table>`;
|
|
683
|
+
}
|
|
684
|
+
function renderRevisionSnapshot(revision) {
|
|
685
|
+
if (revision.frontmatter_json && revision.body) {
|
|
686
|
+
let frontmatter = {};
|
|
687
|
+
try {
|
|
688
|
+
frontmatter = JSON.parse(revision.frontmatter_json);
|
|
689
|
+
} catch {
|
|
690
|
+
frontmatter = {};
|
|
691
|
+
}
|
|
692
|
+
const fmRows = flattenFrontmatter(frontmatter).map(
|
|
693
|
+
(row) => `<tr><td class="k">${escapeHtml(row.key)}</td><td class="v">${escapeHtml(row.value)}</td></tr>`
|
|
694
|
+
).join("");
|
|
695
|
+
return `<table class="kv"><tbody>${fmRows}</tbody></table>
|
|
696
|
+
<pre class="code">${escapeHtml(revision.body)}</pre>`;
|
|
697
|
+
}
|
|
698
|
+
return `<p class="dim">${escapeHtml(revision.body_preview ?? "(no snapshot)")}</p>`;
|
|
699
|
+
}
|
|
700
|
+
function renderRevisionTimeline(revisions) {
|
|
701
|
+
if (revisions.length === 0) {
|
|
702
|
+
return `<p class="dim">No history.</p>`;
|
|
703
|
+
}
|
|
704
|
+
const items = revisions.map(
|
|
705
|
+
(row) => `<tr>
|
|
706
|
+
<td class="dim">${escapeHtml(row.created_at.slice(0, 19))}</td>
|
|
707
|
+
<td>${escapeHtml(row.revision_kind)}</td>
|
|
708
|
+
<td class="dim">${row.model ? escapeHtml(row.model) : "\u2014"}</td>
|
|
709
|
+
<td class="dim mono">${escapeHtml(row.en_hash.slice(0, 8))}</td>
|
|
710
|
+
<td><details><summary>view</summary>${renderRevisionSnapshot(row)}</details></td>
|
|
711
|
+
</tr>`
|
|
712
|
+
).join("");
|
|
713
|
+
return `<table class="data"><thead><tr>
|
|
714
|
+
<th>When</th><th>Kind</th><th>Model</th><th>Hash</th><th></th>
|
|
715
|
+
</tr></thead><tbody>${items}</tbody></table>`;
|
|
716
|
+
}
|
|
717
|
+
function renderLayout(title, body, project, options = {}) {
|
|
718
|
+
const typeLinks = project.listTypes().map((type) => {
|
|
719
|
+
const active = type.id === options.activeTypeId ? " active" : "";
|
|
720
|
+
return `<a class="tree-item${active}" href="/type/${encodePathSegment(type.id)}">
|
|
721
|
+
<span class="tree-label">${escapeHtml(type.config.label)}</span>
|
|
722
|
+
<span class="tree-meta">${escapeHtml(type.id)}</span>
|
|
723
|
+
</a>`;
|
|
724
|
+
}).join("");
|
|
568
725
|
return `<!doctype html>
|
|
569
726
|
<html lang="en">
|
|
570
727
|
<head>
|
|
571
728
|
<meta charset="utf-8" />
|
|
572
729
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
573
|
-
<title>${title} \u2014 Scribe
|
|
730
|
+
<title>${escapeHtml(title)} \u2014 Scribe</title>
|
|
574
731
|
<style>
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
732
|
+
:root {
|
|
733
|
+
--bg: #1e1e1e;
|
|
734
|
+
--sidebar: #252526;
|
|
735
|
+
--bar: #333333;
|
|
736
|
+
--panel: #1e1e1e;
|
|
737
|
+
--border: #3c3c3c;
|
|
738
|
+
--text: #cccccc;
|
|
739
|
+
--dim: #858585;
|
|
740
|
+
--accent: #3794ff;
|
|
741
|
+
--hover: #2a2d2e;
|
|
742
|
+
--active: #37373d;
|
|
743
|
+
--ok: #89d185;
|
|
744
|
+
--stale: #cca700;
|
|
745
|
+
--missing: #f48771;
|
|
746
|
+
--source: #75beff;
|
|
747
|
+
--mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
|
748
|
+
--ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
749
|
+
--fs: 13px;
|
|
750
|
+
--fs-sm: 11px;
|
|
751
|
+
}
|
|
752
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
753
|
+
body { font: var(--fs)/1.4 var(--ui); background: var(--bg); color: var(--text); }
|
|
754
|
+
a { color: var(--accent); text-decoration: none; }
|
|
755
|
+
a:hover { text-decoration: underline; }
|
|
756
|
+
.app { display: flex; height: 100vh; overflow: hidden; }
|
|
757
|
+
|
|
758
|
+
/* activity bar */
|
|
759
|
+
.actbar {
|
|
760
|
+
width: 48px; flex-shrink: 0; background: var(--bar);
|
|
761
|
+
display: flex; flex-direction: column; align-items: center;
|
|
762
|
+
padding: 8px 0; gap: 4px; border-right: 1px solid var(--border);
|
|
763
|
+
}
|
|
764
|
+
.actbar a {
|
|
765
|
+
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
|
|
766
|
+
color: var(--dim); font-size: 18px; text-decoration: none; position: relative;
|
|
767
|
+
}
|
|
768
|
+
.actbar a:hover { color: var(--text); }
|
|
769
|
+
.actbar a.active { color: var(--text); }
|
|
770
|
+
.actbar a.active::before {
|
|
771
|
+
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
|
|
772
|
+
width: 2px; background: var(--accent);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* sidebar */
|
|
776
|
+
.sidebar {
|
|
777
|
+
width: 200px; flex-shrink: 0; background: var(--sidebar);
|
|
778
|
+
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
|
779
|
+
overflow: hidden;
|
|
780
|
+
}
|
|
781
|
+
.sidebar-head {
|
|
782
|
+
padding: 8px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
783
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
784
|
+
}
|
|
785
|
+
.sidebar-body { flex: 1; overflow-y: auto; padding: 2px 0; }
|
|
786
|
+
.tree-item {
|
|
787
|
+
display: flex; flex-direction: column; padding: 3px 12px 3px 20px;
|
|
788
|
+
color: var(--text); text-decoration: none; line-height: 1.3;
|
|
789
|
+
}
|
|
790
|
+
.tree-item:hover { background: var(--hover); text-decoration: none; }
|
|
791
|
+
.tree-item.active { background: var(--active); }
|
|
792
|
+
.tree-label { font-size: var(--fs); }
|
|
793
|
+
.tree-meta { font-size: var(--fs-sm); color: var(--dim); }
|
|
794
|
+
|
|
795
|
+
/* main */
|
|
796
|
+
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
797
|
+
.toolbar {
|
|
798
|
+
display: flex; align-items: center; gap: 6px; padding: 0 12px;
|
|
799
|
+
height: 35px; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
800
|
+
font-size: var(--fs-sm); color: var(--dim); flex-shrink: 0; overflow: hidden;
|
|
801
|
+
}
|
|
802
|
+
.toolbar a { color: var(--dim); }
|
|
803
|
+
.toolbar a:hover { color: var(--text); }
|
|
804
|
+
.toolbar .sep { color: var(--border); }
|
|
805
|
+
.content { flex: 1; overflow-y: auto; padding: 0; }
|
|
806
|
+
|
|
807
|
+
/* tabs (locale switcher) */
|
|
808
|
+
.tabs {
|
|
809
|
+
display: flex; background: var(--sidebar); border-bottom: 1px solid var(--border);
|
|
810
|
+
overflow-x: auto; flex-shrink: 0;
|
|
811
|
+
}
|
|
812
|
+
.tab {
|
|
813
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
|
814
|
+
font-size: var(--fs-sm); color: var(--dim); border-right: 1px solid var(--border);
|
|
815
|
+
text-decoration: none; white-space: nowrap;
|
|
816
|
+
}
|
|
817
|
+
.tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
|
|
818
|
+
.tab.active {
|
|
819
|
+
background: var(--bg); color: var(--text);
|
|
820
|
+
border-bottom: 1px solid var(--bg); margin-bottom: -1px;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/* status dots */
|
|
824
|
+
.status { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--dim); }
|
|
825
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
826
|
+
.dot-source { background: var(--source); }
|
|
827
|
+
.dot-up-to-date { background: var(--ok); }
|
|
828
|
+
.dot-stale { background: var(--stale); }
|
|
829
|
+
.dot-missing { background: var(--missing); opacity: 0.7; }
|
|
830
|
+
|
|
831
|
+
/* sections */
|
|
832
|
+
.section { border-bottom: 1px solid var(--border); }
|
|
833
|
+
.section-head {
|
|
834
|
+
padding: 4px 12px; font-size: var(--fs-sm); font-weight: 600;
|
|
835
|
+
text-transform: uppercase; letter-spacing: 0.04em; color: var(--dim);
|
|
836
|
+
background: var(--sidebar);
|
|
837
|
+
}
|
|
838
|
+
.section-body { padding: 0; }
|
|
839
|
+
|
|
840
|
+
/* tables */
|
|
841
|
+
table { border-collapse: collapse; width: 100%; font-size: var(--fs); }
|
|
842
|
+
.data th, .data td { padding: 2px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
843
|
+
.data th { font-size: var(--fs-sm); font-weight: 600; color: var(--dim); background: var(--sidebar); height: 22px; }
|
|
844
|
+
.data tr:hover td { background: var(--hover); }
|
|
845
|
+
.data .mono { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
846
|
+
|
|
847
|
+
/* key-value (frontmatter) */
|
|
848
|
+
.kv td { padding: 1px 12px; font-family: var(--mono); font-size: var(--fs-sm); vertical-align: top; border-bottom: 1px solid var(--border); }
|
|
849
|
+
.kv .k { width: 160px; color: #9cdcfe; white-space: nowrap; }
|
|
850
|
+
.kv .v { color: #ce9178; white-space: pre-wrap; word-break: break-word; }
|
|
851
|
+
.flag { font-size: 9px; font-weight: 700; margin-right: 4px; opacity: 0.5; }
|
|
852
|
+
.flag.t { color: var(--ok); }
|
|
853
|
+
.flag.s { color: var(--dim); }
|
|
854
|
+
|
|
855
|
+
/* code block */
|
|
856
|
+
.code {
|
|
857
|
+
margin: 0; padding: 8px 12px; font: var(--fs-sm)/1.5 var(--mono);
|
|
858
|
+
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
|
859
|
+
background: var(--bg); border: none;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/* meta row */
|
|
863
|
+
.meta { display: flex; flex-wrap: wrap; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); padding: 4px 0; }
|
|
864
|
+
.meta dt { padding: 0 4px 0 12px; color: var(--dim); }
|
|
865
|
+
.meta dt::after { content: ":"; }
|
|
866
|
+
.meta dd { padding: 0 12px 0 0; font-family: var(--mono); }
|
|
867
|
+
|
|
868
|
+
/* misc */
|
|
869
|
+
.dim { color: var(--dim); }
|
|
870
|
+
.page-title { padding: 8px 12px 4px; font-size: 14px; font-weight: 400; }
|
|
871
|
+
.page-sub { padding: 0 12px 8px; font-size: var(--fs-sm); color: var(--dim); }
|
|
872
|
+
details summary { cursor: pointer; color: var(--accent); font-size: var(--fs-sm); }
|
|
873
|
+
details[open] summary { margin-bottom: 4px; }
|
|
874
|
+
.tag { font-size: var(--fs-sm); color: var(--dim); margin-left: 6px; }
|
|
875
|
+
.tag-warn { color: var(--stale); }
|
|
876
|
+
.tag-err { color: var(--missing); }
|
|
586
877
|
</style>
|
|
587
878
|
</head>
|
|
588
879
|
<body>
|
|
589
|
-
<
|
|
590
|
-
<
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
880
|
+
<div class="app">
|
|
881
|
+
<nav class="actbar">
|
|
882
|
+
<a href="/" title="Overview">\u2302</a>
|
|
883
|
+
<a href="/staleness" title="Staleness">\u26A0</a>
|
|
884
|
+
</nav>
|
|
885
|
+
<aside class="sidebar">
|
|
886
|
+
<div class="sidebar-head">Types</div>
|
|
887
|
+
<div class="sidebar-body">${typeLinks || `<span class="dim" style="padding:8px 12px">\u2014</span>`}</div>
|
|
888
|
+
</aside>
|
|
889
|
+
<div class="main">
|
|
890
|
+
<div class="content">${body}</div>
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
596
893
|
</body>
|
|
597
894
|
</html>`;
|
|
598
895
|
}
|
|
896
|
+
function docTitleFromFrontmatter(frontmatter, enSlug) {
|
|
897
|
+
const title = frontmatter.title;
|
|
898
|
+
if (typeof title === "string" && title.trim()) return title;
|
|
899
|
+
return enSlug;
|
|
900
|
+
}
|
|
599
901
|
async function startStudio(project, options = {}) {
|
|
600
902
|
const app = new hono.Hono();
|
|
601
903
|
const config = project.config;
|
|
602
904
|
app.get("/", (c) => {
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
905
|
+
const db = openStore(config, "readonly");
|
|
906
|
+
const worklist = buildWorklist(config);
|
|
907
|
+
const rows = project.listTypes().map((type) => {
|
|
908
|
+
const enCount = type.list().length;
|
|
909
|
+
const localeCells = config.locales.filter((locale) => locale !== config.defaultLocale).map((locale) => {
|
|
910
|
+
const translated = listTranslationsForLocale(db, type.id, locale).length;
|
|
911
|
+
const stale = worklist.filter(
|
|
912
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "stale"
|
|
913
|
+
).length;
|
|
914
|
+
const missing = worklist.filter(
|
|
915
|
+
(item) => item.contentType === type.id && item.locale === locale && item.reason === "missing"
|
|
916
|
+
).length;
|
|
917
|
+
const tags = [
|
|
918
|
+
stale ? `<span class="tag tag-warn">${stale}s</span>` : "",
|
|
919
|
+
missing ? `<span class="tag tag-err">${missing}m</span>` : ""
|
|
920
|
+
].join("");
|
|
921
|
+
return `<td>${translated}${tags}</td>`;
|
|
607
922
|
}).join("");
|
|
608
|
-
return `<
|
|
923
|
+
return `<tr>
|
|
924
|
+
<td><a href="/type/${encodePathSegment(type.id)}">${escapeHtml(type.config.label)}</a></td>
|
|
925
|
+
<td class="dim">${escapeHtml(type.id)}</td>
|
|
926
|
+
<td>${enCount}</td>
|
|
927
|
+
${localeCells}
|
|
928
|
+
</tr>`;
|
|
609
929
|
});
|
|
610
|
-
|
|
930
|
+
db.close();
|
|
931
|
+
const localeHeaders = config.locales.filter((l) => l !== config.defaultLocale).map((l) => `<th>${escapeHtml(l)}</th>`).join("");
|
|
932
|
+
const html = `<div class="toolbar">Overview</div>
|
|
933
|
+
<table class="data">
|
|
934
|
+
<thead><tr><th>Type</th><th>ID</th><th>EN</th>${localeHeaders}</tr></thead>
|
|
935
|
+
<tbody>${rows.join("")}</tbody>
|
|
936
|
+
</table>`;
|
|
937
|
+
return c.html(renderLayout("Overview", html, project));
|
|
611
938
|
});
|
|
612
|
-
app.get("/
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
939
|
+
app.get("/type/:id", (c) => {
|
|
940
|
+
const typeId = c.req.param("id");
|
|
941
|
+
const type = project.getType(typeId);
|
|
942
|
+
if (!type) {
|
|
943
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
944
|
+
}
|
|
945
|
+
const db = openStore(config, "readonly");
|
|
946
|
+
const locales = config.locales;
|
|
947
|
+
const headerCells = locales.map((locale) => `<th>${escapeHtml(locale)}</th>`).join("");
|
|
948
|
+
const rows = type.list().map((doc) => {
|
|
949
|
+
const title = docTitleFromFrontmatter(doc.frontmatter, doc.slug);
|
|
950
|
+
const statusCells = locales.map((locale) => {
|
|
951
|
+
const { status } = documentStatus(config, db, type, doc.slug, locale);
|
|
952
|
+
return `<td>${statusDot(status)}</td>`;
|
|
953
|
+
}).join("");
|
|
954
|
+
return `<tr>
|
|
955
|
+
<td class="mono"><a href="/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(doc.slug)}">${escapeHtml(doc.slug)}</a></td>
|
|
956
|
+
<td>${escapeHtml(title)}</td>
|
|
957
|
+
${statusCells}
|
|
958
|
+
</tr>`;
|
|
959
|
+
}).join("");
|
|
960
|
+
db.close();
|
|
961
|
+
const html = `<div class="toolbar">
|
|
962
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>${escapeHtml(type.config.label)}
|
|
963
|
+
</div>
|
|
964
|
+
<table class="data">
|
|
965
|
+
<thead><tr><th>Slug</th><th>Title</th>${headerCells}</tr></thead>
|
|
966
|
+
<tbody>${rows || `<tr><td colspan="${2 + locales.length}" class="dim">No documents</td></tr>`}</tbody>
|
|
967
|
+
</table>`;
|
|
968
|
+
return c.html(renderLayout(type.config.label, html, project, { activeTypeId: typeId }));
|
|
620
969
|
});
|
|
621
|
-
app.get("/
|
|
622
|
-
const typeId = c.req.
|
|
623
|
-
const enSlug = c.req.
|
|
624
|
-
|
|
970
|
+
app.get("/type/:id/doc/:enSlug", (c) => {
|
|
971
|
+
const typeId = c.req.param("id");
|
|
972
|
+
const enSlug = c.req.param("enSlug");
|
|
973
|
+
const locale = c.req.query("locale") ?? config.defaultLocale;
|
|
974
|
+
const showRaw = c.req.query("raw") === "1";
|
|
975
|
+
const type = project.getType(typeId);
|
|
976
|
+
if (!type) {
|
|
977
|
+
return c.html(renderLayout("Not found", `<div class="toolbar">Unknown type</div>`, project), 404);
|
|
978
|
+
}
|
|
625
979
|
const db = openStore(config, "readonly");
|
|
626
|
-
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
980
|
+
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
981
|
+
if (!enDoc) {
|
|
982
|
+
db.close();
|
|
983
|
+
return c.html(
|
|
984
|
+
renderLayout("Not found", `<div class="toolbar">Not found</div><p class="dim" style="padding:12px">${escapeHtml(enSlug)}</p>`, project, {
|
|
985
|
+
activeTypeId: typeId
|
|
986
|
+
}),
|
|
987
|
+
404
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
const localeTabs = config.locales.map((loc) => {
|
|
991
|
+
const { status } = documentStatus(config, db, type, enSlug, loc);
|
|
992
|
+
const active = loc === locale ? " active" : "";
|
|
993
|
+
const href = `/type/${encodePathSegment(typeId)}/doc/${encodePathSegment(enSlug)}?locale=${encodePathSegment(loc)}`;
|
|
994
|
+
return `<a class="tab${active}" href="${href}">${escapeHtml(loc)} ${statusDot(status)}</a>`;
|
|
995
|
+
}).join("");
|
|
996
|
+
let contentPanel = "";
|
|
997
|
+
let metaPanel = "";
|
|
998
|
+
let historyPanel = "";
|
|
999
|
+
if (locale === config.defaultLocale) {
|
|
1000
|
+
contentPanel = `<div class="section">
|
|
1001
|
+
<div class="section-head">Frontmatter</div>
|
|
1002
|
+
<div class="section-body">${renderFrontmatterTable(enDoc.frontmatter, type.config.schema)}</div>
|
|
1003
|
+
<div class="section-head">Body</div>
|
|
1004
|
+
<pre class="code">${escapeHtml(enDoc.content)}</pre>
|
|
1005
|
+
</div>`;
|
|
632
1006
|
} else {
|
|
633
|
-
const
|
|
634
|
-
|
|
1007
|
+
const translation = getTranslation(db, typeId, enSlug, locale);
|
|
1008
|
+
const { status, currentEnHash, storedEnHash } = documentStatus(config, db, type, enSlug, locale);
|
|
1009
|
+
if (translation) {
|
|
1010
|
+
const rawFrontmatter = JSON.parse(translation.frontmatter_json);
|
|
1011
|
+
const displayFrontmatter = showRaw ? rawFrontmatter : mergeStructuralOntoLocale(rawFrontmatter, enDoc.frontmatter, type.config.schema);
|
|
1012
|
+
const rawToggle = showRaw ? `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}">merged</a></span>` : `<span class="dim"> \xB7 <a href="?locale=${encodePathSegment(locale)}&raw=1">raw</a></span>`;
|
|
1013
|
+
contentPanel = `<div class="section">
|
|
1014
|
+
<div class="section-head">Frontmatter${rawToggle}</div>
|
|
1015
|
+
<div class="section-body">${renderFrontmatterTable(displayFrontmatter, type.config.schema)}</div>
|
|
1016
|
+
<div class="section-head">Body</div>
|
|
1017
|
+
<pre class="code">${escapeHtml(translation.body)}</pre>
|
|
1018
|
+
</div>`;
|
|
1019
|
+
metaPanel = `<dl class="meta">
|
|
1020
|
+
<dt>status</dt><dd>${statusDot(status)}</dd>
|
|
1021
|
+
<dt>model</dt><dd>${escapeHtml(translation.model)}</dd>
|
|
1022
|
+
<dt>translated</dt><dd>${escapeHtml(translation.translated_at.slice(0, 19))}</dd>
|
|
1023
|
+
<dt>slug</dt><dd>${escapeHtml(translation.slug)}</dd>
|
|
1024
|
+
<dt>en_hash</dt><dd>${escapeHtml(currentEnHash?.slice(0, 12) ?? "\u2014")} / ${escapeHtml(storedEnHash?.slice(0, 12) ?? "\u2014")}</dd>
|
|
1025
|
+
</dl>`;
|
|
1026
|
+
const revisions = listRevisions(db, typeId, enSlug, locale);
|
|
1027
|
+
historyPanel = `<div class="section">
|
|
1028
|
+
<div class="section-head">History</div>
|
|
1029
|
+
<div class="section-body">${renderRevisionTimeline(revisions)}</div>
|
|
1030
|
+
</div>`;
|
|
1031
|
+
} else {
|
|
1032
|
+
contentPanel = `<p class="dim" style="padding:12px">No translation for ${escapeHtml(locale)}.</p>`;
|
|
1033
|
+
metaPanel = `<dl class="meta"><dt>status</dt><dd>${statusDot(status)}</dd></dl>`;
|
|
1034
|
+
}
|
|
635
1035
|
}
|
|
636
1036
|
db.close();
|
|
637
|
-
|
|
1037
|
+
const title = docTitleFromFrontmatter(enDoc.frontmatter, enSlug);
|
|
1038
|
+
const html = `<div class="toolbar">
|
|
1039
|
+
<a href="/">Overview</a><span class="sep">\u203A</span>
|
|
1040
|
+
<a href="/type/${encodePathSegment(typeId)}">${escapeHtml(type.config.label)}</a><span class="sep">\u203A</span>
|
|
1041
|
+
<span>${escapeHtml(enSlug)}</span>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div class="tabs">${localeTabs}</div>
|
|
1044
|
+
${metaPanel}
|
|
1045
|
+
${contentPanel}
|
|
1046
|
+
${historyPanel}`;
|
|
1047
|
+
return c.html(renderLayout(title, html, project, { activeTypeId: typeId }));
|
|
1048
|
+
});
|
|
1049
|
+
app.get("/staleness", (c) => {
|
|
1050
|
+
const worklist = buildWorklist(config);
|
|
1051
|
+
const rows = worklist.slice(0, 500).map((item) => {
|
|
1052
|
+
const href = `/type/${encodePathSegment(item.contentType)}/doc/${encodePathSegment(item.enSlug)}?locale=${encodePathSegment(item.locale)}`;
|
|
1053
|
+
return `<tr>
|
|
1054
|
+
<td>${escapeHtml(item.contentType)}</td>
|
|
1055
|
+
<td class="mono"><a href="${href}">${escapeHtml(item.enSlug)}</a></td>
|
|
1056
|
+
<td>${escapeHtml(item.locale)}</td>
|
|
1057
|
+
<td>${statusDot(item.reason === "stale" ? "stale" : "missing")}</td>
|
|
1058
|
+
</tr>`;
|
|
1059
|
+
}).join("");
|
|
1060
|
+
const html = `<div class="toolbar">Staleness <span class="dim">\xB7 ${worklist.length} entries</span></div>
|
|
1061
|
+
<table class="data">
|
|
1062
|
+
<thead><tr><th>Type</th><th>Slug</th><th>Locale</th><th>Status</th></tr></thead>
|
|
1063
|
+
<tbody>${rows || `<tr><td colspan="4" class="dim">All up to date</td></tr>`}</tbody>
|
|
1064
|
+
</table>`;
|
|
1065
|
+
return c.html(renderLayout("Staleness", html, project));
|
|
638
1066
|
});
|
|
639
1067
|
app.get("/api/staleness-matrix", (c) => {
|
|
1068
|
+
const worklist = buildWorklist(config);
|
|
640
1069
|
const matrix = {};
|
|
641
1070
|
for (const type of project.listTypes()) {
|
|
642
1071
|
matrix[type.id] = {};
|
|
643
1072
|
for (const locale of config.locales) {
|
|
644
1073
|
if (locale === config.defaultLocale) continue;
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const enDoc = readEnDocument(config, type.config, enSlug);
|
|
649
|
-
if (!enDoc) continue;
|
|
650
|
-
const payload = getTranslatablePayload(enDoc, type.config);
|
|
651
|
-
const hash = computePageEnHash(payload.frontmatter, payload.body);
|
|
652
|
-
const db = openStore(config, "readonly");
|
|
653
|
-
const row = db.prepare(
|
|
654
|
-
`SELECT en_hash FROM translations WHERE content_type = ? AND en_slug = ? AND locale = ?`
|
|
655
|
-
).get(type.id, enSlug, locale);
|
|
656
|
-
db.close();
|
|
657
|
-
if (!row || row.en_hash !== hash) stale++;
|
|
658
|
-
}
|
|
659
|
-
matrix[type.id][locale] = stale;
|
|
1074
|
+
matrix[type.id][locale] = worklist.filter(
|
|
1075
|
+
(item) => item.contentType === type.id && item.locale === locale
|
|
1076
|
+
).length;
|
|
660
1077
|
}
|
|
661
1078
|
}
|
|
662
1079
|
return c.json(matrix);
|