@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.
- package/README.md +66 -0
- package/dist/pkg/format.js +1 -0
- package/dist/pkg/index.js +12 -0
- package/dist/pkg/types/globals.d.ts +18 -0
- package/dist/pkg/types/index.d.ts +158 -0
- package/package.json +71 -0
- package/src/components/App.tsx +53 -0
- package/src/components/Passage.tsx +36 -0
- package/src/components/PassageLink.tsx +35 -0
- package/src/components/SaveLoadDialog.tsx +403 -0
- package/src/components/SettingsDialog.tsx +106 -0
- package/src/components/StoryInterface.tsx +31 -0
- package/src/components/macros/Back.tsx +23 -0
- package/src/components/macros/Button.tsx +49 -0
- package/src/components/macros/Checkbox.tsx +41 -0
- package/src/components/macros/Computed.tsx +100 -0
- package/src/components/macros/Cycle.tsx +39 -0
- package/src/components/macros/Do.tsx +46 -0
- package/src/components/macros/For.tsx +113 -0
- package/src/components/macros/Forward.tsx +25 -0
- package/src/components/macros/Goto.tsx +23 -0
- package/src/components/macros/If.tsx +63 -0
- package/src/components/macros/Include.tsx +52 -0
- package/src/components/macros/Listbox.tsx +42 -0
- package/src/components/macros/MacroLink.tsx +107 -0
- package/src/components/macros/Numberbox.tsx +43 -0
- package/src/components/macros/Print.tsx +48 -0
- package/src/components/macros/QuickLoad.tsx +33 -0
- package/src/components/macros/QuickSave.tsx +22 -0
- package/src/components/macros/Radiobutton.tsx +59 -0
- package/src/components/macros/Repeat.tsx +53 -0
- package/src/components/macros/Restart.tsx +27 -0
- package/src/components/macros/Saves.tsx +25 -0
- package/src/components/macros/Set.tsx +36 -0
- package/src/components/macros/SettingsButton.tsx +29 -0
- package/src/components/macros/Stop.tsx +12 -0
- package/src/components/macros/StoryTitle.tsx +20 -0
- package/src/components/macros/Switch.tsx +69 -0
- package/src/components/macros/Textarea.tsx +41 -0
- package/src/components/macros/Textbox.tsx +40 -0
- package/src/components/macros/Timed.tsx +63 -0
- package/src/components/macros/Type.tsx +83 -0
- package/src/components/macros/Unset.tsx +25 -0
- package/src/components/macros/VarDisplay.tsx +44 -0
- package/src/components/macros/Widget.tsx +18 -0
- package/src/components/macros/option-utils.ts +14 -0
- package/src/expression.ts +93 -0
- package/src/index.tsx +120 -0
- package/src/markup/ast.ts +284 -0
- package/src/markup/markdown.ts +21 -0
- package/src/markup/render.tsx +537 -0
- package/src/markup/tokenizer.ts +581 -0
- package/src/parser.ts +72 -0
- package/src/registry.ts +21 -0
- package/src/saves/idb.ts +165 -0
- package/src/saves/save-manager.ts +317 -0
- package/src/saves/types.ts +40 -0
- package/src/settings.ts +96 -0
- package/src/store.ts +317 -0
- package/src/story-api.ts +129 -0
- package/src/story-init.ts +67 -0
- package/src/story-variables.ts +166 -0
- package/src/styles.css +780 -0
- package/src/utils/parse-delay.ts +14 -0
- 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
|
+
}
|