@formfeed/devkit 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -221,16 +221,23 @@ function readJson(path, what) {
221
221
  try {
222
222
  return JSON.parse(text2);
223
223
  } catch (e) {
224
- throw new DevkitError(`${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`, "validation");
224
+ throw new DevkitError(
225
+ `${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`,
226
+ "validation"
227
+ );
225
228
  }
226
229
  }
227
230
  var templateFileName = (kind) => (0, import_engine2.isOfficeKind)(kind) ? `template.${kind}` : "template.html";
228
231
  function sourceFilesIn(dir) {
229
- return ["template.html", ...import_engine2.officeKinds.map((k) => `template.${k}`)].filter((name) => (0, import_node_fs2.existsSync)((0, import_node_path2.join)(dir, name)));
232
+ return ["template.html", ...import_engine2.officeKinds.map((k) => `template.${k}`)].filter(
233
+ (name) => (0, import_node_fs2.existsSync)((0, import_node_path2.join)(dir, name))
234
+ );
230
235
  }
231
236
  function listTemplateSlugs(project) {
232
237
  if (!(0, import_node_fs2.existsSync)(project.templatesDir)) return [];
233
- return (0, import_node_fs2.readdirSync)(project.templatesDir).filter((name) => sourceFilesIn((0, import_node_path2.join)(project.templatesDir, name)).length > 0).sort();
238
+ return (0, import_node_fs2.readdirSync)(project.templatesDir).filter(
239
+ (name) => sourceFilesIn((0, import_node_path2.join)(project.templatesDir, name)).length > 0
240
+ ).sort();
234
241
  }
