@vutex/vutex 0.0.13 → 0.0.16

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.
@@ -0,0 +1,352 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import type { TemplateColorToken, TemplateGeometry, TemplateSectionChrome, VuTeXDiagnostic } from "../types.js";
5
+
6
+ const PAGE_SIZES: Record<string, { width: number; height: number }> = {
7
+ a4: { width: 595.276, height: 841.89 },
8
+ letter: { width: 612, height: 792 },
9
+ };
10
+
11
+ const COLOR_TOKENS: readonly TemplateColorToken[] = [
12
+ "ink",
13
+ "inkSecondary",
14
+ "inkMuted",
15
+ "inkFaint",
16
+ "accent",
17
+ "ruleStrong",
18
+ "rule",
19
+ "ruleFaint",
20
+ "tint",
21
+ "header",
22
+ "folio",
23
+ "headingRule",
24
+ ];
25
+
26
+ function invalid(message: string, details: Record<string, unknown>): VuTeXDiagnostic {
27
+ return {
28
+ code: "VUTEX_PACKAGE_TEMPLATE_INVALID",
29
+ severity: "error",
30
+ message,
31
+ stage: "package",
32
+ args: details,
33
+ };
34
+ }
35
+
36
+ function unsupported(message: string, details: Record<string, unknown>): VuTeXDiagnostic {
37
+ return {
38
+ code: "VUTEX_PACKAGE_TEMPLATE_UNSUPPORTED",
39
+ severity: "error",
40
+ message,
41
+ stage: "package",
42
+ args: details,
43
+ };
44
+ }
45
+
46
+ function positive(value: unknown): value is number {
47
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
48
+ }
49
+
50
+ function margin(value: unknown): number | undefined | null {
51
+ if (value === undefined) {
52
+ return undefined;
53
+ }
54
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
55
+ return value;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** Trimmed running-header text, or `null` when it fails the 1–160 contract. */
61
+ function headerText(value: unknown): string | null {
62
+ if (typeof value !== "string") {
63
+ return null;
64
+ }
65
+ const text = value.trim();
66
+ return text.length === 0 || text.length > 160 ? null : text;
67
+ }
68
+
69
+ /** A `#RRGGBB` colour token, or `null` when the value is malformed. */
70
+ function colorText(value: unknown): string | null {
71
+ return typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value) ? value : null;
72
+ }
73
+
74
+ /**
75
+ * Read the page contract a template package declares in `template.json`.
76
+ *
77
+ * The file carries the template's first layer — a named page size or an
78
+ * explicit width/height in PostScript points plus optional margins — and the
79
+ * second layer: page chrome overrides (`chrome`), section page breaks
80
+ * (`sections`), and a body style reflow (`styles`). Every second-layer block is
81
+ * optional, so a template that declares only geometry keeps theme behaviour.
82
+ */
83
+ export async function readTemplateGeometry(
84
+ directory: string,
85
+ ): Promise<{ geometry?: TemplateGeometry; diagnostic?: VuTeXDiagnostic }> {
86
+ const file = join(directory, "template.json");
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(await readFile(file, "utf8"));
90
+ } catch (error) {
91
+ return {
92
+ diagnostic: invalid(
93
+ `template package at ${directory} has no readable template.json`,
94
+ { directory, reason: error instanceof Error ? error.message : String(error) },
95
+ ),
96
+ };
97
+ }
98
+ if (parsed === null || typeof parsed !== "object") {
99
+ return { diagnostic: invalid(`template.json at ${directory} is not an object`, { directory }) };
100
+ }
101
+ const record = parsed as { page?: unknown; margin?: unknown; chrome?: unknown; sections?: unknown; styles?: unknown };
102
+ if (record.page === null || typeof record.page !== "object") {
103
+ return { diagnostic: invalid(`template.json at ${directory} has no page object`, { directory }) };
104
+ }
105
+ const page = record.page as { size?: unknown; width?: unknown; height?: unknown };
106
+
107
+ let width: number;
108
+ let height: number;
109
+ if (typeof page.size === "string") {
110
+ const named = PAGE_SIZES[page.size];
111
+ if (named === undefined) {
112
+ return {
113
+ diagnostic: invalid(
114
+ `template.json names an unknown page size: ${page.size}`,
115
+ { directory, size: page.size },
116
+ ),
117
+ };
118
+ }
119
+ width = named.width;
120
+ height = named.height;
121
+ } else if (positive(page.width) && positive(page.height)) {
122
+ width = page.width;
123
+ height = page.height;
124
+ } else {
125
+ return {
126
+ diagnostic: invalid(`template.json needs a page size or a positive width and height`, {
127
+ directory,
128
+ }),
129
+ };
130
+ }
131
+
132
+ const geometry: TemplateGeometry = { pageWidth: width, pageHeight: height };
133
+ if (record.margin !== undefined) {
134
+ if (typeof record.margin === "number") {
135
+ const uniform = margin(record.margin);
136
+ if (uniform === null || uniform === undefined) {
137
+ return {
138
+ diagnostic: invalid(`template.json has an invalid margin`, { directory }),
139
+ };
140
+ }
141
+ geometry.marginTop = uniform;
142
+ geometry.marginRight = uniform;
143
+ geometry.marginBottom = uniform;
144
+ geometry.marginLeft = uniform;
145
+ } else if (record.margin !== null && typeof record.margin === "object") {
146
+ const sides = record.margin as Record<string, unknown>;
147
+ for (const side of ["top", "right", "bottom", "left"]) {
148
+ const value = margin(sides[side]);
149
+ if (value === null) {
150
+ return {
151
+ diagnostic: invalid(`template.json has an invalid ${side} margin`, { directory }),
152
+ };
153
+ }
154
+ if (value !== undefined) {
155
+ const key = `margin${side[0].toUpperCase()}${side.slice(1)}` as
156
+ | "marginTop"
157
+ | "marginRight"
158
+ | "marginBottom"
159
+ | "marginLeft";
160
+ geometry[key] = value;
161
+ }
162
+ }
163
+ } else {
164
+ return { diagnostic: invalid(`template.json has an invalid margin`, { directory }) };
165
+ }
166
+ }
167
+ if (record.chrome !== undefined) {
168
+ if (record.chrome === null || typeof record.chrome !== "object") {
169
+ return { diagnostic: invalid(`template.json has an invalid chrome block`, { directory }) };
170
+ }
171
+ const chrome = record.chrome as Record<string, unknown>;
172
+ if (chrome.header !== undefined) {
173
+ const text = headerText(chrome.header);
174
+ if (text === null) {
175
+ return { diagnostic: invalid(`template.json has an invalid chrome header`, { directory }) };
176
+ }
177
+ geometry.chromeHeader = text;
178
+ }
179
+ if (chrome.folio !== undefined) {
180
+ if (typeof chrome.folio !== "boolean") {
181
+ return { diagnostic: invalid(`template.json has an invalid chrome folio flag`, { directory }) };
182
+ }
183
+ geometry.chromeFolio = chrome.folio;
184
+ }
185
+ if (chrome.startPage !== undefined) {
186
+ if (!Number.isInteger(chrome.startPage) || (chrome.startPage as number) < 1 || (chrome.startPage as number) > 99) {
187
+ return { diagnostic: invalid(`template.json has an invalid chrome startPage`, { directory }) };
188
+ }
189
+ geometry.chromeStartPage = chrome.startPage as number;
190
+ }
191
+ if (chrome.align !== undefined) {
192
+ if (chrome.align !== "left" && chrome.align !== "center" && chrome.align !== "right") {
193
+ return { diagnostic: invalid(`template.json has an invalid chrome alignment`, { directory }) };
194
+ }
195
+ geometry.headerAlign = chrome.align;
196
+ }
197
+ if (chrome.sections !== undefined) {
198
+ if (chrome.sections === null || typeof chrome.sections !== "object" || Array.isArray(chrome.sections)) {
199
+ return { diagnostic: invalid(`template.json has an invalid chrome sections block`, { directory }) };
200
+ }
201
+ const entries = chrome.sections as Record<string, unknown>;
202
+ const sections: TemplateSectionChrome[] = [];
203
+ for (const [key, value] of Object.entries(entries)) {
204
+ if (!/^[1-4]$/.test(key)) {
205
+ return {
206
+ diagnostic: invalid(`template.json chrome section level is outside H1 to H4`, {
207
+ directory,
208
+ level: key,
209
+ }),
210
+ };
211
+ }
212
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
213
+ return { diagnostic: invalid(`template.json chrome section ${key} is not an object`, { directory }) };
214
+ }
215
+ const entry = value as { header?: unknown; folio?: unknown };
216
+ if (entry.header === undefined && entry.folio === undefined) {
217
+ return {
218
+ diagnostic: invalid(`template.json chrome section ${key} needs a header or a folio`, {
219
+ directory,
220
+ level: key,
221
+ }),
222
+ };
223
+ }
224
+ const section: TemplateSectionChrome = { level: Number(key) };
225
+ if (entry.header !== undefined) {
226
+ const text = headerText(entry.header);
227
+ if (text === null) {
228
+ return {
229
+ diagnostic: invalid(`template.json chrome section ${key} has an invalid header`, {
230
+ directory,
231
+ level: key,
232
+ }),
233
+ };
234
+ }
235
+ section.header = text;
236
+ }
237
+ if (entry.folio !== undefined) {
238
+ if (typeof entry.folio !== "boolean") {
239
+ return {
240
+ diagnostic: invalid(`template.json chrome section ${key} has an invalid folio flag`, {
241
+ directory,
242
+ level: key,
243
+ }),
244
+ };
245
+ }
246
+ section.folio = entry.folio;
247
+ }
248
+ sections.push(section);
249
+ }
250
+ sections.sort((left, right) => left.level - right.level);
251
+ geometry.chromeSections = sections;
252
+ }
253
+ }
254
+ if (record.sections !== undefined) {
255
+ if (record.sections === null || typeof record.sections !== "object") {
256
+ return { diagnostic: invalid(`template.json has an invalid sections block`, { directory }) };
257
+ }
258
+ const sections = record.sections as { pageBreakBefore?: unknown };
259
+ if (sections.pageBreakBefore !== undefined) {
260
+ if (!Array.isArray(sections.pageBreakBefore)) {
261
+ return { diagnostic: invalid(`template.json sections.pageBreakBefore is not an array`, { directory }) };
262
+ }
263
+ const levels: number[] = [];
264
+ for (const level of sections.pageBreakBefore) {
265
+ if (!Number.isInteger(level) || (level as number) < 1 || (level as number) > 4) {
266
+ return {
267
+ diagnostic: invalid(`template.json page break level is outside H1 to H4`, { directory, level }),
268
+ };
269
+ }
270
+ if (!levels.includes(level as number)) {
271
+ levels.push(level as number);
272
+ }
273
+ }
274
+ geometry.pageBreakBefore = levels;
275
+ }
276
+ }
277
+ if (record.styles !== undefined) {
278
+ if (record.styles === null || typeof record.styles !== "object") {
279
+ return { diagnostic: invalid(`template.json has an invalid styles block`, { directory }) };
280
+ }
281
+ const styles = record.styles as {
282
+ bodySize?: unknown;
283
+ lineHeight?: unknown;
284
+ headingScales?: unknown;
285
+ paragraphGap?: unknown;
286
+ paragraphIndent?: unknown;
287
+ colors?: unknown;
288
+ fontFamily?: unknown;
289
+ fontFamilies?: unknown;
290
+ };
291
+ if (styles.bodySize !== undefined) {
292
+ if (typeof styles.bodySize !== "number" || styles.bodySize < 8 || styles.bodySize > 24) {
293
+ return { diagnostic: invalid(`template.json bodySize must be between 8 and 24 points`, { directory }) };
294
+ }
295
+ geometry.bodySize = styles.bodySize;
296
+ }
297
+ if (styles.lineHeight !== undefined) {
298
+ if (typeof styles.lineHeight !== "number" || styles.lineHeight < 1 || styles.lineHeight > 3) {
299
+ return { diagnostic: invalid(`template.json lineHeight must be between 1 and 3`, { directory }) };
300
+ }
301
+ geometry.lineHeight = styles.lineHeight;
302
+ }
303
+ if (styles.headingScales !== undefined) {
304
+ if (!Array.isArray(styles.headingScales) || styles.headingScales.length !== 4) {
305
+ return { diagnostic: invalid(`template.json headingScales needs exactly four values`, { directory }) };
306
+ }
307
+ if (styles.headingScales.some((scale) => typeof scale !== "number" || scale < 0.8 || scale > 4)) {
308
+ return { diagnostic: invalid(`template.json headingScales must be between 0.8 and 4`, { directory }) };
309
+ }
310
+ geometry.headingScales = styles.headingScales as number[];
311
+ }
312
+ for (const [field, name] of [
313
+ ["paragraphGap", "paragraphGap"],
314
+ ["paragraphIndent", "paragraphIndent"],
315
+ ] as const) {
316
+ const value = styles[field];
317
+ if (value === undefined) {
318
+ continue;
319
+ }
320
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 48) {
321
+ return { diagnostic: invalid(`template.json ${name} must be between 0 and 48 points`, { directory }) };
322
+ }
323
+ geometry[field] = value;
324
+ }
325
+ if (styles.colors !== undefined) {
326
+ if (styles.colors === null || typeof styles.colors !== "object" || Array.isArray(styles.colors)) {
327
+ return { diagnostic: invalid(`template.json has an invalid colors block`, { directory }) };
328
+ }
329
+ const colors: Partial<Record<TemplateColorToken, string>> = {};
330
+ for (const [token, value] of Object.entries(styles.colors as Record<string, unknown>)) {
331
+ if (!COLOR_TOKENS.includes(token as TemplateColorToken)) {
332
+ return { diagnostic: invalid(`template.json colors names an unknown token: ${token}`, { directory, token }) };
333
+ }
334
+ const color = colorText(value);
335
+ if (color === null) {
336
+ return { diagnostic: invalid(`template.json color ${token} must be #RRGGBB`, { directory, token }) };
337
+ }
338
+ colors[token as TemplateColorToken] = color;
339
+ }
340
+ geometry.colors = colors;
341
+ }
342
+ if (styles.fontFamily !== undefined || styles.fontFamilies !== undefined) {
343
+ return {
344
+ diagnostic: unsupported(
345
+ `template.json declares a text font family but the bundled layout only has serif and monospace faces`,
346
+ { directory },
347
+ ),
348
+ };
349
+ }
350
+ }
351
+ return { geometry };
352
+ }