@rohal12/spindle 0.1.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.
Files changed (65) hide show
  1. package/README.md +66 -0
  2. package/dist/pkg/format.js +1 -0
  3. package/dist/pkg/index.js +12 -0
  4. package/dist/pkg/types/globals.d.ts +18 -0
  5. package/dist/pkg/types/index.d.ts +158 -0
  6. package/package.json +71 -0
  7. package/src/components/App.tsx +53 -0
  8. package/src/components/Passage.tsx +36 -0
  9. package/src/components/PassageLink.tsx +35 -0
  10. package/src/components/SaveLoadDialog.tsx +403 -0
  11. package/src/components/SettingsDialog.tsx +106 -0
  12. package/src/components/StoryInterface.tsx +31 -0
  13. package/src/components/macros/Back.tsx +23 -0
  14. package/src/components/macros/Button.tsx +49 -0
  15. package/src/components/macros/Checkbox.tsx +41 -0
  16. package/src/components/macros/Computed.tsx +100 -0
  17. package/src/components/macros/Cycle.tsx +39 -0
  18. package/src/components/macros/Do.tsx +46 -0
  19. package/src/components/macros/For.tsx +113 -0
  20. package/src/components/macros/Forward.tsx +25 -0
  21. package/src/components/macros/Goto.tsx +23 -0
  22. package/src/components/macros/If.tsx +63 -0
  23. package/src/components/macros/Include.tsx +52 -0
  24. package/src/components/macros/Listbox.tsx +42 -0
  25. package/src/components/macros/MacroLink.tsx +107 -0
  26. package/src/components/macros/Numberbox.tsx +43 -0
  27. package/src/components/macros/Print.tsx +48 -0
  28. package/src/components/macros/QuickLoad.tsx +33 -0
  29. package/src/components/macros/QuickSave.tsx +22 -0
  30. package/src/components/macros/Radiobutton.tsx +59 -0
  31. package/src/components/macros/Repeat.tsx +53 -0
  32. package/src/components/macros/Restart.tsx +27 -0
  33. package/src/components/macros/Saves.tsx +25 -0
  34. package/src/components/macros/Set.tsx +36 -0
  35. package/src/components/macros/SettingsButton.tsx +29 -0
  36. package/src/components/macros/Stop.tsx +12 -0
  37. package/src/components/macros/StoryTitle.tsx +20 -0
  38. package/src/components/macros/Switch.tsx +69 -0
  39. package/src/components/macros/Textarea.tsx +41 -0
  40. package/src/components/macros/Textbox.tsx +40 -0
  41. package/src/components/macros/Timed.tsx +63 -0
  42. package/src/components/macros/Type.tsx +83 -0
  43. package/src/components/macros/Unset.tsx +25 -0
  44. package/src/components/macros/VarDisplay.tsx +44 -0
  45. package/src/components/macros/Widget.tsx +18 -0
  46. package/src/components/macros/option-utils.ts +14 -0
  47. package/src/expression.ts +93 -0
  48. package/src/index.tsx +120 -0
  49. package/src/markup/ast.ts +284 -0
  50. package/src/markup/markdown.ts +21 -0
  51. package/src/markup/render.tsx +537 -0
  52. package/src/markup/tokenizer.ts +581 -0
  53. package/src/parser.ts +72 -0
  54. package/src/registry.ts +21 -0
  55. package/src/saves/idb.ts +165 -0
  56. package/src/saves/save-manager.ts +317 -0
  57. package/src/saves/types.ts +40 -0
  58. package/src/settings.ts +96 -0
  59. package/src/store.ts +317 -0
  60. package/src/story-api.ts +129 -0
  61. package/src/story-init.ts +67 -0
  62. package/src/story-variables.ts +166 -0
  63. package/src/styles.css +780 -0
  64. package/src/utils/parse-delay.ts +14 -0
  65. package/src/widgets/widget-registry.ts +15 -0
