@timeax/scaffold 0.0.1 → 0.0.3

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.
@@ -211,4 +211,75 @@ export async function writeScannedStructuresFromConfig(
211
211
  `Wrote structure for group "${result.groupName}" to ${result.structureFilePath}`,
212
212
  );
213
213
  }
214
+ }
215
+
216
+
217
+
218
+ export interface EnsureStructuresResult {
219
+ created: string[];
220
+ existing: string[];
221
+ }
222
+
223
+ /**
224
+ * Ensure all structure files declared in the config exist.
225
+ *
226
+ * - Grouped mode: one file per group (group.structureFile || `${group.name}.txt`)
227
+ * - Single-root mode: config.structureFile || "structure.txt"
228
+ *
229
+ * Existing files are left untouched. Only missing files are created with
230
+ * a small header comment.
231
+ */
232
+ export async function ensureStructureFilesFromConfig(
233
+ cwd: string,
234
+ options: { scaffoldDirOverride?: string } = {},
235
+ ): Promise<EnsureStructuresResult> {
236
+ const { config, scaffoldDir } = await loadScaffoldConfig(cwd, {
237
+ scaffoldDir: options.scaffoldDirOverride,
238
+ });
239
+
240
+ ensureDirSync(scaffoldDir);
241
+
242
+ const created: string[] = [];
243
+ const existing: string[] = [];
244
+
245
+ const seen = new Set<string>();
246
+
247
+ const ensureFile = (fileName: string) => {
248
+ if (!fileName) return;
249
+
250
+ const filePath = path.join(scaffoldDir, fileName);
251
+ const key = path.resolve(filePath);
252
+
253
+ if (seen.has(key)) return;
254
+ seen.add(key);
255
+
256
+ if (fs.existsSync(filePath)) {
257
+ existing.push(filePath);
258
+ return;
259
+ }
260
+
261
+ const header =
262
+ `# ${fileName}\n` +
263
+ `# Structure file for @timeax/scaffold\n` +
264
+ `# Define your desired folders/files here.\n`;
265
+
266
+ fs.writeFileSync(filePath, header, 'utf8');
267
+ created.push(filePath);
268
+ };
269
+
270
+ if (config.groups && config.groups.length > 0) {
271
+ for (const group of config.groups) {
272
+ const fileName = group.structureFile ?? `${group.name}.txt`;
273
+ ensureFile(fileName);
274
+ }
275
+ } else {
276
+ const fileName = config.structureFile ?? 'structure.txt';
277
+ ensureFile(fileName);
278
+ }
279
+
280
+ logger.debug(
281
+ `ensureStructureFilesFromConfig: created=${created.length}, existing=${existing.length}`,
282
+ );
283
+
284
+ return { created, existing };
214
285
  }
@@ -12,6 +12,55 @@ interface ParsedLine {
12
12
  exclude?: string[];
13
13
  }
14
14
 
15
+ /**
16
+ * Strip inline comments from a content segment.
17
+ *
18
+ * Supports:
19
+ * - "index.ts # comment"
20
+ * - "index.ts // comment"
21
+ *
22
+ * Rules:
23
+ * - We assume leading indentation has already been removed.
24
+ * - Leading '#' or '//' (full-line comments) are handled BEFORE this function.
25
+ * - A comment starts at the first '#' or '//' that is
26
+ * preceded by whitespace (space or tab).
27
+ */
28
+ 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();
62
+ }
63
+
15
64
  /**
16
65
  * Parse a single non-empty, non-comment line into a ParsedLine.
17
66
  * Supports inline annotations:
@@ -20,16 +69,39 @@ interface ParsedLine {
20
69
  * - @exclude:pattern,pattern2
21
70
  */