235
242
  function templateDir(project, slug) {
236
243
  return (0, import_node_path2.join)(project.templatesDir, slug);
@@ -239,22 +246,41 @@ var sha256Of = (bytes) => (0, import_node_crypto.createHash)("sha256").update(by
239
246
  function readOfficeFile(dir, name) {
240
247
  const path = (0, import_node_path2.join)(dir, name);
241
248
  const bytes = new Uint8Array((0, import_node_fs2.readFileSync)(path));
242
- return { format: (0, import_node_path2.extname)(name).slice(1), path, bytes, sha256: sha256Of(bytes) };
249
+ return {
250
+ format: (0, import_node_path2.extname)(name).slice(1),
251
+ path,
252
+ bytes,
253
+ sha256: sha256Of(bytes)
254
+ };
243
255
  }
244
256
  function readTemplate(project, slug) {
245
257
  const dir = templateDir(project, slug);
246
258
  const sources = sourceFilesIn(dir);
247
259
  if (sources.length === 0)
248
- throw new DevkitError(`No template "${slug}" in ${project.templatesDir} (template.html, template.docx or template.pptx missing)`);
260
+ throw new DevkitError(
261
+ `No template "${slug}" in ${project.templatesDir} (template.html, template.docx or template.pptx missing)`
262
+ );
249
263
  if (sources.length > 1)
250
- throw new DevkitError(`${dir} holds ${sources.join(" and ")}; a template folder has one of them`, "validation");
264
+ throw new DevkitError(
265
+ `${dir} holds ${sources.join(" and ")}; a template folder has one of them`,
266
+ "validation"
267
+ );
251
268
  const file = sources[0] === "template.html" ? null : readOfficeFile(dir, sources[0]);
252
269
  const html = file ? "" : readText((0, import_node_path2.join)(dir, "template.html")) ?? "";
253
- const metaFile = readJson((0, import_node_path2.join)(dir, "template.json"), "template.json") ?? {};
270
+ const metaFile = readJson(
271
+ (0, import_node_path2.join)(dir, "template.json"),
272
+ "template.json"
273
+ ) ?? {};
254
274
  if (file && metaFile.kind && metaFile.kind !== file.format)
255
- throw new DevkitError(`${slug}: template.json says kind "${metaFile.kind}", but the folder holds ${(0, import_node_path2.basename)(file.path)}`, "validation");
275
+ throw new DevkitError(
276
+ `${slug}: template.json says kind "${metaFile.kind}", but the folder holds ${(0, import_node_path2.basename)(file.path)}`,
277
+ "validation"
278
+ );
256
279
  if (!file && (0, import_engine2.isOfficeKind)(metaFile.kind))
257
- throw new DevkitError(`${slug}: template.json says kind "${metaFile.kind}", but the folder holds template.html instead of template.${metaFile.kind}`, "validation");
280
+ throw new DevkitError(
281
+ `${slug}: template.json says kind "${metaFile.kind}", but the folder holds template.html instead of template.${metaFile.kind}`,
282
+ "validation"
283
+ );
258
284
  const meta = {
259
285
  name: metaFile.name ?? titleFromSlug(slug),
260
286
  kind: file?.format ?? metaFile.kind ?? "pdf",
@@ -262,19 +288,38 @@ function readTemplate(project, slug) {
262
288
  description: metaFile.description ?? null,
263
289
  tags: metaFile.tags ?? []
264
290
  };
265
- const settings = { ...readJson((0, import_node_path2.join)(dir, "settings.json"), "settings.json") ?? {} };
291
+ const settings = {
292
+ ...readJson(
293
+ (0, import_node_path2.join)(dir, "settings.json"),
294
+ "settings.json"
295
+ ) ?? {}
296
+ };
266
297
  const header = file ? null : readText((0, import_node_path2.join)(dir, "header.html"));
267
298
  const footer = file ? null : readText((0, import_node_path2.join)(dir, "footer.html"));
268
- if (header !== null && header.trim()) settings.header = { ...settings.header ?? {}, html: header };
269
- if (footer !== null && footer.trim()) settings.footer = { ...settings.footer ?? {}, html: footer };
299
+ if (header !== null && header.trim())
300
+ settings.header = { ...settings.header ?? {}, html: header };
301
+ if (footer !== null && footer.trim())
302
+ settings.footer = { ...settings.footer ?? {}, html: footer };
303
+ const design = readJson(
304
+ (0, import_node_path2.join)(dir, "design.json"),
305
+ "design.json"
306
+ );
307
+ if (design) settings.design = design;
270
308
  const dataSets = {};
271
309
  const dataDir = (0, import_node_path2.join)(dir, "data");
272
310
  if ((0, import_node_fs2.existsSync)(dataDir)) {
273
311
  for (const file2 of (0, import_node_fs2.readdirSync)(dataDir).filter((f) => f.endsWith(".json")).sort()) {
274
- dataSets[(0, import_node_path2.basename)(file2, ".json")] = readJson((0, import_node_path2.join)(dataDir, file2), `data/${file2}`);
312
+ dataSets[(0, import_node_path2.basename)(file2, ".json")] = readJson(
313
+ (0, import_node_path2.join)(dataDir, file2),
314
+ `data/${file2}`
315
+ );
275
316
  }
276
317
  }
277
- const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
318
+ const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [
319
+ html,
320
+ settings.header?.html,
321
+ settings.footer?.html
322
+ ]);
278
323
  return {
279
324
  slug,
280
325
  dir,
@@ -285,8 +330,14 @@ function readTemplate(project, slug) {
285
330
  head: file ? "" : readText((0, import_node_path2.join)(dir, "head.html")) ?? "",
286
331
  settings,
287
332
  dataSets,
288
- dataSchema: readJson((0, import_node_path2.join)(dir, "schema.json"), "schema.json"),
289
- i18n: readJson((0, import_node_path2.join)(dir, "i18n.json"), "i18n.json"),
333
+ dataSchema: readJson(
334
+ (0, import_node_path2.join)(dir, "schema.json"),
335
+ "schema.json"
336
+ ),
337
+ i18n: readJson(
338
+ (0, import_node_path2.join)(dir, "i18n.json"),
339
+ "i18n.json"
340
+ ),
290
341
  partials,
291
342
  missingPartials: missing,
292
343
  sharedPartials: shared
@@ -296,35 +347,68 @@ function defaultData(tpl, name) {
296
347
  const names = Object.keys(tpl.dataSets);
297
348
  const chosen = name ?? (names.includes("default") ? "default" : names[0]);
298
349
  if (name && !(name in tpl.dataSets))
299
- throw new DevkitError(`Data set "${name}" not found; available: ${names.join(", ") || "none"}`);
350
+ throw new DevkitError(
351
+ `Data set "${name}" not found; available: ${names.join(", ") || "none"}`
352
+ );
300
353
  return chosen ? { name: chosen, data: tpl.dataSets[chosen] } : { name: "empty", data: {} };
301
354
  }
302
355
  function writeTemplate(project, slug, meta, version, file) {
303
356
  const dir = templateDir(project, slug);
304
357
  const office = (0, import_engine2.isOfficeKind)(meta.kind);
305
- if (office && !file) throw new DevkitError(`${slug} is a ${meta.kind} template; its document is needed to write the folder`);
358
+ if (office && !file)
359
+ throw new DevkitError(
360
+ `${slug} is a ${meta.kind} template; its document is needed to write the folder`
361
+ );
306
362
  (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(dir, "data"), { recursive: true });
307
363
  const settings = { ...version.settings ?? {} };
308
364
  const header = office ? "" : settings.header?.html ?? "";
309
365
  const footer = office ? "" : settings.footer?.html ?? "";
310
366
  if (settings.header) settings.header = stripHtml(settings.header);
311
367
  if (settings.footer) settings.footer = stripHtml(settings.footer);
312
- (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "template.json"), JSON.stringify(meta, null, 2) + "\n");
313
- for (const name of ["template.html", ...import_engine2.officeKinds.map((k) => `template.${k}`)])
314
- if (name !== templateFileName(meta.kind) && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(dir, name))) (0, import_node_fs2.rmSync)((0, import_node_path2.join)(dir, name));
368
+ const design = settings.design;
369
+ delete settings.design;
370
+ (0, import_node_fs2.writeFileSync)(
371
+ (0, import_node_path2.join)(dir, "template.json"),
372
+ JSON.stringify(meta, null, 2) + "\n"
373
+ );
374
+ for (const name of [
375
+ "template.html",
376
+ ...import_engine2.officeKinds.map((k) => `template.${k}`)
377
+ ])
378
+ if (name !== templateFileName(meta.kind) && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(dir, name)))
379
+ (0, import_node_fs2.rmSync)((0, import_node_path2.join)(dir, name));
315
380
  if (office) (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, templateFileName(meta.kind)), file);
316
381
  else (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "template.html"), version.html ?? "");
317
382
  writeOrRemove((0, import_node_path2.join)(dir, "style.css"), office ? "" : version.css ?? "");
318
383
  writeOrRemove((0, import_node_path2.join)(dir, "head.html"), office ? "" : version.head ?? "");
319
384
  writeOrRemove((0, import_node_path2.join)(dir, "header.html"), header);
320
385
  writeOrRemove((0, import_node_path2.join)(dir, "footer.html"), footer);
321
- (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
322
- (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "data", "default.json"), JSON.stringify(version.sample_data ?? {}, null, 2) + "\n");
386
+ (0, import_node_fs2.writeFileSync)(
387
+ (0, import_node_path2.join)(dir, "settings.json"),
388
+ JSON.stringify(settings, null, 2) + "\n"
389
+ );
390
+ writeOrRemove(
391
+ (0, import_node_path2.join)(dir, "design.json"),
392
+ design && typeof design === "object" ? JSON.stringify(design, null, 2) + "\n" : ""
393
+ );
394
+ (0, import_node_fs2.writeFileSync)(
395
+ (0, import_node_path2.join)(dir, "data", "default.json"),
396
+ JSON.stringify(version.sample_data ?? {}, null, 2) + "\n"
397
+ );
323
398
  for (const [name, data] of Object.entries(version.data_sets ?? {}))
324
399
  if (name !== "default" && /^[\w.-]+$/.test(name))
325
- (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "data", `${name}.json`), JSON.stringify(data ?? {}, null, 2) + "\n");
326
- writeOrRemove((0, import_node_path2.join)(dir, "schema.json"), version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : "");
327
- writeOrRemove((0, import_node_path2.join)(dir, "i18n.json"), version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : "");
400
+ (0, import_node_fs2.writeFileSync)(
401
+ (0, import_node_path2.join)(dir, "data", `${name}.json`),
402
+ JSON.stringify(data ?? {}, null, 2) + "\n"
403
+ );
404
+ writeOrRemove(
405
+ (0, import_node_path2.join)(dir, "schema.json"),
406
+ version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : ""
407
+ );
408
+ writeOrRemove(
409
+ (0, import_node_path2.join)(dir, "i18n.json"),
410
+ version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : ""
411
+ );
328
412
  writePartials(project, version.partials ?? null);
