@chilts/weave 0.5.0 → 0.7.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/dist/cli/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, watch } from 'node:fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync, rmdirSync, rmSync, watch, } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { compile, compileWithSourceMap } from '../codegen/index.js';
4
- import { LexerError } from '../lexer/index.js';
5
- import { ParserError } from '../parser/index.js';
3
+ import { compile, compileWithSourceMap, definedMixins } from '../codegen/index.js';
4
+ import { lex, LexerError } from '../lexer/index.js';
5
+ import { parse, ParserError } from '../parser/index.js';
6
6
  import { CodegenError } from '../codegen/index.js';
7
7
  export class UsageError extends Error {
8
8
  constructor(message) {
@@ -44,6 +44,9 @@ export function usage() {
44
44
  ' <input> A .weave file or directory containing .weave files',
45
45
  ' <output> Output .ts file or directory for compiled output',
46
46
  '',
47
+ ' In directory mode the output directory belongs to weave: a .ts (and its',
48
+ ' .ts.map) whose .weave source no longer exists is removed.',
49
+ '',
47
50
  'Options:',
48
51
  ' --watch, -w Watch for changes and recompile (directory mode only)',
49
52
  ' --no-sourcemap Disable source map generation',
@@ -70,6 +73,68 @@ function walk(dir, base, results) {
70
73
  }
71
74
  }
72
75
  }
76
+ // Map a file in the output tree back to the .weave that would have produced it.
77
+ // Anything that is not a compiled artefact returns undefined and is left alone.
78
+ function sourceForOutput(relPath) {
79
+ if (relPath.endsWith('.ts.map'))
80
+ return relPath.slice(0, -'.ts.map'.length) + '.weave';
81
+ if (relPath.endsWith('.ts'))
82
+ return relPath.slice(0, -'.ts'.length) + '.weave';
83
+ return undefined;
84
+ }
85
+ function walkAll(dir, base, files, dirs) {
86
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
87
+ const rel = base ? path.join(base, entry.name) : entry.name;
88
+ if (entry.isDirectory()) {
89
+ dirs.push(rel);
90
+ walkAll(path.join(dir, entry.name), rel, files, dirs);
91
+ }
92
+ else {
93
+ files.push(rel);
94
+ }
95
+ }
96
+ }
97
+ // The output tree belongs to weave: a .ts (or .ts.map) whose .weave source has
98
+ // gone is an orphan and is removed, so a deleted template stops being compiled
99
+ // into the app. Files weave never emits — anything that is not .ts/.ts.map — are
100
+ // left where they are, as are directories that still hold one of them.
101
+ export function pruneOrphans(inputDir, outputDir) {
102
+ const files = [];
103
+ const dirs = [];
104
+ try {
105
+ walkAll(outputDir, '', files, dirs);
106
+ }
107
+ catch {
108
+ // Nothing compiled here yet — no output tree to prune.
109
+ return;
110
+ }
111
+ for (const relPath of files) {
112
+ const sourceRel = sourceForOutput(relPath);
113
+ if (sourceRel === undefined)
114
+ continue;
115
+ if (existsSync(path.join(inputDir, sourceRel)))
116
+ continue;
117
+ const outFile = path.join(outputDir, relPath);
118
+ try {
119
+ unlinkSync(outFile);
120
+ console.log(`${outFile} (removed)`);
121
+ }
122
+ catch {
123
+ // Already gone, or not ours to delete.
124
+ }
125
+ }
126
+ // Deepest first, so a directory emptied by the pass above goes with its
127
+ // parent. rmdir refuses a non-empty directory, which is exactly the guard
128
+ // we want — anything still holding a file survives.
129
+ for (const rel of dirs.sort((a, b) => b.length - a.length)) {
130
+ try {
131
+ rmdirSync(path.join(outputDir, rel));
132
+ }
133
+ catch {
134
+ // Not empty, or already removed.
135
+ }
136
+ }
137
+ }
73
138
  export function formatElapsed(ms) {
74
139
  if (ms < 1)
75
140
  return `${Math.round(ms * 1000)}µs`;
@@ -108,6 +173,69 @@ export function formatError(filePath, err, source) {
108
173
  }
109
174
  return result;
110
175
  }
