@timeax/scaffold 0.0.3 → 0.0.5

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,15 +1,16 @@
1
1
  // src/core/structure-txt.ts
2
2
 
3
- import type { StructureEntry, DirEntry, FileEntry } from '../schema';
4
- import { toPosixPath } from '../util/fs-utils';
3
+ import type {StructureEntry, DirEntry, FileEntry} from '../schema';
4
+ import {toPosixPath} from '../util/fs-utils';
5
+ import {mapThrough} from "../ast";
5
6
 
6
7
  interface ParsedLine {
7
- lineNo: number;
8
- indentSpaces: number;
9
- rawPath: string;
10
- stub?: string;
11
- include?: string[];
12
- exclude?: string[];
8
+ lineNo: number;
9
+ indentSpaces: number;
10
+ rawPath: string;
11
+ stub?: string;
12
+ include?: string[];
13
+ exclude?: string[];
13
14
  }
14
15
 
15
16
  /**
@@ -26,39 +27,12 @@ interface ParsedLine {
26
27
  * preceded by whitespace (space or tab).
27
28
  */
28
29
  function stripInlineComment(content: string): string {
29
- let cutIndex = -1;
30
- const len = content.length;
31
-
32
- for (let i = 0; i < len; i++) {
33
- const ch = content[i];
34
- const prev = i > 0 ? content[i - 1] : '';
35
-
36
- // Inline "# ..."
37
- if (ch === '#') {
38
- if (i === 0) continue; // full-line handled earlier
39
- if (prev === ' ' || prev === '\t') {
40
- cutIndex = i;
41
- break;
42
- }
43
- }
44
-
45
- // Inline "// ..."
46
- if (
47
- ch === '/' &&
48
- i + 1 < len &&
49
- content[i + 1] === '/' &&
50
- (prev === ' ' || prev === '\t')
51
- ) {
52
- cutIndex = i;
53
- break;
54
- }
55
- }
56
-
57
- if (cutIndex === -1) {
58
- return content.trimEnd();
59
- }
60
-
61
- return content.slice(0, cutIndex).trimEnd();
30
+ const cutIndex = mapThrough(content);
31
+ if (cutIndex === -1) {
32
+ return content.trimEnd();
33
+ }
34
+
35
+ return content.slice(0, cutIndex).trimEnd();
62
36
  }
63
37
 
64
38
  /**
@@ -69,77 +43,77 @@ function stripInlineComment(content: string): string {
69
43
  * - @exclude:pattern,pattern2
70
44
  */
