orz-paged 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # orz-paged
2
2
 
3
- > **Status: built and browser-verified** — 12 starter templates, 7 light themes,
4
- > the full document-element set, an in-file editor with a template picker, and a
5
- > dynamic switch (e.g. exam answer keys). **Not yet published to npm**, so use it
6
- > from a local checkout: `npm install`, `npm run build`, then
7
- > `npm run gen -- tests/sample.md` produces a working `.paged.html`. See
8
- > [DESIGN.md](./DESIGN.md) for the design.
3
+ > **Status: published (v0.1.0) and browser-verified** — 12 starter templates,
4
+ > 7 light themes, the full document-element set, an in-file editor with a
5
+ > template picker, and a dynamic switch (e.g. exam answer keys). Two packages
6
+ > publish in lockstep: [`orz-paged`](https://www.npmjs.com/package/orz-paged)
7
+ > and [`orz-paged-browser`](https://www.npmjs.com/package/orz-paged-browser).
8
+ > See [DESIGN.md](./DESIGN.md) for the design.
9
9
 
10
10
  Generate a single, self-contained, **editable** `.paged.html` from Markdown — a
11
11
  document shown as **print pages** in any browser (A4/Letter, real margins,
@@ -56,8 +56,7 @@ orz-paged --list-templates # list the templates
56
56
  ```
57
57
 
58
58
  `--inline` (default) embeds the engine + paged.js + every theme. `--cdn` instead
59
- references jsDelivr for the engine + theme — but that needs `orz-paged-browser`
60
- published, so **while orz-paged is unpublished, use the default `--inline`**.
59
+ references jsDelivr for the published `orz-paged-browser` engine + theme.
61
60
  **Fonts, web images, and math/diagram libraries always load from CDN** (internet
62
61
  assumed) — so files stay small. The document's `{{nyml kind: document}}` block
63
62
  (`template:`, `theme:`, …) is the source of truth.
@@ -109,13 +108,22 @@ The package ships an **agent skill** that teaches an AI agent the document forma
109
108
  page templates, headers/footers, the curated elements (CVs, letters, exams), and
110
109
  the dynamic switch. The quickest way to produce a document is to let an agent do it:
111
110
 
112
- - **Claude Code** — copy `orz-paged-skills/` into `~/.claude/skills/orz-paged/`.
113
- - **Any agent** — point it at the local `orz-paged-skills/SKILL.md`, then describe
114
- what you want.
111
+ - **Any agent** — point it at
112
+ `https://cdn.jsdelivr.net/npm/orz-paged/orz-paged-skills/SKILL.md`, then
113
+ describe what you want.
114
+ - **Claude Code** — copy `orz-paged-skills/` into `~/.claude/skills/orz-paged/`
115
+ from this repo or from the installed npm package.
115
116
 
116
- Once orz-paged is published to npm, the skill will also be loadable from jsDelivr
117
- (`https://cdn.jsdelivr.net/npm/orz-paged/orz-paged-skills/SKILL.md`), like the other
118
- orz tools. More install routes: <https://markdown.orz.how/agents.html>
117
+ More install routes: <https://markdown.orz.how/agents.html>
118
+
119
+ ## Host embedding
120
+
121
+ `.paged.html` files conform to **`orz-host-save@1`**: a platform can embed a
122
+ document in an iframe, announce itself with a `postMessage` handshake, and
123
+ receive saves (`{ source, html }`) instead of the file-system path —
124
+ standalone behavior, Export to PDF, and downloads are unchanged, and nothing
125
+ activates without the handshake. The canonical spec lives in the orz-mdhtml
126
+ repo: [PROTOCOL.md](https://github.com/wangyu16/orz-mdhtml/blob/main/PROTOCOL.md).
119
127
 
120
128
  ## Security — treat these as programs, not documents
121
129
 
package/assets/app.js CHANGED
@@ -10,6 +10,7 @@
10
10
  var CM_BASE = 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16';
11
11
  var cm = null;
12
12
  var rerenderTimer = null;
13
+ var dirty = false; // edit-state, surfaced to an embedding host (orz-host-dirty)
13
14
 
14
15
  function $(id) { return document.getElementById(id); }
15
16
  function api() { return window.orzpaged; }
@@ -123,6 +124,7 @@
123
124
  function applyDynChoiceToSource(key, value) {
124
125
  var next = setSourceDynamicChoice(currentSource(), key, value);
125
126
  if (next == null) return;
127
+ setDirty(true);
126
128
  if (cm) cm.setValue(next);
127
129
  else { var ta = $('orz-ta'); if (ta) ta.value = next; }
128
130
  syncSource();
@@ -207,6 +209,7 @@
207
209
  }
208
210
 
209
211
  function scheduleRerender() {
212
+ setDirty(true);
210
213
  if (rerenderTimer) clearTimeout(rerenderTimer);
211
214
  rerenderTimer = setTimeout(function () {
212
215
  syncSource();
@@ -262,7 +265,10 @@
262
265
  return Promise.resolve(false);
263
266
  }
264
267
  function save() {
265
- persist(serialize()).then(function (inPlace) { if (inPlace) toast('Saved'); })
268
+ // A hosting platform (verified handshake) receives the save instead of the
269
+ // file system; the host acknowledges with orz-host-saved (see PROTOCOL.md).
270
+ if (isHosted()) { hostSave(currentSource(), serialize()); return; }
271
+ persist(serialize()).then(function (inPlace) { if (inPlace) { setDirty(false); toast('Saved'); } })
266
272
  .catch(function (e) { if (e && e.name !== 'AbortError') downloadFile(serialize()); });
267
273
  }
268
274
  function downloadFile(html) {
@@ -274,6 +280,59 @@
274
280
  toast('Downloaded a copy');
275
281
  }
276
282
 
283
+ /* ---- host embedding (orz-host-save@1) ----
284
+ * When a platform embeds this file in an iframe and announces the
285
+ * orz-host-save protocol (spec: PROTOCOL.md in the orz-mdhtml repo), Save
286
+ * posts the document to the host instead of touching the file system. Never
287
+ * enabled without the host's hello; protocol messages are accepted only from
288
+ * window.parent, and after the handshake only from the recorded host origin.
289
+ * Message content is read as data, never evaluated. Export/PDF keeps working. */
290
+ var HOST_PROTOCOL = 'orz-host-save';
291
+ var HOST_VERSION = 1;
292
+ var hostOrigin = null; // recorded at handshake; null = unhosted
293
+ var hostSaveTimer = null; // watchdog for a save awaiting acknowledgement
294
+
295
+ function isHosted() { return hostOrigin !== null; }
296
+ // An opaque embedder (sandboxed/srcdoc host) serializes as the string 'null',
297
+ // which postMessage rejects as a targetOrigin — fall back to '*' (the payload
298
+ // contains nothing the host doesn't already have).
299
+ function hostTarget() { return hostOrigin && hostOrigin !== 'null' ? hostOrigin : '*'; }
300
+ function hostPost(msg) { try { window.parent.postMessage(msg, hostTarget()); } catch (e) {} }
301
+ function setDirty(d) {
302
+ d = !!d;
303
+ if (dirty === d) return;
304
+ dirty = d;
305
+ if (isHosted()) hostPost({ type: 'orz-host-dirty', protocol: HOST_PROTOCOL, version: HOST_VERSION, dirty: d });
306
+ }
307
+ function hostSave(src, html) {
308
+ if (hostSaveTimer) return; // one save in flight at a time
309
+ hostSaveTimer = setTimeout(function () {
310
+ hostSaveTimer = null;
311
+ toast('Save failed — no response from the host'); // document intact, still dirty
312
+ }, 10000);
313
+ hostPost({ type: 'orz-host-save', protocol: HOST_PROTOCOL, version: HOST_VERSION, source: src, html: html });
314
+ }
315
+ function onHostMessage(event) {
316
+ // only the embedding parent may speak the protocol
317
+ if (window.parent === window || event.source !== window.parent) return;
318
+ var d = event.data;
319
+ if (!d || typeof d !== 'object') return;
320
+ // after the handshake, hold the parent to the origin it introduced itself with
321
+ if (isHosted() && hostOrigin !== 'null' && event.origin !== hostOrigin) return;
322
+ if (d.type === 'orz-host-hello' && d.protocol === HOST_PROTOCOL && typeof d.version === 'number' && d.version >= 1) {
323
+ hostOrigin = event.origin;
324
+ // reply with the highest version we support ≤ the host's (we speak only 1)
325
+ hostPost({ type: 'orz-host-ready', protocol: HOST_PROTOCOL, version: HOST_VERSION, kind: 'paged' });
326
+ if (dirty) hostPost({ type: 'orz-host-dirty', protocol: HOST_PROTOCOL, version: HOST_VERSION, dirty: true });
327
+ } else if (d.type === 'orz-host-saved' && hostSaveTimer) {
328
+ clearTimeout(hostSaveTimer); hostSaveTimer = null;
329
+ if (d.ok) { setDirty(false); toast('Saved'); }
330
+ else { toast('Save failed' + (d.error ? ' — ' + String(d.error) : '')); }
331
+ }
332
+ }
333
+ // listen from script load so an early hello isn't missed
334
+ window.addEventListener('message', onHostMessage);
335
+
277
336
  /* ---- framework self-update (checked only in edit view) ---- */
278
337
  // SECURITY: the update source is HARDCODED here, never read from the file's
279
338
  // config — so a tampered/forged file cannot redirect "Update" to fetch
@@ -377,6 +436,7 @@
377
436
  // first one would close the wrapper early and leak the rest back in.
378
437
  if (cur) next += '\n\n<!-- Previous content (kept below — edit or delete):\n\n'
379
438
  + cur.replace(/--+>/g, '--​>') + '\n-->\n';
439
+ setDirty(true);
380
440
  if (cm) { cm.setValue(next); cm.focus(); }
381
441
  else { var ta = $('orz-ta'); if (ta) ta.value = next; syncSource(); if (api() && api().refresh) api().refresh(); }
382
442
  toast('Started from "' + t.label + '"');
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js CHANGED
@@ -8,57 +8,11 @@
8
8
  * references the published `orz-paged-browser` on jsDelivr. Fonts, images, and the
9
9
  * math/diagram libs always load from CDN at view time (DESIGN §4).
10
10
  */
11
- import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
12
  import { dirname, join, basename, resolve } from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
13
  import { getBrowserRuntimeScript } from 'orz-markdown/runtime';
15
- import { PREVIEW_CDN } from 'orz-markdown/preview-frame';
16
14
  import { buildHtml } from './template.js';
17
- import { templateList } from './doc/templates.js';
18
- const HERE = dirname(fileURLToPath(import.meta.url));
19
- const ROOT = join(HERE, '..');
20
- const selfVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version || '0.0.0';
21
- /** Pinned CDN assets used at view time — sourced from orz-markdown's shared
22
- * preview-frame helper so every host app loads identical, tested versions.
23
- * Paged is light-only, so highlight.js uses the light (github) stylesheet. */
24
- const CDN = {
25
- katexCss: PREVIEW_CDN.katexCss,
26
- mermaidJs: PREVIEW_CDN.mermaidJs,
27
- smilesJs: PREVIEW_CDN.smilesJs,
28
- chartJs: PREVIEW_CDN.chartJs,
29
- hljsJs: PREVIEW_CDN.hljsJs,
30
- hljsCss: PREVIEW_CDN.hljsLightCss,
31
- };
32
- function findAsset(rel) {
33
- const p = join(ROOT, rel);
34
- if (!existsSync(p))
35
- throw new Error('missing asset: ' + rel + ' (run `npm run build` first)');
36
- return p;
37
- }
38
- /** Drop a theme's `@import url("./base.css")` — base is inlined separately, so the
39
- * relative import points nowhere (404 on http; a fetch hazard on file://). */
40
- function stripBaseImport(css) {
41
- return css.replace(/@import\s+url\(\s*['"]?\.\/base\.css['"]?\s*\)\s*;?/gi, '');
42
- }
43
- function loadThemes() {
44
- const dir = findAsset('assets/themes');
45
- const baseCss = readFileSync(join(dir, 'base.css'), 'utf8');
46
- const themes = readdirSync(dir)
47
- .filter((f) => f.endsWith('.css') && f !== 'base.css')
48
- .map((f) => ({ id: f.replace(/\.css$/, ''), css: stripBaseImport(readFileSync(join(dir, f), 'utf8')) }));
49
- return { baseCss, themes };
50
- }
51
- /** Load every template's starter skeleton from `assets/templates/<stem>.md`. */
52
- function loadTemplates() {
53
- const dir = findAsset('assets/templates');
54
- return templateList().map((t) => ({
55
- id: t.id,
56
- label: t.label,
57
- description: t.description,
58
- group: t.group,
59
- skeleton: readFileSync(join(dir, t.skeleton + '.md'), 'utf8'),
60
- }));
61
- }
15
+ import { selfVersion, CDN, findAsset, loadThemes, loadTemplates, pickDefaultTheme, composeInlineHtml, } from './lib.js';
62
16
  function parseArgs(argv) {
63
17
  const a = { mode: 'inline' };
64
18
  for (let i = 0; i < argv.length; i++) {
@@ -138,30 +92,39 @@ function main() {
138
92
  const inDir = args.input ? dirname(resolve(args.input)) : process.cwd();
139
93
  const outPath = args.out ? resolve(args.out) : join(inDir, base + '.paged.html');
140
94
  const title = args.title || base;
141
- const { baseCss, themes } = loadThemes();
142
- const appJs = readFileSync(findAsset('assets/app.js'), 'utf8');
143
- const runtime = getBrowserRuntimeScript(); // copy-as-Markdown (+ qr) from orz-markdown
144
- let renderer;
95
+ let html;
96
+ let themeCount;
145
97
  if (args.mode === 'cdn') {
146
- renderer = { mode: 'cdn', src: `https://cdn.jsdelivr.net/npm/orz-paged-browser@${selfVersion}/orz-paged.browser.js` };
98
+ // CDN mode references the published engine on jsDelivr (not inlined), so it
99
+ // has its own composition; the default inline path shares composeInlineHtml.
100
+ const { baseCss, themes } = loadThemes();
101
+ const appJs = readFileSync(findAsset('assets/app.js'), 'utf8');
102
+ const runtime = getBrowserRuntimeScript(); // copy-as-Markdown (+ qr) from orz-markdown
103
+ const renderer = {
104
+ mode: 'cdn',
105
+ src: `https://cdn.jsdelivr.net/npm/orz-paged-browser@${selfVersion}/orz-paged.browser.js`,
106
+ };
107
+ themeCount = themes.length;
108
+ html = buildHtml({
109
+ source,
110
+ title,
111
+ rendererVersion: selfVersion,
112
+ renderer,
113
+ baseCss,
114
+ themes,
115
+ defaultTheme: pickDefaultTheme(themes),
116
+ templates,
117
+ cdn: CDN,
118
+ appJs,
119
+ runtime,
120
+ });
147
121
  }
148
122
  else {
149
- renderer = { mode: 'inline', js: readFileSync(findAsset('dist/orz-paged.browser.js'), 'utf8') };
123
+ // Default: fully-inline, via the shared composition used by the library.
124
+ themeCount = loadThemes().themes.length;
125
+ html = composeInlineHtml(source, title);
150
126
  }
151
- const html = buildHtml({
152
- source,
153
- title,
154
- rendererVersion: selfVersion,
155
- renderer,
156
- baseCss,
157
- themes,
158
- defaultTheme: themes.some((t) => t.id === 'light-neat-1') ? 'light-neat-1' : (themes[0]?.id ?? 'none'),
159
- templates,
160
- cdn: CDN,
161
- appJs,
162
- runtime,
163
- });
164
127
  writeFileSync(outPath, html, 'utf8');
165
- console.log(`Wrote ${outPath} (${args.mode}, ${themes.length} themes)`);
128
+ console.log(`Wrote ${outPath} (${args.mode}, ${themeCount} themes)`);
166
129
  }
167
130
  main();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Dynamic switch — conditional content driven by the `dynamic_choices` document
3
+ * setting. Elements carry `data-show-when="key=value"` / `data-hide-when="key=value"`;
4
+ * at render time non-matching ones are removed and the attribute is stripped from
5
+ * those that stay. One source can then print several versions (e.g. an exam with
6
+ * `answer-key: hide` for students and `show` for the instructor key).
7
+ *
8
+ * Operates on a live DOM (browser render) so nested removals are correct.
9
+ */
10
+ /** Normalize a choice key: lowercase, hyphens → underscores (matches settings). */
11
+ export declare function normalizeChoiceKey(key: string): string;
12
+ interface CondEl {
13
+ getAttribute(name: string): string | null;
14
+ removeAttribute(name: string): void;
15
+ remove(): void;
16
+ }
17
+ interface CondRoot {
18
+ querySelectorAll(sel: string): ArrayLike<CondEl>;
19
+ }
20
+ /**
21
+ * Resolve every `data-show-when` / `data-hide-when` under `root` against
22
+ * `choices`, mutating the DOM in place: drop non-matching elements, strip the
23
+ * attribute from the rest.
24
+ */
25
+ export declare function applyDynamicChoices(root: CondRoot, choices: Record<string, string>): void;
26
+ /**
27
+ * Collect the dynamic keys present under `root` and the set of values each is
28
+ * used with — for building a live switcher UI. Returns key → sorted values.
29
+ */
30
+ export declare function collectDynamicChoices(root: CondRoot): Record<string, string[]>;
31
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * orz-paged — built-in document elements.
3
+ *
4
+ * `renderElement(spec, ctx)` turns one `{{nyml kind: element}}` block into
5
+ * semantic HTML + a small scoped stylesheet. Every element is wrapped in
6
+ * `<section class="orz-element orz-el-<kind>">…</section>`. Markdown-typed
7
+ * fields go through the injected `ctx.renderInline` / `ctx.renderBlock` so this
8
+ * module never depends on orz-markdown directly.
9
+ *
10
+ * The `css` returned per kind is identical on every call (the assembler dedupes
11
+ * by kind), uses `var(--accent)` for the accent color, and stays light-only.
12
+ *
13
+ * Spec: docs/document-model.md (Built-in Elements) — reimplemented cleanly.
14
+ */
15
+ import type { ElementSpec, ElementCtx, ElementResult } from '../types.js';
16
+ export declare function renderElement(spec: ElementSpec, ctx: ElementCtx): ElementResult;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Font presets — M3.
3
+ *
4
+ * Maps each curated {@link FontPresetName} to a {@link FontPreset}: a CDN
5
+ * stylesheet URL (`@fontsource/*` served via jsDelivr) plus the matching CSS
6
+ * `font-family` stack. orz-paged assumes internet access, so fonts load from
7
+ * the CDN rather than being bundled.
8
+ *
9
+ * `system-serif` is the one preset with **no** webfont — it relies on the
10
+ * platform's Times-style serif and ships an empty `cssUrl`.
11
+ *
12
+ * Source of truth for the curated list: `docs/document-model.md` (Tier A) and
13
+ * `src/types.ts` (`FontPresetName`).
14
+ */
15
+ import type { FontPreset, FontPresetName } from '../types.js';
16
+ /**
17
+ * Every curated preset → its CDN stylesheet + CSS family stack. `system-serif`
18
+ * carries an empty `cssUrl` (no network font).
19
+ */
20
+ export declare const FONT_PRESETS: Record<FontPresetName, FontPreset>;
21
+ /**
22
+ * Resolve a preset by name. Unknown names fall back to a webfont-less system
23
+ * serif (empty `cssUrl`) rather than throwing.
24
+ */
25
+ export declare function fontPreset(name: string): FontPreset;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Generic nyml-block scanner — shared by the settings reader (M1) and the
3
+ * assembler (A1).
4
+ *
5
+ * `settings.ts` parses the single `kind: document` block inline; this module is
6
+ * the reusable version: it finds **every** `{{nyml … }}` block in a source,
7
+ * parses its YAML-ish `key: value` body (including `key: |` multiline blocks),
8
+ * reads the `kind:` discriminator, and reports the block's char offsets so a
9
+ * caller can splice it out / replace it.
10
+ *
11
+ * Values are always strings: surrounding quotes are stripped, multiline `|`
12
+ * blocks are dedented and joined with `\n`. The `{{nyml … }}` grammar matches
13
+ * what `parseDocSettings` already accepts (`}` is allowed inside the body; only
14
+ * the first `}}` terminates the block).
15
+ */
16
+ /** One scanned `{{nyml … }}` block: its kind, parsed fields, and char range. */
17
+ export interface NymlBlock {
18
+ /** The `kind:` value (e.g. `'document'`, `'element'`), or `''` if absent. */
19
+ kind: string;
20
+ /** snake_case field → raw string value (multiline values preserved). */
21
+ fields: Record<string, string>;
22
+ /** Offset of the opening `{` of `{{nyml` in `source`. */
23
+ start: number;
24
+ /** Offset just past the closing `}}` (so `source.slice(start, end)` is the block). */
25
+ end: number;
26
+ }
27
+ /**
28
+ * Scan a source for every `{{nyml … }}` block, in document order. Each result
29
+ * carries its parsed `kind`, `fields`, and `[start, end)` char offsets. Blocks
30
+ * inside an HTML comment (`<!-- … -->`) are skipped — so a commented-out element
31
+ * (e.g. content the editor's template picker preserves) does not render.
32
+ */
33
+ export declare function scanNymlBlocks(source: string): NymlBlock[];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Page CSS — M5.
3
+ *
4
+ * Turns a fully-resolved {@link DocSettings} into the `@page` rules, margin-box
5
+ * running headers/footers, page-number counters, `:root` design tokens, and
6
+ * layout-behavior CSS that paged.js realizes into physical page boxes.
7
+ *
8
+ * Behavior SPEC: `docs/document-model.md`; token set + page model:
9
+ * `docs/dom-contract.md`. Reimplemented cleanly in TS (light-only output).
10
+ */
11
+ import type { DocSettings } from '../types.js';
12
+ /**
13
+ * Build the full page CSS string for a document. Pure: depends only on `s`.
14
+ */
15
+ export declare function buildPageCss(s: DocSettings): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Document settings — M1.
3
+ *
4
+ * Reads the single `{{nyml kind: document}}` block from a Markdown source,
5
+ * normalizes its snake_case keys into a {@link DocSettingsLayer}, and merges
6
+ * template + user layers over {@link DEFAULTS} into a fully-resolved
7
+ * {@link DocSettings}.
8
+ *
9
+ * Source of truth for defaults and aliases: `docs/document-model.md`
10
+ * (the curated subset; see DESIGN.md §8).
11
+ */
12
+ import type { DocSettings, RawDocSettings, DocSettingsLayer } from '../types.js';
13
+ /** Every field at its documented default (docs/document-model.md). */
14
+ export declare const DEFAULTS: DocSettings;
15
+ /**
16
+ * Locate the first `{{nyml ... }}` block whose body declares `kind: document`,
17
+ * parse its `key: value` lines (including `key: |` multiline blocks) into a
18
+ * {@link RawDocSettings}, and return the source with that block removed so it
19
+ * is not rendered as content. No document block → `{ raw: {}, body: source }`.
20
+ */
21
+ export declare function parseDocSettings(source: string): {
22
+ raw: RawDocSettings;
23
+ body: string;
24
+ };
25
+ /**
26
+ * Map snake_case raw keys → camelCase {@link DocSettings} fields, coercing
27
+ * types and normalizing aliases. Only keys present in `raw` are included;
28
+ * unknown keys are ignored.
29
+ */
30
+ export declare function normalizeRawToLayer(raw: RawDocSettings): DocSettingsLayer;
31
+ /**
32
+ * Shallow-merge layers left → right (later wins). Intended use:
33
+ * `mergeSettings(DEFAULTS, templateLayer, userLayer)`.
34
+ */
35
+ export declare function mergeSettings(...layers: DocSettingsLayer[]): DocSettings;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Curated document templates — LAYOUT defaults + a starter skeleton.
3
+ *
4
+ * Responsibility split: a **template** owns layout only (page size, margins,
5
+ * furniture, and which elements appear / where). A **theme** owns the look —
6
+ * font, accent/decoration color, and element styling — so the same theme renders
7
+ * the same across every template (see ../doc/theme-fonts.ts + assets/themes/*).
8
+ * The default theme is `light-academic-1` (settings.ts DEFAULTS.theme).
9
+ *
10
+ * Each template carries:
11
+ * - a {@link DocSettingsLayer} of *layout defaults* (page size; no font/theme),
12
+ * merged under any `kind: document` field the user sets,
13
+ * - picker metadata (`label`, `description`, `group`),
14
+ * - a `skeleton` stem naming the starter Markdown in `assets/templates/<stem>.md`.
15
+ *
16
+ * `article` / `report` / `exam` ship in two variants — `-page` (a dedicated
17
+ * title/cover page) and `-section` (title-on-content) — that differ only in their
18
+ * skeleton (the title element's `placement: page | section`). Legacy names
19
+ * (`article`/`report`/`exam`) resolve to the `-section` variant.
20
+ */
21
+ import type { TemplateName, DocSettingsLayer } from '../types.js';
22
+ export interface TemplateDef {
23
+ /** Friendly name for the picker, e.g. "Article — title page". */
24
+ label: string;
25
+ /** One-line description (picker tooltip). */
26
+ description: string;
27
+ /** Grouping for the picker, e.g. "Article". */
28
+ group: string;
29
+ /** Starter-markdown file stem: `assets/templates/<skeleton>.md`. */
30
+ skeleton: string;
31
+ /** Settings defaults this template applies. */
32
+ settings: DocSettingsLayer;
33
+ }
34
+ /** The 10 curated templates. */
35
+ export declare const TEMPLATES: Record<TemplateName, TemplateDef>;
36
+ /** Canonicalize a (possibly legacy) template name, or `undefined` if unknown. */
37
+ export declare function canonicalTemplate(name: string): TemplateName | undefined;
38
+ /** Return a template's *settings* defaults layer, or `{}` for an unknown name. */
39
+ export declare function resolveTemplate(name: TemplateName | string): DocSettingsLayer;
40
+ /** The starter-markdown file stem for a template, or `undefined` if unknown. */
41
+ export declare function templateSkeletonName(name: TemplateName | string): string | undefined;
42
+ /** Picker catalog: id + metadata for every template (no settings/skeleton text). */
43
+ export declare function templateList(): Array<{
44
+ id: TemplateName;
45
+ label: string;
46
+ description: string;
47
+ group: string;
48
+ skeleton: string;
49
+ }>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Theme typography — each theme OWNS its font (alongside its colors + element
3
+ * style in the theme CSS). The engine loads the active theme's webfonts at view
4
+ * time and the theme CSS sets the matching `--font-body` / `--font-heading`, so
5
+ * the same theme yields the same font across every template. An explicit
6
+ * `font_preset` in the document settings still overrides this (page CSS wins).
7
+ */
8
+ import type { ThemeName, FontPresetName } from '../types.js';
9
+ export declare const THEME_FONTS: Record<ThemeName, {
10
+ body: FontPresetName;
11
+ heading: FontPresetName;
12
+ }>;
package/dist/lib.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { type ThemeAsset, type TemplateAsset } from './template.js';
2
+ export declare const selfVersion: string;
3
+ /** Pinned CDN assets used at view time — sourced from orz-markdown's shared
4
+ * preview-frame helper so every host app loads identical, tested versions.
5
+ * Paged is light-only, so highlight.js uses the light (github) stylesheet. */
6
+ export declare const CDN: {
7
+ katexCss: "https://cdn.jsdelivr.net/npm/katex@0.16.35/dist/katex.min.css";
8
+ mermaidJs: "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
9
+ smilesJs: "https://unpkg.com/smiles-drawer@1.0.10/dist/smiles-drawer.min.js";
10
+ chartJs: "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js";
11
+ hljsJs: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js";
12
+ hljsCss: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css";
13
+ };
14
+ export declare function findAsset(rel: string): string;
15
+ /** Drop a theme's `@import url("./base.css")` — base is inlined separately, so the
16
+ * relative import points nowhere (404 on http; a fetch hazard on file://). */
17
+ export declare function stripBaseImport(css: string): string;
18
+ export declare function loadThemes(): {
19
+ baseCss: string;
20
+ themes: ThemeAsset[];
21
+ };
22
+ /** Load every template's starter skeleton from `assets/templates/<stem>.md`. */
23
+ export declare function loadTemplates(): TemplateAsset[];
24
+ /** The default theme id, matching the CLI: prefer `light-neat-1`, else the first
25
+ * loaded theme, else `'none'`. */
26
+ export declare function pickDefaultTheme(themes: ThemeAsset[]): string;
27
+ /**
28
+ * The shared inline composition — called by both the CLI (`--inline`, the
29
+ * default) and {@link buildPagedHtml}. Loads all assets from the package, builds
30
+ * an inline renderer, and returns the full document string. Deterministic: paged
31
+ * has no per-document random id, so the same `(source, title, theme?)` always
32
+ * yields byte-identical output.
33
+ *
34
+ * @param theme optional theme id; when omitted (or unknown) the default theme
35
+ * ({@link pickDefaultTheme}) drives the picker's initial selection.
36
+ */
37
+ export declare function composeInlineHtml(source: string, title: string, theme?: string): string;
38
+ /**
39
+ * Generate a self-contained `.paged.html` document, fully inline.
40
+ *
41
+ * Mirrors the CLI's default `--inline` mode; the returned string is
42
+ * byte-identical to CLI inline output for the same source/title/theme.
43
+ *
44
+ * @param opts.markdown the document source (the host passes content).
45
+ * @param opts.title `<title>` + generator title; defaults to `'Untitled'`.
46
+ * @param opts.theme initial theme id; unknown/omitted → the default theme.
47
+ * @param opts.template a starter-skeleton selector, mirroring the CLI's
48
+ * `--template <name>` with no input file: used as the source ONLY when
49
+ * `opts.markdown` is empty/whitespace. When `markdown` has content, it wins.
50
+ * The full template catalog is always embedded regardless (it powers the
51
+ * in-editor picker), so this never changes the catalog — only the initial
52
+ * source. An unknown template name is ignored (falls back to `markdown`).
53
+ */
54
+ export declare function buildPagedHtml(opts: {
55
+ markdown: string;
56
+ title?: string;
57
+ theme?: string;
58
+ template?: string;
59
+ }): string;