329
413
  return dir;
330
414
  }
@@ -333,7 +417,10 @@ function writePartials(project, partials) {
333
417
  const changed = [];
334
418
  for (const [name, source] of Object.entries(partials ?? {})) {
335
419
  if (!partialName.test(name)) continue;
336
- const path = (0, import_node_path2.join)(project.partialsDir, name.includes(".") ? name : `${name}.html`);
420
+ const path = (0, import_node_path2.join)(
421
+ project.partialsDir,
422
+ name.includes(".") ? name : `${name}.html`
423
+ );
337
424
  if (readText(path) === source) continue;
338
425
  (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
339
426
  (0, import_node_fs2.writeFileSync)(path, source);
@@ -379,17 +466,36 @@ function versionPayload(tpl) {
379
466
  function contentHash(tpl) {
380
467
  if (tpl.file) {
381
468
  const payload2 = officeVersionPayload(tpl);
382
- const parts2 = [tpl.file.format, tpl.file.sha256, payload2.settings, payload2.sample_data, payload2.data_sets, payload2.data_schema, payload2.i18n];
469
+ const parts2 = [
470
+ tpl.file.format,
471
+ tpl.file.sha256,
472
+ payload2.settings,
473
+ payload2.sample_data,
474
+ payload2.data_sets,
475
+ payload2.data_schema,
476
+ payload2.i18n
477
+ ];
383
478
  return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify(parts2)).digest("hex");
384
479
  }
385
480
  const payload = versionPayload(tpl);
386
- const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
481
+ const parts = [
482
+ payload.html,
483
+ payload.css,
484
+ payload.head,
485
+ payload.settings,
486
+ payload.sample_data,
487
+ payload.data_sets,
488
+ payload.data_schema,
489
+ payload.i18n
490
+ ];
387
491
  if (payload.partials) parts.push(payload.partials);
388
492
  return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify(parts)).digest("hex");
389
493
  }
390
494
  var statePath = (project) => (0, import_node_path2.join)(project.root, ".formfeed", "state.json");
391
495
  function readState(project) {
392
- return readJson(statePath(project), ".formfeed/state.json") ?? { templates: {} };
496
+ return readJson(statePath(project), ".formfeed/state.json") ?? {
497
+ templates: {}
498
+ };
393
499
  }
394
500
  function writeState(project, state) {
395
501
  (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(project.root, ".formfeed"), { recursive: true });
@@ -415,7 +521,8 @@ function partialResolver(project) {
415
521
  return (name) => {
416
522
  for (const candidate of [name, `${name}.html`]) {
417
523
  const path = (0, import_node_path2.join)(project.partialsDir, candidate);
418
- if ((0, import_node_fs2.existsSync)(path) && (0, import_node_fs2.statSync)(path).isFile()) return (0, import_node_fs2.readFileSync)(path, "utf8");
524
+ if ((0, import_node_fs2.existsSync)(path) && (0, import_node_fs2.statSync)(path).isFile())
525
+ return (0, import_node_fs2.readFileSync)(path, "utf8");
419
526
  }
420
527
  shared ??= new Set(Object.keys(readState(project).sharedPartials ?? {}));
421
528
  const normalised = (0, import_engine2.normalisePartialName)(name);
@@ -436,7 +543,12 @@ function recordSharedPartial(project, name, remote, source) {
436
543
  const state = readState(project);
437
544
  state.sharedPartials = {
438
545
  ...state.sharedPartials ?? {},
439
- [name]: { version: remote.version, engine: remote.engine, contentHash: partialContentHash(source), syncedAt: (/* @__PURE__ */ new Date()).toISOString() }
546
+ [name]: {
547
+ version: remote.version,
548
+ engine: remote.engine,
549
+ contentHash: partialContentHash(source),
550
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
551
+ }
440
552
  };
441
553
  writeState(project, state);
442
554
  }
@@ -494,7 +606,10 @@ function diagnose(tpl, sampleData) {
494
606
  severity: "error",
495
607
  code: "syntax-error",
496
608
  message: e.message,
497
- range: { start: { line: e.line ?? 1, column: e.column ?? 1 }, end: { line: e.line ?? 1, column: (e.column ?? 1) + 1 } }
609
+ range: {
610
+ start: { line: e.line ?? 1, column: e.column ?? 1 },
611
+ end: { line: e.line ?? 1, column: (e.column ?? 1) + 1 }
612
+ }
498
613
  });
499
614
  } else throw e;
500
615
  }
@@ -504,7 +619,8 @@ function diagnose(tpl, sampleData) {
504
619
  ["footer", tpl.settings.footer?.html]
505
620
  ]) {
506
621
  if (!source) continue;
507
- for (const d of engine.analyze(source, { sampleData }).diagnostics) diagnostics.push({ ...d, message: `${part}: ${d.message}` });
622
+ for (const d of engine.analyze(source, { sampleData }).diagnostics)
623
+ diagnostics.push({ ...d, message: `${part}: ${d.message}` });
508
624
  }
509
625
  for (const name of tpl.missingPartials)
510
626
  diagnostics.push({
@@ -514,8 +630,28 @@ function diagnose(tpl, sampleData) {
514
630
  message: `No partial "${name}" in the project's partials folder; if it is shared by the organisation, formfeed partials pull brings it`,
515
631
  range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }
516
632
  });
633
+ const design = designDiagnostic(tpl);
634
+ if (design) diagnostics.push(design);
517
635
  return diagnostics;
518
636
  }
