@sous-io/sous 0.2.32 → 0.2.33
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/README.md +7 -6
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -4
- package/docs/markdown/configuration.md +28 -4
- package/docs/markdown/repositories-authoring.md +22 -2
- package/docs/markdown/repositories-consuming.md +40 -11
- package/docs/markdown/repositories-file-formats.md +28 -13
- package/docs/markdown/repositories-quickstart.md +1 -1
- package/docs/markdown/repositories.md +8 -4
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +25 -17
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +27 -15
- package/src/commands/lock/rebuild.ts +3 -6
- package/src/commands/namespace/show.ts +2 -2
- package/src/commands/recipe/show.ts +26 -9
- package/src/commands/repo/contribute.ts +32 -23
- package/src/commands/repo/gc.ts +1 -1
- package/src/commands/repo/remove.ts +1 -1
- package/src/commands/repo/search.ts +1 -1
- package/src/commands/vars/ask.ts +75 -54
- package/src/lib/build-service.ts +85 -11
- package/src/lib/config-schema.ts +59 -19
- package/src/lib/include-resolver.ts +307 -69
- package/src/lib/markdown-compiler.ts +273 -81
- package/src/lib/project-scaffold/templates.ts +19 -11
- package/src/lib/repos/catalog-inputs.ts +1 -1
- package/src/lib/repos/catalog.ts +98 -72
- package/src/lib/repos/declarations.ts +22 -12
- package/src/lib/repos/formats/index-file.ts +1 -1
- package/src/lib/repos/formats/recipe-manifest.ts +3 -4
- package/src/lib/repos/lock-service.ts +1 -1
- package/src/lib/repos/locked-namespace-resolver.ts +14 -6
- package/src/lib/repos/locked-recipe-lookup.ts +123 -0
- package/src/lib/repos/locked-recipes.ts +1 -1
- package/src/lib/repos/memories-hash-name.ts +33 -0
- package/src/lib/repos/namespace-resolver.ts +198 -119
- package/src/lib/repos/recipe-config-layers.ts +29 -1
- package/src/lib/repos/recipe-key-matcher.ts +62 -0
- package/src/lib/repos/recipe-memories.ts +175 -0
- package/src/lib/repos/recipe-targets.ts +27 -28
- package/src/lib/repos/ref-search.ts +1 -1
- package/src/lib/repos/release/index-builder.ts +23 -15
- package/src/lib/repos/release/plan.ts +14 -7
- package/src/lib/repos/release/settle.ts +38 -121
- package/src/lib/repos/release/submit-checkout.ts +42 -23
- package/src/lib/repos/release/tags.ts +1 -1
- package/src/lib/repos/resolver.ts +90 -72
- package/src/lib/repos/subscription-service.ts +192 -170
- package/src/lib/settings.ts +37 -33
- package/src/lib/vars/ask.ts +3 -3
- package/src/lib/vars/mappings.ts +17 -9
- package/src/services/ref-resolver/container.ts +107 -0
- package/src/services/ref-resolver/dependency.ts +60 -0
- package/src/services/ref-resolver/format.ts +197 -0
- package/src/services/ref-resolver/glob.ts +115 -0
- package/src/services/ref-resolver/hash-names.ts +155 -0
- package/src/services/ref-resolver/index.ts +55 -0
- package/src/services/ref-resolver/injectable.ts +36 -0
- package/src/services/ref-resolver/location.ts +62 -0
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
- package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
- package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
- package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
- package/src/services/ref-resolver/named-refs.ts +144 -0
- package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
- package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
- package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
- package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
- package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
- package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
- package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
- package/src/services/ref-resolver/parts.ts +115 -0
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
- package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
- package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
- package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
- package/src/services/ref-resolver/ref-helpers.ts +62 -0
- package/src/services/ref-resolver/ref-picker-service.ts +204 -0
- package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
- package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
- package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
- package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
- package/src/services/ref-resolver/source.ts +48 -0
- package/src/services/ref-resolver/tokens.ts +25 -0
- package/src/services/ref-resolver/types.ts +155 -0
- package/src/templating/init-liquid-engine.ts +25 -18
- package/src/lib/refs/find.ts +0 -770
- package/src/lib/refs/index.ts +0 -14
- package/src/lib/refs/parse.ts +0 -844
- package/src/lib/refs/pick.ts +0 -147
- package/src/lib/refs/scopes.ts +0 -95
- package/src/lib/refs/settle.ts +0 -219
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
hashContent,
|
|
12
12
|
recordDirCreation,
|
|
13
13
|
} from "./state.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
resolveIncludeFiles,
|
|
16
|
+
splitIncludeQuery,
|
|
17
|
+
type AliasMap,
|
|
18
|
+
type ViewMap,
|
|
19
|
+
} from "./include-resolver.js";
|
|
20
|
+
import { RefSource, sharedRefResolver } from "../services/ref-resolver/index.js";
|
|
15
21
|
import {
|
|
16
22
|
formatNamespaceProblem,
|
|
17
23
|
type NamespaceResolver,
|
|
@@ -58,6 +64,8 @@ export type CompilationConfig = {
|
|
|
58
64
|
targets: CompilationTarget[];
|
|
59
65
|
/** Resolved `@include` alias map (name → ordered base dirs). */
|
|
60
66
|
aliases?: Record<string, string[]>;
|
|
67
|
+
/** The files each view lists (`#memories`), keyed by the name with its `#`. */
|
|
68
|
+
views?: ViewMap;
|
|
61
69
|
/** Variable scope for `${var}` substitution in `@include` paths. */
|
|
62
70
|
includeScope?: Record<string, string>;
|
|
63
71
|
};
|
|
@@ -145,12 +153,55 @@ export function stableVarsFingerprint(vars: Record<string, string>): string {
|
|
|
145
153
|
return JSON.stringify(Object.keys(vars).sort().map((key) => [key, vars[key]]));
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/**
|
|
157
|
+
* An include line: `@`, a path that ends in `.md` and an optional
|
|
158
|
+
* `?name=value` query, and nothing else. The first character is a sigil, a
|
|
159
|
+
* variable, a dot or a letter, a digit or a glob character; the rest may hold
|
|
160
|
+
* the characters of a path, a `${var}`, a glob and a recipe reference's `repo:`
|
|
161
|
+
* qualifier.
|
|
162
|
+
*/
|
|
163
|
+
export const INCLUDE_LINE_PATTERN =
|
|
164
|
+
/^@([~#a-zA-Z0-9_.${}*?[][a-zA-Z0-9_\-/.:${}*?[\],!~#%+^()]*\.md(?:\?[^?\s]*=\S*)?)$/;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether a line that is not a well-formed include still looks like one: it
|
|
168
|
+
* starts with `@` and is a single path-like word, meaning it holds a `/` or a
|
|
169
|
+
* `.md`, or starts with a sigil or a variable (`@~`, `@#`, `@.`, `@$`). A line
|
|
170
|
+
* that merely starts with `@` (a mention or an email address followed by
|
|
171
|
+
* words, or a lone `@name`) does not.
|
|
172
|
+
*
|
|
173
|
+
* looksLikeIncludeLine("@docs/notes.txt"); // -> true, the include has no .md
|
|
174
|
+
* looksLikeIncludeLine("@alice thanks for the review"); // -> false
|
|
175
|
+
* looksLikeIncludeLine("@alice"); // -> false
|
|
176
|
+
*
|
|
177
|
+
* @param line - One line of a file, with trailing whitespace already removed.
|
|
178
|
+
*/
|
|
179
|
+
export function looksLikeIncludeLine(line: string): boolean {
|
|
180
|
+
if (!/^@\S+$/.test(line)) return false;
|
|
181
|
+
const word = line.slice(1);
|
|
182
|
+
return /[/]/.test(word) || /\.md\b/.test(word) || /^[~#.$]/.test(word);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One piece of a read file: a line of its own text, or a file it includes. */
|
|
186
|
+
type Part = string | { file: string };
|
|
187
|
+
|
|
188
|
+
/** A file read for a target: its text, whether its name makes it a template, and its pieces. */
|
|
189
|
+
type FileNode = {
|
|
190
|
+
path: string;
|
|
191
|
+
content: string;
|
|
192
|
+
/** True when the file's own name contains `.tpl.`: the only files that render as Liquid. */
|
|
193
|
+
isTpl: boolean;
|
|
194
|
+
parts: Part[];
|
|
195
|
+
};
|
|
196
|
+
|
|
148
197
|
export class CompilationService {
|
|
149
198
|
private strict: boolean;
|
|
150
199
|
private rebuild: boolean;
|
|
151
200
|
private dryRun: boolean;
|
|
152
|
-
private visited: Set<string>;
|
|
153
201
|
private includeStack: string[];
|
|
202
|
+
private nodes: Map<string, FileNode>;
|
|
203
|
+
private views: ViewMap;
|
|
204
|
+
private includedFilesSeen: Set<string>;
|
|
154
205
|
private errors: string[];
|
|
155
206
|
private includeSourceComments: boolean;
|
|
156
207
|
private currentIncludeSourceComments: boolean;
|
|
@@ -170,8 +221,10 @@ export class CompilationService {
|
|
|
170
221
|
this.strict = options.strict ?? false;
|
|
171
222
|
this.rebuild = options.rebuild ?? false;
|
|
172
223
|
this.dryRun = options.dryRun ?? false;
|
|
173
|
-
this.visited = new Set();
|
|
174
224
|
this.includeStack = [];
|
|
225
|
+
this.nodes = new Map();
|
|
226
|
+
this.views = {};
|
|
227
|
+
this.includedFilesSeen = new Set();
|
|
175
228
|
this.errors = [];
|
|
176
229
|
this.includeSourceComments = false;
|
|
177
230
|
this.currentIncludeSourceComments = false;
|
|
@@ -214,23 +267,36 @@ export class CompilationService {
|
|
|
214
267
|
/**
|
|
215
268
|
* Process @<path> includes in content.
|
|
216
269
|
*
|
|
217
|
-
* Matches an `@`-prefixed `.md` path on its own line
|
|
270
|
+
* Matches an `@`-prefixed `.md` path on its own line, optionally followed by
|
|
271
|
+
* a `?name=value` query (read by the ref service and otherwise ignored). The
|
|
272
|
+
* path may be:
|
|
218
273
|
* - relative to the including file (`@sections/intro.md`),
|
|
219
274
|
* - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
|
|
220
|
-
* -
|
|
221
|
-
* first segment (up to `/` or `:`) names a
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
275
|
+
* - a `#name` path (`@#project/memories/x.md`), or an alias path
|
|
276
|
+
* (`@docs/x.md`), where the first segment (up to `/` or `:`) names a
|
|
277
|
+
* registered alias,
|
|
278
|
+
* - a home-relative path (`@~/notes/x.md`),
|
|
279
|
+
* - or a recipe reference (`@~workflow/task-files/_partials/x.md`), where
|
|
280
|
+
* the `~` sigil is followed by a namespace, a recipe and a file inside it.
|
|
281
|
+
* Recipe references are only consulted when a namespace resolver was
|
|
282
|
+
* supplied.
|
|
283
|
+
* Any of them may use glob syntax, and then includes every file it matches,
|
|
284
|
+
* in sorted path order; a glob that matches nothing is an error.
|
|
285
|
+
*
|
|
286
|
+
* A file may be included any number of times; only a file that includes
|
|
287
|
+
* itself (directly or through others) is an error.
|
|
226
288
|
*
|
|
227
289
|
* Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
|
|
228
290
|
* verbatim, so include syntax can be documented without being executed.
|
|
229
291
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* naming the
|
|
233
|
-
*
|
|
292
|
+
* A line that starts with `@` and looks like an include (see
|
|
293
|
+
* {@link looksLikeIncludeLine}) but is not a well-formed one is a compile
|
|
294
|
+
* error naming the file and the line, never text copied to the output.
|
|
295
|
+
*
|
|
296
|
+
* Resolution produces ordered groups of candidates (see include-resolver);
|
|
297
|
+
* the first group that names a file is used. If none does, it errors, naming
|
|
298
|
+
* the including file and listing every path tried, plus what went wrong with
|
|
299
|
+
* a `~` or `#` reference when one was attempted.
|
|
234
300
|
*
|
|
235
301
|
* @param content - The file's raw text.
|
|
236
302
|
* @param baseDir - Directory the relative candidate resolves against.
|
|
@@ -242,18 +308,16 @@ export class CompilationService {
|
|
|
242
308
|
baseDir: string,
|
|
243
309
|
projectRoot: string,
|
|
244
310
|
fromFile?: string
|
|
245
|
-
):
|
|
246
|
-
// First segment allows ~ and . (so ./ and ../ work), then path chars;
|
|
247
|
-
// separators / and :; allows ${...}.
|
|
248
|
-
const includePattern = /^@([~a-zA-Z0-9_.${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
|
|
311
|
+
): Part[] {
|
|
249
312
|
const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
|
|
250
313
|
const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
314
|
+
const where = fromFile ?? baseDir;
|
|
251
315
|
|
|
252
|
-
const out:
|
|
316
|
+
const out: Part[] = [];
|
|
253
317
|
let fenceChar: string | null = null;
|
|
254
318
|
let fenceLength = 0;
|
|
255
319
|
|
|
256
|
-
for (const line of content.split("\n")) {
|
|
320
|
+
for (const [lineIndex, line] of content.split("\n").entries()) {
|
|
257
321
|
if (fenceChar !== null) {
|
|
258
322
|
// Inside a fence: emit verbatim; only a matching closing fence ends it.
|
|
259
323
|
const close = fenceClosePattern.exec(line);
|
|
@@ -272,28 +336,63 @@ export class CompilationService {
|
|
|
272
336
|
continue;
|
|
273
337
|
}
|
|
274
338
|
|
|
275
|
-
const
|
|
339
|
+
const trimmed = line.trimEnd();
|
|
340
|
+
const match = INCLUDE_LINE_PATTERN.exec(trimmed);
|
|
276
341
|
if (!match) {
|
|
342
|
+
if (looksLikeIncludeLine(trimmed)) {
|
|
343
|
+
this.handleError(
|
|
344
|
+
`Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n` +
|
|
345
|
+
` An include is "@" followed by a path that ends in .md, optionally followed by ` +
|
|
346
|
+
`"?name=value". The path may hold letters, digits and . _ - / : $ { } * ? [ ] , ~ #.\n` +
|
|
347
|
+
` To write a line that starts with "@" as text, put a word after it.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
277
350
|
out.push(line);
|
|
278
351
|
continue;
|
|
279
352
|
}
|
|
280
353
|
|
|
281
354
|
const includePath = match[1].trim();
|
|
282
|
-
const { candidates, namespaceIssue } =
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
355
|
+
const { files, glob, candidates, namespaceIssue, hashIssue, view } = resolveIncludeFiles(
|
|
356
|
+
includePath,
|
|
357
|
+
{
|
|
358
|
+
aliases: this.aliases,
|
|
359
|
+
views: this.views,
|
|
360
|
+
scope: this.includeScope,
|
|
361
|
+
baseDir,
|
|
362
|
+
namespaceResolver: this.namespaceResolver,
|
|
363
|
+
fromFile: where,
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (files.length === 0) {
|
|
368
|
+
// A view is a list that may be empty: a glob over it that selects
|
|
369
|
+
// nothing includes nothing, and the line leaves no trace.
|
|
370
|
+
if (view === true && glob && hashIssue === undefined) continue;
|
|
371
|
+
if (this.namespaceResolver === undefined && includePath.startsWith("~")) {
|
|
372
|
+
// Without a resolver the reference is only a relative path, so say
|
|
373
|
+
// what is wrong with a malformed one instead of "not found".
|
|
374
|
+
const reference = splitIncludeQuery(includePath).path.slice(1);
|
|
375
|
+
if (reference.includes("/") && !reference.includes("${")) {
|
|
376
|
+
try {
|
|
377
|
+
sharedRefResolver().parse(reference, RefSource.Include);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
this.handleError(
|
|
380
|
+
`Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n ${
|
|
381
|
+
error instanceof Error ? error.message : String(error)
|
|
382
|
+
}`
|
|
383
|
+
);
|
|
384
|
+
out.push("");
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
292
389
|
const explanation = namespaceIssue
|
|
293
390
|
? `\n${formatNamespaceProblem(namespaceIssue)}`
|
|
294
|
-
:
|
|
391
|
+
: hashIssue
|
|
392
|
+
? `\n in file: ${where}\n ${hashIssue.split("\n").join("\n ")}`
|
|
393
|
+
: `\n in file: ${where}`;
|
|
295
394
|
this.handleError(
|
|
296
|
-
|
|
395
|
+
`${glob ? "Include matched no files" : "Include not found"}: @${includePath}${explanation}\n tried:\n${candidates
|
|
297
396
|
.map((c) => ` - ${c}`)
|
|
298
397
|
.join("\n")}`
|
|
299
398
|
);
|
|
@@ -301,57 +400,121 @@ export class CompilationService {
|
|
|
301
400
|
continue;
|
|
302
401
|
}
|
|
303
402
|
|
|
304
|
-
|
|
305
|
-
this.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
403
|
+
for (const fullPath of files) {
|
|
404
|
+
if (this.includeStack.includes(fullPath)) {
|
|
405
|
+
this.handleError(
|
|
406
|
+
`Circular dependency detected: ${this.includeStack.join(" -> ")} -> ${fullPath}`
|
|
407
|
+
);
|
|
408
|
+
out.push("");
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
311
411
|
|
|
312
|
-
|
|
313
|
-
if (includedContent !== null) {
|
|
314
|
-
const relativePath = path.relative(projectRoot, fullPath);
|
|
315
|
-
const sourceComment = this.currentIncludeSourceComments
|
|
316
|
-
? `<!-- from: ${relativePath} -->\n`
|
|
317
|
-
: "";
|
|
318
|
-
out.push(sourceComment + includedContent);
|
|
319
|
-
} else {
|
|
320
|
-
out.push("");
|
|
412
|
+
out.push(this.loadFile(fullPath, projectRoot) === null ? "" : { file: fullPath });
|
|
321
413
|
}
|
|
322
414
|
}
|
|
323
415
|
|
|
324
|
-
return out
|
|
416
|
+
return out;
|
|
325
417
|
}
|
|
326
418
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Reads a file and, recursively, every file it includes, without rendering
|
|
421
|
+
* anything. A file already read for this target is not read again (its
|
|
422
|
+
* includes resolve the same way whoever includes it), so a file may be
|
|
423
|
+
* included any number of times; only a cycle is an error.
|
|
424
|
+
*/
|
|
425
|
+
private loadFile(filePath: string, projectRoot: string): FileNode | null {
|
|
426
|
+
const known = this.nodes.get(filePath);
|
|
427
|
+
if (known !== undefined) return known;
|
|
428
|
+
|
|
329
429
|
if (!fs.existsSync(filePath)) {
|
|
330
430
|
this.handleError(`File not found: ${filePath}`);
|
|
331
431
|
return null;
|
|
332
432
|
}
|
|
333
433
|
|
|
334
|
-
if (this.visited.has(filePath)) {
|
|
335
|
-
return "";
|
|
336
|
-
}
|
|
337
|
-
|
|
338
434
|
this.includeStack.push(filePath);
|
|
339
435
|
|
|
340
436
|
try {
|
|
341
437
|
const content = fs.readFileSync(filePath, "utf8");
|
|
342
438
|
const baseDir = path.dirname(filePath);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
439
|
+
const node: FileNode = {
|
|
440
|
+
path: filePath,
|
|
441
|
+
content,
|
|
442
|
+
isTpl: path.basename(filePath).includes(".tpl."),
|
|
443
|
+
parts: [],
|
|
444
|
+
};
|
|
445
|
+
// Registered before its includes are read so the graph lists parents
|
|
446
|
+
// first; the cycle check uses the stack, not this map.
|
|
447
|
+
this.nodes.set(filePath, node);
|
|
448
|
+
node.parts = this.processIncludes(content, baseDir, projectRoot, filePath);
|
|
449
|
+
return node;
|
|
346
450
|
} catch (error) {
|
|
347
451
|
const message = error instanceof Error ? error.message : String(error);
|
|
348
452
|
this.handleError(`Failed to read file ${filePath}: ${message}`);
|
|
453
|
+
this.nodes.delete(filePath);
|
|
349
454
|
return null;
|
|
350
455
|
} finally {
|
|
351
456
|
this.includeStack.pop();
|
|
352
457
|
}
|
|
353
458
|
}
|
|
354
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Turns a read file into its final text.
|
|
462
|
+
*
|
|
463
|
+
* A file whose own name contains `.tpl.` is rendered as Liquid with the
|
|
464
|
+
* given variables; no other file ever is. Each include is left as a unique
|
|
465
|
+
* marker while the file renders, so a condition around an include line is
|
|
466
|
+
* honored, and an included file's own output is never rendered again. Every
|
|
467
|
+
* marker that survives the render is then replaced by that file's final text,
|
|
468
|
+
* produced the same way.
|
|
469
|
+
*
|
|
470
|
+
* @param filePath - The file to expand.
|
|
471
|
+
* @param vars - The output's variable scope; undefined means templates stay unrendered.
|
|
472
|
+
* @param entryDir - The directory of the target's entry point, also searched by `{% render %}`.
|
|
473
|
+
* @param projectRoot - Root used to render source comments as relative paths.
|
|
474
|
+
*/
|
|
475
|
+
private async expand(
|
|
476
|
+
filePath: string,
|
|
477
|
+
vars: Record<string, string> | undefined,
|
|
478
|
+
entryDir: string,
|
|
479
|
+
projectRoot: string
|
|
480
|
+
): Promise<string> {
|
|
481
|
+
const node = this.nodes.get(filePath)!;
|
|
482
|
+
const nonce = `${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
483
|
+
const markers = new Map<string, string>();
|
|
484
|
+
const chunks = node.parts.map((part) => {
|
|
485
|
+
if (typeof part === "string") return part;
|
|
486
|
+
const marker = `\u0001SOUS-INCLUDE-${nonce}-${markers.size}\u0001`;
|
|
487
|
+
markers.set(marker, part.file);
|
|
488
|
+
return marker;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
let text = chunks.join("\n");
|
|
492
|
+
if (node.isTpl && vars !== undefined) {
|
|
493
|
+
const dir = path.dirname(node.path);
|
|
494
|
+
text = await this.renderContent(
|
|
495
|
+
text,
|
|
496
|
+
{ ...vars, sousTemplatePath: node.path, sousTemplateDir: dir },
|
|
497
|
+
[...new Set([dir, entryDir])],
|
|
498
|
+
node.path
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const [marker, child] of markers) {
|
|
503
|
+
if (!text.includes(marker)) continue;
|
|
504
|
+
const body = await this.expand(child, vars, entryDir, projectRoot);
|
|
505
|
+
const comment = this.currentIncludeSourceComments
|
|
506
|
+
? `<!-- from: ${path.relative(projectRoot, child)} -->\n`
|
|
507
|
+
: "";
|
|
508
|
+
text = text.split(marker).join(comment + body);
|
|
509
|
+
}
|
|
510
|
+
return text;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Every file read for the current target, parents before the files they include. */
|
|
514
|
+
private graphNodes(): FileNode[] {
|
|
515
|
+
return [...this.nodes.values()];
|
|
516
|
+
}
|
|
517
|
+
|
|
355
518
|
/**
|
|
356
519
|
* Render template content using LiquidJS with the given variable scope.
|
|
357
520
|
*
|
|
@@ -368,6 +531,7 @@ export class CompilationService {
|
|
|
368
531
|
): Promise<string> {
|
|
369
532
|
const engine = createLiquidEngine(roots, {
|
|
370
533
|
aliases: this.aliases,
|
|
534
|
+
views: this.views,
|
|
371
535
|
scope: { ...this.includeScope, ...vars },
|
|
372
536
|
namespaceResolver: this.namespaceResolver,
|
|
373
537
|
fromFile,
|
|
@@ -473,8 +637,8 @@ ${taskFileContents}
|
|
|
473
637
|
|
|
474
638
|
this.initializeEncoder();
|
|
475
639
|
|
|
476
|
-
this.visited.clear();
|
|
477
640
|
this.includeStack = [];
|
|
641
|
+
this.nodes = new Map();
|
|
478
642
|
this.currentIncludeSourceComments =
|
|
479
643
|
typeof target.includeSourceComments === "boolean"
|
|
480
644
|
? target.includeSourceComments
|
|
@@ -487,9 +651,9 @@ ${taskFileContents}
|
|
|
487
651
|
}
|
|
488
652
|
|
|
489
653
|
const promptsRoot = path.dirname(target.rootInputPath);
|
|
490
|
-
const
|
|
654
|
+
const entry = this.loadFile(target.rootInputPath, promptsRoot);
|
|
491
655
|
|
|
492
|
-
if (
|
|
656
|
+
if (entry === null) {
|
|
493
657
|
displayError(`Failed to compile ${target.rootInputPath}`);
|
|
494
658
|
return false;
|
|
495
659
|
}
|
|
@@ -499,12 +663,24 @@ ${taskFileContents}
|
|
|
499
663
|
// be written: the previous copy stays in place.
|
|
500
664
|
const assembled = this.errors.length === errorsBefore;
|
|
501
665
|
|
|
502
|
-
// Compute source hash once per target from the
|
|
503
|
-
|
|
666
|
+
// Compute the source hash once per target from every file read: the entry
|
|
667
|
+
// point and everything it includes, whatever a condition decides later.
|
|
668
|
+
const graph = this.graphNodes();
|
|
669
|
+
for (const node of graph) this.includedFilesSeen.add(realPathOf(node.path));
|
|
670
|
+
const templates = graph.filter((node) => node.isTpl);
|
|
671
|
+
const contentHash = hashContent(
|
|
672
|
+
JSON.stringify([
|
|
673
|
+
this.currentIncludeSourceComments,
|
|
674
|
+
graph.map((node) => [path.relative(promptsRoot, node.path), node.content]),
|
|
675
|
+
])
|
|
676
|
+
);
|
|
504
677
|
|
|
505
678
|
let allSucceeded = true;
|
|
506
679
|
|
|
507
|
-
const isTpl =
|
|
680
|
+
const isTpl = entry.isTpl;
|
|
681
|
+
// Whether any file of this target renders as Liquid, which makes the
|
|
682
|
+
// output depend on its variables.
|
|
683
|
+
const rendersAnything = templates.length > 0;
|
|
508
684
|
|
|
509
685
|
for (const output of target.outputs) {
|
|
510
686
|
// Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
|
|
@@ -525,8 +701,8 @@ ${taskFileContents}
|
|
|
525
701
|
// changed answer or `_vars` value with the same template must re-render,
|
|
526
702
|
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
527
703
|
// verbatim copy hashes its content alone.
|
|
528
|
-
const srcHash =
|
|
529
|
-
? hashContent(`${
|
|
704
|
+
const srcHash = rendersAnything && output.vars
|
|
705
|
+
? hashContent(`${contentHash}\n${stableVarsFingerprint(output.vars)}`)
|
|
530
706
|
: contentHash;
|
|
531
707
|
|
|
532
708
|
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
@@ -554,8 +730,10 @@ ${taskFileContents}
|
|
|
554
730
|
// resolver always sets `vars` on every output (at minimum the inherited
|
|
555
731
|
// scope). It is a real guard for callers that build a CompilationConfig
|
|
556
732
|
// directly, and it stays as a tripwire in case the resolver ever changes.
|
|
557
|
-
if (
|
|
558
|
-
|
|
733
|
+
if (rendersAnything && !output.vars) {
|
|
734
|
+
for (const template of templates) {
|
|
735
|
+
this.unrenderedTemplates.push(`${template.path} → ${destFile}`);
|
|
736
|
+
}
|
|
559
737
|
// In strict mode this warning is an error, so the output is not written.
|
|
560
738
|
if (this.strict) {
|
|
561
739
|
this.reportNotWritten(destFile);
|
|
@@ -565,18 +743,12 @@ ${taskFileContents}
|
|
|
565
743
|
}
|
|
566
744
|
|
|
567
745
|
const errorsBeforeRender = this.errors.length;
|
|
568
|
-
const resolvedContent =
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
sousTemplateDir: promptsRoot,
|
|
575
|
-
},
|
|
576
|
-
[promptsRoot],
|
|
577
|
-
target.rootInputPath
|
|
578
|
-
)
|
|
579
|
-
: content;
|
|
746
|
+
const resolvedContent = await this.expand(
|
|
747
|
+
target.rootInputPath,
|
|
748
|
+
output.vars,
|
|
749
|
+
promptsRoot,
|
|
750
|
+
promptsRoot
|
|
751
|
+
);
|
|
580
752
|
|
|
581
753
|
// A template that failed to render is not written either.
|
|
582
754
|
if (this.errors.length > errorsBeforeRender) {
|
|
@@ -644,6 +816,15 @@ ${taskFileContents}
|
|
|
644
816
|
return allSucceeded;
|
|
645
817
|
}
|
|
646
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Every file the last compile read, as real paths: the entry points and every
|
|
821
|
+
* file any include line of any target named, by any route (exact path, glob
|
|
822
|
+
* or view). Includes inside an unmet condition count, since they are read.
|
|
823
|
+
*/
|
|
824
|
+
includedFiles(): ReadonlySet<string> {
|
|
825
|
+
return this.includedFilesSeen;
|
|
826
|
+
}
|
|
827
|
+
|
|
647
828
|
/**
|
|
648
829
|
* Compile all targets from the given config. Every target is compiled even
|
|
649
830
|
* after an error, so every error is listed; an output whose target had an
|
|
@@ -675,6 +856,8 @@ ${taskFileContents}
|
|
|
675
856
|
|
|
676
857
|
this.includeSourceComments = config.includeSourceComments === true;
|
|
677
858
|
this.aliases = config.aliases ?? {};
|
|
859
|
+
this.views = config.views ?? {};
|
|
860
|
+
this.includedFilesSeen = new Set();
|
|
678
861
|
this.includeScope = config.includeScope ?? {};
|
|
679
862
|
|
|
680
863
|
this.initializeEncoder();
|
|
@@ -738,3 +921,12 @@ ${taskFileContents}
|
|
|
738
921
|
// Backward-compat alias so existing imports of MarkdownCompiler keep working
|
|
739
922
|
export { CompilationService as MarkdownCompiler };
|
|
740
923
|
export type { CompilationServiceOptions as MarkdownCompilerOptions };
|
|
924
|
+
|
|
925
|
+
/** The path with symbolic links resolved, or the path itself when it cannot be read. */
|
|
926
|
+
function realPathOf(filePath: string): string {
|
|
927
|
+
try {
|
|
928
|
+
return fs.realpathSync(filePath);
|
|
929
|
+
} catch {
|
|
930
|
+
return filePath;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
@@ -33,7 +33,7 @@ export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
|
|
|
33
33
|
* compiled output (`AGENTS.md` at the project root) are never mistaken for one
|
|
34
34
|
* another.
|
|
35
35
|
*/
|
|
36
|
-
export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.md";
|
|
36
|
+
export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.tpl.md";
|
|
37
37
|
|
|
38
38
|
/** The name of the file the starter prompt is compiled into, at the project root. */
|
|
39
39
|
export const STARTER_OUTPUT_NAME = "AGENTS.md";
|
|
@@ -98,14 +98,19 @@ export const config = {
|
|
|
98
98
|
],
|
|
99
99
|
},
|
|
100
100
|
|
|
101
|
-
//
|
|
102
|
-
// project subscribes to the \`core\` namespace on its own, which is how
|
|
103
|
-
// skills that teach an agent about sous reach \`.claude/skills\`. Memories
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
// What to do with the content the recipes this project subscribes to publish.
|
|
102
|
+
// Every project subscribes to the \`core\` namespace on its own, which is how
|
|
103
|
+
// the skills that teach an agent about sous reach \`.claude/skills\`. Memories
|
|
104
|
+
// are not copied anywhere: the starter prompt pulls them in with one include
|
|
105
|
+
// line, and the lists below (uncomment to use them) choose which come first
|
|
106
|
+
// and which are left out. An entry is a glob over \`namespace/recipe\`, or a
|
|
107
|
+
// regular expression written between two slashes.
|
|
108
|
+
recipes: {
|
|
109
|
+
skills: { outputs: ["\${projectRoot}/.claude/skills"] },
|
|
110
|
+
// memories: {
|
|
111
|
+
// first: ["communication/*"],
|
|
112
|
+
// exclude: ["tool-usage/automated-browser-tasks", "/^experimental\\//"],
|
|
113
|
+
// },
|
|
109
114
|
},
|
|
110
115
|
};
|
|
111
116
|
`;
|
|
@@ -132,8 +137,8 @@ export function buildConfigJson(context: ProjectScaffoldContext): string {
|
|
|
132
137
|
},
|
|
133
138
|
],
|
|
134
139
|
},
|
|
135
|
-
|
|
136
|
-
skills: ["${projectRoot}/.claude/skills"],
|
|
140
|
+
recipes: {
|
|
141
|
+
skills: { outputs: ["${projectRoot}/.claude/skills"] },
|
|
137
142
|
},
|
|
138
143
|
};
|
|
139
144
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
@@ -158,6 +163,9 @@ source, run \`sous build\`, and the compiled copy follows; never edit the compil
|
|
|
158
163
|
each one in with a line holding only \`@sections/name.md\`.
|
|
159
164
|
- The skills under \`.claude/skills\` are written by sous from the recipes this
|
|
160
165
|
project subscribes to. \`sous recipe list\` shows what is available.
|
|
166
|
+
|
|
167
|
+
{% comment %}This line pulls in the memories of every recipe this project subscribes to.{% endcomment %}
|
|
168
|
+
@#memories/**/*.md
|
|
161
169
|
`;
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* deliberately the only place that knows: the subscription service for the
|
|
7
7
|
* trusted repositories and their cached indexes, the lockfile service for what
|
|
8
8
|
* is pinned, the links map and the store for a recipe's own files, and
|
|
9
|
-
* `
|
|
9
|
+
* `recipes` for where a content kind lands.
|
|
10
10
|
*
|
|
11
11
|
* By default nothing here downloads anything: a repository whose index has
|
|
12
12
|
* never been fetched is left out of the catalog and named separately, so a
|