@formfeed/devkit 0.2.0 → 0.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/index.cjs +818 -56
- package/index.js +797 -46
- package/package.json +3 -3
- package/types/index.d.cts +8 -3
- package/types/index.d.ts +8 -3
- package/types/lib/brand.d.cts +14 -0
- package/types/lib/brand.d.ts +14 -0
- package/types/lib/local-render.d.cts +32 -3
- package/types/lib/local-render.d.ts +32 -3
- package/types/lib/partials.d.cts +2 -0
- package/types/lib/partials.d.ts +2 -0
- package/types/lib/project-config.d.cts +13 -0
- package/types/lib/project-config.d.ts +13 -0
- package/types/lib/project.d.cts +71 -7
- package/types/lib/project.d.ts +71 -7
- package/types/lib/redact.d.cts +19 -0
- package/types/lib/redact.d.ts +19 -0
- package/types/lib/types/emit-python.d.cts +6 -0
- package/types/lib/types/emit-python.d.ts +6 -0
- package/types/lib/types/emit-ts.d.cts +10 -0
- package/types/lib/types/emit-ts.d.ts +10 -0
- package/types/lib/types/index.d.cts +15 -0
- package/types/lib/types/index.d.ts +15 -0
- package/types/lib/types/model.d.cts +81 -0
- package/types/lib/types/model.d.ts +81 -0
package/index.js
CHANGED
|
@@ -70,10 +70,15 @@ function writeProjectConfig(root, config) {
|
|
|
70
70
|
// packages/devkit/src/lib/project.ts
|
|
71
71
|
import { createHash } from "node:crypto";
|
|
72
72
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
73
|
-
import { basename, dirname as dirname2, join as join2 } from "node:path";
|
|
73
|
+
import { basename, dirname as dirname2, extname, join as join2 } from "node:path";
|
|
74
|
+
import {
|
|
75
|
+
isOfficeKind,
|
|
76
|
+
normalisePartialName as normalisePartialName2,
|
|
77
|
+
officeKinds
|
|
78
|
+
} from "@formfeed/engine";
|
|
74
79
|
|
|
75
80
|
// packages/devkit/src/lib/partials.ts
|
|
76
|
-
import { getEngine } from "@formfeed/engine";
|
|
81
|
+
import { getEngine, normalisePartialName } from "@formfeed/engine";
|
|
77
82
|
function includeNames(engine, source) {
|
|
78
83
|
try {
|
|
79
84
|
return getEngine(engine).analyze(source).includes.map((include) => include.name);
|
|
@@ -83,8 +88,10 @@ function includeNames(engine, source) {
|
|
|
83
88
|
}
|
|
84
89
|
function collectPartials(project, engine, sources) {
|
|
85
90
|
const resolve3 = partialResolver(project);
|
|
91
|
+
const sharedNames = sharedPartialNames(project, engine);
|
|
86
92
|
const partials = {};
|
|
87
93
|
const missing = /* @__PURE__ */ new Set();
|
|
94
|
+
const shared = /* @__PURE__ */ new Set();
|
|
88
95
|
const queue = sources.filter((source) => Boolean(source));
|
|
89
96
|
const seen = /* @__PURE__ */ new Set();
|
|
90
97
|
while (queue.length) {
|
|
@@ -92,6 +99,11 @@ function collectPartials(project, engine, sources) {
|
|
|
92
99
|
if (seen.has(name)) continue;
|
|
93
100
|
seen.add(name);
|
|
94
101
|
const source = resolve3(name);
|
|
102
|
+
if (sharedNames.has(normalisePartialName(name))) {
|
|
103
|
+
shared.add(normalisePartialName(name));
|
|
104
|
+
if (source !== void 0) queue.push(source);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
95
107
|
if (source === void 0) {
|
|
96
108
|
missing.add(name);
|
|
97
109
|
continue;
|
|
@@ -100,65 +112,86 @@ function collectPartials(project, engine, sources) {
|
|
|
100
112
|
queue.push(source);
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
|
-
return { partials, missing: [...missing].sort() };
|
|
115
|
+
return { partials, missing: [...missing].sort(), shared: [...shared].sort() };
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
// packages/devkit/src/lib/project.ts
|
|
107
119
|
var readText = (path) => existsSync2(path) ? readFileSync2(path, "utf8") : null;
|
|
108
120
|
function readJson(path, what) {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
121
|
+
const text2 = readText(path);
|
|
122
|
+
if (text2 === null) return null;
|
|
111
123
|
try {
|
|
112
|
-
return JSON.parse(
|
|
124
|
+
return JSON.parse(text2);
|
|
113
125
|
} catch (e) {
|
|
114
126
|
throw new DevkitError(`${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`, "validation");
|
|
115
127
|
}
|
|
116
128
|
}
|
|
129
|
+
var templateFileName = (kind) => isOfficeKind(kind) ? `template.${kind}` : "template.html";
|
|
130
|
+
function sourceFilesIn(dir) {
|
|
131
|
+
return ["template.html", ...officeKinds.map((k) => `template.${k}`)].filter((name) => existsSync2(join2(dir, name)));
|
|
132
|
+
}
|
|
117
133
|
function listTemplateSlugs(project) {
|
|
118
134
|
if (!existsSync2(project.templatesDir)) return [];
|
|
119
|
-
return readdirSync(project.templatesDir).filter((name) =>
|
|
135
|
+
return readdirSync(project.templatesDir).filter((name) => sourceFilesIn(join2(project.templatesDir, name)).length > 0).sort();
|
|
120
136
|
}
|
|
121
137
|
function templateDir(project, slug) {
|
|
122
138
|
return join2(project.templatesDir, slug);
|
|
123
139
|
}
|
|
140
|
+
var sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
141
|
+
function readOfficeFile(dir, name) {
|
|
142
|
+
const path = join2(dir, name);
|
|
143
|
+
const bytes = new Uint8Array(readFileSync2(path));
|
|
144
|
+
return { format: extname(name).slice(1), path, bytes, sha256: sha256Of(bytes) };
|
|
145
|
+
}
|
|
124
146
|
function readTemplate(project, slug) {
|
|
125
147
|
const dir = templateDir(project, slug);
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
148
|
+
const sources = sourceFilesIn(dir);
|
|
149
|
+
if (sources.length === 0)
|
|
150
|
+
throw new DevkitError(`No template "${slug}" in ${project.templatesDir} (template.html, template.docx or template.pptx missing)`);
|
|
151
|
+
if (sources.length > 1)
|
|
152
|
+
throw new DevkitError(`${dir} holds ${sources.join(" and ")}; a template folder has one of them`, "validation");
|
|
153
|
+
const file = sources[0] === "template.html" ? null : readOfficeFile(dir, sources[0]);
|
|
154
|
+
const html = file ? "" : readText(join2(dir, "template.html")) ?? "";
|
|
128
155
|
const metaFile = readJson(join2(dir, "template.json"), "template.json") ?? {};
|
|
156
|
+
if (file && metaFile.kind && metaFile.kind !== file.format)
|
|
157
|
+
throw new DevkitError(`${slug}: template.json says kind "${metaFile.kind}", but the folder holds ${basename(file.path)}`, "validation");
|
|
158
|
+
if (!file && isOfficeKind(metaFile.kind))
|
|
159
|
+
throw new DevkitError(`${slug}: template.json says kind "${metaFile.kind}", but the folder holds template.html instead of template.${metaFile.kind}`, "validation");
|
|
129
160
|
const meta = {
|
|
130
161
|
name: metaFile.name ?? titleFromSlug(slug),
|
|
131
|
-
kind: metaFile.kind ?? "pdf",
|
|
162
|
+
kind: file?.format ?? metaFile.kind ?? "pdf",
|
|
132
163
|
engine: metaFile.engine ?? project.config.engine,
|
|
133
164
|
description: metaFile.description ?? null,
|
|
134
165
|
tags: metaFile.tags ?? []
|
|
135
166
|
};
|
|
136
167
|
const settings = { ...readJson(join2(dir, "settings.json"), "settings.json") ?? {} };
|
|
137
|
-
const header = readText(join2(dir, "header.html"));
|
|
138
|
-
const footer = readText(join2(dir, "footer.html"));
|
|
168
|
+
const header = file ? null : readText(join2(dir, "header.html"));
|
|
169
|
+
const footer = file ? null : readText(join2(dir, "footer.html"));
|
|
139
170
|
if (header !== null && header.trim()) settings.header = { ...settings.header ?? {}, html: header };
|
|
140
171
|
if (footer !== null && footer.trim()) settings.footer = { ...settings.footer ?? {}, html: footer };
|
|
141
172
|
const dataSets = {};
|
|
142
173
|
const dataDir = join2(dir, "data");
|
|
143
174
|
if (existsSync2(dataDir)) {
|
|
144
|
-
for (const
|
|
145
|
-
dataSets[basename(
|
|
175
|
+
for (const file2 of readdirSync(dataDir).filter((f) => f.endsWith(".json")).sort()) {
|
|
176
|
+
dataSets[basename(file2, ".json")] = readJson(join2(dataDir, file2), `data/${file2}`);
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
|
-
const { partials, missing } = collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
|
|
179
|
+
const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
|
|
149
180
|
return {
|
|
150
181
|
slug,
|
|
151
182
|
dir,
|
|
152
183
|
meta,
|
|
184
|
+
file,
|
|
153
185
|
html,
|
|
154
|
-
css: readText(join2(dir, "style.css")) ?? "",
|
|
155
|
-
head: readText(join2(dir, "head.html")) ?? "",
|
|
186
|
+
css: file ? "" : readText(join2(dir, "style.css")) ?? "",
|
|
187
|
+
head: file ? "" : readText(join2(dir, "head.html")) ?? "",
|
|
156
188
|
settings,
|
|
157
189
|
dataSets,
|
|
158
190
|
dataSchema: readJson(join2(dir, "schema.json"), "schema.json"),
|
|
159
191
|
i18n: readJson(join2(dir, "i18n.json"), "i18n.json"),
|
|
160
192
|
partials,
|
|
161
|
-
missingPartials: missing
|
|
193
|
+
missingPartials: missing,
|
|
194
|
+
sharedPartials: shared
|
|
162
195
|
};
|
|
163
196
|
}
|
|
164
197
|
function defaultData(tpl, name) {
|
|
@@ -168,18 +201,23 @@ function defaultData(tpl, name) {
|
|
|
168
201
|
throw new DevkitError(`Data set "${name}" not found; available: ${names.join(", ") || "none"}`);
|
|
169
202
|
return chosen ? { name: chosen, data: tpl.dataSets[chosen] } : { name: "empty", data: {} };
|
|
170
203
|
}
|
|
171
|
-
function writeTemplate(project, slug, meta, version) {
|
|
204
|
+
function writeTemplate(project, slug, meta, version, file) {
|
|
172
205
|
const dir = templateDir(project, slug);
|
|
206
|
+
const office = isOfficeKind(meta.kind);
|
|
207
|
+
if (office && !file) throw new DevkitError(`${slug} is a ${meta.kind} template; its document is needed to write the folder`);
|
|
173
208
|
mkdirSync(join2(dir, "data"), { recursive: true });
|
|
174
209
|
const settings = { ...version.settings ?? {} };
|
|
175
|
-
const header = settings.header?.html ?? "";
|
|
176
|
-
const footer = settings.footer?.html ?? "";
|
|
210
|
+
const header = office ? "" : settings.header?.html ?? "";
|
|
211
|
+
const footer = office ? "" : settings.footer?.html ?? "";
|
|
177
212
|
if (settings.header) settings.header = stripHtml(settings.header);
|
|
178
213
|
if (settings.footer) settings.footer = stripHtml(settings.footer);
|
|
179
214
|
writeFileSync2(join2(dir, "template.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
215
|
+
for (const name of ["template.html", ...officeKinds.map((k) => `template.${k}`)])
|
|
216
|
+
if (name !== templateFileName(meta.kind) && existsSync2(join2(dir, name))) rmSync(join2(dir, name));
|
|
217
|
+
if (office) writeFileSync2(join2(dir, templateFileName(meta.kind)), file);
|
|
218
|
+
else writeFileSync2(join2(dir, "template.html"), version.html ?? "");
|
|
219
|
+
writeOrRemove(join2(dir, "style.css"), office ? "" : version.css ?? "");
|
|
220
|
+
writeOrRemove(join2(dir, "head.html"), office ? "" : version.head ?? "");
|
|
183
221
|
writeOrRemove(join2(dir, "header.html"), header);
|
|
184
222
|
writeOrRemove(join2(dir, "footer.html"), footer);
|
|
185
223
|
writeFileSync2(join2(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
|
|
@@ -214,25 +252,38 @@ function writeOrRemove(path, content) {
|
|
|
214
252
|
if (content) writeFileSync2(path, content);
|
|
215
253
|
else if (existsSync2(path)) rmSync(path);
|
|
216
254
|
}
|
|
217
|
-
function
|
|
255
|
+
function commonPayload(tpl) {
|
|
218
256
|
const sample = tpl.dataSets["default"] ?? Object.values(tpl.dataSets)[0] ?? {};
|
|
219
257
|
const named = Object.fromEntries(
|
|
220
258
|
Object.entries(tpl.dataSets).filter(([name]) => name !== "default")
|
|
221
259
|
);
|
|
222
260
|
return {
|
|
223
|
-
html: tpl.html,
|
|
224
|
-
css: tpl.css,
|
|
225
|
-
head: tpl.head,
|
|
226
261
|
settings: tpl.settings,
|
|
227
262
|
sample_data: sample ?? {},
|
|
228
263
|
data_sets: named,
|
|
229
264
|
data_schema: tpl.dataSchema,
|
|
230
|
-
i18n: tpl.i18n
|
|
265
|
+
i18n: tpl.i18n
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function officeVersionPayload(tpl) {
|
|
269
|
+
return commonPayload(tpl);
|
|
270
|
+
}
|
|
271
|
+
function versionPayload(tpl) {
|
|
272
|
+
return {
|
|
273
|
+
html: tpl.html,
|
|
274
|
+
css: tpl.css,
|
|
275
|
+
head: tpl.head,
|
|
276
|
+
...commonPayload(tpl),
|
|
231
277
|
// only when the template includes one, so a project without partials sends what it always sent
|
|
232
278
|
...Object.keys(tpl.partials).length ? { partials: tpl.partials } : {}
|
|
233
279
|
};
|
|
234
280
|
}
|
|
235
281
|
function contentHash(tpl) {
|
|
282
|
+
if (tpl.file) {
|
|
283
|
+
const payload2 = officeVersionPayload(tpl);
|
|
284
|
+
const parts2 = [tpl.file.format, tpl.file.sha256, payload2.settings, payload2.sample_data, payload2.data_sets, payload2.data_schema, payload2.i18n];
|
|
285
|
+
return createHash("sha256").update(JSON.stringify(parts2)).digest("hex");
|
|
286
|
+
}
|
|
236
287
|
const payload = versionPayload(tpl);
|
|
237
288
|
const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
|
|
238
289
|
if (payload.partials) parts.push(payload.partials);
|
|
@@ -253,6 +304,7 @@ function recordSync(project, slug, version, tpl) {
|
|
|
253
304
|
number: version.number,
|
|
254
305
|
status: version.status,
|
|
255
306
|
contentHash: contentHash(tpl),
|
|
307
|
+
...tpl.file ? { fileSha256: tpl.file.sha256 } : {},
|
|
256
308
|
syncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
257
309
|
};
|
|
258
310
|
writeState(project, state);
|
|
@@ -261,27 +313,92 @@ function titleFromSlug(slug) {
|
|
|
261
313
|
return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
262
314
|
}
|
|
263
315
|
function partialResolver(project) {
|
|
316
|
+
let shared = null;
|
|
264
317
|
return (name) => {
|
|
265
318
|
for (const candidate of [name, `${name}.html`]) {
|
|
266
319
|
const path = join2(project.partialsDir, candidate);
|
|
267
320
|
if (existsSync2(path) && statSync(path).isFile()) return readFileSync2(path, "utf8");
|
|
268
321
|
}
|
|
269
|
-
|
|
322
|
+
shared ??= new Set(Object.keys(readState(project).sharedPartials ?? {}));
|
|
323
|
+
const normalised = normalisePartialName2(name);
|
|
324
|
+
if (!shared.has(normalised)) return void 0;
|
|
325
|
+
return readText(sharedPartialPath(project, normalised)) ?? void 0;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function sharedPartialPath(project, name) {
|
|
329
|
+
return join2(project.partialsDir, `${name}.html`);
|
|
330
|
+
}
|
|
331
|
+
function sharedPartialNames(project, engine) {
|
|
332
|
+
return new Set(
|
|
333
|
+
Object.entries(readState(project).sharedPartials ?? {}).filter(([, partial]) => partial.engine === engine).map(([name]) => normalisePartialName2(name))
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
var partialContentHash = (source) => createHash("sha256").update(source).digest("hex");
|
|
337
|
+
function recordSharedPartial(project, name, remote, source) {
|
|
338
|
+
const state = readState(project);
|
|
339
|
+
state.sharedPartials = {
|
|
340
|
+
...state.sharedPartials ?? {},
|
|
341
|
+
[name]: { version: remote.version, engine: remote.engine, contentHash: partialContentHash(source), syncedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
342
|
+
};
|
|
343
|
+
writeState(project, state);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// packages/devkit/src/lib/brand.ts
|
|
347
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
348
|
+
import { join as join3 } from "node:path";
|
|
349
|
+
import { emptyBrand } from "@formfeed/engine";
|
|
350
|
+
var brandPath = (project) => join3(project.root, ".formfeed", "brand.json");
|
|
351
|
+
var text = (value) => typeof value === "string" ? value : null;
|
|
352
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
353
|
+
function brandFromJson(json) {
|
|
354
|
+
const body = record(json);
|
|
355
|
+
const empty = emptyBrand();
|
|
356
|
+
const colors = Object.fromEntries(Object.entries(record(body["colors"])).filter((entry) => typeof entry[1] === "string"));
|
|
357
|
+
const fonts = record(body["fonts"]);
|
|
358
|
+
const logo = record(body["logo"]);
|
|
359
|
+
return {
|
|
360
|
+
version: typeof body["version"] === "number" ? body["version"] : empty.version,
|
|
361
|
+
name: text(body["name"]),
|
|
362
|
+
colors,
|
|
363
|
+
fonts: { heading: text(fonts["heading"]), body: text(fonts["body"]) },
|
|
364
|
+
font_size: text(body["font_size"]),
|
|
365
|
+
logo: { primary: text(logo["primary"]), inverse: text(logo["inverse"]), mark: text(logo["mark"]) },
|
|
366
|
+
legal_footer: text(body["legal_footer"])
|
|
270
367
|
};
|
|
271
368
|
}
|
|
369
|
+
function readBrand(project) {
|
|
370
|
+
const path = brandPath(project);
|
|
371
|
+
if (!existsSync3(path)) return emptyBrand();
|
|
372
|
+
try {
|
|
373
|
+
return brandFromJson(JSON.parse(readFileSync3(path, "utf8")));
|
|
374
|
+
} catch (e) {
|
|
375
|
+
throw new DevkitError(`.formfeed/brand.json is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`, "validation");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function writeBrand(project, brand) {
|
|
379
|
+
mkdirSync2(join3(project.root, ".formfeed"), { recursive: true });
|
|
380
|
+
const path = brandPath(project);
|
|
381
|
+
writeFileSync3(path, JSON.stringify(brand, null, 2) + "\n");
|
|
382
|
+
return path;
|
|
383
|
+
}
|
|
272
384
|
|
|
273
385
|
// packages/devkit/src/lib/local-render.ts
|
|
274
386
|
import {
|
|
275
387
|
EngineSyntaxError,
|
|
388
|
+
analyzeOffice,
|
|
276
389
|
defaultHelpers,
|
|
277
390
|
defaultLimits,
|
|
278
391
|
flowDocument,
|
|
279
392
|
getEngine as getEngine2,
|
|
393
|
+
isOfficeKind as isOfficeKind2,
|
|
280
394
|
mergeSettings,
|
|
395
|
+
officeTextParts,
|
|
281
396
|
pagedDocument,
|
|
397
|
+
renderOffice,
|
|
282
398
|
renderVersion
|
|
283
399
|
} from "@formfeed/engine";
|
|
284
400
|
function diagnose(tpl, sampleData) {
|
|
401
|
+
if (tpl.file) return diagnoseOffice(tpl, tpl.file.bytes, sampleData);
|
|
285
402
|
const engine = getEngine2(tpl.meta.engine);
|
|
286
403
|
const diagnostics = [];
|
|
287
404
|
try {
|
|
@@ -313,7 +430,59 @@ function diagnose(tpl, sampleData) {
|
|
|
313
430
|
});
|
|
314
431
|
return diagnostics;
|
|
315
432
|
}
|
|
316
|
-
|
|
433
|
+
var nowhere = { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } };
|
|
434
|
+
function diagnoseOffice(tpl, file, sampleData) {
|
|
435
|
+
let analysis;
|
|
436
|
+
try {
|
|
437
|
+
analysis = analyzeOffice(file, { engine: tpl.meta.engine, sampleData });
|
|
438
|
+
} catch (e) {
|
|
439
|
+
const code = e.code;
|
|
440
|
+
return [{ severity: "error", code: typeof code === "string" ? code : "office-document-invalid", message: e instanceof Error ? e.message : String(e), range: nowhere }];
|
|
441
|
+
}
|
|
442
|
+
return analysis.diagnostics.map((d) => ({
|
|
443
|
+
severity: d.severity,
|
|
444
|
+
code: d.code,
|
|
445
|
+
message: d.text ? `${d.message} (${d.text})` : d.message,
|
|
446
|
+
range: nowhere,
|
|
447
|
+
part: d.part,
|
|
448
|
+
paragraph: d.paragraph
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
async function renderOfficeLocal(project, tpl, data, options = {}) {
|
|
452
|
+
if (!tpl.file) throw new DevkitError(`${tpl.slug} is not a Word or PowerPoint template`, "validation");
|
|
453
|
+
const context = renderContext(project, tpl, options.assetBaseUrl, options.brand);
|
|
454
|
+
if (options.locale) context.locale = options.locale;
|
|
455
|
+
return renderOffice(tpl.file.bytes, { engine: tpl.meta.engine, data, context, images: options.images, random: options.random });
|
|
456
|
+
}
|
|
457
|
+
function officeSnapshot(filled) {
|
|
458
|
+
return officeTextParts(filled).map((part) => `--- ${part.name} ---
|
|
459
|
+
${prettyXml(part.xml)}`).join("\n");
|
|
460
|
+
}
|
|
461
|
+
function prettyXml(xml) {
|
|
462
|
+
const lines = [];
|
|
463
|
+
let depth = 0;
|
|
464
|
+
let open = false;
|
|
465
|
+
for (const token of xml.match(/<[^>]*>|[^<]+/g) ?? []) {
|
|
466
|
+
if (token.startsWith("</")) {
|
|
467
|
+
depth = Math.max(0, depth - 1);
|
|
468
|
+
if (open && lines.length) lines[lines.length - 1] += token;
|
|
469
|
+
else lines.push(" ".repeat(depth) + token);
|
|
470
|
+
open = false;
|
|
471
|
+
} else if (token.startsWith("<?") || token.startsWith("<!")) {
|
|
472
|
+
lines.push(" ".repeat(depth) + token);
|
|
473
|
+
open = false;
|
|
474
|
+
} else if (token.startsWith("<")) {
|
|
475
|
+
lines.push(" ".repeat(depth) + token);
|
|
476
|
+
open = !token.endsWith("/>");
|
|
477
|
+
if (open) depth++;
|
|
478
|
+
} else if (token.trim() || open) {
|
|
479
|
+
if (lines.length) lines[lines.length - 1] += token;
|
|
480
|
+
else lines.push(token);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return lines.join("\n") + "\n";
|
|
484
|
+
}
|
|
485
|
+
function renderContext(project, tpl, assetBaseUrl, brand) {
|
|
317
486
|
const settings = mergeSettings(tpl.settings);
|
|
318
487
|
return {
|
|
319
488
|
locale: settings.locale ?? "en",
|
|
@@ -323,35 +492,43 @@ function renderContext(project, tpl, assetBaseUrl) {
|
|
|
323
492
|
helpers: defaultHelpers(),
|
|
324
493
|
limits: defaultLimits,
|
|
325
494
|
i18n: tpl.i18n ?? void 0,
|
|
326
|
-
assetBaseUrl
|
|
495
|
+
assetBaseUrl,
|
|
496
|
+
// the organisation's kit from `formfeed brand pull`, else the empty kit the server uses too
|
|
497
|
+
brand: brand ?? readBrand(project)
|
|
327
498
|
};
|
|
328
499
|
}
|
|
329
500
|
async function renderLocal(project, tpl, data, options) {
|
|
330
|
-
const
|
|
501
|
+
const kind = htmlKind(tpl);
|
|
502
|
+
const ctx = renderContext(project, tpl, options.assetBaseUrl, options.brand);
|
|
331
503
|
if (options.locale) ctx.locale = options.locale;
|
|
332
504
|
return renderVersion(
|
|
333
|
-
{ engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind
|
|
505
|
+
{ engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind },
|
|
334
506
|
data,
|
|
335
507
|
ctx,
|
|
336
508
|
options.mode,
|
|
337
509
|
options.vendor ? { vendor: options.vendor } : {}
|
|
338
510
|
);
|
|
339
511
|
}
|
|
512
|
+
function htmlKind(tpl) {
|
|
513
|
+
if (isOfficeKind2(tpl.meta.kind)) throw new DevkitError(`${tpl.slug} is a ${tpl.meta.kind} template; fill it with renderOfficeLocal`, "validation");
|
|
514
|
+
return tpl.meta.kind;
|
|
515
|
+
}
|
|
340
516
|
function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
|
|
517
|
+
const kind = htmlKind(tpl);
|
|
341
518
|
const draft = {
|
|
342
519
|
document: rendered.document,
|
|
343
520
|
headerHtml: rendered.headerHtml,
|
|
344
521
|
footerHtml: rendered.footerHtml,
|
|
345
522
|
settings: rendered.settings,
|
|
346
|
-
kind
|
|
523
|
+
kind
|
|
347
524
|
};
|
|
348
|
-
return mode === "paged" &&
|
|
525
|
+
return mode === "paged" && kind === "pdf" ? pagedDocument(draft, { pagedScriptUrl }) : flowDocument(draft);
|
|
349
526
|
}
|
|
350
527
|
|
|
351
528
|
// packages/devkit/src/lib/files.ts
|
|
352
529
|
import { createHash as createHash2 } from "node:crypto";
|
|
353
|
-
import { existsSync as
|
|
354
|
-
import { dirname as dirname3, join as
|
|
530
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
531
|
+
import { dirname as dirname3, join as join4, relative, resolve as resolve2, sep } from "node:path";
|
|
355
532
|
|
|
356
533
|
// packages/api-types/src/lib/assets.ts
|
|
357
534
|
var SEGMENT = "[A-Za-z0-9][A-Za-z0-9._-]*";
|
|
@@ -383,13 +560,13 @@ function sha256File(bytes) {
|
|
|
383
560
|
}
|
|
384
561
|
function listLocalFiles(project) {
|
|
385
562
|
const listing = { files: [], skipped: [] };
|
|
386
|
-
if (!
|
|
387
|
-
const
|
|
563
|
+
if (!existsSync4(project.filesDir)) return listing;
|
|
564
|
+
const walk2 = (dir) => {
|
|
388
565
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
389
566
|
if (entry.name.startsWith(".")) continue;
|
|
390
|
-
const path =
|
|
567
|
+
const path = join4(dir, entry.name);
|
|
391
568
|
if (entry.isDirectory()) {
|
|
392
|
-
|
|
569
|
+
walk2(path);
|
|
393
570
|
continue;
|
|
394
571
|
}
|
|
395
572
|
if (!entry.isFile()) continue;
|
|
@@ -401,11 +578,11 @@ function listLocalFiles(project) {
|
|
|
401
578
|
listing.skipped.push({ path, reason });
|
|
402
579
|
continue;
|
|
403
580
|
}
|
|
404
|
-
const bytes =
|
|
581
|
+
const bytes = readFileSync4(path);
|
|
405
582
|
listing.files.push({ name, path, bytes: size, sha256: sha256File(bytes), contentType });
|
|
406
583
|
}
|
|
407
584
|
};
|
|
408
|
-
|
|
585
|
+
walk2(project.filesDir);
|
|
409
586
|
listing.files.sort((a, b) => a.name.localeCompare(b.name));
|
|
410
587
|
return listing;
|
|
411
588
|
}
|
|
@@ -417,35 +594,609 @@ function localFilePath(project, name) {
|
|
|
417
594
|
}
|
|
418
595
|
function writeLocalFile(project, name, bytes) {
|
|
419
596
|
const path = localFilePath(project, name);
|
|
420
|
-
|
|
421
|
-
|
|
597
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
598
|
+
writeFileSync4(path, bytes);
|
|
422
599
|
return path;
|
|
423
600
|
}
|
|
601
|
+
|
|
602
|
+
// packages/devkit/src/lib/redact.ts
|
|
603
|
+
function redactData(value, paths) {
|
|
604
|
+
if (!paths || paths.length === 0) return redactAll(value);
|
|
605
|
+
return walk(value, paths.map(parseRedactPath));
|
|
606
|
+
}
|
|
607
|
+
function redactString(text2) {
|
|
608
|
+
let out = "";
|
|
609
|
+
for (const ch of text2) {
|
|
610
|
+
if (/\p{Lu}/u.test(ch)) out += "A";
|
|
611
|
+
else if (/\p{L}/u.test(ch)) out += "a";
|
|
612
|
+
else if (/\p{N}/u.test(ch)) out += "0";
|
|
613
|
+
else out += ch;
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
function parseRedactPath(path) {
|
|
618
|
+
const segments = [];
|
|
619
|
+
for (const part of path.trim().split(".")) {
|
|
620
|
+
if (part === "") continue;
|
|
621
|
+
const match = /^([^[\]]*)((?:\[[^\]]*\])*)$/.exec(part);
|
|
622
|
+
if (!match) {
|
|
623
|
+
segments.push(part);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (match[1]) segments.push(match[1]);
|
|
627
|
+
for (const index of match[2].match(/\[[^\]]*\]/g) ?? []) {
|
|
628
|
+
const inner = index.slice(1, -1).trim();
|
|
629
|
+
segments.push(inner === "" || inner === "*" ? "*" : inner);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return segments;
|
|
633
|
+
}
|
|
634
|
+
function redactAll(value) {
|
|
635
|
+
if (typeof value === "string") return redactString(value);
|
|
636
|
+
if (Array.isArray(value)) return value.map(redactAll);
|
|
637
|
+
if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactAll(v)]));
|
|
638
|
+
return value;
|
|
639
|
+
}
|
|
640
|
+
function walk(value, patterns) {
|
|
641
|
+
if (patterns.some((p) => p.length === 0)) return redactAll(value);
|
|
642
|
+
if (Array.isArray(value)) {
|
|
643
|
+
return value.map((item, index) => {
|
|
644
|
+
const next = advance(patterns, String(index));
|
|
645
|
+
return next.length ? walk(item, next) : item;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
if (isRecord(value)) {
|
|
649
|
+
return Object.fromEntries(
|
|
650
|
+
Object.entries(value).map(([key, item]) => {
|
|
651
|
+
const next = advance(patterns, key);
|
|
652
|
+
return [key, next.length ? walk(item, next) : item];
|
|
653
|
+
})
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
return value;
|
|
657
|
+
}
|
|
658
|
+
function advance(patterns, key) {
|
|
659
|
+
return patterns.filter((p) => p[0] === "*" || p[0] === key).map((p) => p.slice(1));
|
|
660
|
+
}
|
|
661
|
+
function isRecord(value) {
|
|
662
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// packages/devkit/src/lib/types/index.ts
|
|
666
|
+
import { inferSchemaFromDataSets } from "@formfeed/engine";
|
|
667
|
+
|
|
668
|
+
// packages/devkit/src/lib/types/model.ts
|
|
669
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
670
|
+
var UNSUPPORTED = [
|
|
671
|
+
"not",
|
|
672
|
+
"if",
|
|
673
|
+
"then",
|
|
674
|
+
"else",
|
|
675
|
+
"patternProperties",
|
|
676
|
+
"prefixItems",
|
|
677
|
+
"contains",
|
|
678
|
+
"dependentSchemas",
|
|
679
|
+
"dependentRequired",
|
|
680
|
+
"unevaluatedProperties",
|
|
681
|
+
"unevaluatedItems",
|
|
682
|
+
"propertyNames"
|
|
683
|
+
];
|
|
684
|
+
function pascalCase(text2) {
|
|
685
|
+
const words = text2.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
686
|
+
const joined = words.map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
687
|
+
if (!joined) return "Field";
|
|
688
|
+
return /^[0-9]/.test(joined) ? `T${joined}` : joined;
|
|
689
|
+
}
|
|
690
|
+
function singular(word) {
|
|
691
|
+
if (/ies$/i.test(word) && word.length > 3) return word.slice(0, -3) + "y";
|
|
692
|
+
if (/(ss|x|ch|sh)es$/i.test(word)) return word.slice(0, -2);
|
|
693
|
+
if (/[^su]s$/i.test(word)) return word.slice(0, -1);
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
var NameRegistry = class {
|
|
697
|
+
used = /* @__PURE__ */ new Set();
|
|
698
|
+
constructor(reserved = []) {
|
|
699
|
+
for (const name of reserved) this.used.add(name);
|
|
700
|
+
}
|
|
701
|
+
claim(wanted) {
|
|
702
|
+
let name = wanted;
|
|
703
|
+
for (let i = 2; this.used.has(name); i++) name = `${wanted}${i}`;
|
|
704
|
+
this.used.add(name);
|
|
705
|
+
return name;
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
function relaxRequired(schema) {
|
|
709
|
+
if (Array.isArray(schema)) return schema.map(relaxRequired);
|
|
710
|
+
if (!isObject(schema)) return schema;
|
|
711
|
+
const out = {};
|
|
712
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
713
|
+
if (key === "required") continue;
|
|
714
|
+
if (key === "properties" || key === "$defs" || key === "definitions") {
|
|
715
|
+
out[key] = isObject(value) ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, relaxRequired(v)])) : value;
|
|
716
|
+
} else if (key === "examples" || key === "enum" || key === "const" || key === "default") {
|
|
717
|
+
out[key] = value;
|
|
718
|
+
} else {
|
|
719
|
+
out[key] = relaxRequired(value);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
function docOf(schema) {
|
|
725
|
+
const lines = [];
|
|
726
|
+
if (typeof schema["title"] === "string" && schema["title"] !== schema["description"]) lines.push(schema["title"]);
|
|
727
|
+
if (typeof schema["description"] === "string") lines.push(...schema["description"].split("\n"));
|
|
728
|
+
if (typeof schema["format"] === "string") lines.push(`Format: ${schema["format"]}`);
|
|
729
|
+
const unsupported = UNSUPPORTED.filter((k) => k in schema);
|
|
730
|
+
if (unsupported.length) lines.push(`Not expressed in the types: ${unsupported.join(", ")}`);
|
|
731
|
+
if (Array.isArray(schema["examples"]) && schema["examples"].length)
|
|
732
|
+
lines.push(`@example ${JSON.stringify(schema["examples"][0])}`);
|
|
733
|
+
if (schema["deprecated"] === true) lines.push("@deprecated");
|
|
734
|
+
return lines;
|
|
735
|
+
}
|
|
736
|
+
var Builder = class {
|
|
737
|
+
constructor(root, base, names) {
|
|
738
|
+
this.root = root;
|
|
739
|
+
this.base = base;
|
|
740
|
+
this.names = names;
|
|
741
|
+
}
|
|
742
|
+
objects = [];
|
|
743
|
+
defs = /* @__PURE__ */ new Map();
|
|
744
|
+
rootName = "";
|
|
745
|
+
/**
|
|
746
|
+
* A plain object schema becomes the interface `rootName` itself; anything else (a nullable object,
|
|
747
|
+
* a union, an array) becomes an alias of that name, so the root never names two things.
|
|
748
|
+
*/
|
|
749
|
+
build(rootName) {
|
|
750
|
+
this.rootName = rootName;
|
|
751
|
+
const s = this.root;
|
|
752
|
+
const plainObject = (s["type"] === "object" || s["type"] === void 0 && isObject(s["properties"])) && isObject(s["properties"]) && Object.keys(s["properties"]).length > 0 && !["$ref", "const", "enum", "anyOf", "oneOf", "allOf"].some((k) => k in s);
|
|
753
|
+
return this.convert(s, null, null, plainObject ? rootName : void 0);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* `owner` + `key` name the objects found below: property `customer` of `Invoice` is
|
|
757
|
+
* `InvoiceCustomer`, an element of `items` is `InvoiceItem`. `exact` forces a name (the root).
|
|
758
|
+
*/
|
|
759
|
+
convert(raw, owner, key, exact, depth = 0) {
|
|
760
|
+
if (raw === true || raw === void 0) return { kind: "unknown" };
|
|
761
|
+
if (raw === false) return { kind: "never" };
|
|
762
|
+
if (!isObject(raw) || depth > 48) return { kind: "unknown" };
|
|
763
|
+
const schema = raw;
|
|
764
|
+
if (typeof schema["$ref"] === "string") return this.reference(schema["$ref"]);
|
|
765
|
+
if ("const" in schema) return literal(schema["const"]);
|
|
766
|
+
if (Array.isArray(schema["enum"])) {
|
|
767
|
+
const members2 = schema["enum"].map(literal);
|
|
768
|
+
return members2.length === 1 ? members2[0] : { kind: "union", members: members2 };
|
|
769
|
+
}
|
|
770
|
+
for (const combinator of ["anyOf", "oneOf"]) {
|
|
771
|
+
const list = schema[combinator];
|
|
772
|
+
if (Array.isArray(list) && list.length) {
|
|
773
|
+
const members2 = list.map(
|
|
774
|
+
(option, i) => this.convert(option, owner, key === null ? `Option${i + 1}` : `${key}Option${i + 1}`, void 0, depth + 1)
|
|
775
|
+
);
|
|
776
|
+
return union(members2);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (Array.isArray(schema["allOf"]) && schema["allOf"].length) {
|
|
780
|
+
const members2 = schema["allOf"].map(
|
|
781
|
+
(part, i) => this.convert(part, owner, key === null ? `Part${i + 1}` : `${key}Part${i + 1}`, void 0, depth + 1)
|
|
782
|
+
);
|
|
783
|
+
return members2.length === 1 ? members2[0] : { kind: "intersection", members: members2 };
|
|
784
|
+
}
|
|
785
|
+
const declared = typeof schema["type"] === "string" ? [schema["type"]] : Array.isArray(schema["type"]) ? schema["type"] : null;
|
|
786
|
+
const types = declared ?? (isObject(schema["properties"]) || isObject(schema["additionalProperties"]) ? ["object"] : "items" in schema ? ["array"] : null);
|
|
787
|
+
if (!types) {
|
|
788
|
+
const unsupported = UNSUPPORTED.filter((k) => k in schema);
|
|
789
|
+
return unsupported.length ? { kind: "unknown", note: unsupported.join(", ") } : { kind: "unknown" };
|
|
790
|
+
}
|
|
791
|
+
const members = types.map((type) => {
|
|
792
|
+
switch (type) {
|
|
793
|
+
case "object":
|
|
794
|
+
return this.object(schema, owner, key, exact, depth);
|
|
795
|
+
case "array":
|
|
796
|
+
return { kind: "array", items: this.convert(schema["items"], owner, itemKey(key), void 0, depth + 1) };
|
|
797
|
+
case "string":
|
|
798
|
+
case "number":
|
|
799
|
+
case "integer":
|
|
800
|
+
case "boolean":
|
|
801
|
+
case "null":
|
|
802
|
+
return { kind: "primitive", name: type };
|
|
803
|
+
default:
|
|
804
|
+
return { kind: "unknown" };
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
return union(members);
|
|
808
|
+
}
|
|
809
|
+
object(schema, owner, key, exact, depth) {
|
|
810
|
+
const properties = isObject(schema["properties"]) ? schema["properties"] : {};
|
|
811
|
+
const additional = schema["additionalProperties"];
|
|
812
|
+
const extra = isObject(additional) ? this.convert(additional, owner, `${key ?? ""}Value`, void 0, depth + 1) : null;
|
|
813
|
+
if (Object.keys(properties).length === 0) return { kind: "record", values: extra ?? { kind: "unknown" } };
|
|
814
|
+
const name = exact ?? this.names.claim(`${owner ?? this.base}${key === null ? "" : pascalCase(key)}`);
|
|
815
|
+
const target = { name, doc: docOf(schema), fields: [], extra };
|
|
816
|
+
this.objects.push(target);
|
|
817
|
+
const required = new Set(Array.isArray(schema["required"]) ? schema["required"] : []);
|
|
818
|
+
const nestedOwner = name === this.rootName ? this.base : name;
|
|
819
|
+
for (const [field, child] of Object.entries(properties)) {
|
|
820
|
+
target.fields.push({
|
|
821
|
+
key: field,
|
|
822
|
+
type: this.convert(child, nestedOwner, field, void 0, depth + 1),
|
|
823
|
+
required: required.has(field),
|
|
824
|
+
doc: isObject(child) ? docOf(child) : []
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return { kind: "ref", name };
|
|
828
|
+
}
|
|
829
|
+
reference(ref) {
|
|
830
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
|
|
831
|
+
if (!match) return { kind: "unknown", note: `$ref ${ref}` };
|
|
832
|
+
const known = this.defs.get(ref);
|
|
833
|
+
if (known) return { kind: "ref", name: known };
|
|
834
|
+
const section = this.root[match[1]];
|
|
835
|
+
const target = isObject(section) ? section[decodePointer(match[2])] : void 0;
|
|
836
|
+
if (!isObject(target)) return { kind: "unknown", note: `$ref ${ref}` };
|
|
837
|
+
const hasProperties = isObject(target["properties"]) && Object.keys(target["properties"]).length > 0;
|
|
838
|
+
if (!hasProperties) return this.convert(target, this.base, decodePointer(match[2]));
|
|
839
|
+
const name = this.names.claim(`${this.base}${pascalCase(decodePointer(match[2]))}`);
|
|
840
|
+
this.defs.set(ref, name);
|
|
841
|
+
this.convert(target, null, null, name);
|
|
842
|
+
return { kind: "ref", name };
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
var decodePointer = (part) => part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
846
|
+
function itemKey(key) {
|
|
847
|
+
if (key === null) return "Item";
|
|
848
|
+
return singular(key) ?? `${key}Item`;
|
|
849
|
+
}
|
|
850
|
+
function literal(value) {
|
|
851
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
852
|
+
return { kind: "literal", value };
|
|
853
|
+
return { kind: "unknown" };
|
|
854
|
+
}
|
|
855
|
+
function union(members) {
|
|
856
|
+
const flat = [];
|
|
857
|
+
for (const member of members) {
|
|
858
|
+
if (member.kind === "union") flat.push(...member.members);
|
|
859
|
+
else flat.push(member);
|
|
860
|
+
}
|
|
861
|
+
if (flat.some((m) => m.kind === "unknown")) return { kind: "unknown" };
|
|
862
|
+
const unique = flat.filter((m, i) => flat.findIndex((n) => JSON.stringify(n) === JSON.stringify(m)) === i);
|
|
863
|
+
return unique.length === 1 ? unique[0] : { kind: "union", members: unique };
|
|
864
|
+
}
|
|
865
|
+
function buildTypes(sources, reserved = []) {
|
|
866
|
+
const names = new NameRegistry(reserved);
|
|
867
|
+
return [...sources].sort((a, b) => a.slug.localeCompare(b.slug)).map((source) => {
|
|
868
|
+
const schema = source.origin === "inferred" ? relaxRequired(source.schema) : source.schema;
|
|
869
|
+
const base = pascalCase(source.slug);
|
|
870
|
+
const rootName = names.claim(`${base}Data`);
|
|
871
|
+
const builder = new Builder(schema, base, names);
|
|
872
|
+
const root = builder.build(rootName);
|
|
873
|
+
return {
|
|
874
|
+
slug: source.slug,
|
|
875
|
+
origin: source.origin,
|
|
876
|
+
version: source.version ?? null,
|
|
877
|
+
channel: source.channel ?? null,
|
|
878
|
+
rootName,
|
|
879
|
+
root,
|
|
880
|
+
objects: builder.objects
|
|
881
|
+
};
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
function originNote(t) {
|
|
885
|
+
switch (t.origin) {
|
|
886
|
+
case "local":
|
|
887
|
+
return "schema.json of the project";
|
|
888
|
+
case "stored":
|
|
889
|
+
return `stored schema of version ${t.version ?? "?"}${t.channel ? ` (channel ${t.channel})` : ""}`;
|
|
890
|
+
case "inferred":
|
|
891
|
+
return "inferred from sample data, every field optional: store a schema to make this a contract";
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// packages/devkit/src/lib/types/emit-python.ts
|
|
896
|
+
var KEYWORDS = /* @__PURE__ */ new Set([
|
|
897
|
+
"False",
|
|
898
|
+
"None",
|
|
899
|
+
"True",
|
|
900
|
+
"and",
|
|
901
|
+
"as",
|
|
902
|
+
"assert",
|
|
903
|
+
"async",
|
|
904
|
+
"await",
|
|
905
|
+
"break",
|
|
906
|
+
"class",
|
|
907
|
+
"continue",
|
|
908
|
+
"def",
|
|
909
|
+
"del",
|
|
910
|
+
"elif",
|
|
911
|
+
"else",
|
|
912
|
+
"except",
|
|
913
|
+
"finally",
|
|
914
|
+
"for",
|
|
915
|
+
"from",
|
|
916
|
+
"global",
|
|
917
|
+
"if",
|
|
918
|
+
"import",
|
|
919
|
+
"in",
|
|
920
|
+
"is",
|
|
921
|
+
"lambda",
|
|
922
|
+
"nonlocal",
|
|
923
|
+
"not",
|
|
924
|
+
"or",
|
|
925
|
+
"pass",
|
|
926
|
+
"raise",
|
|
927
|
+
"return",
|
|
928
|
+
"try",
|
|
929
|
+
"while",
|
|
930
|
+
"with",
|
|
931
|
+
"yield"
|
|
932
|
+
]);
|
|
933
|
+
var isIdentifier = (key) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !KEYWORDS.has(key);
|
|
934
|
+
function methodName(slug) {
|
|
935
|
+
const name = slug.replace(/[^A-Za-z0-9_]+/g, "_").toLowerCase();
|
|
936
|
+
return isIdentifier(name) ? name : `${/^[0-9]/.test(name) ? "t_" : ""}${name}${KEYWORDS.has(name) ? "_" : ""}`;
|
|
937
|
+
}
|
|
938
|
+
function expression(node, quoteRefs) {
|
|
939
|
+
switch (node.kind) {
|
|
940
|
+
case "unknown":
|
|
941
|
+
return "Any";
|
|
942
|
+
case "never":
|
|
943
|
+
return "Never";
|
|
944
|
+
case "primitive":
|
|
945
|
+
return { string: "str", number: "float", integer: "int", boolean: "bool", null: "None" }[node.name];
|
|
946
|
+
case "literal":
|
|
947
|
+
return node.value === null ? "None" : `Literal[${pythonLiteral(node.value)}]`;
|
|
948
|
+
case "array":
|
|
949
|
+
return `list[${expression(node.items, quoteRefs)}]`;
|
|
950
|
+
case "record":
|
|
951
|
+
return `dict[str, ${expression(node.values, quoteRefs)}]`;
|
|
952
|
+
case "ref":
|
|
953
|
+
return quoteRefs ? JSON.stringify(node.name) : node.name;
|
|
954
|
+
case "union": {
|
|
955
|
+
const literals = node.members.filter(
|
|
956
|
+
(m) => m.kind === "literal" && m.value !== null
|
|
957
|
+
);
|
|
958
|
+
const rest = node.members.filter((m) => !(m.kind === "literal" && m.value !== null));
|
|
959
|
+
const parts = [
|
|
960
|
+
...literals.length ? [`Literal[${literals.map((l) => pythonLiteral(l.value)).join(", ")}]`] : [],
|
|
961
|
+
...rest.map((m) => expression(m, quoteRefs))
|
|
962
|
+
];
|
|
963
|
+
if (quoteRefs && rest.some((m) => m.kind === "ref")) return `Union[${parts.join(", ")}]`;
|
|
964
|
+
return parts.join(" | ");
|
|
965
|
+
}
|
|
966
|
+
case "intersection":
|
|
967
|
+
return expression(node.members[0] ?? { kind: "unknown" }, quoteRefs);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
function pythonLiteral(value) {
|
|
971
|
+
if (value === null) return "None";
|
|
972
|
+
if (typeof value === "boolean") return value ? "True" : "False";
|
|
973
|
+
return JSON.stringify(value);
|
|
974
|
+
}
|
|
975
|
+
function docstring(lines, indent) {
|
|
976
|
+
if (lines.length === 0) return [];
|
|
977
|
+
const safe = lines.map((line) => line.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"'));
|
|
978
|
+
if (safe.length === 1) return [`${indent}"""${safe[0]}"""`];
|
|
979
|
+
return [`${indent}"""${safe[0]}`, ...safe.slice(1).map((line) => `${indent}${line}`.trimEnd()), `${indent}"""`];
|
|
980
|
+
}
|
|
981
|
+
function emitObject(object) {
|
|
982
|
+
const functional = object.fields.some((f) => !isIdentifier(f.key));
|
|
983
|
+
const field = (type, required, quote) => {
|
|
984
|
+
const text2 = expression(type, quote);
|
|
985
|
+
return required ? text2 : `NotRequired[${text2}]`;
|
|
986
|
+
};
|
|
987
|
+
if (functional) {
|
|
988
|
+
const out2 = [`${object.name} = TypedDict(`, ` ${JSON.stringify(object.name)},`, " {"];
|
|
989
|
+
for (const f of object.fields) out2.push(` ${JSON.stringify(f.key)}: ${field(f.type, f.required, true)},`);
|
|
990
|
+
out2.push(" },", ")");
|
|
991
|
+
if (object.doc.length) out2.unshift(...object.doc.map((line) => `# ${line}`));
|
|
992
|
+
return out2;
|
|
993
|
+
}
|
|
994
|
+
const out = [`class ${object.name}(TypedDict):`, ...docstring(object.doc, " ")];
|
|
995
|
+
for (const f of object.fields) {
|
|
996
|
+
out.push(` ${f.key}: ${field(f.type, f.required, false)}`);
|
|
997
|
+
out.push(...docstring(f.doc, " "));
|
|
998
|
+
}
|
|
999
|
+
if (object.fields.length === 0) out.push(" pass");
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
function emitPython(templates) {
|
|
1003
|
+
const lines = [
|
|
1004
|
+
"# Generated by `formfeed types`. Do not edit: run it again when a template schema changes.",
|
|
1005
|
+
"#",
|
|
1006
|
+
...templates.map((t) => `# ${t.slug}: ${originNote(t)}`),
|
|
1007
|
+
"",
|
|
1008
|
+
"from __future__ import annotations",
|
|
1009
|
+
"",
|
|
1010
|
+
"from typing import Any, Literal, Union, cast",
|
|
1011
|
+
"",
|
|
1012
|
+
"from typing_extensions import Never, NotRequired, TypedDict",
|
|
1013
|
+
"",
|
|
1014
|
+
"from formfeed import AsyncFormfeed, Formfeed, Render",
|
|
1015
|
+
"",
|
|
1016
|
+
"__all__ = [",
|
|
1017
|
+
...templates.map((t) => ` ${JSON.stringify(t.rootName)},`),
|
|
1018
|
+
' "TypedRenders",',
|
|
1019
|
+
' "AsyncTypedRenders",',
|
|
1020
|
+
' "typed",',
|
|
1021
|
+
' "typed_async",',
|
|
1022
|
+
"]"
|
|
1023
|
+
];
|
|
1024
|
+
for (const template of templates) {
|
|
1025
|
+
lines.push("", "", `# --- ${template.slug}`);
|
|
1026
|
+
for (const object of [...template.objects].reverse()) lines.push("", ...emitObject(object));
|
|
1027
|
+
const isClass = template.root.kind === "ref" && template.root.name === template.rootName;
|
|
1028
|
+
if (!isClass) lines.push("", `${template.rootName} = ${expression(template.root, true)}`);
|
|
1029
|
+
}
|
|
1030
|
+
const wrapper = (name, isAsync) => {
|
|
1031
|
+
const example = templates[0] ? methodName(templates[0].slug) : "invoice";
|
|
1032
|
+
const out = [
|
|
1033
|
+
"",
|
|
1034
|
+
"",
|
|
1035
|
+
`class ${name}:`,
|
|
1036
|
+
` """Renders with \`data\` checked per template: \`${isAsync ? "await typed_async(client)" : "typed(client)"}.${example}(data, **options)\`."""`,
|
|
1037
|
+
"",
|
|
1038
|
+
` def __init__(self, client: ${isAsync ? "AsyncFormfeed" : "Formfeed"}) -> None:`,
|
|
1039
|
+
" self._renders: Any = client.renders"
|
|
1040
|
+
];
|
|
1041
|
+
for (const t of templates) {
|
|
1042
|
+
out.push(
|
|
1043
|
+
"",
|
|
1044
|
+
` ${isAsync ? "async " : ""}def ${methodName(t.slug)}(self, data: ${t.rootName}, **options: Any) -> Render:`,
|
|
1045
|
+
` return cast(Render, ${isAsync ? "await " : ""}self._renders.create(template=${JSON.stringify(t.slug)}, data=data, **options))`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
};
|
|
1050
|
+
lines.push(...wrapper("TypedRenders", false), ...wrapper("AsyncTypedRenders", true));
|
|
1051
|
+
lines.push(
|
|
1052
|
+
"",
|
|
1053
|
+
"",
|
|
1054
|
+
"def typed(client: Formfeed) -> TypedRenders:",
|
|
1055
|
+
" return TypedRenders(client)",
|
|
1056
|
+
"",
|
|
1057
|
+
"",
|
|
1058
|
+
"def typed_async(client: AsyncFormfeed) -> AsyncTypedRenders:",
|
|
1059
|
+
" return AsyncTypedRenders(client)"
|
|
1060
|
+
);
|
|
1061
|
+
return lines.join("\n") + "\n";
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// packages/devkit/src/lib/types/emit-ts.ts
|
|
1065
|
+
var identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
1066
|
+
var quoteKey = (key) => identifier.test(key) ? key : JSON.stringify(key);
|
|
1067
|
+
function expression2(node, nested = false) {
|
|
1068
|
+
switch (node.kind) {
|
|
1069
|
+
case "unknown":
|
|
1070
|
+
return "unknown";
|
|
1071
|
+
case "never":
|
|
1072
|
+
return "never";
|
|
1073
|
+
case "primitive":
|
|
1074
|
+
return node.name === "integer" ? "number" : node.name;
|
|
1075
|
+
case "literal":
|
|
1076
|
+
return JSON.stringify(node.value);
|
|
1077
|
+
case "array": {
|
|
1078
|
+
const inner = expression2(node.items, true);
|
|
1079
|
+
return /[|&]/.test(inner) ? `Array<${expression2(node.items)}>` : `${inner}[]`;
|
|
1080
|
+
}
|
|
1081
|
+
case "record":
|
|
1082
|
+
return `Record<string, ${expression2(node.values)}>`;
|
|
1083
|
+
case "ref":
|
|
1084
|
+
return node.name;
|
|
1085
|
+
case "union": {
|
|
1086
|
+
const text2 = node.members.map((m) => expression2(m, true)).join(" | ");
|
|
1087
|
+
return nested ? `(${text2})` : text2;
|
|
1088
|
+
}
|
|
1089
|
+
case "intersection": {
|
|
1090
|
+
const text2 = node.members.map((m) => expression2(m, true)).join(" & ");
|
|
1091
|
+
return nested ? `(${text2})` : text2;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function docBlock(lines, indent) {
|
|
1096
|
+
if (lines.length === 0) return [];
|
|
1097
|
+
const safe = lines.map((line) => line.replace(/\*\//g, "*\\/"));
|
|
1098
|
+
if (safe.length === 1) return [`${indent}/** ${safe[0]} */`];
|
|
1099
|
+
return [`${indent}/**`, ...safe.map((line) => `${indent} * ${line}`.trimEnd()), `${indent} */`];
|
|
1100
|
+
}
|
|
1101
|
+
function emitObject2(object) {
|
|
1102
|
+
const out = [...docBlock(object.doc, ""), `export interface ${object.name} {`];
|
|
1103
|
+
for (const field of object.fields) {
|
|
1104
|
+
out.push(...docBlock(field.doc, " "));
|
|
1105
|
+
out.push(` ${quoteKey(field.key)}${field.required ? "" : "?"}: ${expression2(field.type)};`);
|
|
1106
|
+
}
|
|
1107
|
+
if (object.extra) {
|
|
1108
|
+
out.push(` /** Further fields: ${expression2(object.extra).replace(/\*\//g, "*\\/")} */`);
|
|
1109
|
+
out.push(" [key: string]: unknown;");
|
|
1110
|
+
}
|
|
1111
|
+
out.push("}");
|
|
1112
|
+
return out;
|
|
1113
|
+
}
|
|
1114
|
+
function emitTypeScript(templates, options = {}) {
|
|
1115
|
+
const sdk = options.sdkModule ?? "@formfeed/sdk";
|
|
1116
|
+
const lines = [
|
|
1117
|
+
"// Generated by `formfeed types`. Do not edit: run it again when a template schema changes.",
|
|
1118
|
+
"//",
|
|
1119
|
+
...templates.map((t) => `// ${t.slug}: ${originNote(t)}`),
|
|
1120
|
+
"",
|
|
1121
|
+
`declare module ${JSON.stringify(sdk)} {`,
|
|
1122
|
+
" interface FormfeedTemplates {",
|
|
1123
|
+
...templates.map((t) => ` ${JSON.stringify(t.slug)}: ${t.rootName};`),
|
|
1124
|
+
" }",
|
|
1125
|
+
"}"
|
|
1126
|
+
];
|
|
1127
|
+
for (const template of templates) {
|
|
1128
|
+
lines.push("", `// --- ${template.slug}`);
|
|
1129
|
+
const isInterface = template.root.kind === "ref" && template.root.name === template.rootName;
|
|
1130
|
+
if (!isInterface) lines.push("", `export type ${template.rootName} = ${expression2(template.root)};`);
|
|
1131
|
+
for (const object of template.objects) lines.push("", ...emitObject2(object));
|
|
1132
|
+
}
|
|
1133
|
+
if (templates.length === 0) lines.push("", "export {};");
|
|
1134
|
+
return lines.join("\n") + "\n";
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// packages/devkit/src/lib/types/index.ts
|
|
1138
|
+
var defaultTypesFile = {
|
|
1139
|
+
ts: "formfeed.d.ts",
|
|
1140
|
+
python: "formfeed_templates.py"
|
|
1141
|
+
};
|
|
1142
|
+
function localTypeSource(tpl) {
|
|
1143
|
+
if (tpl.dataSchema) return { slug: tpl.slug, schema: tpl.dataSchema, origin: "local" };
|
|
1144
|
+
const sets = Object.values(tpl.dataSets);
|
|
1145
|
+
return {
|
|
1146
|
+
slug: tpl.slug,
|
|
1147
|
+
schema: inferSchemaFromDataSets(sets.length ? sets : [{}]),
|
|
1148
|
+
origin: "inferred"
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function generateTypes(sources, language, options = {}) {
|
|
1152
|
+
const reserved = language === "python" ? ["TypedRenders", "AsyncTypedRenders", "Formfeed", "AsyncFormfeed", "Render", "Any", "Literal", "Union", "Never", "NotRequired", "TypedDict"] : ["FormfeedTemplates"];
|
|
1153
|
+
const model = buildTypes(sources, reserved);
|
|
1154
|
+
return language === "python" ? emitPython(model) : emitTypeScript(model, options);
|
|
1155
|
+
}
|
|
424
1156
|
export {
|
|
425
1157
|
DevkitError,
|
|
1158
|
+
brandFromJson,
|
|
1159
|
+
brandPath,
|
|
426
1160
|
collectPartials,
|
|
427
1161
|
contentHash,
|
|
428
1162
|
contentTypeFor,
|
|
429
1163
|
defaultData,
|
|
430
1164
|
defaultProjectConfig,
|
|
1165
|
+
defaultTypesFile,
|
|
431
1166
|
diagnose,
|
|
432
1167
|
findProject,
|
|
1168
|
+
generateTypes,
|
|
433
1169
|
listLocalFiles,
|
|
434
1170
|
listTemplateSlugs,
|
|
435
1171
|
loadProject,
|
|
436
1172
|
localFilePath,
|
|
1173
|
+
localTypeSource,
|
|
1174
|
+
officeSnapshot,
|
|
1175
|
+
officeVersionPayload,
|
|
1176
|
+
parseRedactPath,
|
|
1177
|
+
partialContentHash,
|
|
437
1178
|
partialResolver,
|
|
1179
|
+
prettyXml,
|
|
438
1180
|
previewDocument,
|
|
439
1181
|
projectAround,
|
|
1182
|
+
readBrand,
|
|
440
1183
|
readState,
|
|
441
1184
|
readTemplate,
|
|
1185
|
+
recordSharedPartial,
|
|
442
1186
|
recordSync,
|
|
1187
|
+
redactData,
|
|
1188
|
+
redactString,
|
|
443
1189
|
renderContext,
|
|
444
1190
|
renderLocal,
|
|
1191
|
+
renderOfficeLocal,
|
|
445
1192
|
sha256File,
|
|
1193
|
+
sharedPartialNames,
|
|
1194
|
+
sharedPartialPath,
|
|
446
1195
|
templateDir,
|
|
1196
|
+
templateFileName,
|
|
447
1197
|
titleFromSlug,
|
|
448
1198
|
versionPayload,
|
|
1199
|
+
writeBrand,
|
|
449
1200
|
writeLocalFile,
|
|
450
1201
|
writePartials,
|
|
451
1202
|
writeProjectConfig,
|