637
+ function designDiagnostic(tpl) {
638
+ if (tpl.settings.design === void 0) return null;
639
+ const parsed = (0, import_engine4.parseDesign)(tpl.settings.design);
640
+ if (!parsed.design)
641
+ return {
642
+ severity: "warning",
643
+ code: "design-invalid",
644
+ message: `design.json is not a design the app can open (${parsed.problems.slice(0, 3).join("; ")}); the template opens as code`,
645
+ range: nowhere
646
+ };
647
+ if (parsed.design.output === (0, import_engine4.designOutputHash)(tpl.html, tpl.css)) return null;
648
+ return {
649
+ severity: "warning",
650
+ code: "design-stale",
651
+ message: "template.html or style.css was edited after design.json was written; the app opens the template as code until you choose to keep the code or apply the design again",
652
+ range: nowhere
653
+ };
654
+ }
519
655
  var nowhere = { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } };
520
656
  function diagnoseOffice(tpl, file, sampleData) {
521
657
  let analysis;
@@ -523,7 +659,14 @@ function diagnoseOffice(tpl, file, sampleData) {
523
659
  analysis = (0, import_engine4.analyzeOffice)(file, { engine: tpl.meta.engine, sampleData });
524
660
  } catch (e) {
525
661
  const code = e.code;
526
- return [{ severity: "error", code: typeof code === "string" ? code : "office-document-invalid", message: e instanceof Error ? e.message : String(e), range: nowhere }];
662
+ return [
663
+ {
664
+ severity: "error",
665
+ code: typeof code === "string" ? code : "office-document-invalid",
666
+ message: e instanceof Error ? e.message : String(e),
667
+ range: nowhere
668
+ }
669
+ ];
527
670
  }
528
671
  return analysis.diagnostics.map((d) => ({
529
672
  severity: d.severity,
@@ -535,17 +678,34 @@ function diagnoseOffice(tpl, file, sampleData) {
535
678
  }));
536
679
  }
537
680
  async function renderOfficeLocal(project, tpl, data, options = {}) {
538
- if (!tpl.file) throw new DevkitError(`${tpl.slug} is not a Word or PowerPoint template`, "validation");
539
- const context = renderContext(project, tpl, options.assetBaseUrl, options.brand);
681
+ if (!tpl.file)
682
+ throw new DevkitError(
683
+ `${tpl.slug} is not a Word or PowerPoint template`,
684
+ "validation"
685
+ );
686
+ const context = renderContext(
687
+ project,
688
+ tpl,
689
+ options.assetBaseUrl,
690
+ options.brand
691
+ );
540
692
  if (options.locale) context.locale = options.locale;
541
- return (0, import_engine4.renderOffice)(tpl.file.bytes, { engine: tpl.meta.engine, data, context, images: options.images, random: options.random });
693
+ return (0, import_engine4.renderOffice)(tpl.file.bytes, {
694
+ engine: tpl.meta.engine,
695
+ data,
696
+ context,
697
+ images: options.images,
698
+ random: options.random
699
+ });
542
700
  }
543
701
  function htmlSnapshot(rendered) {
544
702
  let snapshot = rendered.document;
545
- if (rendered.headerHtml) snapshot += `
703
+ if (rendered.headerHtml)
704
+ snapshot += `
546
705
  <!-- formfeed:header -->
547
706
  ${rendered.headerHtml}`;
548
- if (rendered.footerHtml) snapshot += `
707
+ if (rendered.footerHtml)
708
+ snapshot += `
549
709
  <!-- formfeed:footer -->
550
710
  ${rendered.footerHtml}`;
551
711
  return snapshot;
@@ -598,7 +758,14 @@ async function renderLocal(project, tpl, data, options) {
598
758
  const ctx = renderContext(project, tpl, options.assetBaseUrl, options.brand);
599
759
  if (options.locale) ctx.locale = options.locale;
600
760
  return (0, import_engine4.renderVersion)(
601
- { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: (0, import_engine4.mergeSettings)(tpl.settings), kind },
761
+ {
762
+ engine: tpl.meta.engine,
763
+ html: tpl.html,
764
+ css: tpl.css,
765
+ head: tpl.head,
766
+ settings: (0, import_engine4.mergeSettings)(tpl.settings),
767
+ kind
768
+ },
602
769
  data,
603
770
  ctx,
604
771
  options.mode,
@@ -608,16 +775,26 @@ async function renderLocal(project, tpl, data, options) {
608
775
  function renderedSettings(rendered) {
609
776
  const style = rendered.inlineCss ? `<style>${rendered.inlineCss}</style>` : "";
610
777
  const part = (settings2, html) => settings2?.html ? { ...settings2, html: `${style}${html ?? ""}` } : settings2;
611
- const { settings } = rendered;
778
+ const { design: _design, ...settings } = rendered.settings;
779
+ void _design;
612
780
  return {
613
781
  ...settings,
614
782
  ...settings.header ? { header: part(settings.header, rendered.headerHtml) } : {},
615
783
  ...settings.footer ? { footer: part(settings.footer, rendered.footerHtml) } : {},
616
- ...settings.pdf?.metadata?.title !== void 0 ? { pdf: { ...settings.pdf, metadata: { ...settings.pdf.metadata, title: rendered.title ?? "" } } } : {}
784
+ ...settings.pdf?.metadata?.title !== void 0 ? {
785
+ pdf: {
786
+ ...settings.pdf,
787
+ metadata: { ...settings.pdf.metadata, title: rendered.title ?? "" }
788
+ }
789
+ } : {}
617
790
  };
618
791
  }
619
792
  function htmlKind(tpl) {
620
- if ((0, import_engine4.isOfficeKind)(tpl.meta.kind)) throw new DevkitError(`${tpl.slug} is a ${tpl.meta.kind} template; fill it with renderOfficeLocal`, "validation");
793
+ if ((0, import_engine4.isOfficeKind)(tpl.meta.kind))
794
+ throw new DevkitError(
795
+ `${tpl.slug} is a ${tpl.meta.kind} template; fill it with renderOfficeLocal`,
796
+ "validation"
797
+ );
621
798
  return tpl.meta.kind;
622
799
  }
623
800
  function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
package/index.js CHANGED
@@ -95,7 +95,15 @@ function writeProjectConfig(root, config) {
95
95
 
96
96
  // packages/devkit/src/lib/project.ts
97
97
  import { createHash } from "node:crypto";
98
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
98
+ import {
99
+ existsSync as existsSync2,
100
+ mkdirSync,
101
+ readFileSync as readFileSync2,
102
+ readdirSync,
103
+ rmSync,
104
+ statSync,
105
+ writeFileSync as writeFileSync2
106
+ } from "node:fs";
99
107
  import { basename, dirname as dirname2, extname, join as join2 } from "node:path";
100
108
  import {
101
109
  isOfficeKind,
@@ -149,16 +157,23 @@ function readJson(path, what) {
149
157
  try {
150
158
  return JSON.parse(text2);
151
159
  } catch (e) {
152
- throw new DevkitError(`${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`, "validation");
160
+ throw new DevkitError(
161
+ `${what} is not valid JSON: ${path} (${e instanceof Error ? e.message : e})`,
162
+ "validation"
163
+ );
153
164
  }
154
165
  }
155
166
  var templateFileName = (kind) => isOfficeKind(kind) ? `template.${kind}` : "template.html";
156
167
  function sourceFilesIn(dir) {
157
- return ["template.html", ...officeKinds.map((k) => `template.${k}`)].filter((name) => existsSync2(join2(dir, name)));
168
+ return ["template.html", ...officeKinds.map((k) => `template.${k}`)].filter(
169
+ (name) => existsSync2(join2(dir, name))
170
+ );
158
171
  }
159
172
  function listTemplateSlugs(project) {
160
173
  if (!existsSync2(project.templatesDir)) return [];
161
- return readdirSync(project.templatesDir).filter((name) => sourceFilesIn(join2(project.templatesDir, name)).length > 0).sort();
174
+ return readdirSync(project.templatesDir).filter(
175
+ (name) => sourceFilesIn(join2(project.templatesDir, name)).length > 0
176
+ ).sort();
162
177
  }
163
178
  function templateDir(project, slug) {
164
179
  return join2(project.templatesDir, slug);
@@ -167,22 +182,41 @@ var sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
167
182
  function readOfficeFile(dir, name) {
168
183
  const path = join2(dir, name);
169
184
  const bytes = new Uint8Array(readFileSync2(path));
170
- return { format: extname(name).slice(1), path, bytes, sha256: sha256Of(bytes) };
185
+ return {
186
+ format: extname(name).slice(1),
187
+ path,
188
+ bytes,
189
+ sha256: sha256Of(bytes)
190
+ };
171
191
  }
172
192
  function readTemplate(project, slug) {
173
193
  const dir = templateDir(project, slug);
174
194
  const sources = sourceFilesIn(dir);
175
195
  if (sources.length === 0)
176
- throw new DevkitError(`No template "${slug}" in ${project.templatesDir} (template.html, template.docx or template.pptx missing)`);
196
+ throw new DevkitError(
197
+ `No template "${slug}" in ${project.templatesDir} (template.html, template.docx or template.pptx missing)`
198
+ );
177
199
  if (sources.length > 1)
178
- throw new DevkitError(`${dir} holds ${sources.join(" and ")}; a template folder has one of them`, "validation");
200
+ throw new DevkitError(
201
+ `${dir} holds ${sources.join(" and ")}; a template folder has one of them`,
202
+ "validation"
203
+ );
179
204
  const file = sources[0] === "template.html" ? null : readOfficeFile(dir, sources[0]);
180
205
  const html = file ? "" : readText(join2(dir, "template.html")) ?? "";
181
- const metaFile = readJson(join2(dir, "template.json"), "template.json") ?? {};
206
+ const metaFile = readJson(
207
+ join2(dir, "template.json"),
208
+ "template.json"
209
+ ) ?? {};
182
210
  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");
211
+ throw new DevkitError(
212
+ `${slug}: template.json says kind "${metaFile.kind}", but the folder holds ${basename(file.path)}`,
213
+ "validation"
214
+ );
184
215
  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");
216
+ throw new DevkitError(
217
+ `${slug}: template.json says kind "${metaFile.kind}", but the folder holds template.html instead of template.${metaFile.kind}`,
218
+ "validation"
219
+ );
186
220
  const meta = {
187
221
  name: metaFile.name ?? titleFromSlug(slug),
188
222
  kind: file?.format ?? metaFile.kind ?? "pdf",
@@ -190,19 +224,38 @@ function readTemplate(project, slug) {
190
224
  description: metaFile.description ?? null,
191
225
  tags: metaFile.tags ?? []
192
226
  };
193
- const settings = { ...readJson(join2(dir, "settings.json"), "settings.json") ?? {} };
227
+ const settings = {
228
+ ...readJson(
229
+ join2(dir, "settings.json"),
230
+ "settings.json"
231
+ ) ?? {}
232
+ };
194
233
  const header = file ? null : readText(join2(dir, "header.html"));
195
234
  const footer = file ? null : readText(join2(dir, "footer.html"));
196
- if (header !== null && header.trim()) settings.header = { ...settings.header ?? {}, html: header };
197
- if (footer !== null && footer.trim()) settings.footer = { ...settings.footer ?? {}, html: footer };
235
+ if (header !== null && header.trim())
236
+ settings.header = { ...settings.header ?? {}, html: header };
237
+ if (footer !== null && footer.trim())
238
+ settings.footer = { ...settings.footer ?? {}, html: footer };
239
+ const design = readJson(
240
+ join2(dir, "design.json"),
241
+ "design.json"
242
+ );
243
+ if (design) settings.design = design;
198
244
  const dataSets = {};
199
245
  const dataDir = join2(dir, "data");
200
246
  if (existsSync2(dataDir)) {
201
247
  for (const file2 of readdirSync(dataDir).filter((f) => f.endsWith(".json")).sort()) {
202
- dataSets[basename(file2, ".json")] = readJson(join2(dataDir, file2), `data/${file2}`);
248
+ dataSets[basename(file2, ".json")] = readJson(
249
+ join2(dataDir, file2),
250
+ `data/${file2}`
251
+ );
203
252
  }
204
253
  }
205
- const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
254
+ const { partials, missing, shared } = file ? { partials: {}, missing: [], shared: [] } : collectPartials(project, meta.engine, [
255
+ html,
256
+ settings.header?.html,
257
+ settings.footer?.html
258
+ ]);
206
259
  return {
207
260
  slug,
208
261
  dir,
@@ -213,8 +266,14 @@ function readTemplate(project, slug) {
213
266
  head: file ? "" : readText(join2(dir, "head.html")) ?? "",
214
267
  settings,
215
268
  dataSets,
216
- dataSchema: readJson(join2(dir, "schema.json"), "schema.json"),
217
- i18n: readJson(join2(dir, "i18n.json"), "i18n.json"),
269
+ dataSchema: readJson(
270
+ join2(dir, "schema.json"),
271
+ "schema.json"
272
+ ),
273
+ i18n: readJson(
274
+ join2(dir, "i18n.json"),
275
+ "i18n.json"
276
+ ),
218
277
  partials,
219
278
  missingPartials: missing,
220
279
  sharedPartials: shared
@@ -224,35 +283,68 @@ function defaultData(tpl, name) {
224
283
  const names = Object.keys(tpl.dataSets);
225
284
  const chosen = name ?? (names.includes("default") ? "default" : names[0]);
226
285
  if (name && !(name in tpl.dataSets))
227
- throw new DevkitError(`Data set "${name}" not found; available: ${names.join(", ") || "none"}`);
286
+ throw new DevkitError(
287
+ `Data set "${name}" not found; available: ${names.join(", ") || "none"}`
288
+ );
228
289
  return chosen ? { name: chosen, data: tpl.dataSets[chosen] } : { name: "empty", data: {} };
229
290
  }
230
291
  function writeTemplate(project, slug, meta, version, file) {
231
292
  const dir = templateDir(project, slug);
232
293
  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`);
294
+ if (office && !file)
295
+ throw new DevkitError(
296
+ `${slug} is a ${meta.kind} template; its document is needed to write the folder`
297
+ );
234
298
  mkdirSync(join2(dir, "data"), { recursive: true });
235
299
  const settings = { ...version.settings ?? {} };
236
300
  const header = office ? "" : settings.header?.html ?? "";
237
301
  const footer = office ? "" : settings.footer?.html ?? "";
238
302
  if (settings.header) settings.header = stripHtml(settings.header);
239
303
  if (settings.footer) settings.footer = stripHtml(settings.footer);
240
- writeFileSync2(join2(dir, "template.json"), JSON.stringify(meta, null, 2) + "\n");
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));
304
+ const design = settings.design;
305
+ delete settings.design;
306
+ writeFileSync2(
307
+ join2(dir, "template.json"),
308
+ JSON.stringify(meta, null, 2) + "\n"
309
+ );
310
+ for (const name of [
311
+ "template.html",
312
+ ...officeKinds.map((k) => `template.${k}`)
313
+ ])
314
+ if (name !== templateFileName(meta.kind) && existsSync2(join2(dir, name)))
315
+ rmSync(join2(dir, name));
243
316
  if (office) writeFileSync2(join2(dir, templateFileName(meta.kind)), file);
244
317
  else writeFileSync2(join2(dir, "template.html"), version.html ?? "");
245
318
  writeOrRemove(join2(dir, "style.css"), office ? "" : version.css ?? "");
246
319
  writeOrRemove(join2(dir, "head.html"), office ? "" : version.head ?? "");
247
320
  writeOrRemove(join2(dir, "header.html"), header);
248
321
  writeOrRemove(join2(dir, "footer.html"), footer);
249
- writeFileSync2(join2(dir, "settings.json"), JSON.stringify(settings, null, 2) + "\n");
250
- writeFileSync2(join2(dir, "data", "default.json"), JSON.stringify(version.sample_data ?? {}, null, 2) + "\n");
322
+ writeFileSync2(
323
+ join2(dir, "settings.json"),
324
+ JSON.stringify(settings, null, 2) + "\n"
325
+ );
326
+ writeOrRemove(
327
+ join2(dir, "design.json"),
328
+ design && typeof design === "object" ? JSON.stringify(design, null, 2) + "\n" : ""
329
+ );
330
+ writeFileSync2(
331
+ join2(dir, "data", "default.json"),
332
+ JSON.stringify(version.sample_data ?? {}, null, 2) + "\n"
333
+ );
251
334
  for (const [name, data] of Object.entries(version.data_sets ?? {}))
252
335
  if (name !== "default" && /^[\w.-]+$/.test(name))
253
- writeFileSync2(join2(dir, "data", `${name}.json`), JSON.stringify(data ?? {}, null, 2) + "\n");
254
- writeOrRemove(join2(dir, "schema.json"), version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : "");
255
- writeOrRemove(join2(dir, "i18n.json"), version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : "");
336
+ writeFileSync2(
337
+ join2(dir, "data", `${name}.json`),
338
+ JSON.stringify(data ?? {}, null, 2) + "\n"
339
+ );
340
+ writeOrRemove(
341
+ join2(dir, "schema.json"),
342
+ version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : ""
343
+ );
344
+ writeOrRemove(
345
+ join2(dir, "i18n.json"),
346
+ version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : ""
347
+ );
256
348
  writePartials(project, version.partials ?? null);
257
349
  return dir;
258
350
  }
@@ -261,7 +353,10 @@ function writePartials(project, partials) {
261
353
  const changed = [];
262
354
  for (const [name, source] of Object.entries(partials ?? {})) {
263
355
  if (!partialName.test(name)) continue;
264
- const path = join2(project.partialsDir, name.includes(".") ? name : `${name}.html`);
356
+ const path = join2(
357
+ project.partialsDir,
358
+ name.includes(".") ? name : `${name}.html`
359
+ );
265
360
  if (readText(path) === source) continue;
266
361
  mkdirSync(dirname2(path), { recursive: true });
267
362
  writeFileSync2(path, source);
@@ -307,17 +402,36 @@ function versionPayload(tpl) {
307
402
  function contentHash(tpl) {
308
403
  if (tpl.file) {
309
404
  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];
405
+ const parts2 = [
406
+ tpl.file.format,
407
+ tpl.file.sha256,
408
+ payload2.settings,
409
+ payload2.sample_data,
410
+ payload2.data_sets,
411
+ payload2.data_schema,
412
+ payload2.i18n
413
+ ];
311
414
  return createHash("sha256").update(JSON.stringify(parts2)).digest("hex");
312
415
  }
313
416
  const payload = versionPayload(tpl);
314
- const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
417
+ const parts = [
418
+ payload.html,
419
+ payload.css,
420
+ payload.head,
421
+ payload.settings,
422
+ payload.sample_data,
423
+ payload.data_sets,
424
+ payload.data_schema,
425
+ payload.i18n
426
+ ];
315
427
  if (payload.partials) parts.push(payload.partials);
316
428
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
317
429
  }
318
430
  var statePath = (project) => join2(project.root, ".formfeed", "state.json");
319
431
  function readState(project) {
320
- return readJson(statePath(project), ".formfeed/state.json") ?? { templates: {} };
432
+ return readJson(statePath(project), ".formfeed/state.json") ?? {
433
+ templates: {}
434
+ };
321
435
  }
322
436
  function writeState(project, state) {
323
437
  mkdirSync(join2(project.root, ".formfeed"), { recursive: true });
@@ -343,7 +457,8 @@ function partialResolver(project) {
343
457
  return (name) => {
344
458
  for (const candidate of [name, `${name}.html`]) {
345
459
  const path = join2(project.partialsDir, candidate);
346
- if (existsSync2(path) && statSync(path).isFile()) return readFileSync2(path, "utf8");
460
+ if (existsSync2(path) && statSync(path).isFile())
461
+ return readFileSync2(path, "utf8");
347
462
  }
348
463
  shared ??= new Set(Object.keys(readState(project).sharedPartials ?? {}));
349
464
  const normalised = normalisePartialName2(name);
@@ -364,7 +479,12 @@ function recordSharedPartial(project, name, remote, source) {
364
479
  const state = readState(project);
365
480
  state.sharedPartials = {
366
481
  ...state.sharedPartials ?? {},
367
- [name]: { version: remote.version, engine: remote.engine, contentHash: partialContentHash(source), syncedAt: (/* @__PURE__ */ new Date()).toISOString() }
482
+ [name]: {
483
+ version: remote.version,
484
+ engine: remote.engine,
485
+ contentHash: partialContentHash(source),
486
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
487
+ }
368
488
  };
369
489
  writeState(project, state);
370
490
  }
@@ -414,12 +534,14 @@ import {
414
534
  analyzeOffice,
415
535
  defaultHelpers,
416
536
  defaultLimits,
537
+ designOutputHash,
417
538
  flowDocument,
418
539
  getEngine as getEngine2,
419
540
  isOfficeKind as isOfficeKind2,
420
541
  mergeSettings,
421
542
  officeTextParts,
422
543
  pagedDocument,
544
+ parseDesign,
423
545
  renderOffice,
424
546
  renderVersion
425
547
  } from "@formfeed/engine";
@@ -435,7 +557,10 @@ function diagnose(tpl, sampleData) {
435
557
  severity: "error",
436
558
  code: "syntax-error",
437
559
  message: e.message,
438
- range: { start: { line: e.line ?? 1, column: e.column ?? 1 }, end: { line: e.line ?? 1, column: (e.column ?? 1) + 1 } }
560
+ range: {
561
+ start: { line: e.line ?? 1, column: e.column ?? 1 },
562
+ end: { line: e.line ?? 1, column: (e.column ?? 1) + 1 }
563
+ }
439
564
  });
440
565
  } else throw e;
441
566
  }
@@ -445,7 +570,8 @@ function diagnose(tpl, sampleData) {
445
570
  ["footer", tpl.settings.footer?.html]
446
571
  ]) {
447
572
  if (!source) continue;
448
- for (const d of engine.analyze(source, { sampleData }).diagnostics) diagnostics.push({ ...d, message: `${part}: ${d.message}` });
573
+ for (const d of engine.analyze(source, { sampleData }).diagnostics)
574
+ diagnostics.push({ ...d, message: `${part}: ${d.message}` });
449
575
  }
450
576
  for (const name of tpl.missingPartials)
451
577
  diagnostics.push({
@@ -455,8 +581,28 @@ function diagnose(tpl, sampleData) {
455
581
  message: `No partial "${name}" in the project's partials folder; if it is shared by the organisation, formfeed partials pull brings it`,
456
582
  range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }
457
583
  });
584
+ const design = designDiagnostic(tpl);
585
+ if (design) diagnostics.push(design);
458
586
  return diagnostics;
459
587
  }
588
+ function designDiagnostic(tpl) {
589
+ if (tpl.settings.design === void 0) return null;
590
+ const parsed = parseDesign(tpl.settings.design);
591
+ if (!parsed.design)
592
+ return {
593
+ severity: "warning",
594
+ code: "design-invalid",
595
+ message: `design.json is not a design the app can open (${parsed.problems.slice(0, 3).join("; ")}); the template opens as code`,
596
+ range: nowhere
597
+ };
598
+ if (parsed.design.output === designOutputHash(tpl.html, tpl.css)) return null;
599
+ return {
600
+ severity: "warning",
601
+ code: "design-stale",
602
+ message: "template.html or style.css was edited after design.json was written; the app opens the template as code until you choose to keep the code or apply the design again",
603
+ range: nowhere
604
+ };
605
+ }
460
606
  var nowhere = { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } };
461
607
  function diagnoseOffice(tpl, file, sampleData) {
462
608
  let analysis;
@@ -464,7 +610,14 @@ function diagnoseOffice(tpl, file, sampleData) {
464
610
  analysis = analyzeOffice(file, { engine: tpl.meta.engine, sampleData });
465
611
  } catch (e) {
466
612
  const code = e.code;
467
- return [{ severity: "error", code: typeof code === "string" ? code : "office-document-invalid", message: e instanceof Error ? e.message : String(e), range: nowhere }];
613
+ return [
614
+ {
615
+ severity: "error",
616
+ code: typeof code === "string" ? code : "office-document-invalid",
617
+ message: e instanceof Error ? e.message : String(e),
618
+ range: nowhere
619
+ }
620
+ ];
468
621
  }
469
622
  return analysis.diagnostics.map((d) => ({
470
623
  severity: d.severity,
@@ -476,17 +629,34 @@ function diagnoseOffice(tpl, file, sampleData) {
476
629
  }));
477
630
  }
478
631
  async function renderOfficeLocal(project, tpl, data, options = {}) {
479
- if (!tpl.file) throw new DevkitError(`${tpl.slug} is not a Word or PowerPoint template`, "validation");
480
- const context = renderContext(project, tpl, options.assetBaseUrl, options.brand);
632
+ if (!tpl.file)
633
+ throw new DevkitError(
634
+ `${tpl.slug} is not a Word or PowerPoint template`,
635
+ "validation"
636
+ );
637
+ const context = renderContext(
638
+ project,
639
+ tpl,
640
+ options.assetBaseUrl,
641
+ options.brand
642
+ );
481
643
  if (options.locale) context.locale = options.locale;
482
- return renderOffice(tpl.file.bytes, { engine: tpl.meta.engine, data, context, images: options.images, random: options.random });
644
+ return renderOffice(tpl.file.bytes, {
645
+ engine: tpl.meta.engine,
646
+ data,
647
+ context,
648
+ images: options.images,
649
+ random: options.random
650
+ });
483
651
  }
484
652
  function htmlSnapshot(rendered) {
485
653
  let snapshot = rendered.document;
486
- if (rendered.headerHtml) snapshot += `
654
+ if (rendered.headerHtml)
655
+ snapshot += `
487
656
  <!-- formfeed:header -->
488
657
  ${rendered.headerHtml}`;
489
- if (rendered.footerHtml) snapshot += `
658
+ if (rendered.footerHtml)
659
+ snapshot += `
490
660
  <!-- formfeed:footer -->
491
661
  ${rendered.footerHtml}`;
492
662
  return snapshot;
@@ -539,7 +709,14 @@ async function renderLocal(project, tpl, data, options) {
539
709
  const ctx = renderContext(project, tpl, options.assetBaseUrl, options.brand);
540
710
  if (options.locale) ctx.locale = options.locale;
541
711
  return renderVersion(
542
- { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind },
712
+ {
713
+ engine: tpl.meta.engine,
714
+ html: tpl.html,
715
+ css: tpl.css,
716
+ head: tpl.head,
717
+ settings: mergeSettings(tpl.settings),
718
+ kind
719
+ },
543
720
  data,
544
721
  ctx,
545
722
  options.mode,
@@ -549,16 +726,26 @@ async function renderLocal(project, tpl, data, options) {
549
726
  function renderedSettings(rendered) {
550
727
  const style = rendered.inlineCss ? `<style>${rendered.inlineCss}</style>` : "";
551
728
  const part = (settings2, html) => settings2?.html ? { ...settings2, html: `${style}${html ?? ""}` } : settings2;
552
- const { settings } = rendered;
729
+ const { design: _design, ...settings } = rendered.settings;
730
+ void _design;
553
731
  return {
554
732
  ...settings,
555
733
  ...settings.header ? { header: part(settings.header, rendered.headerHtml) } : {},
556
734
  ...settings.footer ? { footer: part(settings.footer, rendered.footerHtml) } : {},
557
- ...settings.pdf?.metadata?.title !== void 0 ? { pdf: { ...settings.pdf, metadata: { ...settings.pdf.metadata, title: rendered.title ?? "" } } } : {}
735
+ ...settings.pdf?.metadata?.title !== void 0 ? {
736
+ pdf: {
737
+ ...settings.pdf,
738
+ metadata: { ...settings.pdf.metadata, title: rendered.title ?? "" }
739
+ }
740
+ } : {}
558
741
  };
559
742
  }
560
743
  function htmlKind(tpl) {
561
- if (isOfficeKind2(tpl.meta.kind)) throw new DevkitError(`${tpl.slug} is a ${tpl.meta.kind} template; fill it with renderOfficeLocal`, "validation");
744
+ if (isOfficeKind2(tpl.meta.kind))
745
+ throw new DevkitError(
746
+ `${tpl.slug} is a ${tpl.meta.kind} template; fill it with renderOfficeLocal`,
747
+ "validation"
748
+ );
562
749
  return tpl.meta.kind;
563
750
  }
564
751
  function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formfeed/devkit",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Formfeed template folders, project config and local rendering, shared by the CLI and @formfeed/testing.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,8 +30,8 @@
30
30
  ],
31
31
  "sideEffects": false,
32
32
  "dependencies": {
33
- "@formfeed/engine": "0.3.9",
34
- "@formfeed/sdk": "0.3.9"
33
+ "@formfeed/engine": "0.3.11",
34
+ "@formfeed/sdk": "0.3.11"
35
35
  },
36
36
  "keywords": [
37
37
  "formfeed",
@@ -5,7 +5,8 @@ import type { Project } from './project-config.cjs';
5
5
  * The template folder of spec 15 §2. `template.json` (name, kind, engine, description, tags) is
6
6
  * the one addition to the spec's layout: `push` needs it to create a template that does not exist
7
7
  * remotely, `pull` writes it. Header and footer live in their own files and fold back into
8
- * `settings.header.html` / `settings.footer.html`, as the editor's tabs do.
8
+ * `settings.header.html` / `settings.footer.html`, as the editor's tabs do; a visual design lives in
9
+ * `design.json` and folds back into `settings.design`.
9
10
  */
10
11
  export interface TemplateMeta {
11
12
  name: string;
@@ -5,7 +5,8 @@ import type { Project } from './project-config.js';
5
5
  * The template folder of spec 15 §2. `template.json` (name, kind, engine, description, tags) is
6
6
  * the one addition to the spec's layout: `push` needs it to create a template that does not exist
7
7
  * remotely, `pull` writes it. Header and footer live in their own files and fold back into
8
- * `settings.header.html` / `settings.footer.html`, as the editor's tabs do.
8
+ * `settings.header.html` / `settings.footer.html`, as the editor's tabs do; a visual design lives in
9
+ * `design.json` and folds back into `settings.design`.
9
10
  */
10
11
  export interface TemplateMeta {
11
12
  name: string;