@formfeed/devkit 0.2.0 → 0.3.1

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