@gentbajko/slopify 2.1.1 → 2.3.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.
- package/dist/assets/document/SOURCE.txt +5 -1
- package/dist/assets/document/sample-cover.jpg +0 -0
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/document-themes.js +80 -0
- package/dist/edge/http/pronunciations.js +17 -0
- package/dist/edge/http/storage.js +106 -21
- package/dist/kernel/db/migrate.js +10 -1
- package/dist/kernel/db/migrations/0016-document-themes.sql +10 -0
- package/dist/kernel/db/migrations/0017-legacy-dicemaster-theme.sql +12 -0
- package/dist/kernel/db/migrations/0018-backup-imports.sql +7 -0
- package/dist/slices/admission/schema.js +15 -3
- package/dist/slices/admission/start.js +16 -1
- package/dist/slices/article/source-lines.js +20 -0
- package/dist/slices/document/legacy-dicemaster.js +118 -0
- package/dist/slices/document/library.js +77 -0
- package/dist/slices/document/model.js +40 -5
- package/dist/slices/document/sample.js +51 -0
- package/dist/slices/document/sources.js +5 -10
- package/dist/slices/document/theme-schema.js +173 -0
- package/dist/slices/document/theme.js +25 -44
- package/dist/slices/narration/shared-glossary.js +51 -0
- package/dist/slices/play-drafts/convert.js +12 -3
- package/dist/slices/play-drafts/schema.js +7 -7
- package/dist/slices/project-templates/from-project.js +9 -1
- package/dist/slices/rebuild/preview-details.js +9 -2
- package/dist/slices/rebuild/recipe-document.js +2 -3
- package/dist/slices/rebuild/recipe-text.js +9 -1
- package/dist/slices/rebuild/runtime-document.js +2 -3
- package/dist/slices/rebuild/runtime-store.js +19 -3
- package/dist/slices/storage/backup-export.js +321 -0
- package/dist/slices/storage/backup-format.js +155 -0
- package/dist/slices/storage/backup-import.js +749 -0
- package/dist/slices/storage/portable.js +27 -1
- package/dist/slices/storage/tar.js +257 -0
- package/dist/web/assets/index-Cw1mnRrx.js +168 -0
- package/dist/web/assets/index-kAKdJa-U.css +1 -0
- package/dist/web/assets/pdf-BkdJFKQ-.js +54 -0
- package/dist/web/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
- package/dist/web/assets/pdf.worker.min-zC6nfbxG.js +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B6NbhpjD.css +0 -1
- package/dist/web/assets/index-DV01Cyjx.js +0 -142
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,46 @@
|
|
|
1
|
-
// The built-in looks a project's PDF can
|
|
2
|
-
// server all offer
|
|
3
|
-
export const documentThemes = ["
|
|
4
|
-
|
|
1
|
+
// The built-in looks a project's PDF can be given. Browser-safe: Play, Edit project and the
|
|
2
|
+
// server all offer the same names from here.
|
|
3
|
+
export const documentThemes = ["plain"];
|
|
4
|
+
// DiceMaster was built in before 2.3.0 and is one brand's look, not Slopify's. A project
|
|
5
|
+
// saved with it keeps it (see legacy-dicemaster.ts), so the name is still read everywhere a
|
|
6
|
+
// config is parsed, but it is never offered.
|
|
7
|
+
export const legacyDocumentTheme = "dicemaster";
|
|
8
|
+
// Every name a saved config may carry.
|
|
9
|
+
export const documentThemeNames = [...documentThemes, legacyDocumentTheme];
|
|
10
|
+
// What a new project, a fresh Play draft and a draft saved before the Document stage get.
|
|
11
|
+
export const defaultDocumentTheme = "plain";
|
|
5
12
|
export const documentThemeLabels = {
|
|
6
|
-
dicemaster: "DiceMaster",
|
|
7
13
|
plain: "Plain",
|
|
14
|
+
dicemaster: "DiceMaster",
|
|
8
15
|
};
|
|
16
|
+
// The built-in a saved project config is drawn with. A project saved before the Document stage
|
|
17
|
+
// had a theme setting has none, and was drawn with DiceMaster, the default of the day; it still
|
|
18
|
+
// is, so the PDFs it already made stay current. New projects always save their theme, so this
|
|
19
|
+
// fallback never reaches one.
|
|
9
20
|
export function documentThemeOf(settings) {
|
|
21
|
+
return settings?.theme ?? legacyDocumentTheme;
|
|
22
|
+
}
|
|
23
|
+
// The built-in a Play draft or template form stands for. A draft is not a project yet, so an
|
|
24
|
+
// absent setting is the current default rather than the old one.
|
|
25
|
+
export function draftDocumentThemeOf(settings) {
|
|
10
26
|
return settings?.theme ?? defaultDocumentTheme;
|
|
11
27
|
}
|
|
28
|
+
// The name a project's theme is shown by: the Library theme's own, or the built-in's label.
|
|
29
|
+
export function documentThemeLabel(settings) {
|
|
30
|
+
return settings?.custom?.name ?? documentThemeLabels[documentThemeOf(settings)];
|
|
31
|
+
}
|
|
32
|
+
export const documentThemeNameMax = 80;
|
|
33
|
+
// The editor's sentences for a name, shared so the page refuses what the server would.
|
|
34
|
+
export function documentThemeNameProblems(name) {
|
|
35
|
+
const trimmed = name.trim();
|
|
36
|
+
if (trimmed === "")
|
|
37
|
+
return [{ field: "name", message: "Give the theme a name." }];
|
|
38
|
+
if (trimmed.length > documentThemeNameMax)
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
field: "name",
|
|
42
|
+
message: `Keep the name under ${String(documentThemeNameMax)} characters.`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
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,173 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { documentThemeNameMax, documentThemeNames } 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. The retired "dicemaster" still
|
|
170
|
+
// parses, so a project or draft saved with it keeps opening.
|
|
171
|
+
export const documentSettingsSchema = z
|
|
172
|
+
.object({ theme: z.enum(documentThemeNames), custom: customDocumentThemeSchema.optional() })
|
|
173
|
+
.strict();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { legacyDiceMasterTheme } from "./legacy-dicemaster.js";
|
|
2
|
+
import { defaultDocumentTheme, documentThemeOf, legacyDocumentTheme, } from "./model.js";
|
|
2
3
|
// "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
|
|
3
4
|
// PDF standard fonts every reader has.
|
|
4
5
|
export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
|
|
@@ -8,10 +9,12 @@ const cinzel = (style, letterSpacing) => ({
|
|
|
8
9
|
style,
|
|
9
10
|
letterSpacing,
|
|
10
11
|
});
|
|
11
|
-
|
|
12
|
+
// The one built-in: an unbranded flat page. Its layout is the one Slopify has always drawn,
|
|
13
|
+
// written out in full rather than as changes to another theme.
|
|
14
|
+
const plain = {
|
|
12
15
|
page: { format: "a4", margin: 22, contentTop: 32 },
|
|
13
|
-
background: { image:
|
|
14
|
-
colors: { heading: "#
|
|
16
|
+
background: { image: null, color: "#fdfaf3" },
|
|
17
|
+
colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
|
|
15
18
|
fonts: {
|
|
16
19
|
body: cinzel("normal", 0.01),
|
|
17
20
|
strong: cinzel("bold", 0.02),
|
|
@@ -23,8 +26,6 @@ const dicemaster = {
|
|
|
23
26
|
dropCap: cinzel("black", 0),
|
|
24
27
|
footer: { family: "times", style: "normal", letterSpacing: 0 },
|
|
25
28
|
},
|
|
26
|
-
// lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
|
|
27
|
-
// rounded; these are the results.
|
|
28
29
|
sizes: {
|
|
29
30
|
brand: 31,
|
|
30
31
|
title: 22,
|
|
@@ -69,18 +70,8 @@ const dicemaster = {
|
|
|
69
70
|
},
|
|
70
71
|
header: { enabled: true, top: 15, maxTitleCharacters: 35 },
|
|
71
72
|
footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
|
|
72
|
-
brand: {
|
|
73
|
-
|
|
74
|
-
url: "https://dicemaster.io/",
|
|
75
|
-
tagline: "Let us Handle the Crunch, You Focus on the Story",
|
|
76
|
-
linkLabel: "Visit DiceMaster.io",
|
|
77
|
-
},
|
|
78
|
-
metadata: {
|
|
79
|
-
author: "Created with DiceMaster.io",
|
|
80
|
-
subject: "{title} - Powered by DiceMaster.io",
|
|
81
|
-
keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
|
|
82
|
-
creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
|
|
83
|
-
},
|
|
73
|
+
brand: { name: null, url: null, tagline: null, linkLabel: null },
|
|
74
|
+
metadata: { author: "", subject: "{title}", keywords: "", creator: "" },
|
|
84
75
|
sources: {
|
|
85
76
|
enabled: true,
|
|
86
77
|
title: "Sources Consulted",
|
|
@@ -90,43 +81,33 @@ const dicemaster = {
|
|
|
90
81
|
gap: 2,
|
|
91
82
|
},
|
|
92
83
|
endPage: {
|
|
93
|
-
enabled:
|
|
94
|
-
title: "About
|
|
84
|
+
enabled: false,
|
|
85
|
+
title: "About",
|
|
95
86
|
titleOffset: 20,
|
|
96
87
|
bodyOffset: 40,
|
|
97
|
-
lines: [
|
|
98
|
-
"About DiceMaster.io:",
|
|
99
|
-
"• Character Engine - Form-driven creation with automatic leveling",
|
|
100
|
-
"• Automated Rules Engine - D&D mechanics and spells run automatically",
|
|
101
|
-
"• AI-Generated NPCs - Instant personalities with full stat blocks",
|
|
102
|
-
"• Granular Inventory Tracking - Live gear & condition management",
|
|
103
|
-
"• Real-Time Rule Guidance - Context-aware clarifications",
|
|
104
|
-
"• Dynamic Storytelling AI - On-the-fly narration & dialogue",
|
|
105
|
-
"• True-Physics Dice - RNG-driven rolls with animated suspense",
|
|
106
|
-
"• Cinematic Combat Module - Positional tracking & narrative summaries",
|
|
107
|
-
"• Voice Commands - Seamless STT/TTS multimodal interaction",
|
|
108
|
-
"• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
|
|
109
|
-
],
|
|
88
|
+
lines: [],
|
|
110
89
|
showDocumentDetails: true,
|
|
111
|
-
link:
|
|
112
|
-
closing:
|
|
90
|
+
link: null,
|
|
91
|
+
closing: null,
|
|
113
92
|
},
|
|
114
93
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
|
|
118
|
-
brand: { name: null, url: null, tagline: null, linkLabel: null },
|
|
119
|
-
metadata: { author: "", subject: "{title}", keywords: "D&D, lore", creator: "" },
|
|
120
|
-
endPage: { enabled: false },
|
|
121
|
-
};
|
|
94
|
+
// A built-in by name. "dicemaster" is still read, from projects saved while it was built in,
|
|
95
|
+
// and gives exactly the values it gave then.
|
|
122
96
|
export function builtInTheme(name) {
|
|
123
|
-
return name ===
|
|
97
|
+
return resolveTheme({}, name === legacyDocumentTheme ? legacyDiceMasterTheme : plain);
|
|
98
|
+
}
|
|
99
|
+
// The theme a project's document is drawn with: its copy of a Library theme, or a built-in.
|
|
100
|
+
export function resolvedDocumentTheme(settings) {
|
|
101
|
+
const custom = settings?.custom;
|
|
102
|
+
return custom === undefined
|
|
103
|
+
? builtInTheme(documentThemeOf(settings))
|
|
104
|
+
: resolveTheme(custom.values);
|
|
124
105
|
}
|
|
125
106
|
export function defaultTheme() {
|
|
126
107
|
return builtInTheme(defaultDocumentTheme);
|
|
127
108
|
}
|
|
128
109
|
// Unknown sections and keys throw, so a typo in a saved theme can't pass silently.
|
|
129
|
-
export function resolveTheme(overrides = {}, base =
|
|
110
|
+
export function resolveTheme(overrides = {}, base = plain) {
|
|
130
111
|
const theme = structuredClone(base);
|
|
131
112
|
for (const [section, values] of Object.entries(overrides)) {
|
|
132
113
|
const target = theme[section];
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
3
|
import { runDraftSchema } from "../admission/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { draftDocumentThemeOf } from "../document/model.js";
|
|
5
5
|
import { defaultSubtitles } from "../subtitles/model.js";
|
|
6
6
|
export function toAdmissionDraft(input) {
|
|
7
7
|
const { form } = input.document;
|
|
@@ -72,11 +72,20 @@ export function toAdmissionDraft(input) {
|
|
|
72
72
|
format: form.format,
|
|
73
73
|
sources,
|
|
74
74
|
...(sources.document === "generate"
|
|
75
|
-
? {
|
|
75
|
+
? {
|
|
76
|
+
document: {
|
|
77
|
+
theme: draftDocumentThemeOf(form.document),
|
|
78
|
+
...(form.document?.custom === undefined ? {} : { custom: form.document.custom }),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
76
81
|
: {}),
|
|
77
82
|
llm: form.llm,
|
|
78
83
|
audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
|
|
79
|
-
?
|
|
84
|
+
? // With the glossary on, sharing it is on unless the draft turned it off, as Play
|
|
85
|
+
// shows it for a draft saved before the switch existed.
|
|
86
|
+
form.audio.usePronunciationGlossary === true && form.audio.shareGlossary === undefined
|
|
87
|
+
? { ...form.audio, shareGlossary: true }
|
|
88
|
+
: form.audio
|
|
80
89
|
: undefined,
|
|
81
90
|
images: form.images,
|
|
82
91
|
articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
|
|
@@ -5,7 +5,7 @@ import { motionStyles, stageSources } from "../admission/model.js";
|
|
|
5
5
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
|
|
6
6
|
import { runDraftSchema } from "../admission/schema.js";
|
|
7
7
|
import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
|
|
8
|
-
import {
|
|
8
|
+
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
9
9
|
import { librarySnapshotSchema } from "../library/snapshot.js";
|
|
10
10
|
import { chunkModes } from "../narration/chunk.js";
|
|
11
11
|
import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
|
|
@@ -35,14 +35,14 @@ export const playDraftFormSchema = z
|
|
|
35
35
|
.strict()
|
|
36
36
|
.readonly(),
|
|
37
37
|
// Absent until the theme is first chosen: the default theme.
|
|
38
|
-
document:
|
|
39
|
-
.object({ theme: z.enum(documentThemes) })
|
|
40
|
-
.strict()
|
|
41
|
-
.readonly()
|
|
42
|
-
.optional(),
|
|
38
|
+
document: documentSettingsSchema.readonly().optional(),
|
|
43
39
|
llm: provider.readonly(),
|
|
44
40
|
audio: provider
|
|
45
|
-
.extend({
|
|
41
|
+
.extend({
|
|
42
|
+
voice: text,
|
|
43
|
+
usePronunciationGlossary: z.boolean().optional(),
|
|
44
|
+
shareGlossary: z.boolean().optional(),
|
|
45
|
+
})
|
|
46
46
|
.readonly(),
|
|
47
47
|
images: provider.readonly(),
|
|
48
48
|
articlePrompt: text,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { sourceOf } from "../admission/model.js";
|
|
3
4
|
import { projectById } from "../admission/repo.js";
|
|
4
5
|
import { detectSlots } from "../admission/substitute.js";
|
|
5
6
|
import { listCheckpoints } from "../checkpoints/repo.js";
|
|
7
|
+
import { documentThemeOf } from "../document/model.js";
|
|
6
8
|
import { requestHash } from "../play-drafts/repo.js";
|
|
7
9
|
import { currentRevisionId, revisionById } from "../revisions/repo.js";
|
|
8
10
|
import { createTemplate, readTemplate } from "./service.js";
|
|
@@ -128,7 +130,13 @@ function documentFromProject(deps, revision) {
|
|
|
128
130
|
title: config.title,
|
|
129
131
|
format: config.format,
|
|
130
132
|
sources: config.sources,
|
|
131
|
-
|
|
133
|
+
// A project from before theme settings was drawn with DiceMaster; the template says so
|
|
134
|
+
// outright, as a draft without a theme would now mean Plain.
|
|
135
|
+
...(config.document === undefined
|
|
136
|
+
? sourceOf(config.sources, "document") === "generate"
|
|
137
|
+
? { document: { theme: documentThemeOf(undefined) } }
|
|
138
|
+
: {}
|
|
139
|
+
: { document: config.document }),
|
|
132
140
|
checkpoints: listCheckpoints(deps.db, revision.projectId, revision.id).map((gate) => gate.stage),
|
|
133
141
|
llm: config.llm ?? { provider: "", model: "" },
|
|
134
142
|
audio: config.audio ?? { provider: "", model: "", voice: "" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
2
|
import { sourceOf } from "../admission/model.js";
|
|
3
3
|
import { motionStyleLabels } from "../admission/rules.js";
|
|
4
|
-
import {
|
|
4
|
+
import { documentThemeLabel } from "../document/model.js";
|
|
5
5
|
import { getRevisionView } from "../revisions/view.js";
|
|
6
6
|
export function previewDetails(deps, view, allRecipes, selected) {
|
|
7
7
|
const parent = view.revision.parentId === null
|
|
@@ -137,7 +137,14 @@ function inputChanges(parent, view) {
|
|
|
137
137
|
add("Intro", before.intro, after.intro);
|
|
138
138
|
add("Outro", before.outro, after.outro);
|
|
139
139
|
add("Subtitle settings", before.subtitles, after.subtitles);
|
|
140
|
-
|
|
140
|
+
const themeBefore = documentThemeLabel(before.document);
|
|
141
|
+
const themeAfter = documentThemeLabel(after.document);
|
|
142
|
+
// A Library theme saved again under the same name still changes the document.
|
|
143
|
+
add("Document theme", themeBefore, themeBefore === themeAfter &&
|
|
144
|
+
JSON.stringify(before.document?.custom?.values) !==
|
|
145
|
+
JSON.stringify(after.document?.custom?.values)
|
|
146
|
+
? `${themeAfter} (updated settings)`
|
|
147
|
+
: themeAfter);
|
|
141
148
|
add("Article text", parent.articleMarkdown, view.articleMarkdown);
|
|
142
149
|
add("Provided research", before.provided.research, after.provided.research);
|
|
143
150
|
for (const key of new Set([...Object.keys(before.values), ...Object.keys(after.values)]))
|