@gentbajko/slopify 2.1.1 → 2.2.0

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 (32) hide show
  1. package/dist/assets/document/SOURCE.txt +4 -0
  2. package/dist/assets/document/sample-cover.jpg +0 -0
  3. package/dist/edge/http/app.js +4 -0
  4. package/dist/edge/http/document-themes.js +80 -0
  5. package/dist/edge/http/pronunciations.js +17 -0
  6. package/dist/kernel/db/migrations/0016-document-themes.sql +10 -0
  7. package/dist/slices/admission/schema.js +15 -3
  8. package/dist/slices/admission/start.js +10 -1
  9. package/dist/slices/article/source-lines.js +20 -0
  10. package/dist/slices/document/library.js +77 -0
  11. package/dist/slices/document/model.js +19 -0
  12. package/dist/slices/document/sample.js +51 -0
  13. package/dist/slices/document/sources.js +5 -10
  14. package/dist/slices/document/theme-schema.js +172 -0
  15. package/dist/slices/document/theme.js +8 -1
  16. package/dist/slices/narration/shared-glossary.js +51 -0
  17. package/dist/slices/play-drafts/convert.js +11 -2
  18. package/dist/slices/play-drafts/schema.js +7 -7
  19. package/dist/slices/rebuild/preview-details.js +9 -2
  20. package/dist/slices/rebuild/recipe-document.js +2 -3
  21. package/dist/slices/rebuild/recipe-text.js +9 -1
  22. package/dist/slices/rebuild/runtime-document.js +2 -3
  23. package/dist/slices/rebuild/runtime-store.js +19 -3
  24. package/dist/web/assets/index-C9ff3eDa.css +1 -0
  25. package/dist/web/assets/index-Dks6ZXcG.js +168 -0
  26. package/dist/web/assets/pdf-BIclMfua.js +54 -0
  27. package/dist/web/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
  28. package/dist/web/assets/pdf.worker.min-zC6nfbxG.js +1 -0
  29. package/dist/web/index.html +2 -2
  30. package/package.json +1 -1
  31. package/dist/web/assets/index-B6NbhpjD.css +0 -1
  32. package/dist/web/assets/index-DV01Cyjx.js +0 -142
@@ -5,3 +5,7 @@ SHA-256: 18f9701b476f299e942cbb99c05ba3267d8341df2b6136e3aebbafad83b03b31
5
5
 
6
6
  fonts/
7
7
  Cinzel, SIL Open Font License 1.1: see fonts/SOURCE.txt and fonts/OFL.txt.
8
+
9
+ sample-cover.jpg
10
+ A plain gradient made with ImageMagick for the Library theme preview, standing in for a project's
11
+ thumbnail. No third-party content.
@@ -6,6 +6,7 @@ import { actionRoutes } from "./actions.js";
6
6
  import { audioPreviewRoutes } from "./audio-preview.js";
7
7
  import { checkpointRoutes } from "./checkpoints.js";
8
8
  import { diagnosticsRoutes } from "./diagnostics.js";
9
+ import { documentThemeRoutes } from "./document-themes.js";
9
10
  import { draftRoutes } from "./drafts.js";
10
11
  import { entryRoutes } from "./entries.js";
11
12
  import { fileRoutes } from "./files.js";
@@ -16,6 +17,7 @@ import { problem, problemFromError, titleOf } from "./problem.js";
16
17
  import { projectTemplateRoutes } from "./project-templates.js";
17
18
  import { projectRoutes } from "./projects.js";
18
19
  import { promptRoutes } from "./prompts.js";
20
+ import { pronunciationRoutes } from "./pronunciations.js";
19
21
  import { providerRoutes } from "./providers.js";
20
22
  import { revisionFileRoutes, revisionFolderRoutes } from "./revision-files.js";
21
23
  import { revisionRoutes } from "./revisions.js";
