@typfolio/core 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/README.md CHANGED
@@ -41,11 +41,22 @@ The font name in the template must match the supplied font. Fonts and WASM are l
41
41
  - `copyAssets` (a `createCompiler` option, default `true`): by default `compile` copies every asset when it captures the request, so mutating, reusing, or detaching a buffer after calling `compile` cannot change the result. `copyAssets: false` mounts the caller's `Uint8Array` views as they are and skips that copy, which matters for multi-megabyte assets. The contract then is: the caller must not mutate, detach, or reuse an asset's buffer until the promise returned by `compile` settles. Validation of paths, types, reserved paths and JSON input is identical either way. `@typfolio/workers` sets it inside its workers, where the request arrives as a private transferred copy; set it yourself only when you own the buffers for the duration of the call.
42
42
  - `pdf`: zero or one standard, tags, and creation timestamp in Unix seconds. Read `compiler.capabilities` for current support. Unsupported options are rejected.
43
43
  - `compile(request, { signal })`: Promise of PDF bytes, diagnostics, and title metadata.
44
+ - A template made with `defineTemplate({ entrypoint?, files, input?, partials? })` takes `{ template, input, partials, assets?, pdf? }`, typed by its schemas (`TemplateRequest<typeof template>`). `defineInput(schema, path)` pairs a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, …) with the path of its JSON Schema file; `definePartial({ files, input, partials? })` is Typst code with runtime data of its own. Each piece of data is checked against its schema before compiling (`INVALID_INPUT`, with the issues; an asynchronous schema is awaited) and mounted at its schema's path, where its file reads it with `input(json.with(...))`. Such a template compiles only the files it declares; a file it imports without declaring it fails with a message that says what to do. See `docs/template-authoring.md`.
45
+ - `createAttachments()`: `add(bytes, extension)` returns a path such as `/attachments/0.png` to put in the data, and `assets` holds the files for the request. The same bytes added twice share one path.
44
46
  - `dispose()`: idempotent; stops new jobs and waits for accepted work.
45
47
 
48
+ `createLazyCompiler(options)` takes the same options but is synchronous and returns a `PdfClient`: nothing is downloaded until its first `compile`, which loads the WASM and fonts once for every later call. A failed load is not kept, so the next call tries again, and calls run one at a time in order, so callers never see `BUSY`. Create it once at module level and pass it to `PdfProvider`, or as `usePdf`'s `client`:
49
+
50
+ ```ts
51
+ import { createLazyCompiler } from '@typfolio/core';
52
+ import wasmUrl from '@typfolio/wasm/compiler.wasm?url';
53
+
54
+ export const pdfClient = createLazyCompiler({ wasm: wasmUrl, fonts: ['/fonts/report.otf'] });
55
+ ```
56
+
46
57
  One active call is allowed per compiler. Concurrent calls reject with `TypstPdfError.code === 'BUSY'`. Core does not schedule jobs. A signal can cancel before synchronous execution; hard cancellation requires a worker. Upstream WASM initialization is shared per JS realm: reuse the same `wasm` value for multiple compilers in that realm.
47
58
 
48
- `TypstPdfError` provides `code`, `message`, `diagnostics`, and (when relevant) `cause`. Compile diagnostic ranges use upstream zero-based line/column notation. Export diagnostics can have only text.
59
+ `TypstPdfError` provides `code`, `message`, `diagnostics`, and (when relevant) `cause`. A diagnostic Typfolio recognises carries a `code` of its own (`file-not-found` with `file`, `missing-glyph` with `text`) and a one-line message that links to what to do; switch on the codes, not the messages. [docs/errors.md](../../docs/errors.md) lists them all. Compile diagnostic ranges use upstream zero-based line/column notation. Export diagnostics can have only text.
49
60
 
50
61
  Remote Typst packages (everything except the bundled one), page ranges, experimental feature flags, and simultaneous PDF standards are not implemented. The pinned upstream builder requires dynamic function construction, so a strict CSP that forbids `unsafe-eval` needs a future binding change. Importing the JS module alone does not instantiate WASM or fetch resources.
51
62
 
@@ -58,7 +69,7 @@ Remote Typst packages (everything except the bundled one), page ranges, experime
58
69
  #let data = input(json.with("./report.schema.json"))
