@chilts/weave 0.7.0 → 0.8.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/CHANGELOG.md +126 -1
- package/ReadMe.md +208 -6
- package/dist/cli/check.d.ts +14 -0
- package/dist/cli/check.js +133 -0
- package/dist/cli/index.d.ts +26 -4
- package/dist/cli/index.js +95 -26
- package/dist/codegen/index.js +269 -217
- package/dist/codegen/sourcemap.d.ts +49 -1
- package/dist/codegen/sourcemap.js +154 -2
- package/dist/diagnostics/index.d.ts +25 -0
- package/dist/diagnostics/index.js +68 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/lexer/index.d.ts +5 -1
- package/dist/lexer/index.js +129 -10
- package/dist/parser/index.d.ts +1 -1
- package/dist/parser/index.js +74 -9
- package/dist/parser/types.d.ts +13 -0
- package/dist/runtime/index.d.ts +40 -2
- package/dist/runtime/index.js +59 -4
- package/package.json +17 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SourceLocation } from '../parser/types.js';
|
|
1
|
+
import type { SourceLocation, ValueLoc } from '../parser/types.js';
|
|
2
2
|
/** A single mapping segment: generated position → source position (all 0-based) */
|
|
3
3
|
export interface Mapping {
|
|
4
4
|
genLine: number;
|
|
@@ -29,6 +29,34 @@ export declare function encodeVLQ(value: number): string;
|
|
|
29
29
|
export declare function encodeMappings(mappings: Mapping[]): string;
|
|
30
30
|
/** Create a CodeChunk with an optional mapping at position (0,0) tied to a source location. */
|
|
31
31
|
export declare function chunk(code: string, loc?: SourceLocation): CodeChunk;
|
|
32
|
+
/** A zero-width chunk that maps the code following it to `loc`. */
|
|
33
|
+
export declare function mark(loc: SourceLocation): CodeChunk;
|
|
34
|
+
/**
|
|
35
|
+
* Concatenate code and chunks, placing each chunk's mappings where it lands:
|
|
36
|
+
* its first line shifts by the column it starts at, every line by the line.
|
|
37
|
+
*/
|
|
38
|
+
export declare function concat(...parts: (string | CodeChunk)[]): CodeChunk;
|
|
39
|
+
/** Captured TS text and where its lines start in the source, when known. */
|
|
40
|
+
export interface Captured {
|
|
41
|
+
text: string;
|
|
42
|
+
loc?: ValueLoc | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The part of a captured value from `start` to `end`, with its line starts
|
|
46
|
+
* moved to match. Trims surrounding whitespace, as the slice is re-emitted on
|
|
47
|
+
* its own.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sliceCaptured(value: Captured, start: number, end?: number): Captured;
|
|
50
|
+
/**
|
|
51
|
+
* A chunk for captured TS, which weave re-emits character for character. Every
|
|
52
|
+
* token (a run of word characters, or any other non-space character) gets a
|
|
53
|
+
* mapping to its own source position, so a diagnostic anywhere in the value
|
|
54
|
+
* lands on the token it names. Splitting on character classes is not a TS
|
|
55
|
+
* parse; it only needs to put a mapping wherever tsc might start a span.
|
|
56
|
+
* Without a location the text is emitted unmapped, covered by whatever
|
|
57
|
+
* mapping precedes it.
|
|
58
|
+
*/
|
|
59
|
+
export declare function verbatim(value: Captured): CodeChunk;
|
|
32
60
|
/** Offset all mappings in an array by a line delta. */
|
|
33
61
|
export declare function offsetMappings(mappings: Mapping[], lineOffset: number): Mapping[];
|
|
34
62
|
/** Count newlines in a string. */
|
|
@@ -40,4 +68,24 @@ export declare function lineCount(code: string): number;
|
|
|
40
68
|
export declare function joinChunks(chunks: CodeChunk[], separator: string): CodeChunk;
|
|
41
69
|
/** Build a complete V3 source map object. */
|
|
42
70
|
export declare function buildSourceMap(mappings: Mapping[], file: string, sourceFile: string): SourceMap;
|
|
71
|
+
/**
|
|
72
|
+
* Decode a V3 `mappings` string into absolute mappings (the inverse of
|
|
73
|
+
* encodeMappings). The source index and names are ignored: weave emits a
|
|
74
|
+
* single source and no names. Segments with no source position are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export declare function decodeMappings(mappings: string): Mapping[];
|
|
77
|
+
/** A position in the original source (1-based line and col). */
|
|
78
|
+
export interface OriginalPosition {
|
|
79
|
+
source: string;
|
|
80
|
+
line: number;
|
|
81
|
+
col: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Find the source position for a generated position (1-based line and col).
|
|
85
|
+
* Uses the last mapping at or before it on the same line, else the last
|
|
86
|
+
* mapping on the nearest earlier line. Weave maps every token of a captured TS
|
|
87
|
+
* expression to its own source position, and generated wrapper code to the
|
|
88
|
+
* start of the node that produced it.
|
|
89
|
+
*/
|
|
90
|
+
export declare function originalPosition(map: SourceMap, line: number, col: number): OriginalPosition | undefined;
|
|
43
91
|
//# sourceMappingURL=sourcemap.d.ts.map
|
|
@@ -24,14 +24,13 @@ export function encodeMappings(mappings) {
|
|
|
24
24
|
return '';
|
|
25
25
|
// Find max generated line
|
|
26
26
|
const maxLine = sorted[sorted.length - 1].genLine;
|
|
27
|
-
let prevGenCol = 0;
|
|
28
27
|
let prevSrcLine = 0;
|
|
29
28
|
let prevSrcCol = 0;
|
|
30
29
|
let idx = 0;
|
|
31
30
|
const lines = [];
|
|
32
31
|
for (let line = 0; line <= maxLine; line++) {
|
|
33
32
|
const segments = [];
|
|
34
|
-
prevGenCol = 0; // genCol resets per line
|
|
33
|
+
let prevGenCol = 0; // genCol resets per line
|
|
35
34
|
while (idx < sorted.length && sorted[idx].genLine === line) {
|
|
36
35
|
const m = sorted[idx];
|
|
37
36
|
let seg = encodeVLQ(m.genCol - prevGenCol);
|
|
@@ -62,6 +61,88 @@ export function chunk(code, loc) {
|
|
|
62
61
|
}
|
|
63
62
|
return { code, mappings };
|
|
64
63
|
}
|
|
64
|
+
/** A zero-width chunk that maps the code following it to `loc`. */
|
|
65
|
+
export function mark(loc) {
|
|
66
|
+
return chunk('', loc);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Concatenate code and chunks, placing each chunk's mappings where it lands:
|
|
70
|
+
* its first line shifts by the column it starts at, every line by the line.
|
|
71
|
+
*/
|
|
72
|
+
export function concat(...parts) {
|
|
73
|
+
let code = '';
|
|
74
|
+
const mappings = [];
|
|
75
|
+
let line = 0;
|
|
76
|
+
let col = 0;
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
const c = typeof part === 'string' ? { code: part, mappings: [] } : part;
|
|
79
|
+
for (const m of c.mappings) {
|
|
80
|
+
mappings.push({
|
|
81
|
+
...m,
|
|
82
|
+
genLine: m.genLine + line,
|
|
83
|
+
genCol: m.genLine === 0 ? m.genCol + col : m.genCol,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
code += c.code;
|
|
87
|
+
const lastNewline = c.code.lastIndexOf('\n');
|
|
88
|
+
if (lastNewline === -1) {
|
|
89
|
+
col += c.code.length;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
line += lineCount(c.code);
|
|
93
|
+
col = c.code.length - lastNewline - 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { code, mappings };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The part of a captured value from `start` to `end`, with its line starts
|
|
100
|
+
* moved to match. Trims surrounding whitespace, as the slice is re-emitted on
|
|
101
|
+
* its own.
|
|
102
|
+
*/
|
|
103
|
+
export function sliceCaptured(value, start, end = value.text.length) {
|
|
104
|
+
const raw = value.text.slice(start, end);
|
|
105
|
+
const from = start + (raw.length - raw.trimStart().length);
|
|
106
|
+
const text = raw.trim();
|
|
107
|
+
if (!value.loc)
|
|
108
|
+
return { text };
|
|
109
|
+
const before = value.text.slice(0, from);
|
|
110
|
+
const line = lineCount(before);
|
|
111
|
+
const first = value.loc[line];
|
|
112
|
+
if (!first)
|
|
113
|
+
return { text };
|
|
114
|
+
const col = first.col + from - (before.lastIndexOf('\n') + 1);
|
|
115
|
+
return { text, loc: [{ line: first.line, col }, ...value.loc.slice(line + 1, line + 1 + lineCount(text))] };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A chunk for captured TS, which weave re-emits character for character. Every
|
|
119
|
+
* token (a run of word characters, or any other non-space character) gets a
|
|
120
|
+
* mapping to its own source position, so a diagnostic anywhere in the value
|
|
121
|
+
* lands on the token it names. Splitting on character classes is not a TS
|
|
122
|
+
* parse; it only needs to put a mapping wherever tsc might start a span.
|
|
123
|
+
* Without a location the text is emitted unmapped, covered by whatever
|
|
124
|
+
* mapping precedes it.
|
|
125
|
+
*/
|
|
126
|
+
export function verbatim(value) {
|
|
127
|
+
const { text, loc } = value;
|
|
128
|
+
const mappings = [];
|
|
129
|
+
if (loc) {
|
|
130
|
+
text.split('\n').forEach((lineText, genLine) => {
|
|
131
|
+
const start = loc[genLine];
|
|
132
|
+
if (!start)
|
|
133
|
+
return;
|
|
134
|
+
for (const m of lineText.matchAll(/[\w$]+|\S/g)) {
|
|
135
|
+
mappings.push({
|
|
136
|
+
genLine,
|
|
137
|
+
genCol: m.index,
|
|
138
|
+
srcLine: start.line - 1,
|
|
139
|
+
srcCol: start.col - 1 + m.index,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { code: text, mappings };
|
|
145
|
+
}
|
|
65
146
|
/** Offset all mappings in an array by a line delta. */
|
|
66
147
|
export function offsetMappings(mappings, lineOffset) {
|
|
67
148
|
if (lineOffset === 0)
|
|
@@ -110,4 +191,75 @@ export function buildSourceMap(mappings, file, sourceFile) {
|
|
|
110
191
|
mappings: encodeMappings(mappings),
|
|
111
192
|
};
|
|
112
193
|
}
|
|
194
|
+
// --- Decoding ---
|
|
195
|
+
const BASE64_VALUES = new Map([...BASE64].map((c, i) => [c, i]));
|
|
196
|
+
/** Decode one segment's VLQ fields. */
|
|
197
|
+
function decodeSegment(segment) {
|
|
198
|
+
const values = [];
|
|
199
|
+
let shift = 0;
|
|
200
|
+
let vlq = 0;
|
|
201
|
+
for (const c of segment) {
|
|
202
|
+
const digit = BASE64_VALUES.get(c);
|
|
203
|
+
if (digit === undefined)
|
|
204
|
+
throw new Error(`Invalid base64 character in source map: ${c}`);
|
|
205
|
+
vlq += (digit & 0b11111) << shift;
|
|
206
|
+
if (digit & 0b100000) {
|
|
207
|
+
shift += 5;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
values.push(vlq & 1 ? -(vlq >>> 1) : vlq >>> 1);
|
|
211
|
+
shift = 0;
|
|
212
|
+
vlq = 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return values;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Decode a V3 `mappings` string into absolute mappings (the inverse of
|
|
219
|
+
* encodeMappings). The source index and names are ignored: weave emits a
|
|
220
|
+
* single source and no names. Segments with no source position are skipped.
|
|
221
|
+
*/
|
|
222
|
+
export function decodeMappings(mappings) {
|
|
223
|
+
const result = [];
|
|
224
|
+
let srcLine = 0;
|
|
225
|
+
let srcCol = 0;
|
|
226
|
+
mappings.split(';').forEach((line, genLine) => {
|
|
227
|
+
let genCol = 0; // genCol resets per line
|
|
228
|
+
for (const segment of line.split(',')) {
|
|
229
|
+
if (segment === '')
|
|
230
|
+
continue;
|
|
231
|
+
const fields = decodeSegment(segment);
|
|
232
|
+
genCol += fields[0];
|
|
233
|
+
if (fields.length < 4)
|
|
234
|
+
continue;
|
|
235
|
+
srcLine += fields[2];
|
|
236
|
+
srcCol += fields[3];
|
|
237
|
+
result.push({ genLine, genCol, srcLine, srcCol });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Find the source position for a generated position (1-based line and col).
|
|
244
|
+
* Uses the last mapping at or before it on the same line, else the last
|
|
245
|
+
* mapping on the nearest earlier line. Weave maps every token of a captured TS
|
|
246
|
+
* expression to its own source position, and generated wrapper code to the
|
|
247
|
+
* start of the node that produced it.
|
|
248
|
+
*/
|
|
249
|
+
export function originalPosition(map, line, col) {
|
|
250
|
+
const source = map.sources[0];
|
|
251
|
+
if (source === undefined)
|
|
252
|
+
return undefined;
|
|
253
|
+
const genLine = line - 1;
|
|
254
|
+
const genCol = col - 1;
|
|
255
|
+
let best;
|
|
256
|
+
for (const m of decodeMappings(map.mappings)) {
|
|
257
|
+
if (m.genLine > genLine || (m.genLine === genLine && m.genCol > genCol))
|
|
258
|
+
break;
|
|
259
|
+
best = m;
|
|
260
|
+
}
|
|
261
|
+
if (!best)
|
|
262
|
+
return undefined;
|
|
263
|
+
return { source, line: best.srcLine + 1, col: best.srcCol + 1 };
|
|
264
|
+
}
|
|
113
265
|
//# sourceMappingURL=sourcemap.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A diagnostic at a 1-based line and col, from tsc or anything else. */
|
|
2
|
+
export interface Diagnostic {
|
|
3
|
+
file: string;
|
|
4
|
+
line: number;
|
|
5
|
+
col: number;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** Returns a file's contents, or undefined if it cannot be read. */
|
|
9
|
+
export type ReadFile = (file: string) => string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Translate a diagnostic in a compiled .ts file to its .weave source, using
|
|
12
|
+
* the source map weave wrote beside it. The returned file is the .weave path,
|
|
13
|
+
* resolved the same way as the diagnostic's file (relative stays relative).
|
|
14
|
+
* Returns undefined when the file has no usable map — it is not a weave
|
|
15
|
+
* output, or was compiled with --no-sourcemap — so the caller can report the
|
|
16
|
+
* diagnostic as-is.
|
|
17
|
+
*/
|
|
18
|
+
export declare function mapDiagnostic(diag: Diagnostic, readFile?: ReadFile): Diagnostic | undefined;
|
|
19
|
+
export declare function formatCodeFrame(source: string, line: number, col: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Format a diagnostic like a LexerError or ParserError: `weave: file:L:C: msg`,
|
|
22
|
+
* followed by a code frame when the source is given.
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatDiagnostic(diag: Diagnostic, source?: string): string;
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { originalPosition } from '../codegen/sourcemap.js';
|
|
4
|
+
function readFromDisk(file) {
|
|
5
|
+
try {
|
|
6
|
+
return readFileSync(file, 'utf-8');
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** The map file a compiled .ts points at, falling back to `<file>.map`. */
|
|
13
|
+
function mapFileFor(file, code) {
|
|
14
|
+
const url = code?.match(/^\/\/# sourceMappingURL=(.+)$/m)?.[1]?.trim();
|
|
15
|
+
return url ? path.join(path.dirname(file), url) : file + '.map';
|
|
16
|
+
}
|
|
17
|
+
function parseSourceMap(json) {
|
|
18
|
+
try {
|
|
19
|
+
const map = JSON.parse(json);
|
|
20
|
+
return map.version === 3 && Array.isArray(map.sources) && typeof map.mappings === 'string'
|
|
21
|
+
? map
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Translate a diagnostic in a compiled .ts file to its .weave source, using
|
|
30
|
+
* the source map weave wrote beside it. The returned file is the .weave path,
|
|
31
|
+
* resolved the same way as the diagnostic's file (relative stays relative).
|
|
32
|
+
* Returns undefined when the file has no usable map — it is not a weave
|
|
33
|
+
* output, or was compiled with --no-sourcemap — so the caller can report the
|
|
34
|
+
* diagnostic as-is.
|
|
35
|
+
*/
|
|
36
|
+
export function mapDiagnostic(diag, readFile = readFromDisk) {
|
|
37
|
+
const json = readFile(mapFileFor(diag.file, readFile(diag.file)));
|
|
38
|
+
if (json === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
const map = parseSourceMap(json);
|
|
41
|
+
if (!map)
|
|
42
|
+
return undefined;
|
|
43
|
+
const pos = originalPosition(map, diag.line, diag.col);
|
|
44
|
+
if (!pos)
|
|
45
|
+
return undefined;
|
|
46
|
+
const file = path.join(path.dirname(diag.file), map.sourceRoot ?? '', pos.source);
|
|
47
|
+
return { file, line: pos.line, col: pos.col, message: diag.message };
|
|
48
|
+
}
|
|
49
|
+
export function formatCodeFrame(source, line, col) {
|
|
50
|
+
const lines = source.split('\n');
|
|
51
|
+
if (line < 1 || line > lines.length)
|
|
52
|
+
return '';
|
|
53
|
+
const sourceLine = lines[line - 1];
|
|
54
|
+
const lineNum = String(line);
|
|
55
|
+
const gutter = ' '.repeat(lineNum.length);
|
|
56
|
+
return `\n ${lineNum} | ${sourceLine}\n ${gutter} | ${' '.repeat(col - 1)}^`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Format a diagnostic like a LexerError or ParserError: `weave: file:L:C: msg`,
|
|
60
|
+
* followed by a code frame when the source is given.
|
|
61
|
+
*/
|
|
62
|
+
export function formatDiagnostic(diag, source) {
|
|
63
|
+
let result = `weave: ${diag.file}:${diag.line}:${diag.col}: ${diag.message}`;
|
|
64
|
+
if (source)
|
|
65
|
+
result += formatCodeFrame(source, diag.line, diag.col);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export { h, doctype, Fragment, raw, mergeClass, escape, SafeHtml } from './runtime/index.js';
|
|
2
|
-
export type { Attrs } from './runtime/index.js';
|
|
1
|
+
export { h, doctype, Fragment, raw, mergeClass, each, interpolate, escape, SafeHtml } from './runtime/index.js';
|
|
2
|
+
export type { Attrs, AttrValue, ClassValue, StyleValue } from './runtime/index.js';
|
|
3
3
|
export { compile, compileWithSourceMap, generate, generateExpr, definedMixins, CodegenError } from './codegen/index.js';
|
|
4
4
|
export type { GenerateResult, GenerateOptions, IncludeResolver, CodeChunk, Mapping, SourceMap } from './codegen/index.js';
|
|
5
|
+
export { decodeMappings, originalPosition } from './codegen/sourcemap.js';
|
|
6
|
+
export type { OriginalPosition } from './codegen/sourcemap.js';
|
|
7
|
+
export { mapDiagnostic, formatDiagnostic, formatCodeFrame } from './diagnostics/index.js';
|
|
8
|
+
export type { Diagnostic, ReadFile } from './diagnostics/index.js';
|
|
5
9
|
export { lex, LexerError } from './lexer/index.js';
|
|
6
10
|
export type { Token, TokenType } from './lexer/index.js';
|
|
7
11
|
export { parse, ParserError } from './parser/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Runtime
|
|
2
|
-
export { h, doctype, Fragment, raw, mergeClass, escape, SafeHtml } from './runtime/index.js';
|
|
2
|
+
export { h, doctype, Fragment, raw, mergeClass, each, interpolate, escape, SafeHtml } from './runtime/index.js';
|
|
3
3
|
// Compiler
|
|
4
4
|
export { compile, compileWithSourceMap, generate, generateExpr, definedMixins, CodegenError } from './codegen/index.js';
|
|
5
|
+
export { decodeMappings, originalPosition } from './codegen/sourcemap.js';
|
|
6
|
+
export { mapDiagnostic, formatDiagnostic, formatCodeFrame } from './diagnostics/index.js';
|
|
5
7
|
export { lex, LexerError } from './lexer/index.js';
|
|
6
8
|
export { parse, ParserError } from './parser/index.js';
|
|
7
9
|
//# sourceMappingURL=index.js.map
|
package/dist/lexer/index.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
export type TokenType = 'NEWLINE' | 'INDENT' | 'DEDENT' | 'EOF' | 'IMPORT' | 'TEMPLATE' | 'MIXIN' | 'EXTENDS' | 'BLOCK' | 'INCLUDE' | 'DOCTYPE' | 'IF' | 'ELSE_IF' | 'ELSE' | 'FOR' | 'TAG' | 'CLASS' | 'ID' | 'ATTR_START' | 'ATTR_END' | 'ATTR_NAME' | 'ATTR_VALUE' | 'SPREAD' | 'BUFFERED_CODE' | 'TEXT' | 'INTERPOLATION' | 'PIPE' | 'RAW_TEXT' | 'MIXIN_CALL' | 'COMMENT' | 'PASSTHROUGH_COMMENT' | 'PARAMS' | 'IDENTIFIER' | 'PATH';
|
|
1
|
+
export type TokenType = 'NEWLINE' | 'INDENT' | 'DEDENT' | 'EOF' | 'IMPORT' | 'TEMPLATE' | 'MIXIN' | 'EXTENDS' | 'BLOCK' | 'INCLUDE' | 'DOCTYPE' | 'IF' | 'UNLESS' | 'ELSE_IF' | 'ELSE' | 'FOR' | 'TAG' | 'CLASS' | 'ID' | 'ATTR_START' | 'ATTR_END' | 'ATTR_NAME' | 'ATTR_VALUE' | 'SPREAD' | 'BUFFERED_CODE' | 'TEXT' | 'INTERPOLATION' | 'PIPE' | 'RAW_TEXT' | 'MIXIN_CALL' | 'COMMENT' | 'PASSTHROUGH_COMMENT' | 'PARAMS' | 'IDENTIFIER' | 'PATH';
|
|
2
2
|
export interface Token {
|
|
3
3
|
type: TokenType;
|
|
4
4
|
value: string;
|
|
5
5
|
line: number;
|
|
6
6
|
col: number;
|
|
7
|
+
starts?: {
|
|
8
|
+
line: number;
|
|
9
|
+
col: number;
|
|
10
|
+
}[];
|
|
7
11
|
}
|
|
8
12
|
export declare class LexerError extends Error {
|
|
9
13
|
readonly line: number;
|
package/dist/lexer/index.js
CHANGED
|
@@ -19,8 +19,24 @@ const KEYWORDS = new Set([
|
|
|
19
19
|
'include',
|
|
20
20
|
'doctype',
|
|
21
21
|
'if',
|
|
22
|
+
'unless',
|
|
22
23
|
'for',
|
|
23
24
|
]);
|
|
25
|
+
// Pug's 'block append' / 'block prepend' have no Weave form: a page fills
|
|
26
|
+
// a block by replacing it, so extra content needs a slot of its own.
|
|
27
|
+
const APPEND_HINT = "there is no 'append' or 'prepend'; add a second, empty slot to the layout " +
|
|
28
|
+
"(e.g. 'block pageScripts' after 'block scripts') and fill that instead";
|
|
29
|
+
// Pug keywords with no Weave equivalent, and what to write instead. Without
|
|
30
|
+
// this they lex as elements and render as e.g. <case> and <when>.
|
|
31
|
+
const PUG_KEYWORDS = new Map([
|
|
32
|
+
['case', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
|
|
33
|
+
['when', "use 'if' / 'else if' / 'else' instead of 'case' / 'when'"],
|
|
34
|
+
['default', "use 'else' instead of 'case' / 'default'"],
|
|
35
|
+
['each', "use 'for ( const item of items )' instead of 'each'"],
|
|
36
|
+
['while', "iterate with 'for ( const item of items )' instead of 'while'"],
|
|
37
|
+
['append', APPEND_HINT],
|
|
38
|
+
['prepend', APPEND_HINT],
|
|
39
|
+
]);
|
|
24
40
|
class Lexer {
|
|
25
41
|
lines;
|
|
26
42
|
lineIndex = 0;
|
|
@@ -198,6 +214,9 @@ class Lexer {
|
|
|
198
214
|
case 'if':
|
|
199
215
|
this.scanIf();
|
|
200
216
|
break;
|
|
217
|
+
case 'unless':
|
|
218
|
+
this.scanUnless();
|
|
219
|
+
break;
|
|
201
220
|
case 'for':
|
|
202
221
|
this.scanFor();
|
|
203
222
|
break;
|
|
@@ -229,6 +248,14 @@ class Lexer {
|
|
|
229
248
|
this.emit('BLOCK', 'block');
|
|
230
249
|
const nameStart = this.skipSpacesFrom('block '.length);
|
|
231
250
|
const name = this.currentLine.slice(nameStart).trim();
|
|
251
|
+
// A bare 'block' (mixin call-site content) has no name.
|
|
252
|
+
if (name !== '' && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
253
|
+
const word = name.split(/\s/)[0];
|
|
254
|
+
const message = word === 'append' || word === 'prepend'
|
|
255
|
+
? `'block ${word}' is not supported; ${APPEND_HINT}`
|
|
256
|
+
: `Block name '${name}' must be a single identifier`;
|
|
257
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + nameStart + 1);
|
|
258
|
+
}
|
|
232
259
|
this.emit('IDENTIFIER', name, nameStart);
|
|
233
260
|
this.pos = this.currentLine.length;
|
|
234
261
|
}
|
|
@@ -272,6 +299,13 @@ class Lexer {
|
|
|
272
299
|
this.emit('BUFFERED_CODE', expr);
|
|
273
300
|
this.pos = this.currentLine.length;
|
|
274
301
|
}
|
|
302
|
+
scanUnless() {
|
|
303
|
+
this.emit('UNLESS', 'unless', 0);
|
|
304
|
+
this.pos = 'unless '.length;
|
|
305
|
+
const expr = this.currentLine.slice(this.pos);
|
|
306
|
+
this.emit('BUFFERED_CODE', expr);
|
|
307
|
+
this.pos = this.currentLine.length;
|
|
308
|
+
}
|
|
275
309
|
scanElseIf() {
|
|
276
310
|
this.emit('ELSE_IF', 'else if', 0);
|
|
277
311
|
this.pos = 'else if '.length;
|
|
@@ -298,6 +332,7 @@ class Lexer {
|
|
|
298
332
|
else {
|
|
299
333
|
const tagStart = this.pos;
|
|
300
334
|
const tag = this.scanTagName();
|
|
335
|
+
this.checkTagName(tag, tagStart);
|
|
301
336
|
this.emit('TAG', tag, tagStart);
|
|
302
337
|
}
|
|
303
338
|
// Classes and IDs
|
|
@@ -312,6 +347,14 @@ class Lexer {
|
|
|
312
347
|
// Raw block: tag!
|
|
313
348
|
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '!') {
|
|
314
349
|
this.pos++; // skip !
|
|
350
|
+
// Anything after the '!' used to be dropped without a word, which
|
|
351
|
+
// silently lost the expression in Pug's unescaped 'p!= html'.
|
|
352
|
+
const rest = this.currentLine.slice(this.pos);
|
|
353
|
+
if (rest.trim() !== '') {
|
|
354
|
+
throw new LexerError(rest.startsWith('=')
|
|
355
|
+
? "Unescaped buffered code ('!=') is not supported; use '= raw(expr)' instead"
|
|
356
|
+
: "A raw block 'tag!' takes no content on its own line; put the raw text on indented lines below it", this.lineIndex + 1, this.indentWidth + this.pos);
|
|
357
|
+
}
|
|
315
358
|
this.rawBlockIndent = elementIndent;
|
|
316
359
|
this.rawBlockBaseIndent = null;
|
|
317
360
|
return;
|
|
@@ -332,6 +375,29 @@ class Lexer {
|
|
|
332
375
|
}
|
|
333
376
|
return this.currentLine.slice(start, this.pos);
|
|
334
377
|
}
|
|
378
|
+
// Rejects tag names that can't be an element. Pug syntax Weave doesn't
|
|
379
|
+
// have used to land here and come out as bogus elements: '- const x = 1'
|
|
380
|
+
// became a '-' element and 'case'/'when' became <case>/<when>.
|
|
381
|
+
checkTagName(tag, tagStart) {
|
|
382
|
+
let message = null;
|
|
383
|
+
if (tag.startsWith('-')) {
|
|
384
|
+
message =
|
|
385
|
+
"Unbuffered code ('- ...') is not supported; templates hold no statements. " +
|
|
386
|
+
"Compute values in TypeScript before rendering, or use a buffered expression 'tag= expr'";
|
|
387
|
+
}
|
|
388
|
+
else if (tag === '') {
|
|
389
|
+
message = `Unexpected '${this.currentLine[tagStart]}'; expected a tag name, '.class' or '#id'`;
|
|
390
|
+
}
|
|
391
|
+
else if (!/^[a-zA-Z]/.test(tag)) {
|
|
392
|
+
message = `Tag name '${tag}' must start with a letter`;
|
|
393
|
+
}
|
|
394
|
+
else if (PUG_KEYWORDS.has(tag)) {
|
|
395
|
+
message = `'${tag}' is not a Weave keyword; ${PUG_KEYWORDS.get(tag)}`;
|
|
396
|
+
}
|
|
397
|
+
if (message !== null) {
|
|
398
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + tagStart + 1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
335
401
|
scanClassesAndIds() {
|
|
336
402
|
let prevClass = null;
|
|
337
403
|
while (this.pos < this.currentLine.length) {
|
|
@@ -341,6 +407,13 @@ class Lexer {
|
|
|
341
407
|
const dotCol = this.indentWidth + dotPos + 1;
|
|
342
408
|
this.pos++; // skip dot
|
|
343
409
|
const { name, raw } = this.scanShorthandName();
|
|
410
|
+
if (name === '') {
|
|
411
|
+
// Pug's 'p.' block text emitted class="" and then lexed
|
|
412
|
+
// the text lines below it as elements.
|
|
413
|
+
throw new LexerError(this.pos >= this.currentLine.length
|
|
414
|
+
? "Block text ('tag.') is not supported; use 'tag!' for raw text, or '|' lines for escaped text"
|
|
415
|
+
: "Expected a class name after '.'", this.lineIndex + 1, dotCol);
|
|
416
|
+
}
|
|
344
417
|
// A dot is a class separator, so '.gap-1.5' silently became
|
|
345
418
|
// 'gap-1' plus a stray '5'. A class that is a bare number is
|
|
346
419
|
// almost certainly that mistake, so reject it and name the fix.
|
|
@@ -357,7 +430,11 @@ class Lexer {
|
|
|
357
430
|
const hashPos = this.pos;
|
|
358
431
|
this.pos++; // skip hash
|
|
359
432
|
prevClass = null;
|
|
360
|
-
|
|
433
|
+
const { name } = this.scanShorthandName();
|
|
434
|
+
if (name === '') {
|
|
435
|
+
throw new LexerError("Expected an id after '#'", this.lineIndex + 1, this.indentWidth + hashPos + 1);
|
|
436
|
+
}
|
|
437
|
+
this.emit('ID', name, hashPos);
|
|
361
438
|
}
|
|
362
439
|
else {
|
|
363
440
|
break;
|
|
@@ -500,7 +577,7 @@ class Lexer {
|
|
|
500
577
|
// Without this guard the caller's loop spins forever emitting
|
|
501
578
|
// empty ATTR_NAME tokens until the process aborts.
|
|
502
579
|
throw new LexerError(`Unexpected '${this.currentLine[this.pos]}' in attribute list; ` +
|
|
503
|
-
|
|
580
|
+
'wrap complex attribute values in (parentheses) or `backticks`', this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
504
581
|
}
|
|
505
582
|
this.emit('ATTR_NAME', name, start);
|
|
506
583
|
// Check for = value
|
|
@@ -586,6 +663,21 @@ class Lexer {
|
|
|
586
663
|
this.pos = this.currentLine.length;
|
|
587
664
|
return;
|
|
588
665
|
}
|
|
666
|
+
// Anything else used to be dropped along with the rest of the line,
|
|
667
|
+
// e.g. the child in Pug's block expansion 'li: a(href="/") Home'.
|
|
668
|
+
let message;
|
|
669
|
+
if (ch === ':') {
|
|
670
|
+
message =
|
|
671
|
+
"Block expansion ('tag: child') is not supported; " +
|
|
672
|
+
'put the child element on its own indented line';
|
|
673
|
+
}
|
|
674
|
+
else if (ch === '/') {
|
|
675
|
+
message = "Self-closing 'tag/' is not supported; void elements like 'br' and 'img' close themselves";
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
message = `Unexpected '${ch}' after element; separate inline text from the tag with a space`;
|
|
679
|
+
}
|
|
680
|
+
throw new LexerError(message, this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
589
681
|
}
|
|
590
682
|
// --- Mixin call scanner ---
|
|
591
683
|
scanMixinCall() {
|
|
@@ -603,6 +695,17 @@ class Lexer {
|
|
|
603
695
|
this.scanAttributes(elementIndent);
|
|
604
696
|
this.emit('ATTR_END', ')', this.pos - 1);
|
|
605
697
|
}
|
|
698
|
+
// One line of call-site content may follow, as for an element:
|
|
699
|
+
// '+btn("x") Save' or '+btn("x")= label'. Trailing spaces alone are
|
|
700
|
+
// not content.
|
|
701
|
+
const rest = this.currentLine.slice(this.pos);
|
|
702
|
+
if (rest.trim() === '')
|
|
703
|
+
return;
|
|
704
|
+
if (rest[0] === ' ' || rest[0] === '=') {
|
|
705
|
+
this.scanTrailingContent();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
throw new LexerError(`Unexpected '${rest[0]}' after mixin call; separate inline content from the call with a space`, this.lineIndex + 1, this.indentWidth + this.pos + 1);
|
|
606
709
|
}
|
|
607
710
|
// --- Pipe scanner ---
|
|
608
711
|
scanPipe() {
|
|
@@ -625,6 +728,16 @@ class Lexer {
|
|
|
625
728
|
let buf = '';
|
|
626
729
|
let bufStart = 0;
|
|
627
730
|
while (i < text.length) {
|
|
731
|
+
// Pug's tag interpolation '#[strong x]' and unescaped '!{html}'
|
|
732
|
+
// used to pass through as literal text, so the markup characters
|
|
733
|
+
// rendered (escaped) on the page.
|
|
734
|
+
if ((text[i] === '#' && text[i + 1] === '[') || (text[i] === '!' && text[i + 1] === '{')) {
|
|
735
|
+
throw new LexerError((text[i] === '#'
|
|
736
|
+
? "Tag interpolation ('#[tag ...]') is not supported; put the element on its own " +
|
|
737
|
+
"indented line, with '|' lines for the text around it"
|
|
738
|
+
: "Unescaped interpolation ('!{...}') is not supported; use '#{raw(expr)}' instead") +
|
|
739
|
+
`. For literal '${text.slice(i, i + 2)}' text, use a string expression: 'tag= "..."'`, this.lineIndex + 1, this.indentWidth + this.pos + i + 1);
|
|
740
|
+
}
|
|
628
741
|
// Check for interpolation #{...}
|
|
629
742
|
if (text[i] === '#' && i + 1 < text.length && text[i + 1] === '{') {
|
|
630
743
|
// Emit accumulated text
|
|
@@ -708,6 +821,7 @@ class Lexer {
|
|
|
708
821
|
this.pos++; // skip opening (
|
|
709
822
|
let depth = 1;
|
|
710
823
|
const pieces = [];
|
|
824
|
+
const starts = [];
|
|
711
825
|
let prefix = ''; // this line's indentation relative to the owner
|
|
712
826
|
for (;;) {
|
|
713
827
|
const start = this.pos;
|
|
@@ -744,17 +858,22 @@ class Lexer {
|
|
|
744
858
|
}
|
|
745
859
|
const isComment = end !== -1 && depth > 0;
|
|
746
860
|
const text = this.currentLine.slice(start, end === -1 ? this.currentLine.length : end).trimEnd();
|
|
747
|
-
|
|
748
|
-
// Closed on the line it opened on: the common, single-line case.
|
|
749
|
-
this.pos++; // skip closing )
|
|
750
|
-
return text.trim();
|
|
751
|
-
}
|
|
861
|
+
const line = this.lineIndex + 1;
|
|
752
862
|
if (pieces.length === 0) {
|
|
863
|
+
const lead = text.length - text.trimStart().length;
|
|
864
|
+
starts.push({ line, col: this.indentWidth + start + lead + 1 });
|
|
865
|
+
if (depth === 0) {
|
|
866
|
+
// Closed on the line it opened on: the common, single-line case.
|
|
867
|
+
this.pos++; // skip closing )
|
|
868
|
+
return { value: text.trim(), starts };
|
|
869
|
+
}
|
|
753
870
|
pieces.push(text.trimStart());
|
|
754
871
|
}
|
|
755
872
|
else if (text !== '' || !isComment) {
|
|
756
873
|
// Lines holding nothing but a comment are dropped entirely.
|
|
874
|
+
// A kept line is `prefix + text`, so it starts at the owner's indent.
|
|
757
875
|
pieces.push(text === '' ? '' : prefix + text);
|
|
876
|
+
starts.push({ line, col: ownerIndent + 1 });
|
|
758
877
|
}
|
|
759
878
|
if (depth === 0)
|
|
760
879
|
break;
|
|
@@ -763,7 +882,7 @@ class Lexer {
|
|
|
763
882
|
}
|
|
764
883
|
this.pos++; // skip closing )
|
|
765
884
|
const content = pieces.join('\n');
|
|
766
|
-
return content.trim() === '' ? '' : content;
|
|
885
|
+
return content.trim() === '' ? { value: '', starts: [] } : { value: content, starts };
|
|
767
886
|
}
|
|
768
887
|
// Moves onto the next non-blank line of a multi-line paren group. Lines in
|
|
769
888
|
// the group must be indented under its owner; the one exception is the
|
|
@@ -911,8 +1030,8 @@ class Lexer {
|
|
|
911
1030
|
if (this.pos < this.currentLine.length && this.currentLine[this.pos] === '(') {
|
|
912
1031
|
const line = this.lineIndex + 1;
|
|
913
1032
|
const col = this.indentWidth + this.pos + 1;
|
|
914
|
-
const
|
|
915
|
-
this.tokens.push({ type: 'PARAMS', value
|
|
1033
|
+
const { value, starts } = this.scanBalancedParens();
|
|
1034
|
+
this.tokens.push({ type: 'PARAMS', value, line, col, starts });
|
|
916
1035
|
}
|
|
917
1036
|
}
|
|
918
1037
|
// Returns the first non-space position at or after `pos` on the current line.
|
package/dist/parser/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Token } from '../lexer/index.js';
|
|
2
|
-
export type { AstNode, SourceLocation, TextSegment, AttributeNode, ConditionalBranch, DocumentNode, TemplateNode, MixinDefinitionNode, MixinCallNode, ElementNode, TextNode, BufferedCodeNode, ConditionalNode, IterationNode, BlockNode, ExtendsNode, IncludeNode, ImportNode, DoctypeNode, CommentNode, PassthroughCommentNode, PipeNode, RawBlockNode, } from './types.js';
|
|
2
|
+
export type { AstNode, SourceLocation, ValueLoc, TextSegment, AttributeNode, ConditionalBranch, DocumentNode, TemplateNode, MixinDefinitionNode, MixinCallNode, ElementNode, TextNode, BufferedCodeNode, ConditionalNode, IterationNode, BlockNode, ExtendsNode, IncludeNode, ImportNode, DoctypeNode, CommentNode, PassthroughCommentNode, PipeNode, RawBlockNode, } from './types.js';
|
|
3
3
|
import type { DocumentNode } from './types.js';
|
|
4
4
|
export declare class ParserError extends Error {
|
|
5
5
|
readonly line: number;
|