@@ -58,6 +60,8 @@ function apiRoutes(deps, startedAt) {
58
60
  .route("/fonts", fontsRoutes(deps))
59
61
  .route("/prompts", promptRoutes(deps))
60
62
  .route("/entries", entryRoutes(deps))
63
+ .route("/document-themes", documentThemeRoutes(deps))
64
+ .route("/pronunciations", pronunciationRoutes(deps))
61
65
  .route("/telemetry", telemetryRoutes(deps))
62
66
  .route("/usage", usageRoutes(deps))
63
67
  .route("/settings", settingsRoutes(deps))
@@ -0,0 +1,80 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { createDocumentTheme, listDocumentThemes, removeDocumentTheme, updateDocumentTheme, } from "../../slices/document/library.js";
5
+ import { documentThemeLabels, documentThemes } from "../../slices/document/model.js";
6
+ import { renderSampleDocument } from "../../slices/document/sample.js";
7
+ import { builtInTheme, resolveTheme } from "../../slices/document/theme.js";
8
+ import { documentThemeSchema } from "../../slices/document/theme-schema.js";
9
+ import { onInvalid, problem, titleOf } from "./problem.js";
10
+ const idParam = z.object({
11
+ id: z
12
+ .string()
13
+ .min(1)
14
+ .max(64)
15
+ .regex(/^[0-9A-Za-z_-]+$/),
16
+ });
17
+ // Shape only: the values' ranges are the slice's, so the editor gets field-by-field sentences.
18
+ const themeBody = z.object({ name: z.string(), values: z.unknown() });
19
+ // Library → Documents. The return type is inferred so Hono keeps the route types the SPA's
20
+ // client is generated from; see stagingRoutes.
21
+ export function documentThemeRoutes(deps) {
22
+ const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
23
+ return (new Hono()
24
+ // The built-ins come with their values, so Duplicate starts from them.
25
+ .get("/", (c) => c.json({
26
+ builtIns: documentThemes.map((name) => ({
27
+ name,
28
+ label: documentThemeLabels[name],
29
+ values: builtInTheme(name),
30
+ })),
31
+ themes: listDocumentThemes(deps.db),
32
+ }))
33
+ .post("/", zValidator("json", themeBody, onInvalid), (c) => {
34
+ const result = createDocumentTheme(library, c.req.valid("json"));
35
+ return result.ok ? c.json(result.value, 201) : refused(c, result);
36
+ })
37
+ .put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", themeBody, onInvalid), (c) => {
38
+ const result = updateDocumentTheme(library, c.req.valid("param").id, c.req.valid("json"));
39
+ return result.ok ? c.json(result.value) : refused(c, result);
40
+ })
41
+ // A project keeps its own copy of a theme's values, so nothing cascades.
42
+ .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
43
+ const result = removeDocumentTheme(library, c.req.valid("param").id);
44
+ return result.ok ? c.body(null, 204) : refused(c, result);
45
+ })
46
+ // The editor's live preview: the sample article laid out with unsaved values.
47
+ .post("/preview", zValidator("json", z.object({ values: documentThemeSchema }), onInvalid), (c) => {
48
+ const rendered = renderSampleDocument(resolveTheme(c.req.valid("json").values));
49
+ return c.body(rendered.bytes.slice().buffer, 200, {
50
+ "Content-Type": "application/pdf",
51
+ "Cache-Control": "no-store",
52
+ });
53
+ }));
54
+ }
55
+ function refused(c, failure) {
56
+ switch (failure.reason) {
57
+ case "invalid":
58
+ return problem(c, {
59
+ status: 400,
60
+ title: titleOf(400),
61
+ detail: "This theme can't be saved yet. Fix the highlighted settings and try again.",
62
+ extensions: { fields: failure.fields },
63
+ });
64
+ case "duplicate-name": {
65
+ const message = "Another document theme already has this name. Choose a different name.";
66
+ return problem(c, {
67
+ status: 409,
68
+ title: titleOf(409),
69
+ detail: message,
70
+ extensions: { fields: [{ field: "name", message }] },
71
+ });
72
+ }
73
+ case "not-found":
74
+ return problem(c, {
75
+ status: 404,
76
+ title: titleOf(404),
77
+ detail: "This document theme no longer exists; it may have been deleted in another tab. Go back to Library → Documents.",
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,17 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { collectSharedGlossary } from "../../slices/narration/shared-glossary.js";
5
+ import { onInvalid } from "./problem.js";
6
+ // The pronunciations of every project's glossary, merged, for Edit project to copy into a
7
+ // project that shares them. `except` leaves out the project being edited, whose own glossary
8
+ // already comes first.
9
+ export function pronunciationRoutes(deps) {
10
+ return new Hono().get("/shared", zValidator("query", z.object({
11
+ except: z
12
+ .string()
13
+ .max(64)
14
+ .regex(/^[0-9A-Za-z_-]*$/)
15
+ .optional(),
16
+ }), onInvalid), (c) => c.json(collectSharedGlossary(deps, c.req.valid("query").except)));
17
+ }
@@ -0,0 +1,10 @@
1
+ -- Document themes saved in Library → Documents. A project keeps its own copy of the values
2
+ -- it was given, so nothing references these rows and a delete never cascades.
3
+ CREATE TABLE document_themes (
4
+ id TEXT PRIMARY KEY,
5
+ name TEXT NOT NULL,
6
+ values_json TEXT NOT NULL CHECK (json_valid(values_json)),
7
+ created_at TEXT NOT NULL,
8
+ updated_at TEXT NOT NULL
9
+ );
10
+ CREATE UNIQUE INDEX document_themes_name ON document_themes(lower(name));
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { thinkingModes } from "../../kernel/ports/llm.js";
3
3
  import { checkpointStageSchema } from "../checkpoints/schema.js";
4
- import { documentThemes } from "../document/model.js";
4
+ import { documentSettingsSchema } from "../document/theme-schema.js";
5
5
  import { chunkModes } from "../narration/chunk.js";
6
6
  import { subtitleConfigSchema } from "../subtitles/model.js";
7
7
  import { entryModes, formats, motionStyles, stageSources } from "./model.js";
@@ -37,7 +37,19 @@ export const runDraftSchema = z.object({
37
37
  }),
38
38
  llm: providerChoice.optional(),
39
39
  audio: providerChoice
40
- .extend({ voice: z.string(), usePronunciationGlossary: z.boolean().optional() })
40
+ .extend({
41
+ voice: z.string(),
42
+ usePronunciationGlossary: z.boolean().optional(),
43
+ shareGlossary: z.boolean().optional(),
44
+ })
45
+ .optional(),
46
+ sharedGlossary: z
47
+ .array(z.object({
48
+ term: z.string().min(1).max(200),
49
+ ipa: z.array(z.string().min(1).max(200)).min(1).max(20).readonly(),
50
+ }))
51
+ .max(20000)
52
+ .readonly()
41
53
  .optional(),
42
54
  images: providerChoice.optional(),
43
55
  articlePrompt: z.string().optional(),
@@ -71,7 +83,7 @@ export const runDraftSchema = z.object({
71
83
  motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
72
84
  edgeSilenceSeconds: z.number().default(defaultEdgeSilenceSeconds),
73
85
  subtitles: subtitleConfigSchema.optional(),
74
- document: z.object({ theme: z.enum(documentThemes) }).optional(),
86
+ document: documentSettingsSchema.optional(),
75
87
  youtubeDescription: z.boolean().optional(),
76
88
  descriptionPrompt: z.string().optional(),
77
89
  });
@@ -1,12 +1,14 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
2
  import { stageKinds } from "../../kernel/pipeline.js";
3
3
  import { storeArticleText } from "../article/store.js";
4
+ import { collectSharedGlossary } from "../narration/shared-glossary.js";
4
5
  import { admitInitialRevision } from "../rebuild/runtime-admission.js";
5
6
  import { adoptBaseline } from "../revisions/adopt.js";
6
7
  import { attachStagedFile, storeText } from "../storage/staging.js";
7
8
  import { releaseStagedFile } from "../storage/staging-refs.js";
8
9
  import { sourceOf, } from "./model.js";
9
10
  import { insertProject, insertStage } from "./repo.js";
11
+ import { usesPronunciationGlossary } from "./rules.js";
10
12
  // Provide → `provided` with its output attached; Off → `skipped`;
11
13
  // everything else → `pending`.
12
14
  export function initialState(source) {
@@ -20,7 +22,14 @@ export function initialState(source) {
20
22
  export function startRun(deps, draft, rendered, retainStaged = false, templates) {
21
23
  const id = deps.ids.next();
22
24
  const at = deps.clock.now().toISOString();
23
- const config = { ...draft, rendered };
25
+ const shared = usesPronunciationGlossary(draft) && draft.audio?.shareGlossary === true
26
+ ? collectSharedGlossary(deps).entries
27
+ : [];
28
+ const config = {
29
+ ...draft,
30
+ rendered,
31
+ ...(shared.length === 0 ? {} : { sharedGlossary: shared }),
32
+ };
24
33
  const project = {
25
34
  id,
26
35
  title: draft.title,
@@ -0,0 +1,20 @@
1
+ // The entries of an article's "Sources Consulted" section, one per source. Models write the
2
+ // list as bullets, as numbered lines, or as plain lines with no blank line between them;
3
+ // markdown would join the last kind into one paragraph, so the section is read line by line
4
+ // instead. A line indented under an entry continues it. The section starts with its own
5
+ // heading (`splitEndMatter` cuts it there), which is not an entry. Browser-safe: the project
6
+ // page lists the same entries the PDF prints.
7
+ const marker = /^(?:[-*+]|\d+[.)])\s+/u;
8
+ export function sourceEntries(section) {
9
+ const lines = section.split(/\r?\n/u).filter((line) => line.trim() !== "");
10
+ const entries = [];
11
+ for (const raw of lines.slice(1)) {
12
+ const line = raw.trim().replace(marker, "").trim();
13
+ const last = entries.length - 1;
14
+ if (/^\s/u.test(raw) && !marker.test(raw.trim()) && last >= 0)
15
+ entries[last] = `${entries[last] ?? ""} ${line}`;
16
+ else if (line !== "")
17
+ entries.push(line);
18
+ }
19
+ return entries;
20
+ }
@@ -0,0 +1,77 @@
1
+ import { isUniqueConstraint } from "../../kernel/db/index.js";
2
+ import { documentThemeNameProblems } from "./model.js";
3
+ import { documentThemeSchema } from "./theme-schema.js";
4
+ export function listDocumentThemes(db) {
5
+ return db
6
+ .prepare("SELECT id,name,values_json,updated_at FROM document_themes ORDER BY lower(name),id")
7
+ .all()
8
+ .flatMap((row) => {
9
+ // A row whose values no longer parse (hand-edited, or from a newer Slopify) is left
10
+ // out rather than breaking the whole list.
11
+ const values = documentThemeSchema.safeParse(JSON.parse(String(row.values_json)));
12
+ return values.success
13
+ ? [
14
+ {
15
+ id: String(row.id),
16
+ name: String(row.name),
17
+ values: values.data,
18
+ updatedAt: String(row.updated_at),
19
+ },
20
+ ]
21
+ : [];
22
+ });
23
+ }
24
+ export function createDocumentTheme(deps, draft) {
25
+ const checked = check(draft);
26
+ if (!checked.ok)
27
+ return checked;
28
+ const now = deps.clock.now().toISOString();
29
+ const theme = { id: deps.ids.next(), ...checked.value, updatedAt: now };
30
+ return written(() => {
31
+ deps.db
32
+ .prepare("INSERT INTO document_themes(id,name,values_json,created_at,updated_at) VALUES (?,?,?,?,?)")
33
+ .run(theme.id, theme.name, JSON.stringify(theme.values), now, now);
34
+ return true;
35
+ }, theme);
36
+ }
37
+ export function updateDocumentTheme(deps, id, draft) {
38
+ const checked = check(draft);
39
+ if (!checked.ok)
40
+ return checked;
41
+ const now = deps.clock.now().toISOString();
42
+ const theme = { id, ...checked.value, updatedAt: now };
43
+ return written(() => deps.db
44
+ .prepare("UPDATE document_themes SET name=?,values_json=?,updated_at=? WHERE id=?")
45
+ .run(theme.name, JSON.stringify(theme.values), now, id).changes > 0, theme);
46
+ }
47
+ export function removeDocumentTheme(deps, id) {
48
+ return deps.db.prepare("DELETE FROM document_themes WHERE id=?").run(id).changes > 0
49
+ ? { ok: true, value: null }
50
+ : { ok: false, reason: "not-found" };
51
+ }
52
+ function check(draft) {
53
+ const named = documentThemeNameProblems(draft.name);
54
+ const values = documentThemeSchema.safeParse(draft.values);
55
+ const fields = [
56
+ ...named,
57
+ ...(values.success
58
+ ? []
59
+ : values.error.issues.map((issue) => ({
60
+ field: `values.${issue.path.join(".")}`,
61
+ message: issue.message,
62
+ }))),
63
+ ];
64
+ if (fields.length > 0 || !values.success)
65
+ return { ok: false, reason: "invalid", fields };
66
+ return { ok: true, value: { name: draft.name.trim(), values: values.data } };
67
+ }
68
+ function written(write, value) {
69
+ try {
70
+ return write() ? { ok: true, value } : { ok: false, reason: "not-found" };
71
+ }
72
+ catch (error) {
73
+ if (isUniqueConstraint(error))
74
+ return { ok: false, reason: "duplicate-name" };
75
+ throw error;
76
+ }
77
+ }
@@ -9,3 +9,22 @@ export const documentThemeLabels = {
9
9
  export function documentThemeOf(settings) {
10
10
  return settings?.theme ?? defaultDocumentTheme;
11
11
  }
12
+ // The name a project's theme is shown by: the Library theme's own, or the built-in's label.
13
+ export function documentThemeLabel(settings) {
14
+ return settings?.custom?.name ?? documentThemeLabels[documentThemeOf(settings)];
15
+ }
16
+ export const documentThemeNameMax = 80;
17
+ // The editor's sentences for a name, shared so the page refuses what the server would.
18
+ export function documentThemeNameProblems(name) {
19
+ const trimmed = name.trim();
20
+ if (trimmed === "")
21
+ return [{ field: "name", message: "Give the theme a name." }];
22
+ if (trimmed.length > documentThemeNameMax)
23
+ return [
24
+ {
25
+ field: "name",
26
+ message: `Keep the name under ${String(documentThemeNameMax)} characters.`,
27
+ },
28
+ ];
29
+ return [];
30
+ }
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readDocumentAssets } from "./fonts.js";
3
+ import { renderDocument } from "./render.js";
4
+ // What the Library theme editor previews: a short article with every kind of block the
5
+ // renderer styles (levels of heading, a drop-cap paragraph, emphasis, a link, a list, a
6
+ // quote, a rule and a sources list), a stand-in cover and a fixed date, so only the theme
7
+ // changes between previews.
8
+ const title = "The Lighthouse at Grey Point";
9
+ const article = `# ${title}
10
+
11
+ The lighthouse at Grey Point has warned ships off the northern reef for nearly two centuries, and its keepers kept **careful logs** of every storm, wreck and *strange light* seen from the gallery. Most of those logs survive in the [county archive](https://example.com/archive), and they read like a slow, patient novel.
12
+
13
+ ## Chapter 1: The First Keepers
14
+
15
+ The first keeper arrived in the spring with a wife, two children and a crate of lamp oil that was meant to last the winter. It lasted six weeks, and the rest of that season was lit by whale oil bought from passing boats at ruinous prices.
16
+
17
+ - The tower was finished in 1834
18
+ - The lamp was replaced twice before 1900
19
+ - The last keeper left in 1961
20
+
21
+ > The sea does not care what the light costs.
22
+
23
+ ### A note on the logs
24
+
25
+ Entries are short, written in pencil, and nearly always begin with the wind.
26
+
27
+ ## Chapter 2: The Automatic Light
28
+
29
+ When the light was automated, the cottage stood empty for thirty years. Walkers still report seeing a lamp in its window on winter evenings, although the power was cut long ago and the door has been bricked up since the last survey.
30
+
31
+ ---
32
+
33
+ The tower is open to visitors on summer weekends.
34
+
35
+ ## Sources Consulted
36
+
37
+ - [County Archive: Grey Point Logs](https://example.com/logs)
38
+ - Coastal Lights of the North, second edition
39
+ `;
40
+ const cover = new URL("../../assets/document/sample-cover.jpg", import.meta.url);
41
+ export function renderSampleDocument(theme) {
42
+ return renderDocument({
43
+ title,
44
+ articleMarkdown: article,
45
+ researchNotes: null,
46
+ cover: new Uint8Array(readFileSync(cover)),
47
+ theme,
48
+ writtenOn: new Date("2026-01-15T12:00:00Z"),
49
+ assets: readDocumentAssets(),
50
+ });
51
+ }
@@ -1,3 +1,4 @@
1
+ import { sourceEntries } from "../article/source-lines.js";
1
2
  import { splitEndMatter } from "../article/split.js";
2
3
  import { markdownBlocks, markdownLinks, runsText } from "./blocks.js";
3
4
  // The pronunciation glossary is narration's business and is left out; the sources section
@@ -16,18 +17,12 @@ export function documentText(articleMarkdown, researchNotes) {
16
17
  return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
17
18
  }
18
19
  function sourceItems(section) {
19
- if (section.trim() === "")
20
- return [];
21
- // The section's own heading, whether a `#` line or a bold paragraph, is its first block.
22
- return markdownBlocks(section)
23
- .slice(1)
24
- .flatMap((block) => {
25
- if (block.kind !== "item" && block.kind !== "paragraph" && block.kind !== "quote")
26
- return [];
27
- const text = runsText(block.runs).trim();
20
+ return sourceEntries(section).flatMap((entry) => {
21
+ const runs = markdownBlocks(entry).flatMap((block) => ("runs" in block ? block.runs : []));
22
+ const text = runsText(runs).trim();
28
23
  if (text === "")
29
24
  return [];
30
- const href = block.runs.find((run) => run.href !== null)?.href ??
25
+ const href = runs.find((run) => run.href !== null)?.href ??
31
26
  /https?:\/\/[^\s)>\]]+/.exec(text)?.[0] ??
32
27
  null;
33
28
  return [{ text, href }];
@@ -0,0 +1,172 @@
1
+ import { z } from "zod";
2
+ import { documentThemeNameMax, documentThemes } from "./model.js";
3
+ import { fontFamilies, fontStyles } from "./theme.js";
4
+ // Every value of a document theme with the range the renderer draws sensibly within, so a
5
+ // saved theme, a project config and the editor's preview all refuse the same values.
6
+ // Browser-safe: the Library editor reads the limits from here too.
7
+ const mm = (min, max) => {
8
+ const range = { error: `Use a value from ${String(min)} to ${String(max)}.` };
9
+ return z.number({ error: "Enter a number." }).finite(range).min(min, range).max(max, range);
10
+ };
11
+ const color = z.string().regex(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i, "Use a colour like #8c1e14.");
12
+ const line = z.string().max(300, { error: "Keep this under 300 characters." });
13
+ const nullableLine = line.nullable();
14
+ const url = z.string().max(500, { error: "Keep this under 500 characters." }).nullable();
15
+ const face = z
16
+ .object({
17
+ family: z.enum(fontFamilies),
18
+ style: z.enum(fontStyles),
19
+ letterSpacing: mm(-0.5, 2),
20
+ })
21
+ .strict();
22
+ export const documentThemeSchema = z
23
+ .object({
24
+ page: z
25
+ .object({
26
+ format: z.enum(["a4", "letter"]),
27
+ margin: mm(5, 60),
28
+ contentTop: mm(5, 80),
29
+ })
30
+ .strict(),
31
+ background: z.object({ image: z.enum(["parchment"]).nullable(), color }).strict(),
32
+ colors: z.object({ heading: color, text: color, muted: color, faint: color }).strict(),
33
+ fonts: z
34
+ .object({
35
+ body: face,
36
+ strong: face,
37
+ emphasis: face,
38
+ heading: face,
39
+ dramatic: face,
40
+ decorative: face,
41
+ dropCap: face,
42
+ footer: face,
43
+ })
44
+ .strict(),
45
+ sizes: z
46
+ .object({
47
+ brand: mm(6, 72),
48
+ title: mm(6, 72),
49
+ section: mm(6, 72),
50
+ heading: mm(6, 72),
51
+ subheading: mm(6, 72),
52
+ body: mm(6, 24),
53
+ footer: mm(5, 24),
54
+ meta: mm(5, 24),
55
+ })
56
+ .strict(),
57
+ spacing: z
58
+ .object({
59
+ bodyLine: mm(3, 20),
60
+ headingLine: mm(3, 30),
61
+ subheadingLine: mm(3, 30),
62
+ paragraphGap: mm(0, 3),
63
+ headingGap: mm(0, 5),
64
+ itemGap: mm(0, 3),
65
+ listIndent: mm(0, 30),
66
+ quoteIndent: mm(0, 40),
67
+ ruleWidth: mm(0, 150),
68
+ })
69
+ .strict(),
70
+ dropCap: z
71
+ .object({
72
+ enabled: z.boolean(),
73
+ lines: z.union([z.literal(2), z.literal(3), z.literal(4)]),
74
+ scale: mm(1, 5),
75
+ gap: mm(0, 20),
76
+ minLength: z
77
+ .number({ error: "Enter a number." })
78
+ .int({ error: "Use a whole number." })
79
+ .min(0, { error: "Use a value from 0 to 2000." })
80
+ .max(2000, { error: "Use a value from 0 to 2000." }),
81
+ minRoom: mm(0, 1),
82
+ })
83
+ .strict(),
84
+ titlePage: z
85
+ .object({
86
+ brandY: mm(10, 200),
87
+ taglineOffset: mm(0, 60),
88
+ titleY: mm(10, 250),
89
+ titleLine: mm(3, 40),
90
+ metaOffset: mm(0, 100),
91
+ metaLine: mm(3, 30),
92
+ showDate: z.boolean(),
93
+ showWordCount: z.boolean(),
94
+ cover: z.object({ enabled: z.boolean(), gap: mm(0, 40), maxHeight: mm(20, 200) }).strict(),
95
+ })
96
+ .strict(),
97
+ contents: z
98
+ .object({
99
+ enabled: z.boolean(),
100
+ title: line,
101
+ depth: z.union([z.literal(1), z.literal(2), z.literal(3)]),
102
+ titleOffset: mm(0, 100),
103
+ firstEntryOffset: mm(0, 150),
104
+ line: mm(3, 30),
105
+ indent: mm(0, 30),
106
+ })
107
+ .strict(),
108
+ header: z
109
+ .object({
110
+ enabled: z.boolean(),
111
+ top: mm(3, 60),
112
+ maxTitleCharacters: z
113
+ .number({ error: "Enter a number." })
114
+ .int({ error: "Use a whole number." })
115
+ .min(5, { error: "Use a value from 5 to 200." })
116
+ .max(200, { error: "Use a value from 5 to 200." }),
117
+ })
118
+ .strict(),
119
+ footer: z
120
+ .object({
121
+ enabled: z.boolean(),
122
+ bottom: mm(3, 60),
123
+ reserve: mm(0, 60),
124
+ text: line,
125
+ })
126
+ .strict(),
127
+ brand: z
128
+ .object({
129
+ name: nullableLine,
130
+ url,
131
+ tagline: nullableLine,
132
+ linkLabel: nullableLine,
133
+ })
134
+ .strict(),
135
+ metadata: z
136
+ .object({ author: line, subject: line, keywords: z.string().max(1000), creator: line })
137
+ .strict(),
138
+ sources: z
139
+ .object({
140
+ enabled: z.boolean(),
141
+ title: line,
142
+ titleOffset: mm(0, 100),
143
+ bodyOffset: mm(0, 150),
144
+ line: mm(3, 30),
145
+ gap: mm(0, 20),
146
+ })
147
+ .strict(),
148
+ endPage: z
149
+ .object({
150
+ enabled: z.boolean(),
151
+ title: line,
152
+ titleOffset: mm(0, 100),
153
+ bodyOffset: mm(0, 150),
154
+ lines: z.array(line).max(60).readonly(),
155
+ showDocumentDetails: z.boolean(),
156
+ link: z.object({ text: line, url }).strict().nullable(),
157
+ closing: nullableLine,
158
+ })
159
+ .strict(),
160
+ })
161
+ .strict();
162
+ export const customDocumentThemeSchema = z
163
+ .object({
164
+ id: z.string().min(1).max(64),
165
+ name: z.string().min(1).max(documentThemeNameMax),
166
+ values: documentThemeSchema,
167
+ })
168
+ .strict();
169
+ // A project's, a draft's and a template's document settings.
170
+ export const documentSettingsSchema = z
171
+ .object({ theme: z.enum(documentThemes), custom: customDocumentThemeSchema.optional() })
172
+ .strict();
@@ -1,4 +1,4 @@
1
- import { defaultDocumentTheme } from "./model.js";
1
+ import { defaultDocumentTheme, documentThemeOf, } from "./model.js";
2
2
  // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
3
3
  // PDF standard fonts every reader has.
4
4
  export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
@@ -122,6 +122,13 @@ const plain = {
122
122
  export function builtInTheme(name) {
123
123
  return name === "plain" ? resolveTheme(plain) : resolveTheme();
124
124
  }
125
+ // The theme a project's document is drawn with: its copy of a Library theme, or a built-in.
126
+ export function resolvedDocumentTheme(settings) {
127
+ const custom = settings?.custom;
128
+ return custom === undefined
129
+ ? builtInTheme(documentThemeOf(settings))
130
+ : resolveTheme(custom.values);
131
+ }
125
132
  export function defaultTheme() {
126
133
  return builtInTheme(defaultDocumentTheme);
127
134
  }
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { outputPath } from "../storage/layout.js";
3
+ import { parsePronunciationGlossary } from "./pronunciation.js";
4
+ export function collectSharedGlossary(deps, exceptProjectId) {
5
+ const rows = deps.db
6
+ .prepare(`SELECT o.project_id AS projectId, a.path AS path FROM revision_outputs o
7
+ JOIN project_heads h ON h.project_id=o.project_id AND h.revision_id=o.revision_id
8
+ JOIN project_assets a ON a.id=o.asset_id
9
+ WHERE o.slot='article:glossary' AND o.selected=1 AND o.state='ready' AND o.project_id<>?
10
+ ORDER BY o.created_at DESC`)
11
+ .all(exceptProjectId ?? "");
12
+ const merged = new Map();
13
+ const contributors = new Set();
14
+ for (const row of rows) {
15
+ const projectId = String(row.projectId);
16
+ let text;
17
+ try {
18
+ text = readFileSync(outputPath(deps.paths, projectId, String(row.path)), "utf8");
19
+ }
20
+ catch {
21
+ // A glossary file removed by hand or by a cleanup has nothing to share.
22
+ continue;
23
+ }
24
+ const parsed = parsePronunciationGlossary(text);
25
+ // A glossary that doesn't parse can't be trusted in someone else's narration either.
26
+ if (!parsed.ok)
27
+ continue;
28
+ for (const entry of parsed.entries) {
29
+ const key = glossaryKey(entry.term);
30
+ if (merged.has(key))
31
+ continue;
32
+ merged.set(key, { term: entry.term, ipa: [...entry.ipa] });
33
+ contributors.add(projectId);
34
+ }
35
+ }
36
+ return {
37
+ entries: [...merged.values()].sort((a, b) => a.term.localeCompare(b.term)),
38
+ projects: contributors.size,
39
+ };
40
+ }
41
+ // A project's own glossary comes first; a shared term it already defines is left out, so
42
+ // there is never a conflict for the parser to refuse.
43
+ export function withSharedGlossary(own, shared) {
44
+ if (shared === undefined || shared.length === 0)
45
+ return own;
46
+ const taken = new Set(own.map((entry) => glossaryKey(entry.term)));
47
+ return [...own, ...shared.filter((entry) => !taken.has(glossaryKey(entry.term)))];
48
+ }
49
+ function glossaryKey(term) {
50
+ return term.normalize("NFC").toLocaleLowerCase("en").replace(/\s+/gu, " ").trim();
51
+ }