@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.
- package/README.md +96 -0
- package/XVML_SPEC.md +1083 -0
- package/dist/XVML_SPEC.md +1083 -0
- package/dist/bin/xvml.js +3 -0
- package/dist/src/agent.js +85 -0
- package/dist/src/cli.js +252 -0
- package/dist/src/index.js +4 -0
- package/dist/src/parser.js +256 -0
- package/dist/src/renderer.js +98 -0
- package/dist/src/styles.js +366 -0
- package/dist/src/templates.js +351 -0
- package/package.json +51 -0
package/dist/bin/xvml.js
ADDED
|
@@ -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
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -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,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
|
+
}
|