@franken-suite/franken-markdown 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 ADDED
@@ -0,0 +1,61 @@
1
+ # franken_markdown WASM Package
2
+
3
+ This directory contains the source wrapper for the browser package. Generated
4
+ `wasm-bindgen` glue is intentionally not committed here; `scripts/check-wasm-package.sh`
5
+ builds it into `target/fmd-checks/wasm-package/`.
6
+
7
+ ## Build
8
+
9
+ ```bash
10
+ rustup target add wasm32-unknown-unknown
11
+ cargo install wasm-bindgen-cli --version 0.2.126 --locked
12
+ scripts/check-wasm-package.sh
13
+ ```
14
+
15
+ The assembled package contains:
16
+
17
+ - `franken_markdown.js` - ergonomic hand-written ESM wrapper,
18
+ - `franken_markdown.d.ts` - exact TypeScript API contract,
19
+ - `demo/` - static browser demo that uses the public package wrapper,
20
+ - `package.json` - package metadata and exports,
21
+ - `pkg/` - generated wasm-bindgen glue and `.wasm` binary.
22
+
23
+ ## Demo
24
+
25
+ After `scripts/check-wasm-package.sh`, serve the assembled package directory
26
+ with any static file server and open `demo/index.html`:
27
+
28
+ ```bash
29
+ python3 -m http.server 8787 --directory target/fmd-checks/wasm-package
30
+ ```
31
+
32
+ Then open `http://127.0.0.1:8787/demo/`. The demo is plain HTML/CSS/ESM and
33
+ does not require a bundler or network access for normal rendering. It imports
34
+ `../franken_markdown.js`, renders the HTML preview through `renderHtml`, and
35
+ downloads PDF bytes through `renderPdf`.
36
+
37
+ ## Browser Usage
38
+
39
+ ```js
40
+ import { createRenderer } from "./franken_markdown.js";
41
+
42
+ const fmd = await createRenderer();
43
+ const html = await fmd.renderHtml("# Hello", { font: "sans", darkMode: "auto" });
44
+ document.body.innerHTML = html.text();
45
+
46
+ const pdf = await fmd.renderPdf("# Report", {
47
+ title: "Report",
48
+ metadataEpochSeconds: 1700000000
49
+ });
50
+ const url = URL.createObjectURL(pdf.blob());
51
+ ```
52
+
53
+ Every render result has:
54
+
55
+ - `format`: `html` or `pdf`,
56
+ - `mimeType`: browser Blob MIME type,
57
+ - `extension`: default download extension,
58
+ - `sourceLength`: Markdown source byte length,
59
+ - `bytes`: `Uint8Array`,
60
+ - `diagnostics`: recoverable parser diagnostics,
61
+ - `text()`, `blob()`, and `filename()` helpers.
package/demo/demo.js ADDED
@@ -0,0 +1,215 @@
1
+ import { createRenderer } from "../franken_markdown.js";
2
+
3
+ const sampleMarkdown = `# franken_markdown demo
4
+
5
+ This preview is rendered by the same browser package API that applications use.
6
+
7
+ | Item | Status | Notes |
8
+ |---|:---:|---|
9
+ | HTML | ready | Self-contained preview with embedded subset fonts |
10
+ | PDF | ready | Deterministic bytes, tagged text, links, and outlines |
11
+ | WASM | first-class | No filesystem, process, threads, or network in the core |
12
+
13
+ > Blockquotes, tables, links, lists, and code blocks use the default theme.
14
+
15
+ \`\`\`rust
16
+ fn main() {
17
+ println!("render markdown");
18
+ }
19
+ \`\`\`
20
+
21
+ - Toggle the serif font for long-form reading.
22
+ - Disable dark CSS if the host page needs a fixed light palette.
23
+ - Download a PDF generated from the same Markdown source.
24
+ `;
25
+
26
+ const els = {
27
+ markdown: requiredElement("#markdown"),
28
+ render: requiredElement("#render"),
29
+ downloadPdf: requiredElement("#download-pdf"),
30
+ preview: requiredElement("#preview"),
31
+ diagnostics: requiredElement("#diagnostics"),
32
+ status: requiredElement("#status"),
33
+ sourceSize: requiredElement("#source-size"),
34
+ previewMeta: requiredElement("#preview-meta"),
35
+ font: requiredElement("#font"),
36
+ darkMode: requiredElement("#dark-mode"),
37
+ title: requiredElement("#title"),
38
+ author: requiredElement("#author"),
39
+ customCss: requiredElement("#custom-css"),
40
+ allowHtml: requiredElement("#allow-html"),
41
+ lineNumbers: requiredElement("#line-numbers")
42
+ };
43
+
44
+ let renderer = null;
45
+ let renderTimer = 0;
46
+ let lastPdfUrl = null;
47
+
48
+ function requiredElement(selector) {
49
+ const element = document.querySelector(selector);
50
+ if (element === null) {
51
+ throw new Error(`franken_markdown demo is missing required element ${selector}`);
52
+ }
53
+ return element;
54
+ }
55
+
56
+ els.markdown.value = sampleMarkdown;
57
+ setBusy(true, "loading wasm package");
58
+ createRenderer()
59
+ .then((created) => {
60
+ renderer = created;
61
+ setBusy(false, "ready");
62
+ schedulePreview();
63
+ })
64
+ .catch((error) => {
65
+ setBusy(false, "wasm load failed");
66
+ showError(error);
67
+ });
68
+
69
+ els.render.addEventListener("click", () => {
70
+ void renderPreview();
71
+ });
72
+
73
+ els.downloadPdf.addEventListener("click", () => {
74
+ void downloadPdf();
75
+ });
76
+
77
+ for (const input of [
78
+ els.markdown,
79
+ els.font,
80
+ els.darkMode,
81
+ els.title,
82
+ els.author,
83
+ els.customCss,
84
+ els.allowHtml,
85
+ els.lineNumbers
86
+ ]) {
87
+ input.addEventListener("input", schedulePreview);
88
+ input.addEventListener("change", schedulePreview);
89
+ }
90
+
91
+ function schedulePreview() {
92
+ window.clearTimeout(renderTimer);
93
+ updateSourceSize();
94
+ renderTimer = window.setTimeout(() => {
95
+ void renderPreview();
96
+ }, 180);
97
+ }
98
+
99
+ async function renderPreview() {
100
+ if (renderer === null) {
101
+ return;
102
+ }
103
+ const markdown = els.markdown.value;
104
+ const options = renderOptions();
105
+ if (markdown.trim() === "") {
106
+ els.preview.srcdoc = "";
107
+ els.previewMeta.textContent = "empty source";
108
+ renderDiagnostics([]);
109
+ return;
110
+ }
111
+ setBusy(true, "rendering html");
112
+ try {
113
+ const output = await renderer.renderHtml(markdown, options);
114
+ els.preview.srcdoc = output.text();
115
+ els.previewMeta.textContent = `${output.sourceLength} source bytes -> ${output.bytes.byteLength} html bytes`;
116
+ renderDiagnostics(output.diagnostics);
117
+ setBusy(false, "ready");
118
+ } catch (error) {
119
+ setBusy(false, "render failed");
120
+ showError(error);
121
+ }
122
+ }
123
+
124
+ async function downloadPdf() {
125
+ if (renderer === null) {
126
+ return;
127
+ }
128
+ const markdown = els.markdown.value;
129
+ if (markdown.trim() === "") {
130
+ showError(new Error("Markdown input is empty; add content before downloading PDF."));
131
+ return;
132
+ }
133
+ setBusy(true, "rendering pdf");
134
+ try {
135
+ const output = await renderer.renderPdf(markdown, renderOptions());
136
+ if (lastPdfUrl !== null) {
137
+ URL.revokeObjectURL(lastPdfUrl);
138
+ }
139
+ lastPdfUrl = URL.createObjectURL(output.blob());
140
+ const link = document.createElement("a");
141
+ link.href = lastPdfUrl;
142
+ link.download = output.filename(filenameBase());
143
+ document.body.appendChild(link);
144
+ link.click();
145
+ link.remove();
146
+ els.previewMeta.textContent = `${output.sourceLength} source bytes -> ${output.bytes.byteLength} pdf bytes`;
147
+ renderDiagnostics(output.diagnostics);
148
+ setBusy(false, "pdf ready");
149
+ } catch (error) {
150
+ setBusy(false, "pdf failed");
151
+ showError(error);
152
+ }
153
+ }
154
+
155
+ function renderOptions() {
156
+ const customCss = els.customCss.value.trim() === "" ? undefined : els.customCss.value;
157
+ return {
158
+ font: els.font.value,
159
+ darkMode: els.darkMode.value,
160
+ title: textOrUndefined(els.title.value),
161
+ author: textOrUndefined(els.author.value),
162
+ customCss,
163
+ allowRawHtml: els.allowHtml.checked,
164
+ codeLineNumbers: els.lineNumbers.checked,
165
+ metadataEpochSeconds: 1700000000
166
+ };
167
+ }
168
+
169
+ function textOrUndefined(value) {
170
+ const text = String(value).trim();
171
+ return text === "" ? undefined : text;
172
+ }
173
+
174
+ function filenameBase() {
175
+ return textOrUndefined(els.title.value)
176
+ ?.toLowerCase()
177
+ .replace(/[^a-z0-9]+/g, "-")
178
+ .replace(/^-|-$/g, "")
179
+ || "franken-markdown";
180
+ }
181
+
182
+ function renderDiagnostics(diagnostics) {
183
+ els.diagnostics.replaceChildren();
184
+ if (diagnostics.length === 0) {
185
+ const item = document.createElement("li");
186
+ item.className = "empty";
187
+ item.textContent = "No diagnostics.";
188
+ els.diagnostics.appendChild(item);
189
+ return;
190
+ }
191
+ for (const diagnostic of diagnostics) {
192
+ const item = document.createElement("li");
193
+ item.className = diagnostic.severity === "error" ? "error" : "";
194
+ item.textContent = `${diagnostic.severity} ${diagnostic.start}-${diagnostic.end}: ${diagnostic.message}`;
195
+ els.diagnostics.appendChild(item);
196
+ }
197
+ }
198
+
199
+ function showError(error) {
200
+ els.diagnostics.replaceChildren();
201
+ const item = document.createElement("li");
202
+ item.className = "error";
203
+ item.textContent = error instanceof Error ? error.message : String(error);
204
+ els.diagnostics.appendChild(item);
205
+ }
206
+
207
+ function updateSourceSize() {
208
+ els.sourceSize.textContent = `${new TextEncoder().encode(els.markdown.value).byteLength} bytes`;
209
+ }
210
+
211
+ function setBusy(busy, message) {
212
+ els.status.textContent = message;
213
+ els.render.disabled = busy;
214
+ els.downloadPdf.disabled = busy;
215
+ }
@@ -0,0 +1,395 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>franken_markdown WASM Demo</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f6f7f9;
11
+ --panel: #ffffff;
12
+ --panel-2: #f0f3f7;
13
+ --text: #16181d;
14
+ --muted: #626a78;
15
+ --line: #d9dee7;
16
+ --accent: #1769aa;
17
+ --accent-2: #0f7f68;
18
+ --danger: #a61b3c;
19
+ --shadow: 0 14px 30px rgba(33, 42, 55, 0.10);
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ body {
27
+ margin: 0;
28
+ min-height: 100vh;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ font-family:
32
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
33
+ "Segoe UI", sans-serif;
34
+ }
35
+
36
+ button,
37
+ input,
38
+ select,
39
+ textarea {
40
+ font: inherit;
41
+ }
42
+
43
+ .app {
44
+ min-height: 100vh;
45
+ display: grid;
46
+ grid-template-rows: auto 1fr;
47
+ }
48
+
49
+ .topbar {
50
+ display: grid;
51
+ grid-template-columns: minmax(210px, 1fr) auto;
52
+ gap: 12px;
53
+ align-items: center;
54
+ padding: 12px 16px;
55
+ border-bottom: 1px solid var(--line);
56
+ background: var(--panel);
57
+ }
58
+
59
+ .brand {
60
+ min-width: 0;
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ gap: 8px 12px;
64
+ align-items: baseline;
65
+ }
66
+
67
+ .brand strong {
68
+ font-size: 15px;
69
+ letter-spacing: 0;
70
+ }
71
+
72
+ .brand span,
73
+ .status {
74
+ color: var(--muted);
75
+ font-size: 12px;
76
+ }
77
+
78
+ .actions {
79
+ display: flex;
80
+ gap: 8px;
81
+ align-items: center;
82
+ }
83
+
84
+ .button {
85
+ border: 1px solid var(--line);
86
+ border-radius: 6px;
87
+ background: var(--panel);
88
+ color: var(--text);
89
+ min-height: 34px;
90
+ padding: 0 12px;
91
+ cursor: pointer;
92
+ }
93
+
94
+ .button.primary {
95
+ border-color: var(--accent);
96
+ background: var(--accent);
97
+ color: #ffffff;
98
+ }
99
+
100
+ .button.secondary {
101
+ border-color: var(--accent-2);
102
+ color: var(--accent-2);
103
+ }
104
+
105
+ .button:disabled {
106
+ cursor: wait;
107
+ opacity: 0.62;
108
+ }
109
+
110
+ .workspace {
111
+ display: grid;
112
+ grid-template-columns: minmax(280px, 0.88fr) minmax(320px, 1.12fr);
113
+ gap: 12px;
114
+ min-height: 0;
115
+ padding: 12px;
116
+ }
117
+
118
+ .pane {
119
+ min-width: 0;
120
+ min-height: 0;
121
+ display: grid;
122
+ grid-template-rows: auto 1fr;
123
+ border: 1px solid var(--line);
124
+ border-radius: 8px;
125
+ background: var(--panel);
126
+ box-shadow: var(--shadow);
127
+ overflow: hidden;
128
+ }
129
+
130
+ .pane header {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ gap: 10px;
135
+ padding: 10px 12px;
136
+ border-bottom: 1px solid var(--line);
137
+ background: var(--panel-2);
138
+ }
139
+
140
+ .pane h2 {
141
+ margin: 0;
142
+ font-size: 13px;
143
+ font-weight: 650;
144
+ }
145
+
146
+ .editor-body {
147
+ display: grid;
148
+ grid-template-rows: auto 1fr;
149
+ min-height: 0;
150
+ }
151
+
152
+ .controls {
153
+ display: grid;
154
+ grid-template-columns: repeat(4, minmax(120px, 1fr));
155
+ gap: 8px;
156
+ padding: 10px;
157
+ border-bottom: 1px solid var(--line);
158
+ }
159
+
160
+ .field {
161
+ min-width: 0;
162
+ display: grid;
163
+ gap: 4px;
164
+ }
165
+
166
+ .field.wide {
167
+ grid-column: span 2;
168
+ }
169
+
170
+ label,
171
+ .check span {
172
+ color: var(--muted);
173
+ font-size: 11px;
174
+ }
175
+
176
+ input,
177
+ select,
178
+ textarea {
179
+ width: 100%;
180
+ border: 1px solid var(--line);
181
+ border-radius: 6px;
182
+ background: #ffffff;
183
+ color: var(--text);
184
+ outline-color: var(--accent);
185
+ }
186
+
187
+ input,
188
+ select {
189
+ height: 34px;
190
+ padding: 0 8px;
191
+ }
192
+
193
+ textarea {
194
+ min-height: 0;
195
+ resize: none;
196
+ line-height: 1.45;
197
+ padding: 10px;
198
+ tab-size: 2;
199
+ }
200
+
201
+ #markdown {
202
+ border: 0;
203
+ border-radius: 0;
204
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
205
+ font-size: 13px;
206
+ }
207
+
208
+ #custom-css {
209
+ min-height: 72px;
210
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
211
+ font-size: 12px;
212
+ }
213
+
214
+ .checks {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ align-items: end;
218
+ gap: 8px 14px;
219
+ }
220
+
221
+ .check {
222
+ display: inline-flex;
223
+ gap: 6px;
224
+ align-items: center;
225
+ min-height: 34px;
226
+ }
227
+
228
+ .check input {
229
+ width: 16px;
230
+ height: 16px;
231
+ }
232
+
233
+ .preview-wrap {
234
+ display: grid;
235
+ grid-template-rows: 1fr minmax(94px, 0.26fr);
236
+ min-height: 0;
237
+ }
238
+
239
+ #preview {
240
+ width: 100%;
241
+ height: 100%;
242
+ min-height: 320px;
243
+ border: 0;
244
+ background: #ffffff;
245
+ }
246
+
247
+ .diagnostics {
248
+ border-top: 1px solid var(--line);
249
+ background: #fbfcfd;
250
+ overflow: auto;
251
+ }
252
+
253
+ .diagnostics h3 {
254
+ margin: 10px 12px 4px;
255
+ color: var(--muted);
256
+ font-size: 11px;
257
+ font-weight: 650;
258
+ text-transform: uppercase;
259
+ }
260
+
261
+ .diagnostics ul {
262
+ margin: 0;
263
+ padding: 0 12px 12px;
264
+ list-style: none;
265
+ }
266
+
267
+ .diagnostics li {
268
+ border-left: 3px solid var(--accent);
269
+ margin-top: 7px;
270
+ padding: 4px 8px;
271
+ color: var(--text);
272
+ font-size: 12px;
273
+ }
274
+
275
+ .diagnostics li.error {
276
+ border-left-color: var(--danger);
277
+ }
278
+
279
+ .empty {
280
+ color: var(--muted);
281
+ }
282
+
283
+ @media (max-width: 980px) {
284
+ .topbar,
285
+ .workspace {
286
+ grid-template-columns: 1fr;
287
+ }
288
+
289
+ .actions {
290
+ justify-content: start;
291
+ }
292
+
293
+ .controls {
294
+ grid-template-columns: repeat(2, minmax(120px, 1fr));
295
+ }
296
+ }
297
+
298
+ @media (max-width: 620px) {
299
+ .controls {
300
+ grid-template-columns: 1fr;
301
+ }
302
+
303
+ .field.wide {
304
+ grid-column: auto;
305
+ }
306
+
307
+ .actions {
308
+ flex-wrap: wrap;
309
+ }
310
+ }
311
+ </style>
312
+ </head>
313
+ <body>
314
+ <main class="app">
315
+ <div class="topbar">
316
+ <div class="brand">
317
+ <strong>franken_markdown</strong>
318
+ <span id="status" class="status">loading wasm package</span>
319
+ </div>
320
+ <div class="actions">
321
+ <button id="render" class="button primary" type="button">Render</button>
322
+ <button id="download-pdf" class="button secondary" type="button">Download PDF</button>
323
+ </div>
324
+ </div>
325
+
326
+ <section class="workspace" aria-label="Markdown demo workspace">
327
+ <section class="pane" aria-label="Markdown input">
328
+ <header>
329
+ <h2>Markdown</h2>
330
+ <span id="source-size" class="status">0 bytes</span>
331
+ </header>
332
+ <div class="editor-body">
333
+ <div class="controls">
334
+ <label class="field">
335
+ Font
336
+ <select id="font">
337
+ <option value="sans">Sans</option>
338
+ <option value="serif">Serif</option>
339
+ </select>
340
+ </label>
341
+ <label class="field">
342
+ Dark CSS
343
+ <select id="dark-mode">
344
+ <option value="auto">Auto</option>
345
+ <option value="disabled">Disabled</option>
346
+ </select>
347
+ </label>
348
+ <label class="field">
349
+ Title
350
+ <input id="title" value="franken_markdown demo" />
351
+ </label>
352
+ <label class="field">
353
+ Author
354
+ <input id="author" value="FMD" />
355
+ </label>
356
+ <label class="field wide">
357
+ Custom CSS
358
+ <textarea id="custom-css" spellcheck="false" placeholder="body { max-width: 860px; }"></textarea>
359
+ </label>
360
+ <div class="checks">
361
+ <label class="check">
362
+ <input id="allow-html" type="checkbox" />
363
+ <span>Allow raw HTML</span>
364
+ </label>
365
+ <label class="check">
366
+ <input id="line-numbers" type="checkbox" checked />
367
+ <span>PDF line numbers</span>
368
+ </label>
369
+ </div>
370
+ </div>
371
+ <textarea id="markdown" spellcheck="false"></textarea>
372
+ </div>
373
+ </section>
374
+
375
+ <section class="pane" aria-label="Rendered output">
376
+ <header>
377
+ <h2>Preview</h2>
378
+ <span id="preview-meta" class="status">waiting</span>
379
+ </header>
380
+ <div class="preview-wrap">
381
+ <iframe id="preview" title="Rendered HTML preview" sandbox=""></iframe>
382
+ <aside class="diagnostics" aria-live="polite">
383
+ <h3>Diagnostics</h3>
384
+ <ul id="diagnostics">
385
+ <li class="empty">No diagnostics yet.</li>
386
+ </ul>
387
+ </aside>
388
+ </div>
389
+ </section>
390
+ </section>
391
+ </main>
392
+
393
+ <script type="module" src="./demo.js"></script>
394
+ </body>
395
+ </html>