@xvml/cli 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.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.js';
3
+ runCli();
@@ -0,0 +1,85 @@
1
+ import { Anthropic } from '@anthropic-ai/sdk';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ // Loaded once per process, cached here.
7
+ let cachedSpec = null;
8
+ async function loadSpec() {
9
+ if (cachedSpec)
10
+ return cachedSpec;
11
+ // ts-node: src/agent.ts → __dirname = .../src/ → ../XVML_SPEC.md = project root
12
+ // compiled: dist/src/agent.js → __dirname = .../dist/src/ → ../../XVML_SPEC.md = dist/ (copied by build)
13
+ const candidates = [
14
+ path.resolve(__dirname, '../XVML_SPEC.md'),
15
+ path.resolve(__dirname, '../../XVML_SPEC.md'),
16
+ ];
17
+ for (const p of candidates) {
18
+ try {
19
+ cachedSpec = await fs.readFile(p, 'utf-8');
20
+ return cachedSpec;
21
+ }
22
+ catch { /* try next */ }
23
+ }
24
+ throw new Error(`XVML_SPEC.md not found. Searched:\n${candidates.join('\n')}\n` +
25
+ 'Run "npm run build" to copy XVML_SPEC.md into dist/.');
26
+ }
27
+ const SYSTEM_PROMPT_PREFIX = `\
28
+ You are a XVML page generator. XVML (eXpressive Visual Markup Language) is a plain-text format that renders as live UI.
29
+
30
+ Below is the complete XVML specification — every command, its arguments, and the HTML it produces.
31
+ You must follow it exactly. Output only valid XVML. No markdown fences, no explanation, no prose.
32
+
33
+ Rules you must never break:
34
+ - Every command starts with @ on its own line
35
+ - String arguments use double quotes: "value"
36
+ - Modifier/keyword arguments are bare words: primary, muted, striped
37
+ - Block commands must be closed with @end (or @@end inside @codeblock)
38
+ - Do NOT use @if @each @bind @on:click @event @agent — they are reserved and cause parse errors
39
+ - Do NOT emit raw HTML — only XVML commands
40
+ - Always start the file with @page, optionally preceded by @spec and @meta
41
+ - Put content inside @card ... @end blocks
42
+ - Use @cols for side-by-side layouts, @layout inline for inline button rows
43
+ - Use @stats / @stat-row for metric grids
44
+ - Use @table for tabular data (first @row is the header)
45
+ - For @alert: variant keyword (info/warn/error/success) comes BEFORE the message string
46
+ - For @stat: value (big display number) is the first string, label is the second
47
+ - For @field: type keyword (email/password/text/number) comes before the label string
48
+ - For @codeblock: close with @@end (not @end) if the code inside contains @end lines
49
+
50
+ --- XVML_SPEC.md ---
51
+ `;
52
+ export async function askClaude(task, options = {}) {
53
+ const key = process.env['ANTHROPIC_API_KEY'];
54
+ if (!key) {
55
+ throw new Error('ANTHROPIC_API_KEY is not set.\n' +
56
+ 'Export it before running: export ANTHROPIC_API_KEY=sk-ant-...');
57
+ }
58
+ const spec = await loadSpec();
59
+ const system = SYSTEM_PROMPT_PREFIX + spec;
60
+ const client = new Anthropic({ apiKey: key });
61
+ const response = await client.messages.create({
62
+ model: options.model ?? 'claude-sonnet-4-6',
63
+ max_tokens: 4096,
64
+ temperature: 0,
65
+ system,
66
+ messages: [
67
+ {
68
+ role: 'user',
69
+ content: `Generate a XVML page for the following task:\n\n${task}`,
70
+ },
71
+ ],
72
+ });
73
+ const block = response.content[0];
74
+ if (block.type !== 'text')
75
+ throw new Error('Unexpected non-text response from Claude');
76
+ return block.text.trim();
77
+ }
78
+ // Derive a safe filename slug from the task description.
79
+ export function slugify(task) {
80
+ return task
81
+ .toLowerCase()
82
+ .replace(/[^a-z0-9]+/g, '-')
83
+ .replace(/^-+|-+$/g, '')
84
+ .slice(0, 48) || 'page';
85
+ }
@@ -0,0 +1,252 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs/promises';
4
+ import { watch as fsWatch } from 'fs';
5
+ import path from 'path';
6
+ import fse from 'fs-extra';
7
+ import { renderFile, outputPath } from './renderer.js';
8
+ import { parse, ParseError } from './parser.js';
9
+ import { askClaude, slugify } from './agent.js';
10
+ const XVMLRC_DEFAULT = JSON.stringify({ outDir: 'docs', spec: 1 }, null, 2);
11
+ async function findVmlFiles(dir) {
12
+ const SKIP = new Set(['node_modules', 'docs', 'dist', '.git']);
13
+ const files = [];
14
+ const entries = await fs.readdir(dir, { withFileTypes: true });
15
+ for (const entry of entries) {
16
+ if (SKIP.has(entry.name) || entry.name.startsWith('.'))
17
+ continue;
18
+ const full = path.join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ files.push(...await findVmlFiles(full));
21
+ }
22
+ else if (entry.name.endsWith('.xvml')) {
23
+ files.push(full);
24
+ }
25
+ }
26
+ return files;
27
+ }
28
+ async function doRender(file) {
29
+ const out = outputPath(file);
30
+ const html = await renderFile(file);
31
+ await fse.outputFile(out, html, 'utf-8');
32
+ console.log(`${chalk.green('✓')} ${chalk.dim(file)} → ${chalk.cyan(out)}`);
33
+ }
34
+ async function doCheck(file) {
35
+ try {
36
+ const source = await fs.readFile(file, 'utf-8');
37
+ parse(source);
38
+ console.log(`${chalk.green('✓')} ${file}`);
39
+ return true;
40
+ }
41
+ catch (err) {
42
+ if (err instanceof ParseError) {
43
+ console.error(`${chalk.red('✕')} ${file}: ${chalk.red(err.message)}`);
44
+ }
45
+ else {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ console.error(`${chalk.red('✕')} ${file}: ${msg}`);
48
+ }
49
+ return false;
50
+ }
51
+ }
52
+ export function buildCli() {
53
+ const program = new Command();
54
+ program
55
+ .name('xvml')
56
+ .description('Renders .xvml files into deterministic self-contained HTML')
57
+ .version('1.0.0');
58
+ // xvml render <file> [--watch]
59
+ program
60
+ .command('render <file>')
61
+ .description('Render a .xvml file to docs/<file>.html')
62
+ .option('-w, --watch', 're-render on file change')
63
+ .action(async (file, opts) => {
64
+ try {
65
+ await doRender(file);
66
+ }
67
+ catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ console.error(`${chalk.red('Error:')} ${msg}`);
70
+ process.exit(1);
71
+ }
72
+ if (opts.watch) {
73
+ console.log(chalk.dim(`Watching ${file} for changes…`));
74
+ let debounce = null;
75
+ fsWatch(file, { persistent: true }, () => {
76
+ if (debounce)
77
+ clearTimeout(debounce);
78
+ debounce = setTimeout(async () => {
79
+ try {
80
+ await doRender(file);
81
+ }
82
+ catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ console.error(`${chalk.red('Error:')} ${msg}`);
85
+ }
86
+ }, 100);
87
+ });
88
+ }
89
+ });
90
+ // xvml check <file|glob...>
91
+ program
92
+ .command('check <patterns...>')
93
+ .description('Check .xvml files for spec compliance')
94
+ .action(async (patterns) => {
95
+ let passed = 0;
96
+ let failed = 0;
97
+ for (const pattern of patterns) {
98
+ const stat = await fs.stat(pattern).catch(() => null);
99
+ if (stat?.isDirectory()) {
100
+ const files = await findVmlFiles(pattern);
101
+ for (const f of files) {
102
+ (await doCheck(f)) ? passed++ : failed++;
103
+ }
104
+ }
105
+ else if (pattern.endsWith('.xvml')) {
106
+ (await doCheck(pattern)) ? passed++ : failed++;
107
+ }
108
+ }
109
+ const total = passed + failed;
110
+ if (total === 0) {
111
+ console.log(chalk.yellow('No .xvml files found.'));
112
+ return;
113
+ }
114
+ console.log(`\n${chalk.bold(String(total))} file${total === 1 ? '' : 's'} checked — ` +
115
+ `${chalk.green(String(passed))} passed, ${failed > 0 ? chalk.red(String(failed)) : chalk.dim('0')} failed`);
116
+ if (failed > 0)
117
+ process.exit(1);
118
+ });
119
+ // xvml build
120
+ program
121
+ .command('build')
122
+ .description('Render all .xvml files in the project to docs/')
123
+ .action(async () => {
124
+ const cwd = process.cwd();
125
+ const files = await findVmlFiles(cwd);
126
+ if (files.length === 0) {
127
+ console.log(chalk.yellow('No .xvml files found.'));
128
+ return;
129
+ }
130
+ let ok = 0;
131
+ let fail = 0;
132
+ for (const file of files) {
133
+ try {
134
+ await doRender(file);
135
+ ok++;
136
+ }
137
+ catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ console.error(`${chalk.red('✕')} ${file}: ${msg}`);
140
+ fail++;
141
+ }
142
+ }
143
+ console.log(`\n${chalk.bold(String(files.length))} file${files.length === 1 ? '' : 's'} built — ` +
144
+ `${chalk.green(String(ok))} ok${fail > 0 ? `, ${chalk.red(String(fail))} failed` : ''}`);
145
+ if (fail > 0)
146
+ process.exit(1);
147
+ });
148
+ // xvml ask "task description" [--out filename] [--model model-id]
149
+ program
150
+ .command('ask <task>')
151
+ .description('Ask Claude to generate a XVML page, then render it')
152
+ .option('-o, --out <filename>', 'output filename without extension (default: slugified task)')
153
+ .option('-m, --model <model>', 'Claude model ID', 'claude-sonnet-4-6')
154
+ .option('--print', 'print generated XVML to stdout without saving or rendering')
155
+ .action(async (task, opts) => {
156
+ const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
157
+ let spinIdx = 0;
158
+ const spinInterval = setInterval(() => {
159
+ process.stdout.write(`\r${chalk.cyan(spinner[spinIdx++ % spinner.length])} Asking Claude…`);
160
+ }, 80);
161
+ let xvml;
162
+ try {
163
+ xvml = await askClaude(task, { model: opts.model });
164
+ }
165
+ catch (err) {
166
+ clearInterval(spinInterval);
167
+ process.stdout.write('\r');
168
+ const msg = err instanceof Error
169
+ ? err.message
170
+ : typeof err === 'object' && err !== null && 'message' in err
171
+ ? String(err['message'])
172
+ : String(err);
173
+ console.error(`${chalk.red('Error:')} ${msg}`);
174
+ process.exit(1);
175
+ }
176
+ clearInterval(spinInterval);
177
+ process.stdout.write('\r');
178
+ if (opts.print) {
179
+ console.log(xvml);
180
+ return;
181
+ }
182
+ // Validate before writing — fail fast with a clear parse error.
183
+ try {
184
+ parse(xvml);
185
+ }
186
+ catch (err) {
187
+ const msg = err instanceof ParseError ? err.message : String(err);
188
+ console.error(`${chalk.red('Parse error in generated XVML:')} ${msg}`);
189
+ console.error(chalk.dim('Generated XVML:\n') + chalk.dim(xvml));
190
+ process.exit(1);
191
+ }
192
+ const slug = opts.out ?? slugify(task);
193
+ const xvmlPath = path.join('examples', `${slug}.xvml`);
194
+ await fse.outputFile(xvmlPath, xvml, 'utf-8');
195
+ console.log(`${chalk.green('✓')} ${chalk.dim('saved')} ${chalk.cyan(xvmlPath)}`);
196
+ try {
197
+ await doRender(xvmlPath);
198
+ }
199
+ catch (err) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ console.error(`${chalk.red('Render error:')} ${msg}`);
202
+ process.exit(1);
203
+ }
204
+ });
205
+ // xvml init
206
+ program
207
+ .command('init')
208
+ .description('Create CLAUDE.md and .xvmlrc in the current directory')
209
+ .action(async () => {
210
+ const cwd = process.cwd();
211
+ const xvmlrcPath = path.join(cwd, '.xvmlrc');
212
+ if (await fse.pathExists(xvmlrcPath)) {
213
+ console.log(chalk.yellow('.xvmlrc already exists — skipping'));
214
+ }
215
+ else {
216
+ await fs.writeFile(xvmlrcPath, XVMLRC_DEFAULT, 'utf-8');
217
+ console.log(`${chalk.green('✓')} Created ${chalk.cyan('.xvmlrc')}`);
218
+ }
219
+ const claudePath = path.join(cwd, 'CLAUDE.md');
220
+ if (await fse.pathExists(claudePath)) {
221
+ console.log(chalk.yellow('CLAUDE.md already exists — skipping'));
222
+ }
223
+ else {
224
+ const content = [
225
+ '# xvml',
226
+ '',
227
+ '- All UI pages must be written as `.xvml` files, never raw `.html`',
228
+ '- After creating any `.xvml` file, always run: `xvml render <file>`',
229
+ '- Output rendered files go to `/docs` folder',
230
+ '- TypeScript only, strict mode, no `any` types',
231
+ '- No external CDN in rendered HTML output',
232
+ '- Rendered HTML must be fully self-contained (CSS + JS inline)',
233
+ '- Deterministic rendering — same `.xvml` input always produces identical HTML',
234
+ '- Temperature `0` on all Claude API calls',
235
+ ].join('\n');
236
+ await fs.writeFile(claudePath, content, 'utf-8');
237
+ console.log(`${chalk.green('✓')} Created ${chalk.cyan('CLAUDE.md')}`);
238
+ }
239
+ });
240
+ return program;
241
+ }
242
+ export function runCli() {
243
+ buildCli().parseAsync(process.argv).catch((err) => {
244
+ const msg = err instanceof Error
245
+ ? err.message
246
+ : typeof err === 'object' && err !== null && 'message' in err
247
+ ? String(err['message'])
248
+ : String(err);
249
+ console.error(`${chalk.red('Error:')} ${msg}`);
250
+ process.exit(1);
251
+ });
252
+ }
@@ -0,0 +1,4 @@
1
+ // Programmatic API — use this when importing @xvml/cli as a library rather than a CLI tool.
2
+ export { renderSource, renderFile, outputPath } from './renderer.js';
3
+ export { parse, ParseError } from './parser.js';
4
+ export { askClaude, slugify } from './agent.js';
@@ -0,0 +1,256 @@
1
+ export class ParseError extends Error {
2
+ line;
3
+ constructor(message, line) {
4
+ super(line !== undefined ? `Line ${line}: ${message}` : message);
5
+ this.line = line;
6
+ this.name = 'ParseError';
7
+ }
8
+ }
9
+ const BLOCK_COMMANDS = new Set([
10
+ 'card', 'section', 'cols', 'stat-row', 'stats', 'list', 'table', 'codeblock', 'theme',
11
+ ]);
12
+ const DOC_DIRECTIVE_COMMANDS = new Set([
13
+ 'spec', 'file', 'meta', 'renderer', 'page',
14
+ ]);
15
+ const RESERVED_COMMANDS = new Set([
16
+ 'if', 'each', 'bind', 'on:click', 'event', 'agent',
17
+ ]);
18
+ const KNOWN_COMMANDS = new Set([
19
+ // layout
20
+ 'page', 'card', 'end', 'section', 'layout', 'cols',
21
+ // content
22
+ 'title', 'subtitle', 'text', 'divider', 'badge',
23
+ // navigation / presentation
24
+ 'nav', 'avatar',
25
+ // form
26
+ 'field', 'button', 'checkbox', 'select', 'link',
27
+ // data
28
+ 'table', 'row', 'stat', 'stat-row', 'stats', 'progress', 'list', 'item',
29
+ // code
30
+ 'codeblock', 'constraint', 'alert',
31
+ // meta
32
+ 'spec', 'file', 'meta', 'import', 'theme', 'renderer',
33
+ ]);
34
+ // Theme names — keywords that identify themes, not page names
35
+ const THEME_KEYWORDS = new Set(['light', 'dark', 'system']);
36
+ function parseArgs(argStr, lineNum) {
37
+ const args = [];
38
+ let i = 0;
39
+ while (i < argStr.length) {
40
+ const ch = argStr[i];
41
+ if (ch === ' ' || ch === '\t') {
42
+ i++;
43
+ continue;
44
+ }
45
+ if (ch === '"') {
46
+ // Quoted string
47
+ let j = i + 1;
48
+ while (j < argStr.length && argStr[j] !== '"')
49
+ j++;
50
+ if (j >= argStr.length)
51
+ throw new ParseError('Unterminated string argument', lineNum);
52
+ args.push({ type: 'string', value: argStr.slice(i + 1, j) });
53
+ i = j + 1;
54
+ }
55
+ else {
56
+ // Bare token — may be keyword, number, or key=value / key="value"
57
+ let j = i;
58
+ while (j < argStr.length && argStr[j] !== ' ' && argStr[j] !== '\t' && argStr[j] !== '=')
59
+ j++;
60
+ if (j < argStr.length && argStr[j] === '=') {
61
+ // key=value or key="value"
62
+ const key = argStr.slice(i, j);
63
+ j++; // skip '='
64
+ if (j < argStr.length && argStr[j] === '"') {
65
+ // key="quoted value"
66
+ let k = j + 1;
67
+ while (k < argStr.length && argStr[k] !== '"')
68
+ k++;
69
+ if (k >= argStr.length)
70
+ throw new ParseError(`Unterminated value for key "${key}"`, lineNum);
71
+ args.push({ type: 'keyvalue', key, value: argStr.slice(j + 1, k) });
72
+ i = k + 1;
73
+ }
74
+ else {
75
+ // key=bare_value
76
+ let k = j;
77
+ while (k < argStr.length && argStr[k] !== ' ' && argStr[k] !== '\t')
78
+ k++;
79
+ args.push({ type: 'keyvalue', key, value: argStr.slice(j, k) });
80
+ i = k;
81
+ }
82
+ }
83
+ else {
84
+ const token = argStr.slice(i, j);
85
+ const num = Number(token);
86
+ if (token !== '' && !isNaN(num)) {
87
+ args.push({ type: 'number', value: num });
88
+ }
89
+ else {
90
+ args.push({ type: 'keyword', value: token });
91
+ }
92
+ i = j;
93
+ }
94
+ }
95
+ }
96
+ return args;
97
+ }
98
+ function parseThemeVars(rawLines, lineNum) {
99
+ const vars = {};
100
+ for (const line of rawLines) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed)
103
+ continue;
104
+ const a = parseArgs(trimmed, lineNum);
105
+ const k = a[0];
106
+ const v = a[1];
107
+ if (k?.type === 'string' && v?.type === 'string') {
108
+ vars[k.value] = v.value;
109
+ }
110
+ }
111
+ return vars;
112
+ }
113
+ function handleDocDirective(doc, cmd, args) {
114
+ switch (cmd) {
115
+ case 'spec': {
116
+ const a = args[0];
117
+ if (a?.type === 'number')
118
+ doc.specVersion = a.value;
119
+ break;
120
+ }
121
+ case 'file': {
122
+ const a = args[0];
123
+ if (a?.type === 'string')
124
+ doc.filePath = a.value;
125
+ break;
126
+ }
127
+ case 'meta': {
128
+ const k = args[0];
129
+ const v = args[1];
130
+ if (k?.type === 'string' && v?.type === 'string') {
131
+ doc.metaTags.push({ key: k.value, value: v.value });
132
+ }
133
+ break;
134
+ }
135
+ case 'renderer': {
136
+ for (const a of args) {
137
+ if (a.type === 'keyword')
138
+ doc.rendererFlags.add(a.value);
139
+ }
140
+ break;
141
+ }
142
+ case 'page': {
143
+ // First arg may be a quoted title OR a bare page-name keyword.
144
+ // Any light/dark/system keyword is the theme; other keywords become the title.
145
+ let title = 'Page';
146
+ let theme = '';
147
+ for (const a of args) {
148
+ if (a.type === 'string') {
149
+ title = a.value;
150
+ }
151
+ else if (a.type === 'keyword') {
152
+ if (THEME_KEYWORDS.has(a.value)) {
153
+ theme = a.value;
154
+ }
155
+ else {
156
+ // bare page-name keyword → capitalize
157
+ title = a.value.charAt(0).toUpperCase() + a.value.slice(1);
158
+ }
159
+ }
160
+ }
161
+ doc.page = { title, theme };
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ export function parse(source) {
167
+ const doc = {
168
+ specVersion: 1,
169
+ filePath: null,
170
+ metaTags: [],
171
+ rendererFlags: new Set(),
172
+ page: null,
173
+ themes: [],
174
+ body: [],
175
+ };
176
+ const lines = source.split('\n');
177
+ const stack = [];
178
+ let rawMode = false;
179
+ let themeMode = false;
180
+ for (let idx = 0; idx < lines.length; idx++) {
181
+ const lineNum = idx + 1;
182
+ const raw = lines[idx];
183
+ const trimmed = raw.trim();
184
+ if (trimmed === '')
185
+ continue;
186
+ if (trimmed.startsWith('@#'))
187
+ continue;
188
+ if (rawMode) {
189
+ // @@end is the exclusive raw-mode sentinel so literal @end can appear inside codeblocks.
190
+ if (trimmed === '@@end') {
191
+ rawMode = false;
192
+ stack.pop();
193
+ }
194
+ else {
195
+ stack[stack.length - 1]?.rawLines.push(raw);
196
+ }
197
+ continue;
198
+ }
199
+ if (themeMode) {
200
+ if (trimmed === '@end') {
201
+ themeMode = false;
202
+ const themeNode = stack.pop();
203
+ if (themeNode) {
204
+ const nameArg = themeNode.args[0];
205
+ const name = nameArg?.type === 'string' ? nameArg.value : 'default';
206
+ doc.themes.push({ name, vars: parseThemeVars(themeNode.rawLines, lineNum) });
207
+ }
208
+ }
209
+ else if (!trimmed.startsWith('@')) {
210
+ stack[stack.length - 1]?.rawLines.push(trimmed);
211
+ }
212
+ continue;
213
+ }
214
+ if (!trimmed.startsWith('@'))
215
+ continue;
216
+ const spaceIdx = trimmed.indexOf(' ');
217
+ const cmdRaw = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx);
218
+ const argStr = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
219
+ const cmd = cmdRaw.toLowerCase();
220
+ if (cmd === 'end') {
221
+ if (stack.length === 0)
222
+ throw new ParseError('@end without open block', lineNum);
223
+ stack.pop();
224
+ continue;
225
+ }
226
+ if (RESERVED_COMMANDS.has(cmd)) {
227
+ throw new ParseError(`@${cmd} is reserved for future XVML versions`, lineNum);
228
+ }
229
+ if (!KNOWN_COMMANDS.has(cmd)) {
230
+ throw new ParseError(`Unknown command: @${cmd}`, lineNum);
231
+ }
232
+ const args = parseArgs(argStr, lineNum);
233
+ if (DOC_DIRECTIVE_COMMANDS.has(cmd)) {
234
+ handleDocDirective(doc, cmd, args);
235
+ continue;
236
+ }
237
+ const node = { command: cmd, args, children: [], rawLines: [] };
238
+ if (stack.length > 0) {
239
+ stack[stack.length - 1].children.push(node);
240
+ }
241
+ else {
242
+ doc.body.push(node);
243
+ }
244
+ if (BLOCK_COMMANDS.has(cmd)) {
245
+ stack.push(node);
246
+ if (cmd === 'codeblock')
247
+ rawMode = true;
248
+ if (cmd === 'theme')
249
+ themeMode = true;
250
+ }
251
+ }
252
+ if (stack.length > 0) {
253
+ throw new ParseError(`Unclosed block: @${stack[stack.length - 1].command}`);
254
+ }
255
+ return doc;
256
+ }