22
71
  function parseLine(line: string, lineNo: number): ParsedLine | null {
23
- const match = line.match(/^(\s*)(.+)$/);
72
+ const match = line.match(/^(\s*)(.*)$/);
24
73
  if (!match) return null;
25
74
 
26
75
  const indentSpaces = match[1].length;
27
- const rest = match[2].trim();
28
- if (!rest || rest.startsWith('#')) return null;
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;
29
94
 
30
- const parts = rest.split(/\s+/);
31
95
  const pathToken = parts[0];
32
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
+
33
105
  let stub: string | undefined;
34
106
  const include: string[] = [];
35
107
  const exclude: string[] = [];
@@ -74,14 +146,17 @@ function parseLine(line: string, lineNo: number): ParsedLine | null {
74
146
  * Convert a structure.txt content into a nested StructureEntry[].
75
147
  *
76
148
  * Rules:
77
- * - Indentation is **2 spaces per level** (strict).
78
- * - Indent must be a multiple of 2.
149
+ * - Indentation is **indentStep** spaces per level (default: 2).
150
+ * - Indent must be a multiple of indentStep.
79
151
  * - You cannot "skip" levels (no jumping from level 0 to 2 directly).
80
152
  * - **Only directories can have children**:
81
153
  * - If you indent under a file, an error is thrown.
82
154
  * - Folders must end with "/" in the txt; paths are normalized to POSIX.
83
155
  */
84
- export function parseStructureText(text: string): StructureEntry[] {
156
+ export function parseStructureText(
157
+ text: string,
158
+ indentStep = 2,
159
+ ): StructureEntry[] {
85
160
  const lines = text.split(/\r?\n/);
86
161
  const parsed: ParsedLine[] = [];
87
162
 
@@ -100,23 +175,22 @@ export function parseStructureText(text: string): StructureEntry[] {
100
175
  };
101
176
 
102
177
  const stack: StackItem[] = [];
103
- const INDENT_STEP = 2;
104
178
 
105
179
  for (const p of parsed) {
106
180
  const { indentSpaces, lineNo } = p;
107
181
 
108
- if (indentSpaces % INDENT_STEP !== 0) {
182
+ if (indentSpaces % indentStep !== 0) {
109
183
  throw new Error(
110
184
  `structure.txt: Invalid indent on line ${lineNo}. ` +
111
- `Indent must be multiples of ${INDENT_STEP} spaces.`,
185
+ `Indent must be multiples of ${indentStep} spaces.`,
112
186
  );
113
187
  }
114
188
 
115
- const level = indentSpaces / INDENT_STEP;
189
+ const level = indentSpaces / indentStep;
116
190
 
117
191
  // Determine parent level and enforce no skipping
118
192
  if (level > stack.length) {
119
- // e.g. current stack depth 1, but line level=2+ is invalid
193
+ // e.g. current stack depth 1, but line level=3 is invalid
120
194
  if (level !== stack.length + 1) {
121
195
  throw new Error(
122
196
  `structure.txt: Invalid indentation on line ${lineNo}. ` +
@@ -146,7 +220,6 @@ export function parseStructureText(text: string): StructureEntry[] {
146
220
  const clean = p.rawPath.replace(/\/$/, '');
147
221
  const basePath = toPosixPath(clean);
148
222
 
149
- // Determine parent based on level
150
223
  // Pop stack until we are at the correct depth
151
224
  while (stack.length > level) {
152
225
  stack.pop();
@@ -193,9 +266,9 @@ export function parseStructureText(text: string): StructureEntry[] {
193
266
  rootEntries.push(fileEntry);
194
267
  }
195
268
 
196
- // files are not added to the stack; they cannot have children
269
+ // We still push files into the stack at this level so that
270
+ // bad indentation under them can be detected and rejected.
197
271
  stack.push({ level, entry: fileEntry, isDir: false });
198
- // but next lines at same or lower level will pop correctly
199
272
  }
200
273
  }
201
274
 
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
-
3
2
  export * from './schema';
4
3
  export * from './core/runner';
5
- export * from './core/scan-structure';
4
+ export * from './core/config-loader';
5
+ export * from './core/scan-structure';
6
+ export * from './core/structure-txt';
@@ -143,9 +143,18 @@ export interface ScaffoldConfig {
143
143
  * runner / CLI.
144
144
  */
145
145
  watch?: boolean;
146
- }
147
146
 
148
147
 
148
+ /**
149
+ * Number of spaces per indent level in structure files.
150
+ * Default: 2.
151
+ *
152
+ * Examples:
153
+ * - 2 → "··entry"
154
+ * - 4 → "····entry"
155
+ */
156
+ indentStep?: number;
157
+ }
149
158
  /**
150
159
  * Options when scanning an existing directory into a structure.txt tree.
151
160
  */
@@ -1,4 +1,5 @@
1
1
  // src/schema/index.ts
2
+ export const SCAFFOLD_ROOT_DIR = '.scaffold';
2
3
  export * from './structure';
3
4
  export * from './hooks';
4
5
  export * from './config';