@formfeed/devkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -21,13 +21,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DevkitError: () => DevkitError,
24
+ collectPartials: () => collectPartials,
24
25
  contentHash: () => contentHash,
26
+ contentTypeFor: () => contentTypeFor,
25
27
  defaultData: () => defaultData,
26
28
  defaultProjectConfig: () => defaultProjectConfig,
27
29
  diagnose: () => diagnose,
28
30
  findProject: () => findProject,
31
+ listLocalFiles: () => listLocalFiles,
29
32
  listTemplateSlugs: () => listTemplateSlugs,
30
33
  loadProject: () => loadProject,
34
+ localFilePath: () => localFilePath,
31
35
  partialResolver: () => partialResolver,
32
36
  previewDocument: () => previewDocument,
33
37
  projectAround: () => projectAround,
@@ -36,9 +40,12 @@ __export(index_exports, {
36
40
  recordSync: () => recordSync,
37
41
  renderContext: () => renderContext,
38
42
  renderLocal: () => renderLocal,
43
+ sha256File: () => sha256File,
39
44
  templateDir: () => templateDir,
40
45
  titleFromSlug: () => titleFromSlug,
41
46
  versionPayload: () => versionPayload,
47
+ writeLocalFile: () => writeLocalFile,
48
+ writePartials: () => writePartials,
42
49
  writeProjectConfig: () => writeProjectConfig,
43
50
  writeState: () => writeState,
44
51
  writeTemplate: () => writeTemplate
@@ -60,6 +67,7 @@ var import_node_path = require("node:path");
60
67
  var defaultProjectConfig = {
61
68
  templatesDir: "templates",
62
69
  partialsDir: "partials",
70
+ filesDir: "files",
63
71
  engine: "jinja2",
64
72
  ignore: ["**/drafts/**"]
65
73
  };
@@ -82,7 +90,14 @@ function loadProject(configPath) {
82
90
  }
83
91
  const config = { ...defaultProjectConfig, ...raw };
84
92
  const root = (0, import_node_path.dirname)(configPath);
85
- return { root, configPath, config, templatesDir: (0, import_node_path.resolve)(root, config.templatesDir), partialsDir: (0, import_node_path.resolve)(root, config.partialsDir) };
93
+ return {
94
+ root,
95
+ configPath,
96
+ config,
97
+ templatesDir: (0, import_node_path.resolve)(root, config.templatesDir),
98
+ partialsDir: (0, import_node_path.resolve)(root, config.partialsDir),
99
+ filesDir: (0, import_node_path.resolve)(root, config.filesDir)
100
+ };
86
101
  }
87
102
  function projectAround(templateFolder) {
88
103
  const dir = (0, import_node_path.resolve)(templateFolder);
@@ -93,9 +108,10 @@ function projectAround(templateFolder) {
93
108
  return {
94
109
  root,
95
110
  configPath: null,
96
- config: { ...defaultProjectConfig, templatesDir, partialsDir: (0, import_node_path.join)(root, "partials") },
111
+ config: { ...defaultProjectConfig, templatesDir, partialsDir: (0, import_node_path.join)(root, "partials"), filesDir: (0, import_node_path.join)(root, "files") },
97
112
  templatesDir,
98
- partialsDir: (0, import_node_path.join)(root, "partials")
113
+ partialsDir: (0, import_node_path.join)(root, "partials"),
114
+ filesDir: (0, import_node_path.join)(root, "files")
99
115
  };
100
116
  }
101
117
  function writeProjectConfig(root, config) {
@@ -109,6 +125,39 @@ function writeProjectConfig(root, config) {
109
125
  var import_node_crypto = require("node:crypto");
110
126
  var import_node_fs2 = require("node:fs");
111
127
  var import_node_path2 = require("node:path");
128
+
129
+ // packages/devkit/src/lib/partials.ts
130
+ var import_engine = require("@formfeed/engine");
131
+ function includeNames(engine, source) {
132
+ try {
133
+ return (0, import_engine.getEngine)(engine).analyze(source).includes.map((include) => include.name);
134
+ } catch {
135
+ return [];
136
+ }
137
+ }
138
+ function collectPartials(project, engine, sources) {
139
+ const resolve3 = partialResolver(project);
140
+ const partials = {};
141
+ const missing = /* @__PURE__ */ new Set();
142
+ const queue = sources.filter((source) => Boolean(source));
143
+ const seen = /* @__PURE__ */ new Set();
144
+ while (queue.length) {
145
+ for (const name of includeNames(engine, queue.shift())) {
146
+ if (seen.has(name)) continue;
147
+ seen.add(name);
148
+ const source = resolve3(name);
149
+ if (source === void 0) {
150
+ missing.add(name);
151
+ continue;
152
+ }
153
+ partials[name] = source;
154
+ queue.push(source);
155
+ }
156
+ }
157
+ return { partials, missing: [...missing].sort() };
158
+ }
159
+
160
+ // packages/devkit/src/lib/project.ts
112
161
  var readText = (path) => (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : null;
113
162
  function readJson(path, what) {
114
163
  const text = readText(path);
@@ -150,6 +199,7 @@ function readTemplate(project, slug) {
150
199
  dataSets[(0, import_node_path2.basename)(file, ".json")] = readJson((0, import_node_path2.join)(dataDir, file), `data/${file}`);
151
200
  }
152
201
  }
202
+ const { partials, missing } = collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
153
203
  return {
154
204
  slug,
155
205
  dir,
@@ -160,7 +210,9 @@ function readTemplate(project, slug) {
160
210
  settings,
161
211
  dataSets,
162
212
  dataSchema: readJson((0, import_node_path2.join)(dir, "schema.json"), "schema.json"),
163
- i18n: readJson((0, import_node_path2.join)(dir, "i18n.json"), "i18n.json")
213
+ i18n: readJson((0, import_node_path2.join)(dir, "i18n.json"), "i18n.json"),
214
+ partials,
215
+ missingPartials: missing
164
216
  };
165
217
  }
166
218
  function defaultData(tpl, name) {
@@ -191,8 +243,22 @@ function writeTemplate(project, slug, meta, version) {
191
243
  (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(dir, "data", `${name}.json`), JSON.stringify(data ?? {}, null, 2) + "\n");
192
244
  writeOrRemove((0, import_node_path2.join)(dir, "schema.json"), version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : "");
193
245
  writeOrRemove((0, import_node_path2.join)(dir, "i18n.json"), version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : "");
246
+ writePartials(project, version.partials ?? null);
194
247
  return dir;
195
248
  }
249
+ var partialName = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/;
250
+ function writePartials(project, partials) {
251
+ const changed = [];
252
+ for (const [name, source] of Object.entries(partials ?? {})) {
253
+ if (!partialName.test(name)) continue;
254
+ const path = (0, import_node_path2.join)(project.partialsDir, name.includes(".") ? name : `${name}.html`);
255
+ if (readText(path) === source) continue;
256
+ (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
257
+ (0, import_node_fs2.writeFileSync)(path, source);
258
+ changed.push(path);
259
+ }
260
+ return changed;
261
+ }
196
262
  function stripHtml(part) {
197
263
  const { html: _html, ...rest } = part;
198
264
  void _html;
@@ -215,12 +281,16 @@ function versionPayload(tpl) {
215
281
  sample_data: sample ?? {},
216
282
  data_sets: named,
217
283
  data_schema: tpl.dataSchema,
218
- i18n: tpl.i18n
284
+ i18n: tpl.i18n,
285
+ // only when the template includes one, so a project without partials sends what it always sent
286
+ ...Object.keys(tpl.partials).length ? { partials: tpl.partials } : {}
219
287
  };
220
288
  }
221
289
  function contentHash(tpl) {
222
290
  const payload = versionPayload(tpl);
223
- return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify([payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n])).digest("hex");
291
+ const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
292
+ if (payload.partials) parts.push(payload.partials);
293
+ return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify(parts)).digest("hex");
224
294
  }
225
295
  var statePath = (project) => (0, import_node_path2.join)(project.root, ".formfeed", "state.json");
226
296
  function readState(project) {
@@ -255,14 +325,14 @@ function partialResolver(project) {
255
325
  }
256
326
 
257
327
  // packages/devkit/src/lib/local-render.ts
258
- var import_engine = require("@formfeed/engine");
328
+ var import_engine2 = require("@formfeed/engine");
259
329
  function diagnose(tpl, sampleData) {
260
- const engine = (0, import_engine.getEngine)(tpl.meta.engine);
330
+ const engine = (0, import_engine2.getEngine)(tpl.meta.engine);
261
331
  const diagnostics = [];
262
332
  try {
263
333
  engine.compile(tpl.html, { name: tpl.slug });
264
334
  } catch (e) {
265
- if (e instanceof import_engine.EngineSyntaxError) {
335
+ if (e instanceof import_engine2.EngineSyntaxError) {
266
336
  diagnostics.push({
267
337
  severity: "error",
268
338
  code: "syntax-error",
@@ -279,25 +349,33 @@ function diagnose(tpl, sampleData) {
279
349
  if (!source) continue;
280
350
  for (const d of engine.analyze(source, { sampleData }).diagnostics) diagnostics.push({ ...d, message: `${part}: ${d.message}` });
281
351
  }
352
+ for (const name of tpl.missingPartials)
353
+ diagnostics.push({
354
+ severity: "error",
355
+ code: "missing-partial",
356
+ message: `No partial "${name}" in the project's partials folder`,
357
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }
358
+ });
282
359
  return diagnostics;
283
360
  }
284
- function renderContext(project, tpl) {
285
- const settings = (0, import_engine.mergeSettings)(tpl.settings);
361
+ function renderContext(project, tpl, assetBaseUrl) {
362
+ const settings = (0, import_engine2.mergeSettings)(tpl.settings);
286
363
  return {
287
364
  locale: settings.locale ?? "en",
288
365
  timezone: settings.timezone ?? "UTC",
289
366
  currency: settings.currency ?? "EUR",
290
367
  partials: partialResolver(project),
291
- helpers: (0, import_engine.defaultHelpers)(),
292
- limits: import_engine.defaultLimits,
293
- i18n: tpl.i18n ?? void 0
368
+ helpers: (0, import_engine2.defaultHelpers)(),
369
+ limits: import_engine2.defaultLimits,
370
+ i18n: tpl.i18n ?? void 0,
371
+ assetBaseUrl
294
372
  };
295
373
  }
296
374
  async function renderLocal(project, tpl, data, options) {
297
- const ctx = renderContext(project, tpl);
375
+ const ctx = renderContext(project, tpl, options.assetBaseUrl);
298
376
  if (options.locale) ctx.locale = options.locale;
299
- return (0, import_engine.renderVersion)(
300
- { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: (0, import_engine.mergeSettings)(tpl.settings), kind: tpl.meta.kind },
377
+ return (0, import_engine2.renderVersion)(
378
+ { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: (0, import_engine2.mergeSettings)(tpl.settings), kind: tpl.meta.kind },
301
379
  data,
302
380
  ctx,
303
381
  options.mode,
@@ -312,18 +390,96 @@ function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
312
390
  settings: rendered.settings,
313
391
  kind: tpl.meta.kind
314
392
  };
315
- return mode === "paged" && tpl.meta.kind === "pdf" ? (0, import_engine.pagedDocument)(draft, { pagedScriptUrl }) : (0, import_engine.flowDocument)(draft);
393
+ return mode === "paged" && tpl.meta.kind === "pdf" ? (0, import_engine2.pagedDocument)(draft, { pagedScriptUrl }) : (0, import_engine2.flowDocument)(draft);
394
+ }
395
+
396
+ // packages/devkit/src/lib/files.ts
397
+ var import_node_crypto2 = require("node:crypto");
398
+ var import_node_fs3 = require("node:fs");
399
+ var import_node_path3 = require("node:path");
400
+
401
+ // packages/api-types/src/lib/assets.ts
402
+ var SEGMENT = "[A-Za-z0-9][A-Za-z0-9._-]*";
403
+ var ASSET_NAME_PATTERN = new RegExp(`^${SEGMENT}(?:/${SEGMENT})*$`);
404
+ var ASSET_MAX_BYTES = 10 * 1024 * 1024;
405
+ var ASSET_CONTENT_TYPES = {
406
+ "image/png": "png",
407
+ "image/jpeg": "jpg",
408
+ "image/webp": "webp",
409
+ "image/gif": "gif",
410
+ "image/svg+xml": "svg",
411
+ "image/avif": "avif",
412
+ "application/pdf": "pdf"
413
+ };
414
+ function isAssetName(name) {
415
+ return name.length > 0 && name.length <= 200 && ASSET_NAME_PATTERN.test(name) && !name.split("/").includes("..");
416
+ }
417
+
418
+ // packages/devkit/src/lib/files.ts
419
+ var byExtension = Object.fromEntries(
420
+ Object.entries(ASSET_CONTENT_TYPES).map(([type, ext]) => [ext, type])
421
+ );
422
+ byExtension["jpeg"] = "image/jpeg";
423
+ function contentTypeFor(name) {
424
+ return byExtension[name.split(".").pop()?.toLowerCase() ?? ""] ?? null;
425
+ }
426
+ function sha256File(bytes) {
427
+ return (0, import_node_crypto2.createHash)("sha256").update(bytes).digest("hex");
428
+ }
429
+ function listLocalFiles(project) {
430
+ const listing = { files: [], skipped: [] };
431
+ if (!(0, import_node_fs3.existsSync)(project.filesDir)) return listing;
432
+ const walk = (dir) => {
433
+ for (const entry of (0, import_node_fs3.readdirSync)(dir, { withFileTypes: true })) {
434
+ if (entry.name.startsWith(".")) continue;
435
+ const path = (0, import_node_path3.join)(dir, entry.name);
436
+ if (entry.isDirectory()) {
437
+ walk(path);
438
+ continue;
439
+ }
440
+ if (!entry.isFile()) continue;
441
+ const name = (0, import_node_path3.relative)(project.filesDir, path).split(import_node_path3.sep).join("/");
442
+ const size = (0, import_node_fs3.statSync)(path).size;
443
+ const contentType = contentTypeFor(name);
444
+ const reason = !isAssetName(name) ? "the name has characters a library name cannot have (letters, digits, dot, dash, underscore)" : !contentType ? "not an image or a PDF" : size === 0 ? "empty" : size > ASSET_MAX_BYTES ? `larger than ${ASSET_MAX_BYTES / 1024 / 1024} MB` : null;
445
+ if (reason) {
446
+ listing.skipped.push({ path, reason });
447
+ continue;
448
+ }
449
+ const bytes = (0, import_node_fs3.readFileSync)(path);
450
+ listing.files.push({ name, path, bytes: size, sha256: sha256File(bytes), contentType });
451
+ }
452
+ };
453
+ walk(project.filesDir);
454
+ listing.files.sort((a, b) => a.name.localeCompare(b.name));
455
+ return listing;
456
+ }
457
+ function localFilePath(project, name) {
458
+ if (!isAssetName(name)) throw new DevkitError(`"${name}" is not a library file name`);
459
+ const path = (0, import_node_path3.resolve)(project.filesDir, ...name.split("/"));
460
+ if (!path.startsWith((0, import_node_path3.resolve)(project.filesDir) + import_node_path3.sep)) throw new DevkitError(`"${name}" is not inside the files folder`);
461
+ return path;
462
+ }
463
+ function writeLocalFile(project, name, bytes) {
464
+ const path = localFilePath(project, name);
465
+ (0, import_node_fs3.mkdirSync)((0, import_node_path3.dirname)(path), { recursive: true });
466
+ (0, import_node_fs3.writeFileSync)(path, bytes);
467
+ return path;
316
468
  }
317
469
  // Annotate the CommonJS export names for ESM import in node:
318
470
  0 && (module.exports = {
319
471
  DevkitError,
472
+ collectPartials,
320
473
  contentHash,
474
+ contentTypeFor,
321
475
  defaultData,
322
476
  defaultProjectConfig,
323
477
  diagnose,
324
478
  findProject,
479
+ listLocalFiles,
325
480
  listTemplateSlugs,
326
481
  loadProject,
482
+ localFilePath,
327
483
  partialResolver,
328
484
  previewDocument,
329
485
  projectAround,
@@ -332,9 +488,12 @@ function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
332
488
  recordSync,
333
489
  renderContext,
334
490
  renderLocal,
491
+ sha256File,
335
492
  templateDir,
336
493
  titleFromSlug,
337
494
  versionPayload,
495
+ writeLocalFile,
496
+ writePartials,
338
497
  writeProjectConfig,
339
498
  writeState,
340
499
  writeTemplate
package/index.js CHANGED
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from "node:path";
13
13
  var defaultProjectConfig = {
14
14
  templatesDir: "templates",
15
15
  partialsDir: "partials",
16
+ filesDir: "files",
16
17
  engine: "jinja2",
17
18
  ignore: ["**/drafts/**"]
18
19
  };
@@ -35,7 +36,14 @@ function loadProject(configPath) {
35
36
  }
36
37
  const config = { ...defaultProjectConfig, ...raw };
37
38
  const root = dirname(configPath);
38
- return { root, configPath, config, templatesDir: resolve(root, config.templatesDir), partialsDir: resolve(root, config.partialsDir) };
39
+ return {
40
+ root,
41
+ configPath,
42
+ config,
43
+ templatesDir: resolve(root, config.templatesDir),
44
+ partialsDir: resolve(root, config.partialsDir),
45
+ filesDir: resolve(root, config.filesDir)
46
+ };
39
47
  }
40
48
  function projectAround(templateFolder) {
41
49
  const dir = resolve(templateFolder);
@@ -46,9 +54,10 @@ function projectAround(templateFolder) {
46
54
  return {
47
55
  root,
48
56
  configPath: null,
49
- config: { ...defaultProjectConfig, templatesDir, partialsDir: join(root, "partials") },
57
+ config: { ...defaultProjectConfig, templatesDir, partialsDir: join(root, "partials"), filesDir: join(root, "files") },
50
58
  templatesDir,
51
- partialsDir: join(root, "partials")
59
+ partialsDir: join(root, "partials"),
60
+ filesDir: join(root, "files")
52
61
  };
53
62
  }
54
63
  function writeProjectConfig(root, config) {
@@ -61,7 +70,40 @@ function writeProjectConfig(root, config) {
61
70
  // packages/devkit/src/lib/project.ts
62
71
  import { createHash } from "node:crypto";
63
72
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
64
- import { basename, join as join2 } from "node:path";
73
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
74
+
75
+ // packages/devkit/src/lib/partials.ts
76
+ import { getEngine } from "@formfeed/engine";
77
+ function includeNames(engine, source) {
78
+ try {
79
+ return getEngine(engine).analyze(source).includes.map((include) => include.name);
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+ function collectPartials(project, engine, sources) {
85
+ const resolve3 = partialResolver(project);
86
+ const partials = {};
87
+ const missing = /* @__PURE__ */ new Set();
88
+ const queue = sources.filter((source) => Boolean(source));
89
+ const seen = /* @__PURE__ */ new Set();
90
+ while (queue.length) {
91
+ for (const name of includeNames(engine, queue.shift())) {
92
+ if (seen.has(name)) continue;
93
+ seen.add(name);
94
+ const source = resolve3(name);
95
+ if (source === void 0) {
96
+ missing.add(name);
97
+ continue;
98
+ }
99
+ partials[name] = source;
100
+ queue.push(source);
101
+ }
102
+ }
103
+ return { partials, missing: [...missing].sort() };
104
+ }
105
+
106
+ // packages/devkit/src/lib/project.ts
65
107
  var readText = (path) => existsSync2(path) ? readFileSync2(path, "utf8") : null;
66
108
  function readJson(path, what) {
67
109
  const text = readText(path);
@@ -103,6 +145,7 @@ function readTemplate(project, slug) {
103
145
  dataSets[basename(file, ".json")] = readJson(join2(dataDir, file), `data/${file}`);
104
146
  }
105
147
  }
148
+ const { partials, missing } = collectPartials(project, meta.engine, [html, settings.header?.html, settings.footer?.html]);
106
149
  return {
107
150
  slug,
108
151
  dir,
@@ -113,7 +156,9 @@ function readTemplate(project, slug) {
113
156
  settings,
114
157
  dataSets,
115
158
  dataSchema: readJson(join2(dir, "schema.json"), "schema.json"),
116
- i18n: readJson(join2(dir, "i18n.json"), "i18n.json")
159
+ i18n: readJson(join2(dir, "i18n.json"), "i18n.json"),
160
+ partials,
161
+ missingPartials: missing
117
162
  };
118
163
  }
119
164
  function defaultData(tpl, name) {
@@ -144,8 +189,22 @@ function writeTemplate(project, slug, meta, version) {
144
189
  writeFileSync2(join2(dir, "data", `${name}.json`), JSON.stringify(data ?? {}, null, 2) + "\n");
145
190
  writeOrRemove(join2(dir, "schema.json"), version.data_schema ? JSON.stringify(version.data_schema, null, 2) + "\n" : "");
146
191
  writeOrRemove(join2(dir, "i18n.json"), version.i18n ? JSON.stringify(version.i18n, null, 2) + "\n" : "");
192
+ writePartials(project, version.partials ?? null);
147
193
  return dir;
148
194
  }
195
+ var partialName = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/;
196
+ function writePartials(project, partials) {
197
+ const changed = [];
198
+ for (const [name, source] of Object.entries(partials ?? {})) {
199
+ if (!partialName.test(name)) continue;
200
+ const path = join2(project.partialsDir, name.includes(".") ? name : `${name}.html`);
201
+ if (readText(path) === source) continue;
202
+ mkdirSync(dirname2(path), { recursive: true });
203
+ writeFileSync2(path, source);
204
+ changed.push(path);
205
+ }
206
+ return changed;
207
+ }
149
208
  function stripHtml(part) {
150
209
  const { html: _html, ...rest } = part;
151
210
  void _html;
@@ -168,12 +227,16 @@ function versionPayload(tpl) {
168
227
  sample_data: sample ?? {},
169
228
  data_sets: named,
170
229
  data_schema: tpl.dataSchema,
171
- i18n: tpl.i18n
230
+ i18n: tpl.i18n,
231
+ // only when the template includes one, so a project without partials sends what it always sent
232
+ ...Object.keys(tpl.partials).length ? { partials: tpl.partials } : {}
172
233
  };
173
234
  }
174
235
  function contentHash(tpl) {
175
236
  const payload = versionPayload(tpl);
176
- return createHash("sha256").update(JSON.stringify([payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n])).digest("hex");
237
+ const parts = [payload.html, payload.css, payload.head, payload.settings, payload.sample_data, payload.data_sets, payload.data_schema, payload.i18n];
238
+ if (payload.partials) parts.push(payload.partials);
239
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
177
240
  }
178
241
  var statePath = (project) => join2(project.root, ".formfeed", "state.json");
179
242
  function readState(project) {
@@ -213,13 +276,13 @@ import {
213
276
  defaultHelpers,
214
277
  defaultLimits,
215
278
  flowDocument,
216
- getEngine,
279
+ getEngine as getEngine2,
217
280
  mergeSettings,
218
281
  pagedDocument,
219
282
  renderVersion
220
283
  } from "@formfeed/engine";
221
284
  function diagnose(tpl, sampleData) {
222
- const engine = getEngine(tpl.meta.engine);
285
+ const engine = getEngine2(tpl.meta.engine);
223
286
  const diagnostics = [];
224
287
  try {
225
288
  engine.compile(tpl.html, { name: tpl.slug });
@@ -241,9 +304,16 @@ function diagnose(tpl, sampleData) {
241
304
  if (!source) continue;
242
305
  for (const d of engine.analyze(source, { sampleData }).diagnostics) diagnostics.push({ ...d, message: `${part}: ${d.message}` });
243
306
  }
307
+ for (const name of tpl.missingPartials)
308
+ diagnostics.push({
309
+ severity: "error",
310
+ code: "missing-partial",
311
+ message: `No partial "${name}" in the project's partials folder`,
312
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }
313
+ });
244
314
  return diagnostics;
245
315
  }
246
- function renderContext(project, tpl) {
316
+ function renderContext(project, tpl, assetBaseUrl) {
247
317
  const settings = mergeSettings(tpl.settings);
248
318
  return {
249
319
  locale: settings.locale ?? "en",
@@ -252,11 +322,12 @@ function renderContext(project, tpl) {
252
322
  partials: partialResolver(project),
253
323
  helpers: defaultHelpers(),
254
324
  limits: defaultLimits,
255
- i18n: tpl.i18n ?? void 0
325
+ i18n: tpl.i18n ?? void 0,
326
+ assetBaseUrl
256
327
  };
257
328
  }
258
329
  async function renderLocal(project, tpl, data, options) {
259
- const ctx = renderContext(project, tpl);
330
+ const ctx = renderContext(project, tpl, options.assetBaseUrl);
260
331
  if (options.locale) ctx.locale = options.locale;
261
332
  return renderVersion(
262
333
  { engine: tpl.meta.engine, html: tpl.html, css: tpl.css, head: tpl.head, settings: mergeSettings(tpl.settings), kind: tpl.meta.kind },
@@ -276,15 +347,93 @@ function previewDocument(tpl, rendered, mode, pagedScriptUrl) {
276
347
  };
277
348
  return mode === "paged" && tpl.meta.kind === "pdf" ? pagedDocument(draft, { pagedScriptUrl }) : flowDocument(draft);
278
349
  }
350
+
351
+ // packages/devkit/src/lib/files.ts
352
+ 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";
355
+
356
+ // packages/api-types/src/lib/assets.ts
357
+ var SEGMENT = "[A-Za-z0-9][A-Za-z0-9._-]*";
358
+ var ASSET_NAME_PATTERN = new RegExp(`^${SEGMENT}(?:/${SEGMENT})*$`);
359
+ var ASSET_MAX_BYTES = 10 * 1024 * 1024;
360
+ var ASSET_CONTENT_TYPES = {
361
+ "image/png": "png",
362
+ "image/jpeg": "jpg",
363
+ "image/webp": "webp",
364
+ "image/gif": "gif",
365
+ "image/svg+xml": "svg",
366
+ "image/avif": "avif",
367
+ "application/pdf": "pdf"
368
+ };
369
+ function isAssetName(name) {
370
+ return name.length > 0 && name.length <= 200 && ASSET_NAME_PATTERN.test(name) && !name.split("/").includes("..");
371
+ }
372
+
373
+ // packages/devkit/src/lib/files.ts
374
+ var byExtension = Object.fromEntries(
375
+ Object.entries(ASSET_CONTENT_TYPES).map(([type, ext]) => [ext, type])
376
+ );
377
+ byExtension["jpeg"] = "image/jpeg";
378
+ function contentTypeFor(name) {
379
+ return byExtension[name.split(".").pop()?.toLowerCase() ?? ""] ?? null;
380
+ }
381
+ function sha256File(bytes) {
382
+ return createHash2("sha256").update(bytes).digest("hex");
383
+ }
384
+ function listLocalFiles(project) {
385
+ const listing = { files: [], skipped: [] };
386
+ if (!existsSync3(project.filesDir)) return listing;
387
+ const walk = (dir) => {
388
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
389
+ if (entry.name.startsWith(".")) continue;
390
+ const path = join3(dir, entry.name);
391
+ if (entry.isDirectory()) {
392
+ walk(path);
393
+ continue;
394
+ }
395
+ if (!entry.isFile()) continue;
396
+ const name = relative(project.filesDir, path).split(sep).join("/");
397
+ const size = statSync2(path).size;
398
+ const contentType = contentTypeFor(name);
399
+ const reason = !isAssetName(name) ? "the name has characters a library name cannot have (letters, digits, dot, dash, underscore)" : !contentType ? "not an image or a PDF" : size === 0 ? "empty" : size > ASSET_MAX_BYTES ? `larger than ${ASSET_MAX_BYTES / 1024 / 1024} MB` : null;
400
+ if (reason) {
401
+ listing.skipped.push({ path, reason });
402
+ continue;
403
+ }
404
+ const bytes = readFileSync3(path);
405
+ listing.files.push({ name, path, bytes: size, sha256: sha256File(bytes), contentType });
406
+ }
407
+ };
408
+ walk(project.filesDir);
409
+ listing.files.sort((a, b) => a.name.localeCompare(b.name));
410
+ return listing;
411
+ }
412
+ function localFilePath(project, name) {
413
+ if (!isAssetName(name)) throw new DevkitError(`"${name}" is not a library file name`);
414
+ const path = resolve2(project.filesDir, ...name.split("/"));
415
+ if (!path.startsWith(resolve2(project.filesDir) + sep)) throw new DevkitError(`"${name}" is not inside the files folder`);
416
+ return path;
417
+ }
418
+ function writeLocalFile(project, name, bytes) {
419
+ const path = localFilePath(project, name);
420
+ mkdirSync2(dirname3(path), { recursive: true });
421
+ writeFileSync3(path, bytes);
422
+ return path;
423
+ }
279
424
  export {
280
425
  DevkitError,
426
+ collectPartials,
281
427
  contentHash,
428
+ contentTypeFor,
282
429
  defaultData,
283
430
  defaultProjectConfig,
284
431
  diagnose,
285
432
  findProject,
433
+ listLocalFiles,
286
434
  listTemplateSlugs,
287
435
  loadProject,
436
+ localFilePath,
288
437
  partialResolver,
289
438
  previewDocument,
290
439
  projectAround,
@@ -293,9 +442,12 @@ export {
293
442
  recordSync,
294
443
  renderContext,
295
444
  renderLocal,
445
+ sha256File,
296
446
  templateDir,
297
447
  titleFromSlug,
298
448
  versionPayload,
449
+ writeLocalFile,
450
+ writePartials,
299
451
  writeProjectConfig,
300
452
  writeState,
301
453
  writeTemplate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formfeed/devkit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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.1.0",
34
- "@formfeed/sdk": "0.1.0"
33
+ "@formfeed/engine": "0.2.0",
34
+ "@formfeed/sdk": "0.2.0"
35
35
  },
36
36
  "keywords": [
37
37
  "formfeed",
package/types/index.d.cts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { DevkitError } from './lib/errors.cjs';
2
2
  export { defaultProjectConfig, findProject, loadProject, projectAround, writeProjectConfig } from './lib/project-config.cjs';
3
3
  export type { Project, ProjectConfig } from './lib/project-config.cjs';
4
- export { contentHash, defaultData, listTemplateSlugs, partialResolver, readState, readTemplate, recordSync, templateDir, titleFromSlug, versionPayload, writeState, writeTemplate, } from './lib/project.cjs';
4
+ export { contentHash, defaultData, listTemplateSlugs, partialResolver, readState, readTemplate, recordSync, templateDir, titleFromSlug, versionPayload, writePartials, writeState, writeTemplate, } from './lib/project.cjs';
5
5
  export type { LocalTemplate, ProjectState, TemplateMeta, TemplateState } from './lib/project.cjs';
6
+ export { collectPartials } from './lib/partials.cjs';
7
+ export type { CollectedPartials } from './lib/partials.cjs';
6
8
  export { diagnose, previewDocument, renderContext, renderLocal } from './lib/local-render.cjs';
9
+ export { contentTypeFor, listLocalFiles, localFilePath, sha256File, writeLocalFile } from './lib/files.cjs';
10
+ export type { LocalFile, LocalFileListing } from './lib/files.cjs';
7
11
  export type { LocalRenderOptions } from './lib/local-render.cjs';
package/types/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { DevkitError } from './lib/errors.js';
2
2
  export { defaultProjectConfig, findProject, loadProject, projectAround, writeProjectConfig } from './lib/project-config.js';
3
3
  export type { Project, ProjectConfig } from './lib/project-config.js';
4
- export { contentHash, defaultData, listTemplateSlugs, partialResolver, readState, readTemplate, recordSync, templateDir, titleFromSlug, versionPayload, writeState, writeTemplate, } from './lib/project.js';
4
+ export { contentHash, defaultData, listTemplateSlugs, partialResolver, readState, readTemplate, recordSync, templateDir, titleFromSlug, versionPayload, writePartials, writeState, writeTemplate, } from './lib/project.js';
5
5
  export type { LocalTemplate, ProjectState, TemplateMeta, TemplateState } from './lib/project.js';
6
+ export { collectPartials } from './lib/partials.js';
7
+ export type { CollectedPartials } from './lib/partials.js';
6
8
  export { diagnose, previewDocument, renderContext, renderLocal } from './lib/local-render.js';
9
+ export { contentTypeFor, listLocalFiles, localFilePath, sha256File, writeLocalFile } from './lib/files.js';
10
+ export type { LocalFile, LocalFileListing } from './lib/files.js';
7
11
  export type { LocalRenderOptions } from './lib/local-render.js';
@@ -0,0 +1,32 @@
1
+ import type { Project } from './project-config.cjs';
2
+ /**
3
+ * The project's `files/` folder: the local copy of the workspace file library (spec 04 §2.3). A
4
+ * file's path below the folder is its library name, so `files/brand/logo.png` is what a template
5
+ * reaches with `asset('brand/logo.png')`, locally in `formfeed dev` and remotely after `files push`.
6
+ */
7
+ export interface LocalFile {
8
+ /** The library name: the path below the files folder, with `/`. */
9
+ name: string;
10
+ path: string;
11
+ bytes: number;
12
+ sha256: string;
13
+ /** From the extension; null for a type the library does not take. */
14
+ contentType: string | null;
15
+ }
16
+ export interface LocalFileListing {
17
+ files: LocalFile[];
18
+ /** Paths the library would refuse: an unusable name, an unknown type, too large or empty. */
19
+ skipped: Array<{
20
+ path: string;
21
+ reason: string;
22
+ }>;
23
+ }
24
+ /** The content type the library stores for a name, going by its extension. */
25
+ export declare function contentTypeFor(name: string): string | null;
26
+ export declare function sha256File(bytes: Uint8Array): string;
27
+ /** Every file below the files folder, with the ones the library would refuse listed apart. */
28
+ export declare function listLocalFiles(project: Project): LocalFileListing;
29
+ /** The path of a library name inside the files folder; refuses a name that would leave it. */
30
+ export declare function localFilePath(project: Project, name: string): string;
31
+ /** Writes a pulled file below the files folder, creating folders for names like `brand/logo.png`. */
32
+ export declare function writeLocalFile(project: Project, name: string, bytes: Uint8Array): string;
@@ -0,0 +1,32 @@
1
+ import type { Project } from './project-config.js';
2
+ /**
3
+ * The project's `files/` folder: the local copy of the workspace file library (spec 04 §2.3). A
4
+ * file's path below the folder is its library name, so `files/brand/logo.png` is what a template
5
+ * reaches with `asset('brand/logo.png')`, locally in `formfeed dev` and remotely after `files push`.
6
+ */
7
+ export interface LocalFile {
8
+ /** The library name: the path below the files folder, with `/`. */
9
+ name: string;
10
+ path: string;
11
+ bytes: number;
12
+ sha256: string;
13
+ /** From the extension; null for a type the library does not take. */
14
+ contentType: string | null;
15
+ }
16
+ export interface LocalFileListing {
17
+ files: LocalFile[];
18
+ /** Paths the library would refuse: an unusable name, an unknown type, too large or empty. */
19
+ skipped: Array<{
20
+ path: string;
21
+ reason: string;
22
+ }>;
23
+ }
24
+ /** The content type the library stores for a name, going by its extension. */
25
+ export declare function contentTypeFor(name: string): string | null;
26
+ export declare function sha256File(bytes: Uint8Array): string;
27
+ /** Every file below the files folder, with the ones the library would refuse listed apart. */
28
+ export declare function listLocalFiles(project: Project): LocalFileListing;
29
+ /** The path of a library name inside the files folder; refuses a name that would leave it. */
30
+ export declare function localFilePath(project: Project, name: string): string;
31
+ /** Writes a pulled file below the files folder, creating folders for names like `brand/logo.png`. */
32
+ export declare function writeLocalFile(project: Project, name: string, bytes: Uint8Array): string;
@@ -3,11 +3,18 @@ import type { Project } from './project-config.cjs';
3
3
  import { type LocalTemplate } from './project.cjs';
4
4
  /** The same diagnostics the editor shows: analysis of the body against the sample data plus compile errors. */
5
5
  export declare function diagnose(tpl: LocalTemplate, sampleData: unknown): Diagnostic[];
6
- export declare function renderContext(project: Project, tpl: LocalTemplate): RenderContext;
6
+ /**
7
+ * `assetBaseUrl` is where `asset()` and relative image paths resolve: the dev server's `/files`
8
+ * locally, the workspace library's CDN base when the document goes to the API. Without it the
9
+ * references stay relative, which keeps snapshots independent of any host.
10
+ */
11
+ export declare function renderContext(project: Project, tpl: LocalTemplate, assetBaseUrl?: string): RenderContext;
7
12
  export interface LocalRenderOptions {
8
13
  mode: 'preview' | 'print';
9
14
  locale?: string;
10
15
  vendor?: AssembleVendor;
16
+ /** Where `asset()` and relative image paths resolve (see `renderContext`). */
17
+ assetBaseUrl?: string;
11
18
  }
12
19
  /** Assembles the complete document with the shared engine; no browser involved. */
13
20
  export declare function renderLocal(project: Project, tpl: LocalTemplate, data: unknown, options: LocalRenderOptions): Promise<RenderedDocument>;
@@ -3,11 +3,18 @@ import type { Project } from './project-config.js';
3
3
  import { type LocalTemplate } from './project.js';
4
4
  /** The same diagnostics the editor shows: analysis of the body against the sample data plus compile errors. */
5
5
  export declare function diagnose(tpl: LocalTemplate, sampleData: unknown): Diagnostic[];
6
- export declare function renderContext(project: Project, tpl: LocalTemplate): RenderContext;
6
+ /**
7
+ * `assetBaseUrl` is where `asset()` and relative image paths resolve: the dev server's `/files`
8
+ * locally, the workspace library's CDN base when the document goes to the API. Without it the
9
+ * references stay relative, which keeps snapshots independent of any host.
10
+ */
11
+ export declare function renderContext(project: Project, tpl: LocalTemplate, assetBaseUrl?: string): RenderContext;
7
12
  export interface LocalRenderOptions {
8
13
  mode: 'preview' | 'print';
9
14
  locale?: string;
10
15
  vendor?: AssembleVendor;
16
+ /** Where `asset()` and relative image paths resolve (see `renderContext`). */
17
+ assetBaseUrl?: string;
11
18
  }
12
19
  /** Assembles the complete document with the shared engine; no browser involved. */
13
20
  export declare function renderLocal(project: Project, tpl: LocalTemplate, data: unknown, options: LocalRenderOptions): Promise<RenderedDocument>;
@@ -0,0 +1,17 @@
1
+ import { type EngineId } from '@formfeed/engine';
2
+ import type { Project } from './project-config.cjs';
3
+ /**
4
+ * The partials a template includes, read from the project's `partials/` folder. They travel with
5
+ * the version (`formfeed templates push`), so a published template resolves `{% include %}` in the
6
+ * API and the editor, not only in the CLI.
7
+ *
8
+ * Includes nest, so every partial is analysed in turn; a name that has no file is reported instead
9
+ * of failing, and `validate` and `push` show it.
10
+ */
11
+ export interface CollectedPartials {
12
+ /** name → source, the shape the API stores. */
13
+ partials: Record<string, string>;
14
+ /** Names no file in the partials folder matches. */
15
+ missing: string[];
16
+ }
17
+ export declare function collectPartials(project: Project, engine: EngineId, sources: Array<string | undefined>): CollectedPartials;
@@ -0,0 +1,17 @@
1
+ import { type EngineId } from '@formfeed/engine';
2
+ import type { Project } from './project-config.js';
3
+ /**
4
+ * The partials a template includes, read from the project's `partials/` folder. They travel with
5
+ * the version (`formfeed templates push`), so a published template resolves `{% include %}` in the
6
+ * API and the editor, not only in the CLI.
7
+ *
8
+ * Includes nest, so every partial is analysed in turn; a name that has no file is reported instead
9
+ * of failing, and `validate` and `push` show it.
10
+ */
11
+ export interface CollectedPartials {
12
+ /** name → source, the shape the API stores. */
13
+ partials: Record<string, string>;
14
+ /** Names no file in the partials folder matches. */
15
+ missing: string[];
16
+ }
17
+ export declare function collectPartials(project: Project, engine: EngineId, sources: Array<string | undefined>): CollectedPartials;
@@ -5,6 +5,8 @@ export interface ProjectConfig {
5
5
  region?: 'eu' | 'us';
6
6
  templatesDir: string;
7
7
  partialsDir: string;
8
+ /** The local copy of the workspace file library (`formfeed files push|pull`). */
9
+ filesDir: string;
8
10
  engine: EngineId;
9
11
  ignore: string[];
10
12
  }
@@ -15,6 +17,7 @@ export interface Project {
15
17
  config: ProjectConfig;
16
18
  templatesDir: string;
17
19
  partialsDir: string;
20
+ filesDir: string;
18
21
  }
19
22
  export declare const defaultProjectConfig: ProjectConfig;
20
23
  /** Walks up from `cwd` to the nearest `formfeed.json`. */
@@ -5,6 +5,8 @@ export interface ProjectConfig {
5
5
  region?: 'eu' | 'us';
6
6
  templatesDir: string;
7
7
  partialsDir: string;
8
+ /** The local copy of the workspace file library (`formfeed files push|pull`). */
9
+ filesDir: string;
8
10
  engine: EngineId;
9
11
  ignore: string[];
10
12
  }
@@ -15,6 +17,7 @@ export interface Project {
15
17
  config: ProjectConfig;
16
18
  templatesDir: string;
17
19
  partialsDir: string;
20
+ filesDir: string;
18
21
  }
19
22
  export declare const defaultProjectConfig: ProjectConfig;
20
23
  /** Walks up from `cwd` to the nearest `formfeed.json`. */
@@ -27,6 +27,10 @@ export interface LocalTemplate {
27
27
  dataSets: Record<string, unknown>;
28
28
  dataSchema: Record<string, unknown> | null;
29
29
  i18n: Record<string, Record<string, string>> | null;
30
+ /** Partials the template includes, read from the project's folder; they travel with the version. */
31
+ partials: Record<string, string>;
32
+ /** Included names without a file; `validate` reports them and `push` refuses them. */
33
+ missingPartials: string[];
30
34
  }
31
35
  export interface TemplateState {
32
36
  /** Checksum of the remote version the folder was pulled from or pushed as. */
@@ -50,9 +54,17 @@ export declare function defaultData(tpl: LocalTemplate, name?: string): {
50
54
  };
51
55
  export declare function writeTemplate(project: Project, slug: string, meta: TemplateMeta, version: Pick<TemplateVersion, 'html' | 'css' | 'head' | 'settings' | 'sample_data' | 'data_schema' | 'i18n'> & {
52
56
  data_sets?: Record<string, unknown> | null;
57
+ partials?: Record<string, string> | null;
53
58
  }): string;
59
+ /**
60
+ * Writes the partials of a pulled version into the project's folder (`name` or `name.html`, the
61
+ * two spellings `partialResolver` reads). Returns the files whose content changed, so `pull` can
62
+ * say that a partial shared with another template was replaced.
63
+ */
64
+ export declare function writePartials(project: Project, partials: Record<string, string> | null): string[];
54
65
  /** Files of a push request: what the API's TemplateVersionCreate takes. */
55
66
  export declare function versionPayload(tpl: LocalTemplate): {
67
+ partials?: Record<string, string> | undefined;
56
68
  html: string;
57
69
  css: string;
58
70
  head: string;
@@ -27,6 +27,10 @@ export interface LocalTemplate {
27
27
  dataSets: Record<string, unknown>;
28
28
  dataSchema: Record<string, unknown> | null;
29
29
  i18n: Record<string, Record<string, string>> | null;
30
+ /** Partials the template includes, read from the project's folder; they travel with the version. */
31
+ partials: Record<string, string>;
32
+ /** Included names without a file; `validate` reports them and `push` refuses them. */
33
+ missingPartials: string[];
30
34
  }
31
35
  export interface TemplateState {
32
36
  /** Checksum of the remote version the folder was pulled from or pushed as. */
@@ -50,9 +54,17 @@ export declare function defaultData(tpl: LocalTemplate, name?: string): {
50
54
  };
51
55
  export declare function writeTemplate(project: Project, slug: string, meta: TemplateMeta, version: Pick<TemplateVersion, 'html' | 'css' | 'head' | 'settings' | 'sample_data' | 'data_schema' | 'i18n'> & {
52
56
  data_sets?: Record<string, unknown> | null;
57
+ partials?: Record<string, string> | null;
53
58
  }): string;
59
+ /**
60
+ * Writes the partials of a pulled version into the project's folder (`name` or `name.html`, the
61
+ * two spellings `partialResolver` reads). Returns the files whose content changed, so `pull` can
62
+ * say that a partial shared with another template was replaced.
63
+ */
64
+ export declare function writePartials(project: Project, partials: Record<string, string> | null): string[];
54
65
  /** Files of a push request: what the API's TemplateVersionCreate takes. */
55
66
  export declare function versionPayload(tpl: LocalTemplate): {
67
+ partials?: Record<string, string> | undefined;
56
68
  html: string;
57
69
  css: string;
58
70
  head: string;