@@ -0,0 +1,25 @@
1
+ import { useLayoutEffect } from 'preact/hooks';
2
+ import { useStoryStore } from '../../store';
3
+
4
+ interface UnsetProps {
5
+ rawArgs: string;
6
+ }
7
+
8
+ export function Unset({ rawArgs }: UnsetProps) {
9
+ useLayoutEffect(() => {
10
+ const state = useStoryStore.getState();
11
+ const name = rawArgs.trim();
12
+
13
+ if (name.startsWith('$')) {
14
+ state.deleteVariable(name.slice(1));
15
+ } else if (name.startsWith('_')) {
16
+ state.deleteTemporary(name.slice(1));
17
+ } else {
18
+ console.error(
19
+ `spindle: {unset} expects a variable ($name or _name), got "${name}"`,
20
+ );
21
+ }
22
+ }, []);
23
+
24
+ return null;
25
+ }
@@ -0,0 +1,44 @@
1
+ import { useStoryStore } from '../../store';
2
+ import { useContext } from 'preact/hooks';
3
+ import { LocalsContext } from '../../markup/render';
4
+
5
+ interface VarDisplayProps {
6
+ name: string;
7
+ scope: 'variable' | 'temporary';
8
+ className?: string;
9
+ id?: string;
10
+ }
11
+
12
+ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
13
+ const locals = useContext(LocalsContext);
14
+ const parts = name.split('.');
15
+ const root = parts[0];
16
+ const storeValue = useStoryStore((s) =>
17
+ scope === 'variable' ? s.variables[root] : s.temporary[root],
18
+ );
19
+
20
+ // Locals (from for-loops) override store values
21
+ const key = scope === 'variable' ? `$${root}` : `_${root}`;
22
+ let value = key in locals ? locals[key] : storeValue;
23
+
24
+ // Resolve dot path (e.g. "character.name" → character['name'])
25
+ for (let i = 1; i < parts.length; i++) {
26
+ if (value == null || typeof value !== 'object') {
27
+ value = undefined;
28
+ break;
29
+ }
30
+ value = (value as Record<string, unknown>)[parts[i]];
31
+ }
32
+
33
+ const display = value == null ? '' : String(value);
34
+ if (className || id)
35
+ return (
36
+ <span
37
+ id={id}
38
+ class={className}
39
+ >
40
+ {display}
41
+ </span>
42
+ );
43
+ return <>{display}</>;
44
+ }
@@ -0,0 +1,18 @@
1
+ import { useLayoutEffect } from 'preact/hooks';
2
+ import { registerWidget } from '../../widgets/widget-registry';
3
+ import type { ASTNode } from '../../markup/ast';
4
+
5
+ interface WidgetProps {
6
+ rawArgs: string;
7
+ children: ASTNode[];
8
+ }
9
+
10
+ export function Widget({ rawArgs, children }: WidgetProps) {
11
+ const name = rawArgs.trim().replace(/["']/g, '');
12
+
13
+ useLayoutEffect(() => {
14
+ registerWidget(name, children);
15
+ }, []);
16
+
17
+ return null;
18
+ }
@@ -0,0 +1,14 @@
1
+ import type { ASTNode } from '../../markup/ast';
2
+
3
+ /**
4
+ * Walk AST children to find {option} macro nodes, returning their rawArgs as values.
5
+ */
6
+ export function extractOptions(children: ASTNode[]): string[] {
7
+ const options: string[] = [];
8
+ for (const node of children) {
9
+ if (node.type === 'macro' && node.name === 'option') {
10
+ options.push(node.rawArgs.trim());
11
+ }
12
+ }
13
+ return options;
14
+ }
@@ -0,0 +1,93 @@
1
+ import type { StoryState } from './store';
2
+ import { useStoryStore } from './store';
3
+
4
+ const fnCache = new Map<string, Function>();
5
+
6
+ /**
7
+ * Transform expression: $var → variables["var"], _var → temporary["var"]
8
+ * Only transforms when $ or _ appears as a word boundary (not inside strings naively,
9
+ * but authors already have full JS access so this is acceptable).
10
+ */
11
+ function transform(expr: string): string {
12
+ return expr
13
+ .replace(/\$(\w+)/g, 'variables["$1"]')
14
+ .replace(/\b_(\w+)/g, 'temporary["$1"]');
15
+ }
16
+
17
+ const preamble =
18
+ 'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll}=__fns;';
19
+
20
+ function getOrCompile(key: string, body: string): Function {
21
+ let fn = fnCache.get(key);
22
+ if (!fn) {
23
+ fn = new Function('variables', 'temporary', '__fns', preamble + body);
24
+ fnCache.set(key, fn);
25
+ }
26
+ return fn;
27
+ }
28
+
29
+ export function buildExpressionFns() {
30
+ const state = useStoryStore.getState();
31
+ const { visitCounts, renderCounts } = state;
32
+
33
+ const visited = (name: string): number => visitCounts[name] ?? 0;
34
+ const hasVisited = (name: string): boolean => visited(name) > 0;
35
+ const hasVisitedAny = (...names: string[]): boolean =>
36
+ names.some((n) => visited(n) > 0);
37
+ const hasVisitedAll = (...names: string[]): boolean =>
38
+ names.every((n) => visited(n) > 0);
39
+
40
+ const rendered = (name: string): number => renderCounts[name] ?? 0;
41
+ const hasRendered = (name: string): boolean => rendered(name) > 0;
42
+ const hasRenderedAny = (...names: string[]): boolean =>
43
+ names.some((n) => rendered(n) > 0);
44
+ const hasRenderedAll = (...names: string[]): boolean =>
45
+ names.every((n) => rendered(n) > 0);
46
+
47
+ return {
48
+ visited,
49
+ hasVisited,
50
+ hasVisitedAny,
51
+ hasVisitedAll,
52
+ rendered,
53
+ hasRendered,
54
+ hasRenderedAny,
55
+ hasRenderedAll,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Evaluate an expression and return its value.
61
+ * e.g. evaluate("$health + 10", variables, temporary) → number
62
+ */
63
+ export function evaluate(
64
+ expr: string,
65
+ variables: Record<string, unknown>,
66
+ temporary: Record<string, unknown>,
67
+ ): unknown {
68
+ const transformed = transform(expr);
69
+ const body = `return (${transformed});`;
70
+ const fn = getOrCompile(body, body);
71
+ return fn(variables, temporary, buildExpressionFns());
72
+ }
73
+
74
+ /**
75
+ * Execute statements (no return value).
76
+ * e.g. execute("$health = 100; $name = 'Hero'", variables, temporary)
77
+ */
78
+ export function execute(
79
+ code: string,
80
+ variables: Record<string, unknown>,
81
+ temporary: Record<string, unknown>,
82
+ ): void {
83
+ const transformed = transform(code);
84
+ const fn = getOrCompile('exec:' + transformed, transformed);
85
+ fn(variables, temporary, buildExpressionFns());
86
+ }
87
+
88
+ /**
89
+ * Convenience: evaluate using store state directly.
90
+ */
91
+ export function evaluateWithState(expr: string, state: StoryState): unknown {
92
+ return evaluate(expr, state.variables, state.temporary);
93
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,120 @@
1
+ import { render } from 'preact';
2
+ import { App } from './components/App';
3
+ import { parseStoryData } from './parser';
4
+ import { useStoryStore } from './store';
5
+ import { installStoryAPI } from './story-api';
6
+ import { executeStoryInit } from './story-init';
7
+ import {
8
+ parseStoryVariables,
9
+ validatePassages,
10
+ extractDefaults,
11
+ } from './story-variables';
12
+ import { tokenize } from './markup/tokenizer';
13
+ import { buildAST } from './markup/ast';
14
+ import { registerWidget } from './widgets/widget-registry';
15
+ import type { ASTNode } from './markup/ast';
16
+ import './styles.css';
17
+
18
+ function renderErrors(root: HTMLElement, errors: string[]) {
19
+ root.innerHTML = '';
20
+ const container = document.createElement('div');
21
+ container.style.cssText =
22
+ 'font-family:monospace;padding:2rem;max-width:60rem;margin:0 auto';
23
+ const heading = document.createElement('h1');
24
+ heading.style.color = '#c00';
25
+ heading.textContent = 'Story Validation Errors';
26
+ container.appendChild(heading);
27
+ const list = document.createElement('ul');
28
+ list.style.cssText = 'line-height:1.6';
29
+ for (const msg of errors) {
30
+ const li = document.createElement('li');
31
+ li.textContent = msg;
32
+ list.appendChild(li);
33
+ }
34
+ container.appendChild(list);
35
+ root.appendChild(container);
36
+ }
37
+
38
+ function boot() {
39
+ const storyData = parseStoryData();
40
+
41
+ // Install Story API before author script runs
42
+ installStoryAPI();
43
+
44
+ // Apply author CSS
45
+ if (storyData.userCSS) {
46
+ const style = document.createElement('style');
47
+ style.textContent = storyData.userCSS;
48
+ document.head.appendChild(style);
49
+ }
50
+
51
+ // Execute author JavaScript
52
+ if (storyData.userScript) {
53
+ try {
54
+ new Function(storyData.userScript)();
55
+ } catch (err) {
56
+ console.error('spindle: Error in story JavaScript:', err);
57
+ }
58
+ }
59
+
60
+ // Parse StoryVariables and validate all passages
61
+ let defaults: Record<string, unknown> = {};
62
+ const storyVarsPassage = storyData.passages.get('StoryVariables');
63
+
64
+ if (!storyVarsPassage) {
65
+ const msg =
66
+ 'Missing StoryVariables passage. Add a :: StoryVariables passage to declare your variables.';
67
+ const root = document.getElementById('root');
68
+ if (root) renderErrors(root, [msg]);
69
+ throw new Error(`spindle: ${msg}`);
70
+ }
71
+
72
+ const schema = parseStoryVariables(storyVarsPassage.content);
73
+ const errors = validatePassages(storyData.passages, schema);
74
+
75
+ if (errors.length > 0) {
76
+ const root = document.getElementById('root');
77
+ if (root) renderErrors(root, errors);
78
+ throw new Error(
79
+ `spindle: ${errors.length} validation error(s):\n${errors.join('\n')}`,
80
+ );
81
+ }
82
+
83
+ defaults = extractDefaults(schema);
84
+
85
+ useStoryStore.getState().init(storyData, defaults);
86
+
87
+ // Execute StoryInit passage if it exists
88
+ executeStoryInit();
89
+
90
+ // Register widgets from passages tagged "widget"
91
+ for (const [, passage] of storyData.passages) {
92
+ if (passage.tags.includes('widget')) {
93
+ const widgetTokens = tokenize(passage.content);
94
+ const widgetAST = buildAST(widgetTokens);
95
+ for (const node of widgetAST) {
96
+ if (
97
+ node.type === 'macro' &&
98
+ node.name === 'widget' &&
99
+ node.rawArgs
100
+ ) {
101
+ const widgetName = node.rawArgs.trim().replace(/["']/g, '');
102
+ registerWidget(widgetName, node.children as ASTNode[]);
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ const root = document.getElementById('root');
109
+ if (!root) {
110
+ throw new Error('spindle: No <div id="root"> element found.');
111
+ }
112
+
113
+ render(<App />, root);
114
+ }
115
+
116
+ if (document.readyState === 'loading') {
117
+ document.addEventListener('DOMContentLoaded', boot);
118
+ } else {
119
+ boot();
120
+ }
@@ -0,0 +1,284 @@
1
+ import type { Token } from './tokenizer';
2
+
3
+ export interface TextNode {
4
+ type: 'text';
5
+ value: string;
6
+ }
7
+
8
+ export interface LinkNode {
9
+ type: 'link';
10
+ display: string;
11
+ target: string;
12
+ className?: string;
13
+ id?: string;
14
+ }
15
+
16
+ export interface VariableNode {
17
+ type: 'variable';
18
+ name: string;
19
+ scope: 'variable' | 'temporary';
20
+ className?: string;
21
+ id?: string;
22
+ }
23
+
24
+ export interface Branch {
25
+ rawArgs: string;
26
+ className?: string;
27
+ id?: string;
28
+ children: ASTNode[];
29
+ }
30
+
31
+ export interface MacroNode {
32
+ type: 'macro';
33
+ name: string;
34
+ rawArgs: string;
35
+ children: ASTNode[];
36
+ branches?: Branch[];
37
+ className?: string;
38
+ id?: string;
39
+ }
40
+
41
+ export interface HtmlNode {
42
+ type: 'html';
43
+ tag: string;
44
+ attributes: Record<string, string>;
45
+ children: ASTNode[];
46
+ }
47
+
48
+ export type ASTNode = TextNode | LinkNode | VariableNode | MacroNode | HtmlNode;
49
+
50
+ /** Macros that require a closing tag and can contain children */
51
+ const BLOCK_MACROS = new Set([
52
+ 'if',
53
+ 'for',
54
+ 'do',
55
+ 'button',
56
+ 'link',
57
+ 'listbox',
58
+ 'cycle',
59
+ 'switch',
60
+ 'timed',
61
+ 'repeat',
62
+ 'type',
63
+ 'widget',
64
+ ]);
65
+
66
+ /** Map from branch macro name → required parent macro name */
67
+ const BRANCH_PARENT: Record<string, string> = {
68
+ elseif: 'if',
69
+ else: 'if',
70
+ case: 'switch',
71
+ default: 'switch',
72
+ next: 'timed',
73
+ };
74
+
75
+ /** Block macros that use the branches[] array */
76
+ const BRANCHING_BLOCK_MACROS = new Set(['if', 'switch', 'timed']);
77
+
78
+ /**
79
+ * Build an AST from a token array. Block macros are nested into trees
80
+ * using a stack. Throws on unclosed or mismatched macros.
81
+ */
82
+ export function buildAST(tokens: Token[]): ASTNode[] {
83
+ const root: ASTNode[] = [];
84
+
85
+ // Stack entries: the node being built and its token start position
86
+ const stack: { node: MacroNode | HtmlNode; start: number }[] = [];
87
+
88
+ function current(): ASTNode[] {
89
+ if (stack.length === 0) return root;
90
+ const top = stack[stack.length - 1].node;
91
+ // For if-blocks, append to the last branch's children
92
+ if (
93
+ top.type === 'macro' &&
94
+ top.branches &&
95
+ top.branches.length > 0
96
+ ) {
97
+ return top.branches[top.branches.length - 1].children;
98
+ }
99
+ return top.children;
100
+ }
101
+
102
+ for (const token of tokens) {
103
+ switch (token.type) {
104
+ case 'text':
105
+ current().push({ type: 'text', value: token.value });
106
+ break;
107
+
108
+ case 'link': {
109
+ const linkNode: LinkNode = {
110
+ type: 'link',
111
+ display: token.display,
112
+ target: token.target,
113
+ };
114
+ if (token.className) linkNode.className = token.className;
115
+ if (token.id) linkNode.id = token.id;
116
+ current().push(linkNode);
117
+ break;
118
+ }
119
+
120
+ case 'variable': {
121
+ const varNode: VariableNode = {
122
+ type: 'variable',
123
+ name: token.name,
124
+ scope: token.scope,
125
+ };
126
+ if (token.className) varNode.className = token.className;
127
+ if (token.id) varNode.id = token.id;
128
+ current().push(varNode);
129
+ break;
130
+ }
131
+
132
+ case 'html': {
133
+ if (token.isSelfClose) {
134
+ // Self-closing HTML tag (br, hr, img, etc.)
135
+ current().push({
136
+ type: 'html',
137
+ tag: token.tag,
138
+ attributes: token.attributes,
139
+ children: [],
140
+ });
141
+ break;
142
+ }
143
+
144
+ if (token.isClose) {
145
+ // Closing HTML tag — pop from stack
146
+ if (stack.length === 0) {
147
+ throw new Error(
148
+ `Unexpected closing </${token.tag}> (at character ${token.start})`,
149
+ );
150
+ }
151
+
152
+ const top = stack[stack.length - 1];
153
+ if (top.node.type !== 'html' || top.node.tag !== token.tag) {
154
+ const expected =
155
+ top.node.type === 'html'
156
+ ? `</${top.node.tag}>`
157
+ : `{/${top.node.name}}`;
158
+ throw new Error(
159
+ `Expected ${expected} but found </${token.tag}> (at character ${token.start})`,
160
+ );
161
+ }
162
+
163
+ stack.pop();
164
+ current().push(top.node);
165
+ break;
166
+ }
167
+
168
+ // Opening HTML tag — push onto stack
169
+ const htmlNode: HtmlNode = {
170
+ type: 'html',
171
+ tag: token.tag,
172
+ attributes: token.attributes,
173
+ children: [],
174
+ };
175
+ stack.push({ node: htmlNode, start: token.start });
176
+ break;
177
+ }
178
+
179
+ case 'macro': {
180
+ if (token.isClose) {
181
+ // Closing tag — pop from stack
182
+ if (stack.length === 0) {
183
+ throw new Error(
184
+ `Unexpected closing {/${token.name}} (at character ${token.start})`,
185
+ );
186
+ }
187
+
188
+ const top = stack[stack.length - 1];
189
+ if (top.node.type !== 'macro' || top.node.name !== token.name) {
190
+ const expected =
191
+ top.node.type === 'macro'
192
+ ? `{/${top.node.name}}`
193
+ : `</${top.node.tag}>`;
194
+ throw new Error(
195
+ `Expected ${expected} but found {/${token.name}} (at character ${token.start})`,
196
+ );
197
+ }
198
+
199
+ stack.pop();
200
+ current().push(top.node);
201
+ break;
202
+ }
203
+
204
+ // Handle branch macros (elseif/else, case/default, next)
205
+ if (BRANCH_PARENT[token.name]) {
206
+ const expectedParent = BRANCH_PARENT[token.name];
207
+ const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
208
+ if (
209
+ !topNode ||
210
+ topNode.type !== 'macro' ||
211
+ topNode.name !== expectedParent
212
+ ) {
213
+ throw new Error(
214
+ `{${token.name}} without matching {${expectedParent}} (at character ${token.start})`,
215
+ );
216
+ }
217
+
218
+ const branch: Branch = {
219
+ rawArgs: token.rawArgs,
220
+ children: [],
221
+ };
222
+ if (token.className) branch.className = token.className;
223
+ if (token.id) branch.id = token.id;
224
+ topNode.branches!.push(branch);
225
+ break;
226
+ }
227
+
228
+ // Block macro — push onto stack
229
+ if (BLOCK_MACROS.has(token.name)) {
230
+ const node: MacroNode = {
231
+ type: 'macro',
232
+ name: token.name,
233
+ rawArgs: token.rawArgs,
234
+ children: [],
235
+ };
236
+
237
+ // Branching blocks: className/id goes on the first branch, not the node
238
+ if (BRANCHING_BLOCK_MACROS.has(token.name)) {
239
+ const firstBranch: Branch = {
240
+ rawArgs: token.rawArgs,
241
+ children: [],
242
+ };
243
+ if (token.className) firstBranch.className = token.className;
244
+ if (token.id) firstBranch.id = token.id;
245
+ node.branches = [firstBranch];
246
+ } else {
247
+ if (token.className) node.className = token.className;
248
+ if (token.id) node.id = token.id;
249
+ }
250
+
251
+ stack.push({ node, start: token.start });
252
+ break;
253
+ }
254
+
255
+ // Self-closing macro (set, print, etc.)
256
+ {
257
+ const macroNode: MacroNode = {
258
+ type: 'macro',
259
+ name: token.name,
260
+ rawArgs: token.rawArgs,
261
+ children: [],
262
+ };
263
+ if (token.className) macroNode.className = token.className;
264
+ if (token.id) macroNode.id = token.id;
265
+ current().push(macroNode);
266
+ }
267
+ break;
268
+ }
269
+ }
270
+ }
271
+
272
+ if (stack.length > 0) {
273
+ const unclosed = stack[stack.length - 1];
274
+ const label =
275
+ unclosed.node.type === 'html'
276
+ ? `<${unclosed.node.tag}>`
277
+ : `{${unclosed.node.name}} macro`;
278
+ throw new Error(
279
+ `Unclosed ${label} (opened at character ${unclosed.start})`,
280
+ );
281
+ }
282
+
283
+ return root;
284
+ }
@@ -0,0 +1,21 @@
1
+ import { micromark } from 'micromark';
2
+ import {
3
+ gfmTable,
4
+ gfmTableHtml,
5
+ } from 'micromark-extension-gfm-table';
6
+ import {
7
+ gfmStrikethrough,
8
+ gfmStrikethroughHtml,
9
+ } from 'micromark-extension-gfm-strikethrough';
10
+
11
+ /**
12
+ * Parse a text string as CommonMark markdown and return an HTML string.
13
+ * Includes GFM table and strikethrough extensions.
14
+ */
15
+ export function markdownToHtml(text: string): string {
16
+ return micromark(text, {
17
+ allowDangerousHtml: true,
18
+ extensions: [gfmTable(), gfmStrikethrough()],
19
+ htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()],
20
+ });
21
+ }