@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.
@@ -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
@@ -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;
@@ -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
- this.emit('ID', this.scanShorthandName().name, hashPos);
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
- `wrap complex attribute values in (parentheses) or \`backticks\``, this.lineIndex + 1, this.indentWidth + this.pos + 1);
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
- if (depth === 0 && pieces.length === 0) {
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 params = this.scanBalancedParens();
915
- this.tokens.push({ type: 'PARAMS', value: params, line, col });
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.
@@ -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;