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