@formfeed/devkit 0.1.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.js CHANGED
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from "node:path";
13
13
  var defaultProjectConfig = {
14
14
  templatesDir: "templates",
15
15
  partialsDir: "partials",
16
+ filesDir: "files",
16
17
  engine: "jinja2",
17
18
  ignore: ["**/drafts/**"]
18
19
  };
@@ -35,7 +36,14 @@ function loadProject(configPath) {
35
36
  }
36
37
  const config = { ...defaultProjectConfig, ...raw };
37
38
  const root = dirname(configPath);
38
- return { root, configPath, config, templatesDir: resolve(root, config.templatesDir), partialsDir: resolve(root, config.partialsDir) };
39
+ return {
40
+ root,
41
+ configPath,
42
+ config,
43
+ templatesDir: resolve(root, config.templatesDir),
44
+ partialsDir: resolve(root, config.partialsDir),
45
+ filesDir: resolve(root, config.filesDir)
46
+ };
39
47
  }
40
48
  function projectAround(templateFolder) {
41
49
  const dir = resolve(templateFolder);
@@ -46,9 +54,10 @@ function projectAround(templateFolder) {
46
54
  return {
47
55
  root,
48
56
  configPath: null,
49
- config: { ...defaultProjectConfig, templatesDir, partialsDir: join(root, "partials") },
57
+ config: { ...defaultProjectConfig, templatesDir, partialsDir: join(root, "partials"), filesDir: join(root, "files") },
50
58
  templatesDir,
51
- partialsDir: join(root, "partials")
59
+ partialsDir: join(root, "partials"),
60
+ filesDir: join(root, "files")
52
61
  };
53
62
  }
54
63
  function writeProjectConfig(root, config) {
@@ -61,59 +70,128 @@ function writeProjectConfig(root, config) {
61
70
  // packages/devkit/src/lib/project.ts
62
71
  import { createHash } from "node:crypto";
63
72
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
64
- import { basename, 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";
79
+
80
+ // packages/devkit/src/lib/partials.ts
81
+ import { getEngine, normalisePartialName } from "@formfeed/engine";
82
+ function includeNames(engine, source) {
83
+ try {
84
+ return getEngine(engine).analyze(source).includes.map((include) => include.name);
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+ function collectPartials(project, engine, sources) {
90
+ const resolve3 = partialResolver(project);
91
+ const sharedNames = sharedPartialNames(project, engine);
92
+ const partials = {};
93
+ const missing = /* @__PURE__ */ new Set();
94
+ const shared = /* @__PURE__ */ new Set();
95
+ const queue = sources.filter((source) => Boolean(source));
96
+ const seen = /* @__PURE__ */ new Set();
97
+ while (queue.length) {
98
+ for (const name of includeNames(engine, queue.shift())) {
99
+ if (seen.has(name)) continue;
100
+ seen.add(name);
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
+ }
107
+ if (source === void 0) {
108
+ missing.add(name);
109
+ continue;
110
+ }
111
+ partials[name] = source;
112
+ queue.push(source);
113
+ }
114
+ }
115
+ return { partials, missing: [...missing].sort(), shared: [...shared].sort() };
116
+ }
117
+
118
+ // packages/devkit/src/lib/project.ts
65
119
  var readText = (path) => existsSync2(path) ? readFileSync2(path, "utf8") : null;
66
120
  function readJson(path, what) {
67
- const text = readText(path);
68
- if (text === null) return null;
121
+ const text2 = readText(path);
122
+ if (text2 === null) return null;
69
123
  try {
70
- return JSON.parse(text);
124
+ return JSON.parse(text2);
71
125
  } catch (e) {
72
126
  throw new DevkitError(`${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`, "validation");
73
127
  }
74
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
+ }
75
133
  function listTemplateSlugs(project) {
76
134
  if (!existsSync2(project.templatesDir)) return [];
77
- return readdirSync(project.templatesDir).filter((name) => existsSync2(join2(project.templatesDir, name, "template.html"))).sort();
135
+ return readdirSync(project.templatesDir).filter((name) => sourceFilesIn(join2(project.templatesDir, name)).length > 0).sort();
78
136
  }
79
137
  function templateDir(project, slug) {
80
138
  return join2(project.templatesDir, slug);
81
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
+ }
82
146
  function readTemplate(project, slug) {
83
147
  const dir = templateDir(project, slug);
84
- const html = readText(join2(dir, "template.html"));
85
- if (html === null) throw new DevkitError(`No template "${slug}" in ${project.templatesDir} (template.html missing)`);
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")) ?? "";
86
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");
87
160
  const meta = {
88
161
  name: metaFile.name ?? titleFromSlug(slug),
89
- kind: metaFile.kind ?? "pdf",
162
+ kind: file?.format ?? metaFile.kind ?? "pdf",
90
163
  engine: metaFile.engine ?? project.config.engine,
91
164
  description: metaFile.description ?? null,
92
165
  tags: metaFile.tags ?? []
93
166
  };
94
167
  const settings = { ...readJson(join2(dir, "settings.json"), "settings.json") ?? {} };
95
- const header = readText(join2(dir, "header.html"));
96
- 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"));
97
170
  if (header !== null && header.trim()) settings.header = { ...settings.header ?? {}, html: header };
98
171
  if (footer !== null && footer.trim()) settings.footer = { ...settings.footer ?? {}, html: footer };
99
172
  const dataSets = {};
100
173
  const dataDir = join2(dir, "data");
101
174
  if (existsSync2(dataDir)) {
102
- for (const file of readdirSync(dataDir).filter((f) => f.endsWith(".json")).sort()) {
103
- dataSets[basename(file, ".json")] = readJson(join2(dataDir, file), `data/${file}`);
175
+ for (const file2 of readdirSync(dataDir).filter((f) => f.endsWith(".json")).sort()) {
176
+ dataSets[basename(file2, ".json")] = readJson(join2(dataDir, file2), `data/${file2}`);
104
177
  }
105
178
  }
179
+ const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
106
180
  return {
107
181
  slug,
108
182
  dir,
109
183
  meta,
184
+ file,
110
185
  html,
111
- css: readText(join2(dir, "style.css")) ?? "",
112
- head: readText(join2(dir, "head.html")) ?? "",
186
+ css: file ? "" : readText(join2(dir, "style.css")) ?? "",
187
+ head: file ? "" : readText(join2(dir, "head.html")) ?? "",
113
188
  settings,
114
189
  dataSets,
115
190
  dataSchema: readJson(join2(dir, "schema.json"), "schema.json"),
116
- i18n: readJson(join2(dir, "i18n.json"), "i18n.json")
191
+ i18n: readJson(join2(dir, "i18n.json"), "i18n.json"),
192
+ partials,
193
+ missingPartials: missing,
194
+ sharedPartials: shared
117
195
  };
118
196
  }
119
197
  function defaultData(tpl, name) {
@@ -123,18 +201,23 @@ function defaultData(tpl, name) {
123
201
  throw new DevkitError(`Data set "${name}" not found; available: ${names.join(", ") || "none"}`);
124
202
  return chosen ? { name: chosen, data: tpl.dataSets[chosen] } : { name: "empty", data: {} };
125
203
  }
126
- function writeTemplate(project, slug, meta, version) {
204
+ function writeTemplate(project, slug, meta, version, file) {
127
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`);
128
208
  mkdirSync(join2(dir, "data"), { recursive: true });
129
209
  const settings = { ...version.settings ?? {} };
130
- const header = settings.header?.html ?? "";
131
- const footer = settings.footer?.html ?? "";
210
+ const header = office ? "" : settings.header?.html ?? "";
211
+ const footer = office ? "" : settings.footer?.html ?? "";
132
212
  if (settings.header) settings.header = stripHtml(settings.header);
133
213
  if (settings.footer) settings.footer = stripHtml(settings.footer);
134
214
  writeFileSync2(join2(dir, "template.json"), JSON.stringify(meta, null, 2) + "\n");
135
- writeFileSync2(join2(dir, "template.html"), version.html ?? "");
136
- writeOrRemove(join2(dir, "style.css"), version.css ?? "");
137
- writeOrRemove(join2(dir, "head.html"), version.head ?? "");
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 ?? "");
138
221
  writeOrRemove(join2(dir, "header.html"), header);
139
222
  writeOrRemove(join2(dir, "footer.html"), footer);
140
223
  writeFileSync2(join2(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
@@ -144,8 +227,22 @@ function writeTemplate(project, slug, meta, version) {
144
227
  writeFileSync2(join2(dir, "data", `${name}.json`), JSON.stringify(data ?? {}, null, 2) + "\n");
145
228
  writeOrRemove(join2(dir, "schema.json"), version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : "");
146
229
  writeOrRemove(join2(dir, "i18n.json"), version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : "");
230
+ writePartials(project, version.partials ?? null);
147
231
  return dir;
148
232
  }
233
+ var partialName = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/;
234
+ function writePartials(project, partials) {
235
+ const changed = [];
236
+ for (const [name, source] of Object.entries(partials ?? {})) {
237
+ if (!partialName.test(name)) continue;
238
+ const path = join2(project.partialsDir, name.includes(".") ? name : `${name}.html`);
239
+ if (readText(path) === source) continue;
240
+ mkdirSync(dirname2(path), { recursive: true });
241
+ writeFileSync2(path, source);
242
+ changed.push(path);
243
+ }
244
+ return changed;
245
+ }
149
246
  function stripHtml(part) {
150
247
  const { html: _html, ...rest } = part;
151
248
  void _html;
@@ -155,15 +252,12 @@ function writeOrRemove(path, content) {
155
252
  if (content) writeFileSync2(path, content);
156
253
  else if (existsSync2(path)) rmSync(path);
157
254
  }
158
- function versionPayload(tpl) {
255
+ function commonPayload(tpl) {
159
256
  const sample = tpl.dataSets["default"] ?? Object.values(tpl.dataSets)[0] ?? {};
160
257
  const named = Object.fromEntries(
161
258
  Object.entries(tpl.dataSets).filter(([name]) => name !== "default")
162
259
  );
163
260
  return {
164
- html: tpl.html,
165
- css: tpl.css,
166
- head: tpl.head,
167
261
  settings: tpl.settings,
168
262
  sample_data: sample ?? {},
169
263
  data_sets: named,
@@ -171,9 +265,29 @@ function versionPayload(tpl) {
171
265
  i18n: tpl.i18n
172
266
  };
173
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),
277
+ // only when the template includes one, so a project without partials sends what it always sent
278
+ ...Object.keys(tpl.partials).length ? { partials: tpl.partials } : {}
279
+ };
280
+ }
174
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
+ }
175
287
  const payload = versionPayload(tpl);
176
- return createHash("sha256").update(JSON.stringify([payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n])).digest("hex");
288
+ const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
289
+ if (payload.partials) parts.push(payload.partials);
290
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
177
291
  }
178
292
  var statePath = (project) => join2(project.root, ".formfeed", "state.json");
179
293
  function readState(project) {
@@ -190,6 +304,7 @@ function recordSync(project, slug, version, tpl) {
190
304
  number: version.number,
191
305
  status: version.status,
192
306
  contentHash: contentHash(tpl),
307
+ ...tpl.file ? { fileSha256: tpl.file.sha256 } : {},
193
308
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
194
309
  };
195
310
  writeState(project, state);
@@ -198,28 +313,93 @@ function titleFromSlug(slug) {
198
313
  return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
199
314
  }
200
315
  function partialResolver(project) {
316
+ let shared = null;
201
317
  return (name) => {
202
318
  for (const candidate of [name, `${name}.html`]) {
203
319
  const path = join2(project.partialsDir, candidate);
204
320
  if (existsSync2(path) && statSync(path).isFile()) return readFileSync2(path, "utf8");
205
321
  }
206
- return void 0;
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() }
207
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"])
367
+ };
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;
208
383
  }
209
384
 
210
385
  // packages/devkit/src/lib/local-render.ts
211
386
  import {
212
387
  EngineSyntaxError,
388
+ analyzeOffice,
213
389
  defaultHelpers,
214
390
  defaultLimits,
215
391
  flowDocument,
216
- getEngine,
392
+ getEngine as getEngine2,
393
+ isOfficeKind as isOfficeKind2,
217
394
  mergeSettings,
395
+ officeTextParts,
218
396
  pagedDocument,
397
+ renderOffice,
219
398
  renderVersion
220
399
  } from "@formfeed/engine";
221
400
  function diagnose(tpl, sampleData) {
222
- const engine = getEngine(tpl.meta.engine);
401
+ if (tpl.file) return diagnoseOffice(tpl, tpl.file.bytes, sampleData);
402
+ const engine = getEngine2(tpl.meta.engine);
223
403
  const diagnostics = [];
224
404
  try {
225
405
  engine.compile(tpl.html, { name: tpl.slug });
@@ -241,9 +421,68 @@ function diagnose(tpl, sampleData) {
241
421
  if (!source) continue;
242
422
  for (const d of engine.analyze(source, { sampleData }).diagnostics) diagnostics.push({ ...d, message: `${part}: ${d.message}` });
243
423
  }
424
+ for (const name of tpl.missingPartials)
425
+ diagnostics.push({
426
+ severity: "error",
427
+ code: "missing-partial",
428
+ message: `No partial "${name}" in the project's partials folder`,
429
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }
430
+ });
244
431
  return diagnostics;
245
432
  }
246
- function renderContext(project, tpl) {
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) {
247
486
  const settings = mergeSettings(tpl.settings);
248
487
  return {
249
488
  locale: settings.locale ?? "en",
@@ -252,50 +491,714 @@ function renderContext(project, tpl) {
252
491
  partials: partialResolver(project),
253
492
  helpers: defaultHelpers(),
254
493
  limits: defaultLimits,
255
- i18n: tpl.i18n ?? void 0
494
+ i18n: tpl.i18n ?? void 0,
495
+ assetBaseUrl,
496
+ // the organisation's kit from `formfeed brand pull`, else the empty kit the server uses too
497
+ brand: brand ?? readBrand(project)
256
498
  };
257
499
  }
258
500
  async function renderLocal(project, tpl, data, options) {
259
- const ctx = renderContext(project, tpl);
501
+ const kind = htmlKind(tpl);
502
+ const ctx = renderContext(project, tpl, options.assetBaseUrl, options.brand);
260
503
  if (options.locale) ctx.locale = options.locale;
261
504
  return renderVersion(
262
- { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind: tpl.meta.kind },
505
+ { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind },
263
506
  data,
264
507
  ctx,
265
508
  options.mode,
266
509
  options.vendor ? { vendor: options.vendor } : {}
267
510
  );
268
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
+ }
269
516
  function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
517
+ const kind = htmlKind(tpl);
270
518
  const draft = {
271
519
  document: rendered.document,
272
520
  headerHtml: rendered.headerHtml,
273
521
  footerHtml: rendered.footerHtml,
274
522
  settings: rendered.settings,
275
- kind: tpl.meta.kind
523
+ kind
276
524
  };
277
- return mode === "paged" && tpl.meta.kind === "pdf" ? pagedDocument(draft, { pagedScriptUrl }) : flowDocument(draft);
525
+ return mode === "paged" && kind === "pdf" ? pagedDocument(draft, { pagedScriptUrl }) : flowDocument(draft);
526
+ }
527
+
528
+ // packages/devkit/src/lib/files.ts
529
+ import { createHash as createHash2 } from "node:crypto";
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";
532
+
533
+ // packages/api-types/src/lib/assets.ts
534
+ var SEGMENT = "[A-Za-z0-9][A-Za-z0-9._-]*";
535
+ var ASSET_NAME_PATTERN = new RegExp(`^${SEGMENT}(?:/${SEGMENT})*$`);
536
+ var ASSET_MAX_BYTES = 10 * 1024 * 1024;
537
+ var ASSET_CONTENT_TYPES = {
538
+ "image/png": "png",
539
+ "image/jpeg": "jpg",
540
+ "image/webp": "webp",
541
+ "image/gif": "gif",
542
+ "image/svg+xml": "svg",
543
+ "image/avif": "avif",
544
+ "application/pdf": "pdf"
545
+ };
546
+ function isAssetName(name) {
547
+ return name.length > 0 && name.length <= 200 && ASSET_NAME_PATTERN.test(name) && !name.split("/").includes("..");
548
+ }
549
+
550
+ // packages/devkit/src/lib/files.ts
551
+ var byExtension = Object.fromEntries(
552
+ Object.entries(ASSET_CONTENT_TYPES).map(([type, ext]) => [ext, type])
553
+ );
554
+ byExtension["jpeg"] = "image/jpeg";
555
+ function contentTypeFor(name) {
556
+ return byExtension[name.split(".").pop()?.toLowerCase() ?? ""] ?? null;
557
+ }
558
+ function sha256File(bytes) {
559
+ return createHash2("sha256").update(bytes).digest("hex");
560
+ }
561
+ function listLocalFiles(project) {
562
+ const listing = { files: [], skipped: [] };
563
+ if (!existsSync4(project.filesDir)) return listing;
564
+ const walk2 = (dir) => {
565
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
566
+ if (entry.name.startsWith(".")) continue;
567
+ const path = join4(dir, entry.name);
568
+ if (entry.isDirectory()) {
569
+ walk2(path);
570
+ continue;
571
+ }
572
+ if (!entry.isFile()) continue;
573
+ const name = relative(project.filesDir, path).split(sep).join("/");
574
+ const size = statSync2(path).size;
575
+ const contentType = contentTypeFor(name);
576
+ const reason = !isAssetName(name) ? "the name has characters a library name cannot have (letters, digits, dot, dash, underscore)" : !contentType ? "not an image or a PDF" : size === 0 ? "empty" : size > ASSET_MAX_BYTES ? `larger than ${ASSET_MAX_BYTES / 1024 / 1024} MB` : null;
577
+ if (reason) {
578
+ listing.skipped.push({ path, reason });
579
+ continue;
580
+ }
581
+ const bytes = readFileSync4(path);
582
+ listing.files.push({ name, path, bytes: size, sha256: sha256File(bytes), contentType });
583
+ }
584
+ };
585
+ walk2(project.filesDir);
586
+ listing.files.sort((a, b) => a.name.localeCompare(b.name));
587
+ return listing;
588
+ }
589
+ function localFilePath(project, name) {
590
+ if (!isAssetName(name)) throw new DevkitError(`"${name}" is not a library file name`);
591
+ const path = resolve2(project.filesDir, ...name.split("/"));
592
+ if (!path.startsWith(resolve2(project.filesDir) + sep)) throw new DevkitError(`"${name}" is not inside the files folder`);
593
+ return path;
594
+ }
595
+ function writeLocalFile(project, name, bytes) {
596
+ const path = localFilePath(project, name);
597
+ mkdirSync3(dirname3(path), { recursive: true });
598
+ writeFileSync4(path, bytes);
599
+ return path;
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);
278
1155
  }
279
1156
  export {
280
1157
  DevkitError,
1158
+ brandFromJson,
1159
+ brandPath,
1160
+ collectPartials,
281
1161
  contentHash,
1162
+ contentTypeFor,
282
1163
  defaultData,
283
1164
  defaultProjectConfig,
1165
+ defaultTypesFile,
284
1166
  diagnose,
285
1167
  findProject,
1168
+ generateTypes,
1169
+ listLocalFiles,
286
1170
  listTemplateSlugs,
287
1171
  loadProject,
1172
+ localFilePath,
1173
+ localTypeSource,
1174
+ officeSnapshot,
1175
+ officeVersionPayload,
1176
+ parseRedactPath,
1177
+ partialContentHash,
288
1178
  partialResolver,
1179
+ prettyXml,
289
1180
  previewDocument,
290
1181
  projectAround,
1182
+ readBrand,
291
1183
  readState,
292
1184
  readTemplate,
1185
+ recordSharedPartial,
293
1186
  recordSync,
1187
+ redactData,
1188
+ redactString,
294
1189
  renderContext,
295
1190
  renderLocal,
1191
+ renderOfficeLocal,
1192
+ sha256File,
1193
+ sharedPartialNames,
1194
+ sharedPartialPath,
296
1195
  templateDir,
1196
+ templateFileName,
297
1197
  titleFromSlug,
298
1198
  versionPayload,
1199
+ writeBrand,
1200
+ writeLocalFile,
1201
+ writePartials,
299
1202
  writeProjectConfig,
300
1203
  writeState,
301
1204
  writeTemplate