59
70
  ```
60
71
 
61
- Under Typfolio `input()` returns `request.input` and never reads the schema. In an editor (where nothing sets `sys.inputs`) it imports the sample builder from `sample.typ`, inside the function body, and builds a sample from the JSON Schema so completion works; `--input typfolio-sample=minimal` previews the sparse sample instead of the default `full` one. The runtime embeds only `lib.typ`. `compiler.capabilities.typstPackages` is `['@local/typfolio:0.1.0']`; any other `@namespace/name:version` import fails with a `COMPILATION` error that names the supported package. The directory layout is the one Tinymist expects for `--package-path`; see `docs/template-authoring.md` for the editor setting. The shipped `.typ`/`.toml` files are the source of truth: `tools/generate-typst-package.mjs` (the first step of `npm run build`) generates `src/typst-package.generated.ts` (`typst.toml` and `lib.typ`, the runtime module) and `packages/dev-tools/src/sample-source.generated.ts` (`sample.typ`, imported only by `@typfolio/dev-tools`). Neither is edited by hand.
72
+ Under Typfolio `input()` returns `request.input` and never reads the schema; in a template made with `defineTemplate`, it returns the data mounted at the schema's path. In an editor (where nothing sets `sys.inputs`) it imports the sample builder from `sample.typ`, inside the function body, and builds a sample from the JSON Schema so completion works; `--input typfolio-sample=minimal` previews the sparse sample instead of the default `full` one. The runtime embeds only `lib.typ`. `compiler.capabilities.typstPackages` is `['@local/typfolio:0.1.0']`; any other `@namespace/name:version` import fails with a `COMPILATION` error that names the supported package. The directory layout is the one Tinymist expects for `--package-path`; see `docs/template-authoring.md` for the editor setting. The shipped `.typ`/`.toml` files are the source of truth: `tools/generate-typst-package.ts` (the first step of `npm run build`) generates `src/typst-package.generated.ts` (`typst.toml` and `lib.typ`, the runtime module) and `packages/dev-tools/src/sample-source.generated.ts` (`sample.typ`, imported only by `@typfolio/dev-tools`). Neither is edited by hand.
62
73
 
63
74
  ## Dev-time tools
64
75
 
@@ -69,8 +80,8 @@ Tools used while building and testing templates, not while serving requests, liv
69
80
  Browser-only helpers live behind separate subpath entries so Node, worker, and server code that imports `@typfolio/core` never loads DOM code. The main entry exports none of them, and none touch browser globals at import time.
70
81
 
71
82
  - `@typfolio/core/capture`: `captureProps`, `captureAssets`, `TypfolioCaptureError`, and the adapter and result types.
72
- - `@typfolio/core/capture/svg`: `svgCapture`, a dependency-free adapter that serializes one inline SVG and preserves vector output.
73
- - `@typfolio/core/capture/dom`: `domPngCapture`, an opt-in PNG fallback for arbitrary DOM. It loads `html2canvas` by dynamic import only when it first runs. `html2canvas` is an optional peer dependency: install it only if you use this adapter. If it is missing when the default rasterizer runs, the capture rejects with `CAPTURE_FAILED` and the import error as `cause`; passing your own `rasterize` never loads it.
83
+ - `@typfolio/core/capture/svg`: `svgCapture`, a dependency-free adapter that serializes one inline SVG and preserves vector output, and `serializeSvg(svg, { fontFamily?, beforeSerialize? })`, the same serializer for an SVG element you already hold. It inlines the computed paint and text styles (chart libraries often style SVGs from page CSS), rewrites `url(#id)` references to be local, writes the drawn size, and returns the file as bytes. The SVG must be in a document and have a rendered size. `beforeSerialize(svg)` runs on the copy just before it is written (remove a tooltip, add a `<title>`); the live SVG is never passed to it. `@typfolio/react`'s `usePdf` uses it for charts.
84
+ - `@typfolio/core/capture/dom`: `domPngCapture`, an opt-in PNG fallback for arbitrary DOM. It loads `html2canvas` by dynamic import only when it first runs. `html2canvas` is an optional peer dependency: install it only if you use this adapter. If it is missing when the default rasterizer runs, the capture rejects with `CAPTURE_FAILED` and the import error as `cause`; passing your own `rasterize` never loads it. `beforeRasterize(element)` runs on html2canvas's copy of the element before it is drawn, never on the live page.
74
85
 
75
86
  Each marked region owns its adapter `kind` and its alt text. `captureProps` returns the data attributes (`data-typfolio-capture`, `data-typfolio-capture-kind`, and `data-typfolio-capture-alt` or `data-typfolio-capture-decorative`) to spread onto any element. They add no role or accessible name. `@typfolio/react/capture` offers a `CaptureRegion` component over the same attributes; plain DOM and other frameworks can spread `captureProps` directly.
76
87
 
@@ -89,7 +100,7 @@ for (const [name, value] of Object.entries(captureProps({
89
100
 
90
101
  const captured = await captureAssets(document.querySelector('#report'), {
91
102
  adapters: {
92
- svg: svgCapture({ removeSelectors: ['.chart-tooltip'] }),
103
+ svg: svgCapture({ beforeSerialize: svg => svg.querySelector('.chart-tooltip')?.remove() }),
93
104
  png: domPngCapture({ pixelRatio: 2, backgroundColor: '#fff' }),
94
105
  },
95
106
  require: ['performance-chart'],
@@ -5,7 +5,8 @@ export interface DomPngRasterizeOptions {
5
5
  readonly useCORS: boolean;
6
6
  readonly imageTimeoutMs?: number;
7
7
  readonly logging: boolean;
8
- readonly removeSelectors: readonly string[];
8
+ /** Runs on the rasterizer's clone of the element, never on the live page. */
9
+ readonly beforeRasterize?: (element: HTMLElement) => void;
9
10
  }
10
11
  export type DomPngRasterizer = (element: HTMLElement, options: DomPngRasterizeOptions) => Promise<HTMLCanvasElement>;
11
12
  export interface DomPngCaptureOptions {
@@ -16,8 +17,12 @@ export interface DomPngCaptureOptions {
16
17
  readonly useCORS?: boolean;
17
18
  readonly imageTimeoutMs?: number;
18
19
  readonly logging?: boolean;
19
- readonly removeSelectors?: readonly string[];
20
+ /**
21
+ * Runs on html2canvas's clone of the element before it is drawn: remove transient parts such
22
+ * as tooltips or focus rings. The live element on the page is never passed here.
23
+ */
24
+ readonly beforeRasterize?: (element: HTMLElement) => void;
20
25
  /** Custom-host and test escape hatch. */
21
26
  readonly rasterize?: DomPngRasterizer;
22
27
  }
23
- export declare function domPngCapture({ pixelRatio, backgroundColor, useCORS, imageTimeoutMs, logging, removeSelectors, rasterize, }?: DomPngCaptureOptions): CaptureAdapter;
28
+ export declare function domPngCapture({ pixelRatio, backgroundColor, useCORS, imageTimeoutMs, logging, beforeRasterize, rasterize, }?: DomPngCaptureOptions): CaptureAdapter;
@@ -47,10 +47,7 @@ async function defaultRasterizer(element, options, context) {
47
47
  imageTimeout: options.imageTimeoutMs,
48
48
  logging: options.logging,
49
49
  onclone(_document, clonedElement) {
50
- for (const selector of options.removeSelectors) {
51
- for (const removable of clonedElement.querySelectorAll(selector))
52
- removable.remove();
53
- }
50
+ options.beforeRasterize?.(clonedElement);
54
51
  },
55
52
  scale: options.pixelRatio,
56
53
  useCORS: options.useCORS,
@@ -77,17 +74,16 @@ function pngBytes(canvas, context) {
77
74
  }
78
75
  });
79
76
  }
80
- export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, imageTimeoutMs, logging = false, removeSelectors = [], rasterize, } = {}) {
77
+ export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, imageTimeoutMs, logging = false, beforeRasterize, rasterize, } = {}) {
81
78
  if (pixelRatio !== undefined && (!Number.isFinite(pixelRatio) || pixelRatio <= 0)) {
82
79
  throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM pixelRatio must be greater than zero');
83
80
  }
84
81
  if (imageTimeoutMs !== undefined && (!Number.isInteger(imageTimeoutMs) || imageTimeoutMs < 0)) {
85
82
  throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM imageTimeoutMs must be a non-negative integer');
86
83
  }
87
- if (removeSelectors.some(value => !value.trim())) {
88
- throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM removeSelectors must be non-empty');
84
+ if (beforeRasterize !== undefined && typeof beforeRasterize !== 'function') {
85
+ throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM beforeRasterize must be a function');
89
86
  }
90
- const selectors = Object.freeze([...removeSelectors]);
91
87
  return Object.freeze({
92
88
  name: 'DOM PNG capture',
93
89
  extensions: Object.freeze(['.png']),
@@ -107,7 +103,7 @@ export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, im
107
103
  useCORS,
108
104
  imageTimeoutMs,
109
105
  logging,
110
- removeSelectors: selectors,
106
+ beforeRasterize,
111
107
  };
112
108
  const canvas = rasterize === undefined
113
109
  ? await defaultRasterizer(element, rasterizeOptions, context)
@@ -1,10 +1,23 @@
1
1
  import { type CaptureAdapter } from '../capture.js';
2
- export interface SvgCaptureOptions {
2
+ export interface SvgSerializeOptions {
3
+ /** Overrides text/tspan fonts only in the serialized clone. */
4
+ readonly fontFamily?: string;
5
+ /**
6
+ * Runs on the clone just before it is serialized, after its styles are inlined and its size
7
+ * is set: remove transient parts such as tooltips, add a `<title>`, rewrite labels. The live
8
+ * SVG on the page is never passed here.
9
+ */
10
+ readonly beforeSerialize?: (svg: SVGSVGElement) => void;
11
+ }
12
+ export interface SvgCaptureOptions extends SvgSerializeOptions {
3
13
  /** Selects exactly one SVG inside the marked region. Defaults to `svg`. */
4
14
  readonly selector?: string;
5
- /** Overrides text/tspan fonts only in the exported clone. */
6
- readonly fontFamily?: string;
7
- /** Removes transient descendants such as chart tooltips from the clone. */
8
- readonly removeSelectors?: readonly string[];
9
15
  }
10
- export declare function svgCapture({ selector, fontFamily, removeSelectors, }?: SvgCaptureOptions): CaptureAdapter;
16
+ /**
17
+ * Serializes a drawn SVG as a standalone file: a clone with the computed paint and text styles
18
+ * inlined (chart libraries often style SVGs from document CSS, which a standalone file does not
19
+ * have), local `url(#id)` references, and the size it was drawn at. The SVG must be in a
20
+ * document and have a rendered size.
21
+ */
22
+ export declare function serializeSvg(svg: SVGSVGElement, options?: SvgSerializeOptions): Uint8Array;
23
+ export declare function svgCapture({ selector, fontFamily, beforeSerialize, }?: SvgCaptureOptions): CaptureAdapter;
@@ -47,69 +47,88 @@ function findSvg(element, selector, context) {
47
47
  }
48
48
  return matches[0];
49
49
  }
50
- export function svgCapture({ selector = 'svg', fontFamily, removeSelectors = [], } = {}) {
51
- if (!selector.trim()) {
52
- throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG selector must be non-empty');
53
- }
50
+ function checkSerializeOptions({ fontFamily, beforeSerialize }) {
54
51
  if (fontFamily !== undefined && !fontFamily.trim()) {
55
52
  throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG fontFamily must be non-empty');
56
53
  }
57
- if (removeSelectors.some(value => !value.trim())) {
58
- throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG removeSelectors must be non-empty');
54
+ if (beforeSerialize !== undefined && typeof beforeSerialize !== 'function') {
55
+ throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG beforeSerialize must be a function');
56
+ }
57
+ }
58
+ /**
59
+ * Serializes a drawn SVG as a standalone file: a clone with the computed paint and text styles
60
+ * inlined (chart libraries often style SVGs from document CSS, which a standalone file does not
61
+ * have), local `url(#id)` references, and the size it was drawn at. The SVG must be in a
62
+ * document and have a rendered size.
63
+ */
64
+ export function serializeSvg(svg, options = {}) {
65
+ checkSerializeOptions(options);
66
+ const { fontFamily, beforeSerialize } = options;
67
+ const view = svg.ownerDocument.defaultView;
68
+ if (view === null) {
69
+ throw new TypfolioCaptureError('NOT_READY', 'The SVG document is no longer available');
59
70
  }
60
- const selectors = [...removeSelectors];
71
+ const bounds = svg.getBoundingClientRect();
72
+ if (!(bounds.width > 0) || !(bounds.height > 0)) {
73
+ throw new TypfolioCaptureError('NOT_READY', 'The SVG has no rendered size');
74
+ }
75
+ const cloned = svg.cloneNode(true);
76
+ if (!isSvg(cloned)) {
77
+ throw new TypfolioCaptureError('CAPTURE_FAILED', 'The SVG could not be cloned');
78
+ }
79
+ const originals = [svg, ...svg.querySelectorAll('*')];
80
+ const copies = [cloned, ...cloned.querySelectorAll('*')];
81
+ originals.forEach((original, index) => {
82
+ const copy = copies[index];
83
+ const computed = view.getComputedStyle(original);
84
+ const isText = original.localName === 'text' || original.localName === 'tspan';
85
+ if (hasStyle(copy)) {
86
+ for (const property of svgStyles) {
87
+ const value = computed.getPropertyValue(property);
88
+ if (value)
89
+ copy.style.setProperty(property, value);
90
+ }
91
+ if (fontFamily && isText)
92
+ copy.style.setProperty('font-family', fontFamily);
93
+ }
94
+ if (fontFamily && isText)
95
+ copy.setAttribute('font-family', fontFamily);
96
+ for (const attribute of copy.attributes) {
97
+ copy.setAttribute(attribute.name, attribute.value.replace(/url\(["']?[^)#]*#([^)"' ]+)["']?\)/g, 'url(#$1)'));
98
+ }
99
+ });
100
+ for (const removable of cloned.querySelectorAll('script, foreignObject'))
101
+ removable.remove();
102
+ cloned.setAttribute('width', String(bounds.width));
103
+ cloned.setAttribute('height', String(bounds.height));
104
+ if (!cloned.hasAttribute('viewBox')) {
105
+ cloned.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height}`);
106
+ }
107
+ beforeSerialize?.(cloned);
108
+ // The serializer declares the SVG namespace itself. An `xmlns` attribute on the clone (chart
109
+ // libraries such as Highcharts set one) can be written a second time: invalid XML.
110
+ cloned.removeAttribute('xmlns');
111
+ return encoder.encode(new view.XMLSerializer().serializeToString(cloned));
112
+ }
113
+ export function svgCapture({ selector = 'svg', fontFamily, beforeSerialize, } = {}) {
114
+ if (!selector.trim()) {
115
+ throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG selector must be non-empty');
116
+ }
117
+ const serializeOptions = { fontFamily, beforeSerialize };
118
+ checkSerializeOptions(serializeOptions);
61
119
  return Object.freeze({
62
120
  name: 'SVG capture',
63
121
  extensions: Object.freeze(['.svg']),
64
122
  capture(element, context) {
65
123
  const svg = findSvg(element, selector, context);
66
- const view = svg.ownerDocument.defaultView;
67
- if (view === null) {
124
+ if (svg.ownerDocument.defaultView === null) {
68
125
  throw new TypfolioCaptureError('NOT_READY', 'The SVG document is no longer available', context.captureId, context.path);
69
126
  }
70
127
  const bounds = svg.getBoundingClientRect();
71
128
  if (!(bounds.width > 0) || !(bounds.height > 0)) {
72
129
  throw new TypfolioCaptureError('NOT_READY', `Capture ${context.captureId} has no rendered size`, context.captureId, context.path);
73
130
  }
74
- const cloned = svg.cloneNode(true);
75
- if (!isSvg(cloned)) {
76
- throw new TypfolioCaptureError('CAPTURE_FAILED', `Capture ${context.captureId} could not clone its SVG`, context.captureId, context.path);
77
- }
78
- const originals = [svg, ...svg.querySelectorAll('*')];
79
- const copies = [cloned, ...cloned.querySelectorAll('*')];
80
- originals.forEach((original, index) => {
81
- const copy = copies[index];
82
- const computed = view.getComputedStyle(original);
83
- if (hasStyle(copy)) {
84
- for (const property of svgStyles) {
85
- const value = computed.getPropertyValue(property);
86
- if (value)
87
- copy.style.setProperty(property, value);
88
- }
89
- if (fontFamily && (original.localName === 'text' || original.localName === 'tspan')) {
90
- copy.style.setProperty('font-family', fontFamily);
91
- }
92
- }
93
- if (fontFamily && (original.localName === 'text' || original.localName === 'tspan')) {
94
- copy.setAttribute('font-family', fontFamily);
95
- }
96
- for (const attribute of copy.attributes) {
97
- copy.setAttribute(attribute.name, attribute.value.replace(/url\(["']?[^)#]*#([^)"' ]+)["']?\)/g, 'url(#$1)'));
98
- }
99
- });
100
- for (const removable of cloned.querySelectorAll('script, foreignObject'))
101
- removable.remove();
102
- for (const removableSelector of selectors) {
103
- for (const removable of cloned.querySelectorAll(removableSelector))
104
- removable.remove();
105
- }
106
- cloned.setAttribute('xmlns', svgNamespace);
107
- cloned.setAttribute('width', String(bounds.width));
108
- cloned.setAttribute('height', String(bounds.height));
109
- if (!cloned.hasAttribute('viewBox')) {
110
- cloned.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height}`);
111
- }
112
- return encoder.encode(new view.XMLSerializer().serializeToString(cloned));
131
+ return serializeSvg(svg, serializeOptions);
113
132
  },
114
133
  });
115
134
  }
@@ -0,0 +1,156 @@
1
+ import { type PdfOptions, type PdfRequest } from './types.js';
2
+ /**
3
+ * The Standard Schema interface (https://standardschema.dev), declared here so core depends on no
4
+ * schema library: Zod, Valibot, ArkType and others implement it.
5
+ */
6
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
7
+ readonly '~standard': {
8
+ readonly version: 1;
9
+ readonly vendor: string;
10
+ readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
11
+ readonly types?: {
12
+ readonly input: Input;
13
+ readonly output: Output;
14
+ } | undefined;
15
+ };
16
+ }
17
+ export type StandardSchemaResult<Output> = {
18
+ readonly value: Output;
19
+ readonly issues?: undefined;
20
+ } | {
21
+ readonly issues: readonly StandardSchemaIssue[];
22
+ };
23
+ export interface StandardSchemaIssue {
24
+ readonly message: string;
25
+ readonly path?: readonly (PropertyKey | {
26
+ readonly key: PropertyKey;
27
+ })[] | undefined;
28
+ }
29
+ /** What a schema accepts: the data a caller passes. */
30
+ export type InferSchemaInput<S> = S extends StandardSchemaV1<infer Input, unknown> ? Input : unknown;
31
+ /**
32
+ * A runtime input of a template or a partial: the data, and where its JSON Schema file sits in the
33
+ * template's file tree. The Typst file reads it with `input(json.with("./<file>.schema.json"))`: in
34
+ * an editor that file is the schema (for a sample), and when Typfolio compiles, the data is mounted
35
+ * at the same path. The path is the input's identity: nothing else names it.
36
+ */
37
+ export interface TemplateInput<T = unknown> {
38
+ /** Virtual path of the schema file, relative to the template root, e.g. `common/header.schema.json`. */
39
+ readonly path: string;
40
+ /** Validates the data before compiling, and types it. Optional. */
41
+ readonly schema?: StandardSchemaV1<T, unknown>;
42
+ /** Type-only. */
43
+ readonly '~type'?: T;
44
+ }
45
+ /** Pairs a schema with the virtual path of its JSON Schema file. */
46
+ export declare function defineInput<S extends StandardSchemaV1>(schema: S, path: string): TemplateInput<InferSchemaInput<S>>;
47
+ /** Typst code that takes runtime data of its own: its files, its input, and the partials it uses. */
48
+ export interface PartialDefinition<T = unknown, P extends PartialsMap = PartialsMap> {
49
+ readonly kind: 'typfolio-partial';
50
+ readonly files: Readonly<Record<string, string>>;
51
+ readonly input: TemplateInput<T>;
52
+ readonly partials: P;
53
+ }
54
+ export type PartialsMap = Readonly<Record<string, PartialDefinition<any, any>>>;
55
+ type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends (arg: infer I) => void ? I : never;
56
+ /** The data a map of partials needs, nested partials included, by key. */
57
+ export type PartialsData<P extends PartialsMap> = {
58
+ [K in keyof P]: P[K] extends PartialDefinition<infer T, PartialsMap> ? T : never;
59
+ } & UnionToIntersection<{
60
+ [K in keyof P]: P[K] extends PartialDefinition<unknown, infer N> ? PartialsData<N> : never;
61
+ }[keyof P]>;
62
+ export declare function definePartial<T, P extends PartialsMap = Record<never, never>>(definition: {
63
+ readonly files: Readonly<Record<string, string>>;
64
+ readonly input: TemplateInput<T>;
65
+ readonly partials?: P;
66
+ }): PartialDefinition<T, P>;
67
+ /** One runtime input of a compiled template: `key` is `null` for the template's own input. */
68
+ export interface TemplateInputSlot {
69
+ readonly key: string | null;
70
+ readonly path: string;
71
+ readonly schema?: StandardSchemaV1;
72
+ }
73
+ /**
74
+ * A template: its Typst files (its own and every partial's), its entrypoint, and its runtime inputs.
75
+ * `Input` and `Partials` type a request for it.
76
+ */
77
+ export interface TemplateDefinition<Input = undefined, Partials = Record<never, never>> {
78
+ readonly kind: 'typfolio-template';
79
+ readonly entrypoint: string;
80
+ readonly files: Readonly<Record<string, string>>;
81
+ readonly inputs: readonly TemplateInputSlot[];
82
+ /** Type-only. */
83
+ readonly '~types'?: {
84
+ readonly input: Input;
85
+ readonly partials: Partials;
86
+ };
87
+ }
88
+ export declare function defineTemplate<I = undefined, P extends PartialsMap = Record<never, never>>(definition: {
89
+ /** Defaults to `main.typ`. */
90
+ readonly entrypoint?: string;
91
+ readonly files: Readonly<Record<string, string>>;
92
+ readonly input?: TemplateInput<I>;
93
+ readonly partials?: P;
94
+ }): TemplateDefinition<I, PartialsData<P>>;
95
+ export declare function isTemplateDefinition(value: unknown): value is TemplateDefinition<unknown, unknown>;
96
+ /** A request for a defined template, typed by it: its input and its partials' data. */
97
+ export type TemplateRequest<T> = T extends TemplateDefinition<infer I, infer P> ? {
98
+ template: T;
99
+ assets?: Readonly<Record<string, Uint8Array>>;
100
+ pdf?: PdfOptions;
101
+ } & ([I] extends [undefined] ? {
102
+ input?: undefined;
103
+ } : unknown extends I ? {
104
+ input?: unknown;
105
+ } : {
106
+ input: I;
107
+ }) & ([keyof P] extends [never] ? {
108
+ partials?: Record<never, never>;
109
+ } : {
110
+ partials: P;
111
+ }) : never;
112
+ /**
113
+ * What `compile` checks a request against: one for a template made with `defineTemplate` must be its
114
+ * `TemplateRequest`; any other request is accepted as it is.
115
+ */
116
+ export type CheckedRequest<R> = R extends {
117
+ template: infer T;
118
+ } ? T extends TemplateDefinition<unknown, unknown> ? TemplateRequest<T> : unknown : unknown;
119
+ /** Any request a client accepts: a plain one, one for a defined template, or the low-level form. */
120
+ export type AnyPdfRequest = PdfRequest<unknown> | DocumentsRequest | {
121
+ template: TemplateDefinition<unknown, unknown>;
122
+ input?: unknown;
123
+ partials?: Readonly<Record<string, unknown>>;
124
+ assets?: Readonly<Record<string, Uint8Array>>;
125
+ pdf?: PdfOptions;
126
+ };
127
+ /** The low-level form of a request: documents mounted at their schema paths. */
128
+ export interface DocumentsRequest {
129
+ template: {
130
+ entrypoint: string;
131
+ files: Readonly<Record<string, string>>;
132
+ };
133
+ input: null;
134
+ documents: Readonly<Record<string, unknown>>;
135
+ assets?: Readonly<Record<string, Uint8Array>>;
136
+ pdf?: PdfOptions;
137
+ }
138
+ /**
139
+ * Turns a request for a defined template into its low-level form, checking each document against its
140
+ * schema. A schema that validates synchronously throws here, before anything is captured; one that
141
+ * returns a promise is checked by the returned `validated`, which the caller awaits before compiling.
142
+ * A plain request passes through.
143
+ */
144
+ export declare function resolveRequest(request: AnyPdfRequest): {
145
+ request: PdfRequest<unknown> | DocumentsRequest;
146
+ validated?: Promise<void>;
147
+ };
148
+ /**
149
+ * Files a request carries, referenced from its data by path: `add(bytes, "png")` returns
150
+ * `/attachments/0.png` and keeps the bytes for `assets`. The same bytes added twice share one path.
151
+ */
152
+ export declare function createAttachments(directory?: string): {
153
+ add(bytes: Uint8Array, extension: string): string;
154
+ readonly assets: Readonly<Record<string, Uint8Array>>;
155
+ };
156
+ export {};
@@ -0,0 +1,161 @@
1
+ import { TypstPdfError } from './types.js';
2
+ /** Pairs a schema with the virtual path of its JSON Schema file. */
3
+ export function defineInput(schema, path) {
4
+ if (!isStandardSchema(schema))
5
+ throw new TypeError('defineInput: schema must implement Standard Schema (~standard)');
6
+ return Object.freeze({ path: relativePath(path), schema: schema });
7
+ }
8
+ export function definePartial(definition) {
9
+ const { files, input, partials = {} } = definition ?? {};
10
+ if (!input || typeof input.path !== 'string')
11
+ throw new TypeError('definePartial: input must come from defineInput(schema, path)');
12
+ return Object.freeze({ kind: 'typfolio-partial', files: Object.freeze({ ...files }), input, partials: Object.freeze({ ...partials }) });
13
+ }
14
+ export function defineTemplate(definition) {
15
+ const { entrypoint = 'main.typ', files, input, partials = {} } = definition ?? {};
16
+ const merged = { ...files };
17
+ const slots = [];
18
+ const paths = new Map();
19
+ const claim = (path, owner) => {
20
+ const previous = paths.get(path);
21
+ if (previous !== undefined)
22
+ throw new TypeError(`defineTemplate: ${owner} and ${previous} both read their input from ${path}`);
23
+ paths.set(path, owner);
24
+ };
25
+ if (input !== undefined) {
26
+ if (typeof input.path !== 'string')
27
+ throw new TypeError('defineTemplate: input must come from defineInput(schema, path)');
28
+ const path = relativePath(input.path);
29
+ claim(path, 'the template');
30
+ slots.push({ key: null, path, ...(input.schema ? { schema: input.schema } : {}) });
31
+ }
32
+ // Partials, nested ones included, each once: the same definition under one key may be reached twice.
33
+ const byKey = new Map();
34
+ const visit = (map, trail) => {
35
+ for (const [key, partial] of Object.entries(map)) {
36
+ if (!partial || partial.kind !== 'typfolio-partial')
37
+ throw new TypeError(`defineTemplate: ${trail}${key} is not a definePartial(...)`);
38
+ const known = byKey.get(key);
39
+ if (known === partial)
40
+ continue;
41
+ if (known !== undefined)
42
+ throw new TypeError(`defineTemplate: two different partials are both named "${key}"`);
43
+ byKey.set(key, partial);
44
+ const path = relativePath(partial.input.path);
45
+ claim(path, `partial "${key}"`);
46
+ slots.push({ key, path, ...(partial.input.schema ? { schema: partial.input.schema } : {}) });
47
+ for (const [path, source] of Object.entries(partial.files)) {
48
+ if (path in merged && merged[path] !== source)
49
+ throw new TypeError(`defineTemplate: ${path} is given twice with different contents (partial "${key}")`);
50
+ merged[path] = source;
51
+ }
52
+ visit(partial.partials, `${trail}${key}.`);
53
+ }
54
+ };
55
+ visit(partials, '');
56
+ for (const path of paths.keys()) {
57
+ if (Object.keys(merged).some(file => relativePath(file) === path)) {
58
+ throw new TypeError(`defineTemplate: ${path} is an input path; do not list the schema file in files (it is not shipped, the data is mounted there)`);
59
+ }
60
+ }
61
+ return Object.freeze({
62
+ kind: 'typfolio-template',
63
+ entrypoint,
64
+ files: Object.freeze(merged),
65
+ inputs: Object.freeze(slots),
66
+ });
67
+ }
68
+ export function isTemplateDefinition(value) {
69
+ return typeof value === 'object' && value !== null && value.kind === 'typfolio-template';
70
+ }
71
+ function invalidInput(slot, issues) {
72
+ const owner = slot.key === null ? 'The template input' : `The data of partial "${slot.key}"`;
73
+ const lines = issues.slice(0, 10).map(issue => {
74
+ const path = (issue.path ?? []).map(part => (typeof part === 'object' ? part.key : part)).map(String).join('.');
75
+ return `- ${path ? `${path}: ` : ''}${issue.message}`;
76
+ });
77
+ const more = issues.length > 10 ? `\n- … ${issues.length - 10} more` : '';
78
+ return Object.assign(new TypstPdfError('INVALID_INPUT', `${owner} does not match its schema (${slot.path}):\n${lines.join('\n')}${more}`, [], { cause: issues }), { issues });
79
+ }
80
+ /**
81
+ * Turns a request for a defined template into its low-level form, checking each document against its
82
+ * schema. A schema that validates synchronously throws here, before anything is captured; one that
83
+ * returns a promise is checked by the returned `validated`, which the caller awaits before compiling.
84
+ * A plain request passes through.
85
+ */
86
+ export function resolveRequest(request) {
87
+ if (!request || !isTemplateDefinition(request.template))
88
+ return { request: request };
89
+ const { template, partials = {} } = request;
90
+ for (const key of Object.keys(partials)) {
91
+ if (!template.inputs.some(slot => slot.key === key)) {
92
+ throw new TypstPdfError('INVALID_REQUEST', `partials.${key}: this template has no partial "${key}"; it has ${template.inputs.filter(slot => slot.key !== null).map(slot => `"${slot.key}"`).join(', ') || 'none'}`);
93
+ }
94
+ }
95
+ const documents = {};
96
+ const pending = [];
97
+ for (const slot of template.inputs) {
98
+ const value = slot.key === null ? request.input : partials[slot.key];
99
+ if (value === undefined) {
100
+ throw new TypstPdfError('INVALID_REQUEST', slot.key === null
101
+ ? `The template reads its input from ${slot.path}, but the request has no input`
102
+ : `Partial "${slot.key}" reads its input from ${slot.path}, but the request has no partials.${slot.key}`);
103
+ }
104
+ documents[slot.path] = value;
105
+ if (slot.schema) {
106
+ const result = slot.schema['~standard'].validate(value);
107
+ if (result instanceof Promise) {
108
+ pending.push(result.then(settled => { if (settled.issues)
109
+ throw invalidInput(slot, settled.issues); }));
110
+ }
111
+ else if (result.issues) {
112
+ throw invalidInput(slot, result.issues);
113
+ }
114
+ }
115
+ }
116
+ const resolved = {
117
+ template: { entrypoint: template.entrypoint, files: template.files },
118
+ input: null,
119
+ documents,
120
+ ...(request.assets === undefined ? {} : { assets: request.assets }),
121
+ ...(request.pdf === undefined ? {} : { pdf: request.pdf }),
122
+ };
123
+ return pending.length ? { request: resolved, validated: Promise.all(pending).then(() => undefined) } : { request: resolved };
124
+ }
125
+ /**
126
+ * Files a request carries, referenced from its data by path: `add(bytes, "png")` returns
127
+ * `/attachments/0.png` and keeps the bytes for `assets`. The same bytes added twice share one path.
128
+ */
129
+ export function createAttachments(directory = 'attachments') {
130
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(directory) || directory.endsWith('/') || directory.includes('..')) {
131
+ throw new TypeError(`createAttachments: directory must be a relative path, got ${JSON.stringify(directory)}`);
132
+ }
133
+ const assets = {};
134
+ const seen = new Map();
135
+ return {
136
+ add(bytes, extension) {
137
+ if (!(bytes instanceof Uint8Array))
138
+ throw new TypeError('attach: bytes must be a Uint8Array');
139
+ const ext = String(extension).replace(/^\./, '').toLowerCase();
140
+ if (!/^[a-z0-9]+$/.test(ext))
141
+ throw new TypeError(`attach: the extension must be letters and digits, got ${JSON.stringify(extension)}`);
142
+ const known = seen.get(bytes);
143
+ if (known !== undefined)
144
+ return known;
145
+ const name = `${directory}/${seen.size}.${ext}`;
146
+ seen.set(bytes, `/${name}`);
147
+ assets[name] = bytes;
148
+ return `/${name}`;
149
+ },
150
+ get assets() {
151
+ return assets;
152
+ },
153
+ };
154
+ }
155
+ function isStandardSchema(value) {
156
+ return typeof value === 'object' && value !== null && typeof value['~standard']?.validate === 'function';
157
+ }
158
+ // Paths are checked when compiling, where every file and input path is; here they are only compared.
159
+ function relativePath(path) {
160
+ return String(path).replace(/^\/+/, '');
161
+ }