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.
Files changed (64) hide show
  1. package/README.md +2 -3
  2. package/bin/scribe.js +2 -11
  3. package/dist/cli/index.cjs +1437 -225
  4. package/dist/cli/index.cjs.map +1 -1
  5. package/dist/cli/index.js +1437 -226
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/prompt-translate.d.ts +16 -0
  8. package/dist/cli/prompt-translate.d.ts.map +1 -0
  9. package/dist/cli/translate-progress.d.ts +11 -0
  10. package/dist/cli/translate-progress.d.ts.map +1 -0
  11. package/dist/index.cjs +591 -147
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +587 -148
  14. package/dist/index.js.map +1 -1
  15. package/dist/runtime.cjs +14 -2
  16. package/dist/runtime.cjs.map +1 -1
  17. package/dist/runtime.js +14 -2
  18. package/dist/runtime.js.map +1 -1
  19. package/dist/src/config/resolve-config.d.ts.map +1 -1
  20. package/dist/src/core/field.d.ts +2 -0
  21. package/dist/src/core/field.d.ts.map +1 -1
  22. package/dist/src/core/types.d.ts +5 -1
  23. package/dist/src/core/types.d.ts.map +1 -1
  24. package/dist/src/export/build-static-raw-exports.d.ts +37 -0
  25. package/dist/src/export/build-static-raw-exports.d.ts.map +1 -0
  26. package/dist/src/export/write-static-raw-exports.d.ts +12 -0
  27. package/dist/src/export/write-static-raw-exports.d.ts.map +1 -0
  28. package/dist/src/history/record-revision.d.ts +2 -0
  29. package/dist/src/history/record-revision.d.ts.map +1 -1
  30. package/dist/src/index.d.ts +7 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/storage/sqlite.d.ts.map +1 -1
  33. package/dist/src/storage/translations.d.ts +6 -0
  34. package/dist/src/storage/translations.d.ts.map +1 -1
  35. package/dist/src/translate/gemini-client.d.ts +7 -0
  36. package/dist/src/translate/gemini-client.d.ts.map +1 -1
  37. package/dist/src/translate/gemini-models.d.ts +8 -0
  38. package/dist/src/translate/gemini-models.d.ts.map +1 -0
  39. package/dist/src/translate/gemini-pricing.d.ts +11 -0
  40. package/dist/src/translate/gemini-pricing.d.ts.map +1 -0
  41. package/dist/src/translate/page-translator.d.ts +38 -1
  42. package/dist/src/translate/page-translator.d.ts.map +1 -1
  43. package/dist/src/translate/prompts/translation-prompt.d.ts +1 -2
  44. package/dist/src/translate/prompts/translation-prompt.d.ts.map +1 -1
  45. package/dist/src/translate/response-schema.d.ts +7 -0
  46. package/dist/src/translate/response-schema.d.ts.map +1 -0
  47. package/dist/src/translate/validate-translation.d.ts +14 -0
  48. package/dist/src/translate/validate-translation.d.ts.map +1 -0
  49. package/dist/src/translate/worklist.d.ts +3 -0
  50. package/dist/src/translate/worklist.d.ts.map +1 -1
  51. package/dist/src/validate/validate-assets.d.ts +20 -0
  52. package/dist/src/validate/validate-assets.d.ts.map +1 -0
  53. package/dist/src/validate/validate-project.d.ts +1 -1
  54. package/dist/src/validate/validate-project.d.ts.map +1 -1
  55. package/dist/src/version.d.ts +4 -0
  56. package/dist/src/version.d.ts.map +1 -0
  57. package/dist/studio/server.cjs +611 -194
  58. package/dist/studio/server.cjs.map +1 -1
  59. package/dist/studio/server.d.ts +1 -1
  60. package/dist/studio/server.d.ts.map +1 -1
  61. package/dist/studio/server.js +611 -194
  62. package/dist/studio/server.js.map +1 -1
  63. package/package.json +16 -5
  64. package/bin/ensure-built.mjs +0 -31
@@ -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 FIELD_KIND = /* @__PURE__ */ Symbol.for("@genlook/scribe/fieldKind");
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
- fs2__default.default.mkdirSync(path2__default.default.dirname(storePath), { recursive: true });
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 listTranslationsForType(db, contentType) {
395
- return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
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 renderLayout(title, body) {
619
+ function escapeHtml(value) {
620
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 Studio</title>
730
+ <title>${escapeHtml(title)} \u2014 Scribe</title>
574
731
  <style>
575
- body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b0d10; color: #e8eaed; }
576
- header { padding: 16px 24px; border-bottom: 1px solid #222; display: flex; gap: 16px; }
577
- header a { color: #9ecbff; text-decoration: none; }
578
- main { padding: 24px; }
579
- table { border-collapse: collapse; width: 100%; }
580
- th, td { border: 1px solid #333; padding: 8px 10px; text-align: left; vertical-align: top; }
581
- th { background: #151922; }
582
- .muted { color: #9aa0a6; }
583
- .stale { color: #ffb4a2; }
584
- .ok { color: #8ce99a; }
585
- .card { background: #151922; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
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
- <header>
590
- <strong>Scribe Studio</strong>
591
- <a href="/">Browser</a>
592
- <a href="/staleness">Staleness</a>
593
- <a href="/history">History</a>
594
- </header>
595
- <main>${body}</main>
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 cards = project.listTypes().map((type) => {
604
- const localeRows = config.locales.map((locale) => {
605
- const count = type.load().get(locale)?.bySlug.size ?? 0;
606
- return `<li>${locale}: ${count}</li>`;
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 `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
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
- return c.html(renderLayout("Browser", cards.join("")));
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("/staleness", (c) => {
613
- const worklist = buildWorklist(config);
614
- const rows = worklist.slice(0, 500).map(
615
- (item) => `<tr><td>${item.contentType}</td><td>${item.enSlug}</td><td>${item.locale}</td><td class="stale">${item.reason}</td></tr>`
616
- ).join("");
617
- const html = `<h1>Translation staleness</h1><p class="muted">${worklist.length} stale/missing entries (showing up to 500)</p>
618
- <table><thead><tr><th>Type</th><th>EN slug</th><th>Locale</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
619
- return c.html(renderLayout("Staleness", html));
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("/history", (c) => {
622
- const typeId = c.req.query("type") ?? project.listTypes()[0]?.id;
623
- const enSlug = c.req.query("slug");
624
- if (!typeId) return c.html(renderLayout("History", "<p>No content types.</p>"));
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
- 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>`;
627
- if (enSlug) {
628
- const rows = listRevisions(db, typeId, enSlug).slice(0, 100).map(
629
- (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>`
630
- ).join("");
631
- 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>`;
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 translations = listTranslationsForType(db, typeId).slice(0, 100);
634
- body += `<ul>${translations.map((row) => `<li><a href="/history?type=${typeId}&slug=${row.en_slug}">${row.en_slug}</a> (${row.locale})</li>`).join("")}</ul>`;
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
- return c.html(renderLayout("History", body));
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
- const enSlugs = type.list().map((doc) => doc.slug);
646
- let stale = 0;
647
- for (const enSlug of enSlugs) {
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);