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
@@ -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 FIELD_KIND = /* @__PURE__ */ Symbol.for("@genlook/scribe/fieldKind");
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
- fs2.mkdirSync(path2.dirname(storePath), { recursive: true });
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 listTranslationsForType(db, contentType) {
386
- return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
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 renderLayout(title, body) {
610
+ function escapeHtml(value) {
611
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 Studio</title>
721
+ <title>${escapeHtml(title)} \u2014 Scribe</title>
565
722
  <style>
566
- body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b0d10; color: #e8eaed; }
567
- header { padding: 16px 24px; border-bottom: 1px solid #222; display: flex; gap: 16px; }
568
- header a { color: #9ecbff; text-decoration: none; }
569
- main { padding: 24px; }
570
- table { border-collapse: collapse; width: 100%; }
571
- th, td { border: 1px solid #333; padding: 8px 10px; text-align: left; vertical-align: top; }
572
- th { background: #151922; }
573
- .muted { color: #9aa0a6; }
574
- .stale { color: #ffb4a2; }
575
- .ok { color: #8ce99a; }
576
- .card { background: #151922; border: 1px solid #333; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
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
- <header>
581
- <strong>Scribe Studio</strong>
582
- <a href="/">Browser</a>
583
- <a href="/staleness">Staleness</a>
584
- <a href="/history">History</a>
585
- </header>
586
- <main>${body}</main>
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 cards = project.listTypes().map((type) => {
595
- const localeRows = config.locales.map((locale) => {
596
- const count = type.load().get(locale)?.bySlug.size ?? 0;
597
- return `<li>${locale}: ${count}</li>`;
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 `<div class="card"><h2>${type.config.label}</h2><ul>${localeRows}</ul></div>`;
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
- return c.html(renderLayout("Browser", cards.join("")));
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("/staleness", (c) => {
604
- const worklist = buildWorklist(config);
605
- const rows = worklist.slice(0, 500).map(
606
- (item) => `<tr><td>${item.contentType}</td><td>${item.enSlug}</td><td>${item.locale}</td><td class="stale">${item.reason}</td></tr>`
607
- ).join("");
608
- const html = `<h1>Translation staleness</h1><p class="muted">${worklist.length} stale/missing entries (showing up to 500)</p>
609
- <table><thead><tr><th>Type</th><th>EN slug</th><th>Locale</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
610
- return c.html(renderLayout("Staleness", html));
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("/history", (c) => {
613
- const typeId = c.req.query("type") ?? project.listTypes()[0]?.id;
614
- const enSlug = c.req.query("slug");
615
- if (!typeId) return c.html(renderLayout("History", "<p>No content types.</p>"));
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
- 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>`;
618
- if (enSlug) {
619
- const rows = listRevisions(db, typeId, enSlug).slice(0, 100).map(
620
- (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>`
621
- ).join("");
622
- 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>`;
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 translations = listTranslationsForType(db, typeId).slice(0, 100);
625
- body += `<ul>${translations.map((row) => `<li><a href="/history?type=${typeId}&slug=${row.en_slug}">${row.en_slug}</a> (${row.locale})</li>`).join("")}</ul>`;
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
- return c.html(renderLayout("History", body));
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
- const enSlugs = type.list().map((doc) => doc.slug);
637
- let stale = 0;
638
- for (const enSlug of enSlugs) {
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);