@formfeed/devkit 0.2.0 → 0.3.0

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