176
+ /** Resolves `include` paths against the including file and reads their mixins. */
177
+ export function includeResolver(inputFile) {
178
+ return includePath => {
179
+ const file = path.resolve(path.dirname(inputFile), includePath + '.weave');
180
+ let source;
181
+ try {
182
+ source = readFileSync(file, 'utf-8');
183
+ }
184
+ catch (err) {
185
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
186
+ throw new Error(`${path.relative(process.cwd(), file)}: no such file`);
187
+ }
188
+ throw err;
189
+ }
190
+ return definedMixins(parse(lex(source)));
191
+ };
192
+ }
193
+ /**
194
+ * Absolute paths of the .weave files a template includes. A template that does
195
+ * not lex or parse includes nothing as far as the watcher is concerned — its
196
+ * own compile reports the error.
197
+ */
198
+ export function includedFiles(inputFile, source) {
199
+ if (!/^include\s/m.test(source))
200
+ return [];
201
+ let doc;
202
+ try {
203
+ doc = parse(lex(source));
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ return doc.children.flatMap(c => c.type === 'Include' ? [path.resolve(path.dirname(inputFile), c.path + '.weave')] : []);
209
+ }
210
+ /**
211
+ * Templates under inputDir (relative paths) that include relPath. An includer's
212
+ * import line is built from the mixins its includes define, so it must be
213
+ * recompiled when one of them changes. Includes do not re-export, so only
214
+ * direct includers are affected.
215
+ */
216
+ export function findIncluders(inputDir, relPath) {
217
+ const target = path.resolve(inputDir, relPath);
218
+ let files;
219
+ try {
220
+ files = discoverFiles(inputDir);
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ return files.filter(file => {
226
+ if (file === relPath)
227
+ return false;
228
+ const inFile = path.join(inputDir, file);
229
+ let source;
230
+ try {
231
+ source = readFileSync(inFile, 'utf-8');
232
+ }
233
+ catch {
234
+ return false;
235
+ }
236
+ return includedFiles(inFile, source).includes(target);
237
+ });
238
+ }
111
239
  function writeCompiled(inputFile, outputFile, source, sourcemap) {
112
240
  const start = performance.now();
113
241
  if (sourcemap) {
@@ -116,15 +244,19 @@ function writeCompiled(inputFile, outputFile, source, sourcemap) {
116
244
  const { code, sourceMap } = compileWithSourceMap(source, {
117
245
  file: basename,
118
246
  sourceFile: sourceRelPath,
247
+ resolveInclude: includeResolver(inputFile),
119
248
  });
120
249
  mkdirSync(path.dirname(outputFile), { recursive: true });
121
250
  writeFileSync(outputFile, code + `//# sourceMappingURL=${basename}.map\n`);
122
251
  writeFileSync(outputFile + '.map', JSON.stringify(sourceMap));
123
252
  }
124
253
  else {
125
- const result = compile(source);
254
+ const result = compile(source, { resolveInclude: includeResolver(inputFile) });
126
255
  mkdirSync(path.dirname(outputFile), { recursive: true });
127
256
  writeFileSync(outputFile, result);
257
+ // A map from an earlier sourcemap build no longer matches this .ts,
258
+ // and pruning keeps it because the .weave still exists.
259
+ rmSync(outputFile + '.map', { force: true });
128
260
  }
129
261
  return performance.now() - start;
130
262
  }
@@ -165,24 +297,200 @@ export function compileDir(inputDir, outputDir, sourcemap = true) {
165
297
  hasErrors = true;
166
298
  }
167
299
  }
300
+ pruneOrphans(inputDir, outputDir);
168
301
  return hasErrors;
169
302
  }
303
+ // How often the reconciliation pass runs (see reconcile()).
304
+ const RECONCILE_INTERVAL_MS = 2000;
305
+ // Watch the input tree and recompile on change. Returns a function that stops
306
+ // watching.
307
+ //
308
+ // Each directory gets its own non-recursive fs.watch rather than one
309
+ // `{ recursive: true }` watch on the root. On Linux, Node implements recursive
310
+ // watching in JS by watching every *file*, and inotify binds a file watch to
311
+ // the inode — so once an editor saves atomically (write a temp file, rename it
312
+ // over the original) the watch is left on the dead inode and every later save
313
+ // of that file goes unseen. A directory watch reports the rename itself, so it
314
+ // survives atomic saves. New subdirectories are picked up as they appear.
170
315
  export function watchDir(inputDir, outputDir, sourcemap = true) {
171
316
  compileDir(inputDir, outputDir, sourcemap);
172
317
  console.log('\nwatching for changes...');
173
318
  const pending = new Map();
174
- watch(inputDir, { recursive: true }, (_event, filename) => {
175
- if (!filename || !filename.endsWith('.weave'))
176
- return;
319
+ // Keyed by directory relative to inputDir; '' is the root.
320
+ const watchers = new Map();
321
+ const schedule = (relPath) => {
177
322
  // Debounce: fs.watch fires multiple events per save
178
- const existing = pending.get(filename);
323
+ const existing = pending.get(relPath);
179
324
  if (existing)
180
325
  clearTimeout(existing);
181
- pending.set(filename, setTimeout(() => {
182
- pending.delete(filename);
183
- compileWatchedFile(inputDir, outputDir, filename, sourcemap);
326
+ pending.set(relPath, setTimeout(() => {
327
+ pending.delete(relPath);
328
+ compileWatchedFile(inputDir, outputDir, relPath, sourcemap);
329
+ // Compiled directly rather than scheduled, so an include cycle
330
+ // cannot keep re-triggering itself. One with its own pending
331
+ // timer will be compiled by that.
332
+ for (const includer of findIncluders(inputDir, relPath)) {
333
+ if (!pending.has(includer))
334
+ compileWatchedFile(inputDir, outputDir, includer, sourcemap);
335
+ }
184
336
  }, 50));
185
- });
337
+ };
338
+ const isDir = (relPath) => {
339
+ try {
340
+ return statSync(path.join(inputDir, relPath)).isDirectory();
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ };
346
+ // Close the watcher for relDir and everything beneath it.
347
+ const unwatchTree = (relDir) => {
348
+ for (const [dir, watcher] of watchers) {
349
+ if (relDir === '' || dir === relDir || dir.startsWith(relDir + path.sep)) {
350
+ watcher.close();
351
+ watchers.delete(dir);
352
+ }
353
+ }
354
+ };
355
+ const watchTree = (relDir) => {
356
+ if (watchers.has(relDir))
357
+ return;
358
+ const absDir = path.join(inputDir, relDir);
359
+ let watcher;
360
+ try {
361
+ watcher = watch(absDir, (_event, filename) => onEvent(relDir, filename));
362
+ }
363
+ catch {
364
+ // Gone before we got to it.
365
+ return;
366
+ }
367
+ watcher.on('error', () => unwatchTree(relDir));
368
+ watchers.set(relDir, watcher);
369
+ let entries;
370
+ try {
371
+ entries = readdirSync(absDir, { withFileTypes: true });
372
+ }
373
+ catch {
374
+ return;
375
+ }
376
+ for (const entry of entries) {
377
+ if (entry.isDirectory())
378
+ watchTree(path.join(relDir, entry.name));
379
+ }
380
+ };
381
+ const onEvent = (relDir, filename) => {
382
+ if (!filename)
383
+ return;
384
+ const relPath = path.join(relDir, filename);
385
+ if (filename.endsWith('.weave')) {
386
+ schedule(relPath);
387
+ }
388
+ else if (watchers.has(relPath)) {
389
+ // A watched directory was removed or renamed away.
390
+ if (!isDir(relPath)) {
391
+ unwatchTree(relPath);
392
+ pruneOrphans(inputDir, outputDir);
393
+ }
394
+ }
395
+ else if (isDir(relPath)) {
396
+ // A new directory. Anything written into it before its watcher was
397
+ // attached produced no event, so compile what is already there.
398
+ watchTree(relPath);
399
+ for (const file of discoverFiles(path.join(inputDir, relPath))) {
400
+ schedule(path.join(relPath, file));
401
+ }
402
+ }
403
+ };
404
+ // Bring the watcher set in line with the directories on disk, in case a
405
+ // mkdir or rmdir event was missed.
406
+ const syncWatchers = () => {
407
+ for (const dir of watchers.keys()) {
408
+ if (!isDir(dir))
409
+ unwatchTree(dir);
410
+ }
411
+ watchTree('');
412
+ };
413
+ watchTree('');
414
+ // Safety net: fs.watch is not guaranteed to deliver every event. Under load
415
+ // the kernel's inotify queue can overflow (IN_Q_OVERFLOW) and drop events,
416
+ // and atomic-save renames can be coalesced away — leaving a file silently
417
+ // stale forever. Periodically reconcile by comparing source and output
418
+ // mtimes, recompiling anything the watcher missed.
419
+ const interval = setInterval(() => {
420
+ syncWatchers();
421
+ reconcile(inputDir, outputDir, sourcemap, pending);
422
+ }, RECONCILE_INTERVAL_MS);
423
+ return () => {
424
+ clearInterval(interval);
425
+ for (const timer of pending.values())
426
+ clearTimeout(timer);
427
+ pending.clear();
428
+ unwatchTree('');
429
+ };
430
+ }
431
+ // Walk the input dir and recompile any .weave whose .ts output is missing or
432
+ // older than the source or any file it includes. Files with a pending debounce
433
+ // timer are skipped so we never race an edit the watcher is already handling —
434
+ // this only catches changes whose fs.watch event was dropped.
435
+ export function reconcile(inputDir, outputDir, sourcemap, pending) {
436
+ let files;
437
+ try {
438
+ files = discoverFiles(inputDir);
439
+ }
440
+ catch {
441
+ // Input dir may be temporarily unavailable — skip this cycle.
442
+ return;
443
+ }
444
+ for (const relPath of files) {
445
+ if (pending.has(relPath))
446
+ continue;
447
+ const inFile = path.join(inputDir, relPath);
448
+ const outFile = path.join(outputDir, relPath.replace(/\.weave$/, '.ts'));
449
+ let sourceMtime;
450
+ try {
451
+ sourceMtime = statSync(inFile).mtimeMs;
452
+ }
453
+ catch {
454
+ continue;
455
+ }
456
+ let outMtime;
457
+ try {
458
+ outMtime = statSync(outFile).mtimeMs;
459
+ }
460
+ catch {
461
+ outMtime = undefined;
462
+ }
463
+ let source;
464
+ try {
465
+ source = readFileSync(inFile, 'utf-8');
466
+ }
467
+ catch {
468
+ continue;
469
+ }
470
+ // The output also depends on the mixins each include defines, so an
471
+ // include edited since the last compile makes it stale too.
472
+ let newestMtime = sourceMtime;
473
+ for (const included of includedFiles(inFile, source)) {
474
+ try {
475
+ newestMtime = Math.max(newestMtime, statSync(included).mtimeMs);
476
+ }
477
+ catch {
478
+ // A missing include is reported when the template compiles.
479
+ }
480
+ }
481
+ // Up to date — the watcher already handled this file.
482
+ if (outMtime !== undefined && outMtime >= newestMtime)
483
+ continue;
484
+ try {
485
+ const elapsed = writeCompiled(inFile, outFile, source, sourcemap);
486
+ console.log(`${outFile} (reconciled, ${formatElapsed(elapsed)})`);
487
+ }
488
+ catch (err) {
489
+ console.error(formatError(inFile, err, source));
490
+ }
491
+ }
492
+ // A delete event can be dropped just as an edit can, so sweep here too.
493
+ pruneOrphans(inputDir, outputDir);
186
494
  }
187
495
  function compileWatchedFile(inputDir, outputDir, relPath, sourcemap) {
188
496
  const inFile = path.join(inputDir, relPath);
@@ -192,7 +500,8 @@ function compileWatchedFile(inputDir, outputDir, relPath, sourcemap) {
192
500
  source = readFileSync(inFile, 'utf-8');
193
501
  }
194
502
  catch {
195
- // File may have been deleted — ignore
503
+ // The template was deleted (or renamed away) — take its output with it.
504
+ pruneOrphans(inputDir, outputDir);
196
505
  return;
197
506
  }
198
507
  try {
@@ -5,22 +5,36 @@ export interface GenerateResult {
5
5
  code: string;
6
6
  mappings: Mapping[];
7
7
  }
8
+ /**
9
+ * Lists the mixins defined by the `.weave` file an `include` path names.
10
+ * The path is exactly as written after `include`; throw if it cannot be read.
11
+ */
12
+ export type IncludeResolver = (includePath: string) => string[];
13
+ export interface GenerateOptions {
14
+ /**
15
+ * Without a resolver, a lone `include` is assumed to provide every called
16
+ * mixin not defined in this file, and more than one `include` is an error.
17
+ */
18
+ resolveInclude?: IncludeResolver;
19
+ }
8
20
  export declare class CodegenError extends Error {
9
21
  readonly line: number;
10
22
  readonly col: number;
11
23
  constructor(message: string, line: number, col: number);
12
24
  }
25
+ /** Names of the mixins a document defines at top level. */
26
+ export declare function definedMixins(doc: DocumentNode): string[];
13
27
  /** Generate a TypeScript expression from a single AST node. */
14
28
  export declare function generateExpr(node: AstNode): string;
15
29
  /** Generate TypeScript source from a DocumentNode. */
16
- export declare function generate(doc: DocumentNode): GenerateResult;
30
+ export declare function generate(doc: DocumentNode, options?: GenerateOptions): GenerateResult;
17
31
  /** Full pipeline: source string → TypeScript source. */
18
- export declare function compile(source: string): string;
32
+ export declare function compile(source: string, options?: GenerateOptions): string;
19
33
  /** Full pipeline with source map generation. */
20
34
  export declare function compileWithSourceMap(source: string, opts: {
21
35
  file: string;
22
36
  sourceFile: string;
23
- }): {
37
+ } & GenerateOptions): {
24
38
  code: string;
25
39
  sourceMap: SourceMap;
26
40
  };