71
45
  function parseLine(line: string, lineNo: number): ParsedLine | null {
72
- const match = line.match(/^(\s*)(.*)$/);
73
- if (!match) return null;
74
-
75
- const indentSpaces = match[1].length;
76
- let rest = match[2];
77
-
78
- // If line (after indent) is empty, skip
79
- if (!rest.trim()) return null;
80
-
81
- // Full-line comments after indent
82
- const trimmedRest = rest.trimStart();
83
- if (trimmedRest.startsWith('#') || trimmedRest.startsWith('//')) {
84
- return null;
85
- }
86
-
87
- // Strip inline comments (# or //) before parsing tokens
88
- const stripped = stripInlineComment(rest);
89
- const trimmed = stripped.trim();
90
- if (!trimmed) return null;
91
-
92
- const parts = trimmed.split(/\s+/);
93
- if (!parts.length) return null;
94
-
95
- const pathToken = parts[0];
96
-
97
- // 🚫 Reserve ":" for annotations only – paths may not contain it.
98
- if (pathToken.includes(':')) {
99
- throw new Error(
100
- `structure.txt: ":" is reserved for annotations (@stub:, @include:, etc). ` +
101
- `Invalid path "${pathToken}" on line ${lineNo}.`,
102
- );
103
- }
104
-
105
- let stub: string | undefined;
106
- const include: string[] = [];
107
- const exclude: string[] = [];
108
-
109
- for (const token of parts.slice(1)) {
110
- if (token.startsWith('@stub:')) {
111
- stub = token.slice('@stub:'.length);
112
- } else if (token.startsWith('@include:')) {
113
- const val = token.slice('@include:'.length);
114
- if (val) {
115
- include.push(
116
- ...val
117
- .split(',')
118
- .map((s) => s.trim())
119
- .filter(Boolean),
120
- );
121
- }
122
- } else if (token.startsWith('@exclude:')) {
123
- const val = token.slice('@exclude:'.length);
124
- if (val) {
125
- exclude.push(
126
- ...val
127
- .split(',')
128
- .map((s) => s.trim())
129
- .filter(Boolean),
130
- );
131
- }
132
- }
133
- }
134
-
135
- return {
136
- lineNo,
137
- indentSpaces,
138
- rawPath: pathToken,
139
- stub,
140
- include: include.length ? include : undefined,
141
- exclude: exclude.length ? exclude : undefined,
142
- };
46
+ const match = line.match(/^(\s*)(.*)$/);
47
+ if (!match) return null;
48
+
49
+ const indentSpaces = match[1].length;
50
+ let rest = match[2];
51
+
52
+ // If line (after indent) is empty, skip
53
+ if (!rest.trim()) return null;
54
+
55
+ // Full-line comments after indent
56
+ const trimmedRest = rest.trimStart();
57
+ if (trimmedRest.startsWith('#') || trimmedRest.startsWith('//')) {
58
+ return null;
59
+ }
60
+
61
+ // Strip inline comments (# or //) before parsing tokens
62
+ const stripped = stripInlineComment(rest);
63
+ const trimmed = stripped.trim();
64
+ if (!trimmed) return null;
65
+
66
+ const parts = trimmed.split(/\s+/);
67
+ if (!parts.length) return null;
68
+
69
+ const pathToken = parts[0];
70
+
71
+ // 🚫 Reserve ":" for annotations only – paths may not contain it.
72
+ if (pathToken.includes(':')) {
73
+ throw new Error(
74
+ `structure.txt: ":" is reserved for annotations (@stub:, @include:, etc). ` +
75
+ `Invalid path "${pathToken}" on line ${lineNo}.`,
76
+ );
77
+ }
78
+
79
+ let stub: string | undefined;
80
+ const include: string[] = [];
81
+ const exclude: string[] = [];
82
+
83
+ for (const token of parts.slice(1)) {
84
+ if (token.startsWith('@stub:')) {
85
+ stub = token.slice('@stub:'.length);
86
+ } else if (token.startsWith('@include:')) {
87
+ const val = token.slice('@include:'.length);
88
+ if (val) {
89
+ include.push(
90
+ ...val
91
+ .split(',')
92
+ .map((s) => s.trim())
93
+ .filter(Boolean),
94
+ );
95
+ }
96
+ } else if (token.startsWith('@exclude:')) {
97
+ const val = token.slice('@exclude:'.length);
98
+ if (val) {
99
+ exclude.push(
100
+ ...val
101
+ .split(',')
102
+ .map((s) => s.trim())
103
+ .filter(Boolean),
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ return {
110
+ lineNo,
111
+ indentSpaces,
112
+ rawPath: pathToken,
113
+ stub,
114
+ include: include.length ? include : undefined,
115
+ exclude: exclude.length ? exclude : undefined,
116
+ };
143
117
  }
144
118
 
145
119
  /**
@@ -154,123 +128,123 @@ function parseLine(line: string, lineNo: number): ParsedLine | null {
154
128
  * - Folders must end with "/" in the txt; paths are normalized to POSIX.
155
129
  */
156
130
  export function parseStructureText(
157
- text: string,
158
- indentStep = 2,
131
+ text: string,
132
+ indentStep = 2,
159
133
  ): StructureEntry[] {
160
- const lines = text.split(/\r?\n/);
161
- const parsed: ParsedLine[] = [];
162
-
163
- for (let i = 0; i < lines.length; i++) {
164
- const lineNo = i + 1;
165
- const p = parseLine(lines[i], lineNo);
166
- if (p) parsed.push(p);
167
- }
168
-
169
- const rootEntries: StructureEntry[] = [];
134
+ const lines = text.split(/\r?\n/);
135
+ const parsed: ParsedLine[] = [];
170
136
 
171
- type StackItem = {
172
- level: number;
173
- entry: DirEntry | FileEntry;
174
- isDir: boolean;
175
- };
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const lineNo = i + 1;
139
+ const p = parseLine(lines[i], lineNo);
140
+ if (p) parsed.push(p);
141
+ }
176
142
 
177
- const stack: StackItem[] = [];
143
+ const rootEntries: StructureEntry[] = [];
178
144
 
179
- for (const p of parsed) {
180
- const { indentSpaces, lineNo } = p;
145
+ type StackItem = {
146
+ level: number;
147
+ entry: DirEntry | FileEntry;
148
+ isDir: boolean;
149
+ };
181
150
 
182
- if (indentSpaces % indentStep !== 0) {
183
- throw new Error(
184
- `structure.txt: Invalid indent on line ${lineNo}. ` +
185
- `Indent must be multiples of ${indentStep} spaces.`,
186
- );
187
- }
151
+ const stack: StackItem[] = [];
188
152
 
189
- const level = indentSpaces / indentStep;
153
+ for (const p of parsed) {
154
+ const {indentSpaces, lineNo} = p;
190
155
 
191
- // Determine parent level and enforce no skipping
192
- if (level > stack.length) {
193
- // e.g. current stack depth 1, but line level=3 is invalid
194
- if (level !== stack.length + 1) {
195
- throw new Error(
196
- `structure.txt: Invalid indentation on line ${lineNo}. ` +
197
- `You cannot jump more than one level at a time. ` +
198
- `Previous depth: ${stack.length}, this line depth: ${level}.`,
199
- );
200
- }
201
- }
202
-
203
- // If this line is indented (level > 0), parent must exist and must be dir
204
- if (level > 0) {
205
- const parent = stack[level - 1]; // parent level is (level - 1)
206
- if (!parent) {
207
- throw new Error(
208
- `structure.txt: Indented entry without a parent on line ${lineNo}.`,
209
- );
210
- }
211
- if (!parent.isDir) {
156
+ if (indentSpaces % indentStep !== 0) {
212
157
  throw new Error(
213
- `structure.txt: Cannot indent under a file on line ${lineNo}. ` +
214
- `Files cannot have children. Parent: "${parent.entry.path}".`,
158
+ `structure.txt: Invalid indent on line ${lineNo}. ` +
159
+ `Indent must be multiples of ${indentStep} spaces.`,
215
160
  );
216
- }
217
- }
218
-
219
- const isDir = p.rawPath.endsWith('/');
220
- const clean = p.rawPath.replace(/\/$/, '');
221
- const basePath = toPosixPath(clean);
222
-
223
- // Pop stack until we are at the correct depth
224
- while (stack.length > level) {
225
- stack.pop();
226
- }
227
-
228
- const parent = stack[stack.length - 1]?.entry as DirEntry | undefined;
229
- const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
230
-
231
- const fullPath = parentPath
232
- ? `${parentPath}/${basePath}${isDir ? '/' : ''}`
233
- : `${basePath}${isDir ? '/' : ''}`;
234
-
235
- if (isDir) {
236
- const dirEntry: DirEntry = {
237
- type: 'dir',
238
- path: fullPath,
239
- children: [],
240
- ...(p.stub ? { stub: p.stub } : {}),
241
- ...(p.include ? { include: p.include } : {}),
242
- ...(p.exclude ? { exclude: p.exclude } : {}),
243
- };
244
-
245
- if (parent && parent.type === 'dir') {
246
- parent.children = parent.children ?? [];
247
- parent.children.push(dirEntry);
248
- } else if (!parent) {
249
- rootEntries.push(dirEntry);
250
- }
251
-
252
- stack.push({ level, entry: dirEntry, isDir: true });
253
- } else {
254
- const fileEntry: FileEntry = {
255
- type: 'file',
256
- path: fullPath,
257
- ...(p.stub ? { stub: p.stub } : {}),
258
- ...(p.include ? { include: p.include } : {}),
259
- ...(p.exclude ? { exclude: p.exclude } : {}),
260
- };
261
-
262
- if (parent && parent.type === 'dir') {
263
- parent.children = parent.children ?? [];
264
- parent.children.push(fileEntry);
265
- } else if (!parent) {
266
- rootEntries.push(fileEntry);
267
- }
268
-
269
- // We still push files into the stack at this level so that
270
- // bad indentation under them can be detected and rejected.
271
- stack.push({ level, entry: fileEntry, isDir: false });
272
- }
273
- }
274
-
275
- return rootEntries;
161
+ }
162
+
163
+ const level = indentSpaces / indentStep;
164
+
165
+ // Determine parent level and enforce no skipping
166
+ if (level > stack.length) {
167
+ // e.g. current stack depth 1, but line level=3 is invalid
168
+ if (level !== stack.length + 1) {
169
+ throw new Error(
170
+ `structure.txt: Invalid indentation on line ${lineNo}. ` +
171
+ `You cannot jump more than one level at a time. ` +
172
+ `Previous depth: ${stack.length}, this line depth: ${level}.`,
173
+ );
174
+ }
175
+ }
176
+
177
+ // If this line is indented (level > 0), parent must exist and must be dir
178
+ if (level > 0) {
179
+ const parent = stack[level - 1]; // parent level is (level - 1)
180
+ if (!parent) {
181
+ throw new Error(
182
+ `structure.txt: Indented entry without a parent on line ${lineNo}.`,
183
+ );
184
+ }
185
+ if (!parent.isDir) {
186
+ throw new Error(
187
+ `structure.txt: Cannot indent under a file on line ${lineNo}. ` +
188
+ `Files cannot have children. Parent: "${parent.entry.path}".`,
189
+ );
190
+ }
191
+ }
192
+
193
+ const isDir = p.rawPath.endsWith('/');
194
+ const clean = p.rawPath.replace(/\/$/, '');
195
+ const basePath = toPosixPath(clean);
196
+
197
+ // Pop stack until we are at the correct depth
198
+ while (stack.length > level) {
199
+ stack.pop();
200
+ }
201
+
202
+ const parent = stack[stack.length - 1]?.entry as DirEntry | undefined;
203
+ const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
204
+
205
+ const fullPath = parentPath
206
+ ? `${parentPath}/${basePath}${isDir ? '/' : ''}`
207
+ : `${basePath}${isDir ? '/' : ''}`;
208
+
209
+ if (isDir) {
210
+ const dirEntry: DirEntry = {
211
+ type: 'dir',
212
+ path: fullPath,
213
+ children: [],
214
+ ...(p.stub ? {stub: p.stub} : {}),
215
+ ...(p.include ? {include: p.include} : {}),
216
+ ...(p.exclude ? {exclude: p.exclude} : {}),
217
+ };
218
+
219
+ if (parent && parent.type === 'dir') {
220
+ parent.children = parent.children ?? [];
221
+ parent.children.push(dirEntry);
222
+ } else if (!parent) {
223
+ rootEntries.push(dirEntry);
224
+ }
225
+
226
+ stack.push({level, entry: dirEntry, isDir: true});
227
+ } else {
228
+ const fileEntry: FileEntry = {
229
+ type: 'file',
230
+ path: fullPath,
231
+ ...(p.stub ? {stub: p.stub} : {}),
232
+ ...(p.include ? {include: p.include} : {}),
233
+ ...(p.exclude ? {exclude: p.exclude} : {}),
234
+ };
235
+
236
+ if (parent && parent.type === 'dir') {
237
+ parent.children = parent.children ?? [];
238
+ parent.children.push(fileEntry);
239
+ } else if (!parent) {
240
+ rootEntries.push(fileEntry);
241
+ }
242
+
243
+ // We still push files into the stack at this level so that
244
+ // bad indentation under them can be detected and rejected.
245
+ stack.push({level, entry: fileEntry, isDir: false});
246
+ }
247
+ }
248
+
249
+ return rootEntries;
276
250
  }
@@ -2,105 +2,113 @@
2
2
 
3
3
  import path from 'path';
4
4
  import chokidar from 'chokidar';
5
- import { runOnce, type RunOptions } from './runner';
6
- import { defaultLogger, type Logger } from '../util/logger';
5
+ import {runOnce, type RunOptions} from './runner';
6
+ import {defaultLogger, type Logger} from '../util/logger';
7
+ import {SCAFFOLD_ROOT_DIR} from '..';
7
8
 
8
9
  export interface WatchOptions extends RunOptions {
9
- /**
10
- * Debounce delay in milliseconds between detected changes
11
- * and a scaffold re-run.
12
- *
13
- * Default: 150 ms
14
- */
15
- debounceMs?: number;
10
+ /**
11
+ * Debounce delay in milliseconds between detected changes
12
+ * and a scaffold re-run.
13
+ *
14
+ * Default: 150 ms
15
+ */
16
+ debounceMs?: number;
16
17
 
17
- /**
18
- * Optional logger; falls back to defaultLogger.child('[watch]').
19
- */
20
- logger?: Logger;
18
+ /**
19
+ * Optional logger; falls back to defaultLogger.child('[watch]').
20
+ */
21
+ logger?: Logger;
21
22
  }
22
23
 
23
24
  /**
24
25
  * Watch the scaffold directory and re-run scaffold on changes.
25
26
  *
26
27
  * This watches:
27
- * - scaffold/config.* files
28
- * - scaffold/*.txt files (structures)
28
+ * - .scaffold/config.* files
29
+ * - .scaffold/*.txt / *.tss / *.stx files (structures)
29
30
  *
30
31
  * CLI can call this when `--watch` is enabled.
32
+ * Any `format` options in RunOptions are passed straight through to `runOnce`,
33
+ * so formatting from config / CLI is applied on each re-run.
31
34
  */
32
35
  export function watchScaffold(cwd: string, options: WatchOptions = {}): void {
33
- const logger = options.logger ?? defaultLogger.child('[watch]');
36
+ const logger = options.logger ?? defaultLogger.child('[watch]');
34
37
 
35
- const scaffoldDir = options.scaffoldDir
36
- ? path.resolve(cwd, options.scaffoldDir)
37
- : path.resolve(cwd, 'scaffold');
38
+ const scaffoldDir = options.scaffoldDir
39
+ ? path.resolve(cwd, options.scaffoldDir)
40
+ : path.resolve(cwd, SCAFFOLD_ROOT_DIR);
38
41
 
39
- const debounceMs = options.debounceMs ?? 150;
42
+ const debounceMs = options.debounceMs ?? 150;
40
43
 
41
- logger.info(`Watching scaffold directory: ${scaffoldDir}`);
44
+ logger.info(`Watching scaffold directory: ${scaffoldDir}`);
42
45
 
43
- let timer: NodeJS.Timeout | undefined;
44
- let running = false;
45
- let pending = false;
46
+ let timer: NodeJS.Timeout | undefined;
47
+ let running = false;
48
+ let pending = false;
46
49
 
47
- async function run() {
48
- if (running) {
49
- pending = true;
50
- return;
51
- }
52
- running = true;
53
- try {
54
- logger.info('Change detected → running scaffold...');
55
- await runOnce(cwd, {
56
- ...options,
57
- // we already resolved scaffoldDir for watcher; pass it down
58
- scaffoldDir,
59
- });
60
- logger.info('Scaffold run completed.');
61
- } catch (err) {
62
- logger.error('Scaffold run failed:', err);
63
- } finally {
64
- running = false;
65
- if (pending) {
66
- pending = false;
67
- timer = setTimeout(run, debounceMs);
68
- }
69
- }
70
- }
50
+ async function run() {
51
+ if (running) {
52
+ pending = true;
53
+ return;
54
+ }
55
+ running = true;
56
+ try {
57
+ logger.info('Change detected → running scaffold...');
58
+ await runOnce(cwd, {
59
+ ...options,
60
+ // we already resolved scaffoldDir for watcher; pass it down
61
+ scaffoldDir,
62
+ });
63
+ logger.info('Scaffold run completed.');
64
+ } catch (err) {
65
+ logger.error('Scaffold run failed:', err);
66
+ } finally {
67
+ running = false;
68
+ if (pending) {
69
+ pending = false;
70
+ timer = setTimeout(run, debounceMs);
71
+ }
72
+ }
73
+ }
71
74
 
72
- function scheduleRun() {
73
- if (timer) clearTimeout(timer);
74
- timer = setTimeout(run, debounceMs);
75
- }
75
+ function scheduleRun() {
76
+ if (timer) clearTimeout(timer);
77
+ timer = setTimeout(run, debounceMs);
78
+ }
76
79
 
77
- const watcher = chokidar.watch(
78
- [
79
- path.join(scaffoldDir, 'config.*'),
80
- path.join(scaffoldDir, '*.txt'),
81
- ],
82
- {
83
- ignoreInitial: false,
84
- },
85
- );
80
+ const watcher = chokidar.watch(
81
+ [
82
+ // config files (ts/js/etc.)
83
+ path.join(scaffoldDir, 'config.*'),
86
84
 
87
- watcher
88
- .on('add', (filePath) => {
89
- logger.debug(`File added: ${filePath}`);
90
- scheduleRun();
91
- })
92
- .on('change', (filePath) => {
93
- logger.debug(`File changed: ${filePath}`);
94
- scheduleRun();
95
- })
96
- .on('unlink', (filePath) => {
97
- logger.debug(`File removed: ${filePath}`);
98
- scheduleRun();
99
- })
100
- .on('error', (error) => {
101
- logger.error('Watcher error:', error);
102
- });
85
+ // structure files: plain txt + our custom extensions
86
+ path.join(scaffoldDir, '*.txt'),
87
+ path.join(scaffoldDir, '*.tss'),
88
+ path.join(scaffoldDir, '*.stx'),
89
+ ],
90
+ {
91
+ ignoreInitial: false,
92
+ },
93
+ );
103
94
 
104
- // Initial run
105
- scheduleRun();
95
+ watcher
96
+ .on('add', (filePath) => {
97
+ logger.debug(`File added: ${filePath}`);
98
+ scheduleRun();
99
+ })
100
+ .on('change', (filePath) => {
101
+ logger.debug(`File changed: ${filePath}`);
102
+ scheduleRun();
103
+ })
104
+ .on('unlink', (filePath) => {
105
+ logger.debug(`File removed: ${filePath}`);
106
+ scheduleRun();
107
+ })
108
+ .on('error', (error) => {
109
+ logger.error('Watcher error:', error);
110
+ });
111
+
112
+ // Initial run
113
+ scheduleRun();
106
114
  }