@sous-io/sous 0.2.36 → 1.0.1
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 +14 -256
- package/dist/bin.js +11 -0
- package/dist/sous.js +1093 -0
- package/docs/markdown/README.md +5 -4
- package/docs/markdown/_sidebar.md +21 -1
- package/docs/markdown/commands.md +294 -431
- package/docs/markdown/config-discovery.md +25 -14
- package/docs/markdown/config-inspection.md +53 -31
- package/docs/markdown/config-layers.md +168 -66
- package/docs/markdown/config-variables.md +44 -11
- package/docs/markdown/configuration.md +104 -17
- package/docs/markdown/design-principles.md +4 -1
- package/docs/markdown/repositories-authoring.md +30 -0
- package/docs/markdown/repositories-consuming.md +72 -14
- package/docs/markdown/repositories-file-formats.md +31 -13
- package/docs/markdown/repositories-providers.md +48 -57
- package/docs/markdown/repositories-quickstart.md +3 -2
- package/docs/markdown/repositories-troubleshooting.md +27 -1
- package/docs/markdown/repositories-variables.md +6 -1
- package/docs/markdown/repositories.md +18 -8
- package/package.json +48 -107
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
- package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +351 -132
- package/bin/run.js +0 -51
- package/src/base-command.ts +0 -457
- package/src/commands/build.ts +0 -181
- package/src/commands/clear.ts +0 -79
- package/src/commands/compile.ts +0 -155
- package/src/commands/config/get.ts +0 -110
- package/src/commands/config/show.ts +0 -32
- package/src/commands/config/validate.ts +0 -53
- package/src/commands/help.ts +0 -46
- package/src/commands/init.ts +0 -289
- package/src/commands/launch.ts +0 -139
- package/src/commands/lock/rebuild.ts +0 -239
- package/src/commands/lock/show.ts +0 -115
- package/src/commands/namespace/list.ts +0 -138
- package/src/commands/namespace/show.ts +0 -130
- package/src/commands/prune.ts +0 -43
- package/src/commands/recipe/list.ts +0 -112
- package/src/commands/recipe/show.ts +0 -439
- package/src/commands/repo/add.ts +0 -145
- package/src/commands/repo/contribute.ts +0 -604
- package/src/commands/repo/gc.ts +0 -173
- package/src/commands/repo/init.ts +0 -136
- package/src/commands/repo/link.ts +0 -899
- package/src/commands/repo/list.ts +0 -236
- package/src/commands/repo/release.ts +0 -685
- package/src/commands/repo/remove.ts +0 -194
- package/src/commands/repo/search.ts +0 -243
- package/src/commands/repo/submit.ts +0 -341
- package/src/commands/repo/unlink.ts +0 -461
- package/src/commands/subscription/add.ts +0 -286
- package/src/commands/subscription/list.ts +0 -208
- package/src/commands/subscription/remove.ts +0 -182
- package/src/commands/subscription/update.ts +0 -216
- package/src/commands/update.ts +0 -178
- package/src/commands/vars/ask.ts +0 -417
- package/src/commands/vars/index.ts +0 -79
- package/src/commands/vars/list.ts +0 -67
- package/src/commands/vars/show.ts +0 -77
- package/src/config-command.ts +0 -30
- package/src/lib/build-preparation.ts +0 -128
- package/src/lib/build-service.ts +0 -580
- package/src/lib/config-discovery.ts +0 -378
- package/src/lib/config-inspect.ts +0 -145
- package/src/lib/config-kernel.mjs +0 -379
- package/src/lib/config-schema.ts +0 -401
- package/src/lib/env-file.ts +0 -328
- package/src/lib/env-local.ts +0 -212
- package/src/lib/errors.ts +0 -32
- package/src/lib/glob-pattern.ts +0 -24
- package/src/lib/include-resolver.ts +0 -523
- package/src/lib/interactive.ts +0 -165
- package/src/lib/markdown-compiler.ts +0 -947
- package/src/lib/package-info.ts +0 -25
- package/src/lib/pid-service.ts +0 -99
- package/src/lib/project-install.d.mts +0 -44
- package/src/lib/project-install.mjs +0 -234
- package/src/lib/project-scaffold/index.ts +0 -332
- package/src/lib/project-scaffold/templates.ts +0 -240
- package/src/lib/repos/catalog-display.ts +0 -215
- package/src/lib/repos/catalog-inputs.ts +0 -288
- package/src/lib/repos/catalog.ts +0 -842
- package/src/lib/repos/contribute.ts +0 -452
- package/src/lib/repos/core-recipe.ts +0 -105
- package/src/lib/repos/declarations.ts +0 -187
- package/src/lib/repos/defaults.ts +0 -175
- package/src/lib/repos/formats/common.ts +0 -440
- package/src/lib/repos/formats/index-file.ts +0 -253
- package/src/lib/repos/formats/links-map.ts +0 -98
- package/src/lib/repos/formats/lockfile.ts +0 -169
- package/src/lib/repos/formats/patterns.ts +0 -57
- package/src/lib/repos/formats/recipe-manifest.ts +0 -205
- package/src/lib/repos/formats/repo-manifest.ts +0 -97
- package/src/lib/repos/formats/store-entry.ts +0 -84
- package/src/lib/repos/formats/variable-definition.ts +0 -268
- package/src/lib/repos/freshness.ts +0 -264
- package/src/lib/repos/git-clone.ts +0 -850
- package/src/lib/repos/identity.ts +0 -89
- package/src/lib/repos/index.ts +0 -57
- package/src/lib/repos/links.ts +0 -356
- package/src/lib/repos/load-manifest.ts +0 -237
- package/src/lib/repos/lock-service.ts +0 -454
- package/src/lib/repos/locked-namespace-resolver.ts +0 -232
- package/src/lib/repos/locked-recipe-lookup.ts +0 -123
- package/src/lib/repos/locked-recipes.ts +0 -254
- package/src/lib/repos/managed-layer.ts +0 -422
- package/src/lib/repos/memories-hash-name.ts +0 -33
- package/src/lib/repos/namespace-resolver.ts +0 -527
- package/src/lib/repos/providers/base.ts +0 -268
- package/src/lib/repos/providers/git.ts +0 -237
- package/src/lib/repos/providers/github.ts +0 -589
- package/src/lib/repos/providers/gitlab.ts +0 -332
- package/src/lib/repos/providers/http.ts +0 -128
- package/src/lib/repos/providers/index-cache.ts +0 -421
- package/src/lib/repos/providers/index.ts +0 -106
- package/src/lib/repos/providers/local.ts +0 -391
- package/src/lib/repos/providers/provider.ts +0 -615
- package/src/lib/repos/recipe-config-layers.ts +0 -316
- package/src/lib/repos/recipe-key-matcher.ts +0 -62
- package/src/lib/repos/recipe-memories.ts +0 -178
- package/src/lib/repos/recipe-targets.ts +0 -236
- package/src/lib/repos/ref-search.ts +0 -46
- package/src/lib/repos/release/bump.ts +0 -161
- package/src/lib/repos/release/changelog.ts +0 -461
- package/src/lib/repos/release/git-state.ts +0 -460
- package/src/lib/repos/release/index-builder.ts +0 -1108
- package/src/lib/repos/release/index.ts +0 -19
- package/src/lib/repos/release/plan.ts +0 -521
- package/src/lib/repos/release/settle.ts +0 -154
- package/src/lib/repos/release/submissions.ts +0 -214
- package/src/lib/repos/release/submit-checkout.ts +0 -290
- package/src/lib/repos/release/submit-questions.ts +0 -153
- package/src/lib/repos/release/submit-service.ts +0 -898
- package/src/lib/repos/release/tags.ts +0 -239
- package/src/lib/repos/release/validate.ts +0 -463
- package/src/lib/repos/resolver.ts +0 -1028
- package/src/lib/repos/scaffold/index.ts +0 -245
- package/src/lib/repos/scaffold/templates.ts +0 -517
- package/src/lib/repos/seed.ts +0 -473
- package/src/lib/repos/store/contract.ts +0 -64
- package/src/lib/repos/store/hash.ts +0 -191
- package/src/lib/repos/store/recipe-store.ts +0 -599
- package/src/lib/repos/store/settings.ts +0 -58
- package/src/lib/repos/subscription-service.ts +0 -3859
- package/src/lib/repos/trust.ts +0 -447
- package/src/lib/repos/update-plan.ts +0 -235
- package/src/lib/self-update/index.ts +0 -14
- package/src/lib/self-update/installs.ts +0 -412
- package/src/lib/self-update/managers.ts +0 -126
- package/src/lib/self-update/plan.ts +0 -315
- package/src/lib/self-update/registry.ts +0 -99
- package/src/lib/self-update/run.ts +0 -506
- package/src/lib/self-update/versions.ts +0 -243
- package/src/lib/settings.ts +0 -1105
- package/src/lib/sous-home.ts +0 -104
- package/src/lib/state.ts +0 -167
- package/src/lib/vars/answers.ts +0 -192
- package/src/lib/vars/ask.ts +0 -1152
- package/src/lib/vars/definition-source.ts +0 -261
- package/src/lib/vars/display.ts +0 -231
- package/src/lib/vars/index.ts +0 -20
- package/src/lib/vars/ladder.ts +0 -282
- package/src/lib/vars/mappings.ts +0 -260
- package/src/lib/vars/names.ts +0 -94
- package/src/lib/vars/preanswers.ts +0 -395
- package/src/lib/vars/question-plan.ts +0 -241
- package/src/lib/vars/report.ts +0 -228
- package/src/lib/vars/safe-regex.ts +0 -235
- package/src/lib/vars/secrets.ts +0 -131
- package/src/lib/vars/validate.ts +0 -312
- package/src/lib/version-report.ts +0 -90
- package/src/lib/watch-loop.ts +0 -148
- package/src/lib/watch-service.ts +0 -116
- package/src/services/ref-resolver/container.ts +0 -107
- package/src/services/ref-resolver/dependency.ts +0 -60
- package/src/services/ref-resolver/format.ts +0 -197
- package/src/services/ref-resolver/glob.ts +0 -115
- package/src/services/ref-resolver/hash-names.ts +0 -155
- package/src/services/ref-resolver/index.ts +0 -55
- package/src/services/ref-resolver/injectable.ts +0 -36
- package/src/services/ref-resolver/location.ts +0 -62
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
- package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
- package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
- package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
- package/src/services/ref-resolver/named-refs.ts +0 -144
- package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
- package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
- package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
- package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
- package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
- package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
- package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
- package/src/services/ref-resolver/parts.ts +0 -115
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
- package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
- package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
- package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
- package/src/services/ref-resolver/ref-helpers.ts +0 -62
- package/src/services/ref-resolver/ref-picker-service.ts +0 -204
- package/src/services/ref-resolver/ref-report.ts +0 -84
- package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
- package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
- package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
- package/src/services/ref-resolver/source.ts +0 -48
- package/src/services/ref-resolver/tokens.ts +0 -25
- package/src/services/ref-resolver/types.ts +0 -155
- package/src/templating/filters/bullet-list.ts +0 -9
- package/src/templating/filters/index.ts +0 -8
- package/src/templating/init-liquid-engine.ts +0 -139
- package/src/templating/lib/glob-files.ts +0 -74
- package/src/templating/lib/import-export.ts +0 -32
- package/src/templating/lib/secret-scope.ts +0 -41
- package/src/templating/lib/tag-args.ts +0 -19
- package/src/templating/tags/exportScalarVarsJs.ts +0 -46
- package/src/templating/tags/getFiles.ts +0 -89
- package/src/templating/tags/index.ts +0 -14
- package/src/templating/tags/listFiles.ts +0 -54
- package/src/templating/tags/showVars.ts +0 -28
- package/src/utils/choice-prompt.ts +0 -143
- package/src/utils/command-errors.ts +0 -186
- package/src/utils/command-help.ts +0 -45
- package/src/utils/confirm-prompt.ts +0 -110
- package/src/utils/flags.ts +0 -177
- package/src/utils/formatting.ts +0 -849
- package/src/utils/prompts.ts +0 -53
- package/src/utils/sous-directory.ts +0 -245
- package/src/utils/table.ts +0 -603
- package/src/utils/value-prompt.ts +0 -119
|
@@ -1,947 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { get_encoding, type Tiktoken } from "tiktoken";
|
|
5
|
-
import { displayError, log, showVariable, subheading, warning } from "../utils/formatting.js";
|
|
6
|
-
import { createLiquidEngine } from "../templating/init-liquid-engine.js";
|
|
7
|
-
import {
|
|
8
|
-
StateService,
|
|
9
|
-
type StateFile,
|
|
10
|
-
type StateFileEntry,
|
|
11
|
-
hashContent,
|
|
12
|
-
recordDirCreation,
|
|
13
|
-
} from "./state.js";
|
|
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";
|
|
21
|
-
import {
|
|
22
|
-
formatNamespaceProblem,
|
|
23
|
-
type NamespaceResolver,
|
|
24
|
-
} from "./repos/namespace-resolver.js";
|
|
25
|
-
|
|
26
|
-
export type ResolvedOutput = {
|
|
27
|
-
destinationFile?: string;
|
|
28
|
-
destinationDir?: string;
|
|
29
|
-
/** Resolved variable scope for this output; ${varName} references in compiled content are substituted. */
|
|
30
|
-
vars?: Record<string, string>;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/** Resolved runtime context configuration for AGENTS-style compilation targets. */
|
|
34
|
-
export type ResolvedRuntimeContext = {
|
|
35
|
-
/** Absolute path to the git repo used for branch detection. */
|
|
36
|
-
gitRoot: string;
|
|
37
|
-
/** Absolute path where the generated session context file is written. */
|
|
38
|
-
outputPath: string;
|
|
39
|
-
/** Root directory for task files; branch name is appended to find the active task file. */
|
|
40
|
-
taskFileRoot: string;
|
|
41
|
-
/** Pattern used to determine whether the current branch has a task file. */
|
|
42
|
-
branchPattern: RegExp;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export type CompilationTarget = {
|
|
46
|
-
rootInputPath: string;
|
|
47
|
-
outputs: ResolvedOutput[];
|
|
48
|
-
includeSourceComments?: boolean;
|
|
49
|
-
/**
|
|
50
|
-
* Base directory used to compute relative output paths when mirroring source structure
|
|
51
|
-
* under a destinationDir. Populated by the settings resolver (inferred from the entryGlob
|
|
52
|
-
* pattern, or set explicitly via globBase in the target config).
|
|
53
|
-
*/
|
|
54
|
-
globBase?: string;
|
|
55
|
-
/**
|
|
56
|
-
* When set, generates a runtime session context file (branch name, task file) before
|
|
57
|
-
* compilation. Only set for AGENTS-style targets that have runtimeContext configured.
|
|
58
|
-
*/
|
|
59
|
-
runtimeContext?: ResolvedRuntimeContext;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export type CompilationConfig = {
|
|
63
|
-
includeSourceComments?: boolean;
|
|
64
|
-
targets: CompilationTarget[];
|
|
65
|
-
/** Resolved `@include` alias map (name → ordered base dirs). */
|
|
66
|
-
aliases?: Record<string, string[]>;
|
|
67
|
-
/** The files each view lists (`#memories`), keyed by the name with its `#`. */
|
|
68
|
-
views?: ViewMap;
|
|
69
|
-
/** Variable scope for `${var}` substitution in `@include` paths. */
|
|
70
|
-
includeScope?: Record<string, string>;
|
|
71
|
-
/**
|
|
72
|
-
* Every variable name a recipe declares `secret: true`. The tags that dump a
|
|
73
|
-
* whole scope hide these (and anything that very probably is a secret); a
|
|
74
|
-
* template that names one explicitly still renders it.
|
|
75
|
-
*/
|
|
76
|
-
secretVariables?: string[];
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export type CompilationServiceOptions = {
|
|
80
|
-
/**
|
|
81
|
-
* Treat compile warnings as errors. An error always fails the compile; this
|
|
82
|
-
* only decides whether a warning (a `.tpl.` file copied without being
|
|
83
|
-
* rendered, a git branch that could not be read) does too.
|
|
84
|
-
*/
|
|
85
|
-
strict?: boolean;
|
|
86
|
-
rebuild?: boolean;
|
|
87
|
-
dryRun?: boolean;
|
|
88
|
-
/**
|
|
89
|
-
* Resolves `@~<namespace>/<recipe>/<path>` includes and the matching
|
|
90
|
-
* `{% render %}` paths against recipe namespaces. Omit it and the `~` sigil
|
|
91
|
-
* only ever means an alias, which is the behavior for projects that use no
|
|
92
|
-
* repositories.
|
|
93
|
-
*/
|
|
94
|
-
namespaceResolver?: NamespaceResolver;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Infers the static base directory from a glob pattern.
|
|
99
|
-
* Returns the longest path prefix before the first glob character (* ? { [).
|
|
100
|
-
*
|
|
101
|
-
* Examples:
|
|
102
|
-
* "/foo/bar/**\/*" → "/foo/bar"
|
|
103
|
-
* "/foo/bar/*\/baz.md" → "/foo/bar"
|
|
104
|
-
* "/**\/*" → "/"
|
|
105
|
-
*/
|
|
106
|
-
/**
|
|
107
|
-
* The file one output of one target writes, or undefined when the output names
|
|
108
|
-
* neither a destination file nor a destination directory.
|
|
109
|
-
*
|
|
110
|
-
* A `destinationFile` is used as it stands. A `destinationDir` mirrors the
|
|
111
|
-
* source tree underneath it, relative to the target's `globBase`, with `.tpl.`
|
|
112
|
-
* stripped from the name.
|
|
113
|
-
*
|
|
114
|
-
* Exported because prune needs the same answer without compiling: a group of
|
|
115
|
-
* targets that share one destination directory (every subscribed recipe writing
|
|
116
|
-
* into `.claude/skills`, say) can only be pruned precisely if the exact set of
|
|
117
|
-
* files they write is known.
|
|
118
|
-
*
|
|
119
|
-
* @param target - The compilation target.
|
|
120
|
-
* @param output - One of its outputs.
|
|
121
|
-
*/
|
|
122
|
-
export function resolveOutputPath(
|
|
123
|
-
target: Pick<CompilationTarget, "rootInputPath" | "globBase">,
|
|
124
|
-
output: ResolvedOutput
|
|
125
|
-
): string | undefined {
|
|
126
|
-
if (output.destinationFile) return output.destinationFile;
|
|
127
|
-
if (!output.destinationDir) return undefined;
|
|
128
|
-
|
|
129
|
-
const sourceRelative = target.globBase
|
|
130
|
-
? path.relative(target.globBase, target.rootInputPath)
|
|
131
|
-
: path.basename(target.rootInputPath);
|
|
132
|
-
|
|
133
|
-
return path.join(output.destinationDir, sourceRelative.replace(/\.tpl\./, "."));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function inferGlobBase(pattern: string): string {
|
|
137
|
-
const parts = pattern.split("/");
|
|
138
|
-
const staticParts: string[] = [];
|
|
139
|
-
for (const part of parts) {
|
|
140
|
-
if (/[*?{[]/.test(part)) break;
|
|
141
|
-
staticParts.push(part);
|
|
142
|
-
}
|
|
143
|
-
// Remove trailing empty string from a trailing slash (e.g. "/foo/bar/")
|
|
144
|
-
if (staticParts.length > 0 && staticParts[staticParts.length - 1] === "") {
|
|
145
|
-
staticParts.pop();
|
|
146
|
-
}
|
|
147
|
-
const joined = staticParts.join("/");
|
|
148
|
-
return joined || "/";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A stable text form of an output's variable scope, for the source hash of a
|
|
153
|
-
* rendered output. Keys are sorted so two scopes holding the same values hash
|
|
154
|
-
* the same whatever order they were assembled in.
|
|
155
|
-
*
|
|
156
|
-
* @param vars - The variable scope an output renders with.
|
|
157
|
-
*/
|
|
158
|
-
export function stableVarsFingerprint(vars: Record<string, string>): string {
|
|
159
|
-
return JSON.stringify(Object.keys(vars).sort().map((key) => [key, vars[key]]));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* An include line: `@`, a path that ends in `.md` and an optional
|
|
164
|
-
* `?name=value` query, and nothing else. The first character is a sigil, a
|
|
165
|
-
* variable, a dot or a letter, a digit or a glob character; the rest may hold
|
|
166
|
-
* the characters of a path, a `${var}`, a glob and a recipe reference's `repo:`
|
|
167
|
-
* qualifier.
|
|
168
|
-
*/
|
|
169
|
-
export const INCLUDE_LINE_PATTERN =
|
|
170
|
-
/^@([~#a-zA-Z0-9_.${}*?[][a-zA-Z0-9_\-/.:${}*?[\],!~#%+^()]*\.md(?:\?[^?\s]*=\S*)?)$/;
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Whether a line that is not a well-formed include still looks like one: it
|
|
174
|
-
* starts with `@` and is a single path-like word, meaning it holds a `/` or a
|
|
175
|
-
* `.md`, or starts with a sigil or a variable (`@~`, `@#`, `@.`, `@$`). A line
|
|
176
|
-
* that merely starts with `@` (a mention or an email address followed by
|
|
177
|
-
* words, or a lone `@name`) does not.
|
|
178
|
-
*
|
|
179
|
-
* looksLikeIncludeLine("@docs/notes.txt"); // -> true, the include has no .md
|
|
180
|
-
* looksLikeIncludeLine("@alice thanks for the review"); // -> false
|
|
181
|
-
* looksLikeIncludeLine("@alice"); // -> false
|
|
182
|
-
*
|
|
183
|
-
* @param line - One line of a file, with trailing whitespace already removed.
|
|
184
|
-
*/
|
|
185
|
-
export function looksLikeIncludeLine(line: string): boolean {
|
|
186
|
-
if (!/^@\S+$/.test(line)) return false;
|
|
187
|
-
const word = line.slice(1);
|
|
188
|
-
return /[/]/.test(word) || /\.md\b/.test(word) || /^[~#.$]/.test(word);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** One piece of a read file: a line of its own text, or a file it includes. */
|
|
192
|
-
type Part = string | { file: string };
|
|
193
|
-
|
|
194
|
-
/** A file read for a target: its text, whether its name makes it a template, and its pieces. */
|
|
195
|
-
type FileNode = {
|
|
196
|
-
path: string;
|
|
197
|
-
content: string;
|
|
198
|
-
/** True when the file's own name contains `.tpl.`: the only files that render as Liquid. */
|
|
199
|
-
isTpl: boolean;
|
|
200
|
-
parts: Part[];
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
export class CompilationService {
|
|
204
|
-
private strict: boolean;
|
|
205
|
-
private rebuild: boolean;
|
|
206
|
-
private dryRun: boolean;
|
|
207
|
-
private includeStack: string[];
|
|
208
|
-
private nodes: Map<string, FileNode>;
|
|
209
|
-
private views: ViewMap;
|
|
210
|
-
private includedFilesSeen: Set<string>;
|
|
211
|
-
private errors: string[];
|
|
212
|
-
private includeSourceComments: boolean;
|
|
213
|
-
private currentIncludeSourceComments: boolean;
|
|
214
|
-
private encoder: Tiktoken | null;
|
|
215
|
-
private numberFormatter: Intl.NumberFormat;
|
|
216
|
-
private aliases: AliasMap;
|
|
217
|
-
private includeScope: Record<string, string>;
|
|
218
|
-
/** Every variable name a recipe declares secret, for the scope-dumping tags. */
|
|
219
|
-
private secretVariables: string[];
|
|
220
|
-
private namespaceResolver?: NamespaceResolver;
|
|
221
|
-
/**
|
|
222
|
-
* `.tpl.` outputs that were written without a variable scope, so LiquidJS never
|
|
223
|
-
* ran and the template shipped with its tags intact. Reported at the end of the
|
|
224
|
-
* compile run. Each entry is `<source> → <destination>`.
|
|
225
|
-
*/
|
|
226
|
-
private unrenderedTemplates: string[];
|
|
227
|
-
|
|
228
|
-
constructor(options: CompilationServiceOptions = {}) {
|
|
229
|
-
this.strict = options.strict ?? false;
|
|
230
|
-
this.rebuild = options.rebuild ?? false;
|
|
231
|
-
this.dryRun = options.dryRun ?? false;
|
|
232
|
-
this.includeStack = [];
|
|
233
|
-
this.nodes = new Map();
|
|
234
|
-
this.views = {};
|
|
235
|
-
this.includedFilesSeen = new Set();
|
|
236
|
-
this.errors = [];
|
|
237
|
-
this.includeSourceComments = false;
|
|
238
|
-
this.currentIncludeSourceComments = false;
|
|
239
|
-
this.encoder = null;
|
|
240
|
-
this.numberFormatter = new Intl.NumberFormat("en-US");
|
|
241
|
-
this.aliases = {};
|
|
242
|
-
this.includeScope = {};
|
|
243
|
-
this.secretVariables = [];
|
|
244
|
-
this.namespaceResolver = options.namespaceResolver;
|
|
245
|
-
this.unrenderedTemplates = [];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Lazily initialize the tokenizer. */
|
|
249
|
-
private initializeEncoder(): void {
|
|
250
|
-
if (this.encoder) return;
|
|
251
|
-
this.encoder = get_encoding("o200k_base");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Records a compile error and prints it. The compile carries on, so every
|
|
256
|
-
* target is compiled and every error is listed; the output the error belongs
|
|
257
|
-
* to is not written, and the compile as a whole reports failure.
|
|
258
|
-
*/
|
|
259
|
-
private handleError(message: string): void {
|
|
260
|
-
this.errors.push(message);
|
|
261
|
-
displayError(message);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Records a compile warning: printed as a warning, or, in strict mode,
|
|
266
|
-
* recorded as an error, with everything an error brings.
|
|
267
|
-
*/
|
|
268
|
-
private handleWarning(message: string): void {
|
|
269
|
-
if (this.strict) {
|
|
270
|
-
this.handleError(message);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
warning(message);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Process @<path> includes in content.
|
|
278
|
-
*
|
|
279
|
-
* Matches an `@`-prefixed `.md` path on its own line, optionally followed by
|
|
280
|
-
* a `?name=value` query (read by the ref service and otherwise ignored). The
|
|
281
|
-
* path may be:
|
|
282
|
-
* - relative to the including file (`@sections/intro.md`),
|
|
283
|
-
* - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
|
|
284
|
-
* - a `#name` path (`@#project/memories/x.md`), or an alias path
|
|
285
|
-
* (`@docs/x.md`), where the first segment (up to `/` or `:`) names a
|
|
286
|
-
* registered alias,
|
|
287
|
-
* - a home-relative path (`@~/notes/x.md`),
|
|
288
|
-
* - or a recipe reference (`@~workflow/task-files/_partials/x.md`), where
|
|
289
|
-
* the `~` sigil is followed by a namespace, a recipe and a file inside it.
|
|
290
|
-
* Recipe references are only consulted when a namespace resolver was
|
|
291
|
-
* supplied.
|
|
292
|
-
* Any of them may use glob syntax, and then includes every file it matches,
|
|
293
|
-
* in sorted path order; a glob that matches nothing is an error.
|
|
294
|
-
*
|
|
295
|
-
* A file may be included any number of times; only a file that includes
|
|
296
|
-
* itself (directly or through others) is an error.
|
|
297
|
-
*
|
|
298
|
-
* Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
|
|
299
|
-
* verbatim, so include syntax can be documented without being executed.
|
|
300
|
-
*
|
|
301
|
-
* A line that starts with `@` and looks like an include (see
|
|
302
|
-
* {@link looksLikeIncludeLine}) but is not a well-formed one is a compile
|
|
303
|
-
* error naming the file and the line, never text copied to the output.
|
|
304
|
-
*
|
|
305
|
-
* Resolution produces ordered groups of candidates (see include-resolver);
|
|
306
|
-
* the first group that names a file is used. If none does, it errors, naming
|
|
307
|
-
* the including file and listing every path tried, plus what went wrong with
|
|
308
|
-
* a `~` or `#` reference when one was attempted.
|
|
309
|
-
*
|
|
310
|
-
* @param content - The file's raw text.
|
|
311
|
-
* @param baseDir - Directory the relative candidate resolves against.
|
|
312
|
-
* @param projectRoot - Root used to render source comments as relative paths.
|
|
313
|
-
* @param fromFile - Absolute path of the file being processed; defaults to `baseDir`.
|
|
314
|
-
*/
|
|
315
|
-
private processIncludes(
|
|
316
|
-
content: string,
|
|
317
|
-
baseDir: string,
|
|
318
|
-
projectRoot: string,
|
|
319
|
-
fromFile?: string
|
|
320
|
-
): Part[] {
|
|
321
|
-
const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
|
|
322
|
-
const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
323
|
-
const where = fromFile ?? baseDir;
|
|
324
|
-
|
|
325
|
-
const out: Part[] = [];
|
|
326
|
-
let fenceChar: string | null = null;
|
|
327
|
-
let fenceLength = 0;
|
|
328
|
-
|
|
329
|
-
for (const [lineIndex, line] of content.split("\n").entries()) {
|
|
330
|
-
if (fenceChar !== null) {
|
|
331
|
-
// Inside a fence: emit verbatim; only a matching closing fence ends it.
|
|
332
|
-
const close = fenceClosePattern.exec(line);
|
|
333
|
-
if (close && close[1][0] === fenceChar && close[1].length >= fenceLength) {
|
|
334
|
-
fenceChar = null;
|
|
335
|
-
}
|
|
336
|
-
out.push(line);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const open = fenceOpenPattern.exec(line);
|
|
341
|
-
if (open) {
|
|
342
|
-
fenceChar = open[1][0];
|
|
343
|
-
fenceLength = open[1].length;
|
|
344
|
-
out.push(line);
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const trimmed = line.trimEnd();
|
|
349
|
-
const match = INCLUDE_LINE_PATTERN.exec(trimmed);
|
|
350
|
-
if (!match) {
|
|
351
|
-
if (looksLikeIncludeLine(trimmed)) {
|
|
352
|
-
this.handleError(
|
|
353
|
-
`Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n` +
|
|
354
|
-
` An include is "@" followed by a path that ends in .md, optionally followed by ` +
|
|
355
|
-
`"?name=value". The path may hold letters, digits and . _ - / : $ { } * ? [ ] , ~ #.\n` +
|
|
356
|
-
` To write a line that starts with "@" as text, put a word after it.`
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
out.push(line);
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const includePath = match[1].trim();
|
|
364
|
-
const { files, glob, candidates, namespaceIssue, hashIssue, view } = resolveIncludeFiles(
|
|
365
|
-
includePath,
|
|
366
|
-
{
|
|
367
|
-
aliases: this.aliases,
|
|
368
|
-
views: this.views,
|
|
369
|
-
scope: this.includeScope,
|
|
370
|
-
baseDir,
|
|
371
|
-
namespaceResolver: this.namespaceResolver,
|
|
372
|
-
fromFile: where,
|
|
373
|
-
}
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
if (files.length === 0) {
|
|
377
|
-
// A view is a list that may be empty: a glob over it that selects
|
|
378
|
-
// nothing includes nothing, and the line leaves no trace.
|
|
379
|
-
if (view === true && glob && hashIssue === undefined) continue;
|
|
380
|
-
if (this.namespaceResolver === undefined && includePath.startsWith("~")) {
|
|
381
|
-
// Without a resolver the reference is only a relative path, so say
|
|
382
|
-
// what is wrong with a malformed one instead of "not found".
|
|
383
|
-
const reference = splitIncludeQuery(includePath).path.slice(1);
|
|
384
|
-
if (reference.includes("/") && !reference.includes("${")) {
|
|
385
|
-
try {
|
|
386
|
-
sharedRefResolver().parse(reference, RefSource.Include);
|
|
387
|
-
} catch (error) {
|
|
388
|
-
this.handleError(
|
|
389
|
-
`Malformed include line: ${trimmed}\n in file: ${where}, line ${lineIndex + 1}\n ${
|
|
390
|
-
error instanceof Error ? error.message : String(error)
|
|
391
|
-
}`
|
|
392
|
-
);
|
|
393
|
-
out.push("");
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
const explanation = namespaceIssue
|
|
399
|
-
? `\n${formatNamespaceProblem(namespaceIssue)}`
|
|
400
|
-
: hashIssue
|
|
401
|
-
? `\n in file: ${where}\n ${hashIssue.split("\n").join("\n ")}`
|
|
402
|
-
: `\n in file: ${where}`;
|
|
403
|
-
this.handleError(
|
|
404
|
-
`${glob ? "Include matched no files" : "Include not found"}: @${includePath}${explanation}\n tried:\n${candidates
|
|
405
|
-
.map((c) => ` - ${c}`)
|
|
406
|
-
.join("\n")}`
|
|
407
|
-
);
|
|
408
|
-
out.push("");
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
for (const fullPath of files) {
|
|
413
|
-
if (this.includeStack.includes(fullPath)) {
|
|
414
|
-
this.handleError(
|
|
415
|
-
`Circular dependency detected: ${this.includeStack.join(" -> ")} -> ${fullPath}`
|
|
416
|
-
);
|
|
417
|
-
out.push("");
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
out.push(this.loadFile(fullPath, projectRoot) === null ? "" : { file: fullPath });
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return out;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Reads a file and, recursively, every file it includes, without rendering
|
|
430
|
-
* anything. A file already read for this target is not read again (its
|
|
431
|
-
* includes resolve the same way whoever includes it), so a file may be
|
|
432
|
-
* included any number of times; only a cycle is an error.
|
|
433
|
-
*/
|
|
434
|
-
private loadFile(filePath: string, projectRoot: string): FileNode | null {
|
|
435
|
-
const known = this.nodes.get(filePath);
|
|
436
|
-
if (known !== undefined) return known;
|
|
437
|
-
|
|
438
|
-
if (!fs.existsSync(filePath)) {
|
|
439
|
-
this.handleError(`File not found: ${filePath}`);
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
this.includeStack.push(filePath);
|
|
444
|
-
|
|
445
|
-
try {
|
|
446
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
447
|
-
const baseDir = path.dirname(filePath);
|
|
448
|
-
const node: FileNode = {
|
|
449
|
-
path: filePath,
|
|
450
|
-
content,
|
|
451
|
-
isTpl: path.basename(filePath).includes(".tpl."),
|
|
452
|
-
parts: [],
|
|
453
|
-
};
|
|
454
|
-
// Registered before its includes are read so the graph lists parents
|
|
455
|
-
// first; the cycle check uses the stack, not this map.
|
|
456
|
-
this.nodes.set(filePath, node);
|
|
457
|
-
node.parts = this.processIncludes(content, baseDir, projectRoot, filePath);
|
|
458
|
-
return node;
|
|
459
|
-
} catch (error) {
|
|
460
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
461
|
-
this.handleError(`Failed to read file ${filePath}: ${message}`);
|
|
462
|
-
this.nodes.delete(filePath);
|
|
463
|
-
return null;
|
|
464
|
-
} finally {
|
|
465
|
-
this.includeStack.pop();
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Turns a read file into its final text.
|
|
471
|
-
*
|
|
472
|
-
* A file whose own name contains `.tpl.` is rendered as Liquid with the
|
|
473
|
-
* given variables; no other file ever is. Each include is left as a unique
|
|
474
|
-
* marker while the file renders, so a condition around an include line is
|
|
475
|
-
* honored, and an included file's own output is never rendered again. Every
|
|
476
|
-
* marker that survives the render is then replaced by that file's final text,
|
|
477
|
-
* produced the same way.
|
|
478
|
-
*
|
|
479
|
-
* @param filePath - The file to expand.
|
|
480
|
-
* @param vars - The output's variable scope; undefined means templates stay unrendered.
|
|
481
|
-
* @param entryDir - The directory of the target's entry point, also searched by `{% render %}`.
|
|
482
|
-
* @param projectRoot - Root used to render source comments as relative paths.
|
|
483
|
-
*/
|
|
484
|
-
private async expand(
|
|
485
|
-
filePath: string,
|
|
486
|
-
vars: Record<string, string> | undefined,
|
|
487
|
-
entryDir: string,
|
|
488
|
-
projectRoot: string
|
|
489
|
-
): Promise<string> {
|
|
490
|
-
const node = this.nodes.get(filePath)!;
|
|
491
|
-
const nonce = `${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
492
|
-
const markers = new Map<string, string>();
|
|
493
|
-
const chunks = node.parts.map((part) => {
|
|
494
|
-
if (typeof part === "string") return part;
|
|
495
|
-
const marker = `\u0001SOUS-INCLUDE-${nonce}-${markers.size}\u0001`;
|
|
496
|
-
markers.set(marker, part.file);
|
|
497
|
-
return marker;
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
let text = chunks.join("\n");
|
|
501
|
-
if (node.isTpl && vars !== undefined) {
|
|
502
|
-
const dir = path.dirname(node.path);
|
|
503
|
-
text = await this.renderContent(
|
|
504
|
-
text,
|
|
505
|
-
{ ...vars, sousTemplatePath: node.path, sousTemplateDir: dir },
|
|
506
|
-
[...new Set([dir, entryDir])],
|
|
507
|
-
node.path
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
for (const [marker, child] of markers) {
|
|
512
|
-
if (!text.includes(marker)) continue;
|
|
513
|
-
const body = await this.expand(child, vars, entryDir, projectRoot);
|
|
514
|
-
const comment = this.currentIncludeSourceComments
|
|
515
|
-
? `<!-- from: ${path.relative(projectRoot, child)} -->\n`
|
|
516
|
-
: "";
|
|
517
|
-
text = text.split(marker).join(comment + body);
|
|
518
|
-
}
|
|
519
|
-
return text;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** Every file read for the current target, parents before the files they include. */
|
|
523
|
-
private graphNodes(): FileNode[] {
|
|
524
|
-
return [...this.nodes.values()];
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Render template content using LiquidJS with the given variable scope.
|
|
529
|
-
*
|
|
530
|
-
* @param content - The template text.
|
|
531
|
-
* @param vars - Variable scope handed to LiquidJS and to `${var}` path substitution.
|
|
532
|
-
* @param roots - Filesystem roots searched by `{% render %}`.
|
|
533
|
-
* @param fromFile - Absolute path of the template, so `~namespace` render paths are scoped correctly.
|
|
534
|
-
*/
|
|
535
|
-
private async renderContent(
|
|
536
|
-
content: string,
|
|
537
|
-
vars: Record<string, string>,
|
|
538
|
-
roots: string[],
|
|
539
|
-
fromFile?: string
|
|
540
|
-
): Promise<string> {
|
|
541
|
-
const engine = createLiquidEngine(roots, {
|
|
542
|
-
aliases: this.aliases,
|
|
543
|
-
views: this.views,
|
|
544
|
-
scope: { ...this.includeScope, ...vars },
|
|
545
|
-
namespaceResolver: this.namespaceResolver,
|
|
546
|
-
fromFile,
|
|
547
|
-
secretVariables: this.secretVariables,
|
|
548
|
-
});
|
|
549
|
-
try {
|
|
550
|
-
return await engine.parseAndRender(content, vars);
|
|
551
|
-
} catch (error) {
|
|
552
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
553
|
-
this.handleError(`Template rendering error: ${message}`);
|
|
554
|
-
return content;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Generate and write the runtime session context include file. */
|
|
559
|
-
private generateRuntimeSessionContext(target: CompilationTarget): void {
|
|
560
|
-
const ctx = target.runtimeContext!;
|
|
561
|
-
const runtimeDir = path.dirname(ctx.outputPath);
|
|
562
|
-
|
|
563
|
-
if (!fs.existsSync(runtimeDir)) {
|
|
564
|
-
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
let branchName = "unknown";
|
|
568
|
-
|
|
569
|
-
try {
|
|
570
|
-
branchName = execFileSync(
|
|
571
|
-
"git",
|
|
572
|
-
["-C", ctx.gitRoot, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
573
|
-
{ encoding: "utf8" }
|
|
574
|
-
).trim();
|
|
575
|
-
} catch (error) {
|
|
576
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
577
|
-
this.handleWarning(
|
|
578
|
-
`Sous could not read the current git branch, so the runtime context says ` +
|
|
579
|
-
`"unknown": ${message}`
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const runtimeHeader = `## Runtime Session Context
|
|
584
|
-
|
|
585
|
-
The following information is specific to this chat session. It was generated automatically and
|
|
586
|
-
embedded into this AGENTS.md file in order to save you and the user some effort in setting up
|
|
587
|
-
to begin work.
|
|
588
|
-
|
|
589
|
-
### Environment Info
|
|
590
|
-
|
|
591
|
-
Current git branch: \`${branchName}\`
|
|
592
|
-
|
|
593
|
-
### Current Task File
|
|
594
|
-
|
|
595
|
-
`;
|
|
596
|
-
|
|
597
|
-
let taskFileBlock = "";
|
|
598
|
-
|
|
599
|
-
if (ctx.branchPattern.test(branchName)) {
|
|
600
|
-
const taskFilePath = path.join(ctx.taskFileRoot, `${branchName}.md`);
|
|
601
|
-
|
|
602
|
-
if (!fs.existsSync(taskFilePath)) {
|
|
603
|
-
taskFileBlock = `
|
|
604
|
-
Although we're currently on a feature branch that is tied to a Jira ticket, this branch does not,
|
|
605
|
-
yet, have a task file. It's likely that one of the first things we'll be doing in this session is
|
|
606
|
-
instantiating a task file, but be sure to ask before you do that.
|
|
607
|
-
`;
|
|
608
|
-
} else {
|
|
609
|
-
const taskFileContents = fs.readFileSync(taskFilePath, "utf8").replace(/\n+$/u, "");
|
|
610
|
-
taskFileBlock = `
|
|
611
|
-
We're currently on a feature branch that has an existing task file. The full contents of the task
|
|
612
|
-
file are included below:
|
|
613
|
-
|
|
614
|
-
--- Start Task File: ${taskFilePath} ---
|
|
615
|
-
${taskFileContents}
|
|
616
|
-
--- End Task File: ${taskFilePath} ---
|
|
617
|
-
`;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
fs.writeFileSync(ctx.outputPath, `${runtimeHeader}${taskFileBlock}\n`, "utf8");
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Says that an output was not written because its target had an error, and
|
|
626
|
-
* what that leaves on disk.
|
|
627
|
-
*
|
|
628
|
-
* @param destFile - The output that was not written.
|
|
629
|
-
*/
|
|
630
|
-
private reportNotWritten(destFile: string): void {
|
|
631
|
-
const verb = this.dryRun ? "would not be written" : "not written";
|
|
632
|
-
log(
|
|
633
|
-
fs.existsSync(destFile)
|
|
634
|
-
? ` ✗ ${destFile} (${verb} because of an error; the previous copy stays)`
|
|
635
|
-
: ` ✗ ${destFile} (${verb} because of an error)`
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** Compile a single target, writing to all of its outputs. */
|
|
640
|
-
private async compileTarget(
|
|
641
|
-
target: CompilationTarget,
|
|
642
|
-
state: StateFile,
|
|
643
|
-
stateFileEntries: StateFileEntry[]
|
|
644
|
-
): Promise<boolean> {
|
|
645
|
-
subheading(path.basename(target.rootInputPath), "▷");
|
|
646
|
-
showVariable("Entry Point", target.rootInputPath);
|
|
647
|
-
|
|
648
|
-
this.initializeEncoder();
|
|
649
|
-
|
|
650
|
-
this.includeStack = [];
|
|
651
|
-
this.nodes = new Map();
|
|
652
|
-
this.currentIncludeSourceComments =
|
|
653
|
-
typeof target.includeSourceComments === "boolean"
|
|
654
|
-
? target.includeSourceComments
|
|
655
|
-
: this.includeSourceComments;
|
|
656
|
-
|
|
657
|
-
const errorsBefore = this.errors.length;
|
|
658
|
-
|
|
659
|
-
if (target.runtimeContext) {
|
|
660
|
-
this.generateRuntimeSessionContext(target);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const promptsRoot = path.dirname(target.rootInputPath);
|
|
664
|
-
const entry = this.loadFile(target.rootInputPath, promptsRoot);
|
|
665
|
-
|
|
666
|
-
if (entry === null) {
|
|
667
|
-
displayError(`Failed to compile ${target.rootInputPath}`);
|
|
668
|
-
return false;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// An include that failed left a hole in the assembled content (and in strict
|
|
672
|
-
// mode, a warning counts as an error), so none of this target's outputs may
|
|
673
|
-
// be written: the previous copy stays in place.
|
|
674
|
-
const assembled = this.errors.length === errorsBefore;
|
|
675
|
-
|
|
676
|
-
// Compute the source hash once per target from every file read: the entry
|
|
677
|
-
// point and everything it includes, whatever a condition decides later.
|
|
678
|
-
const graph = this.graphNodes();
|
|
679
|
-
for (const node of graph) this.includedFilesSeen.add(realPathOf(node.path));
|
|
680
|
-
const templates = graph.filter((node) => node.isTpl);
|
|
681
|
-
const contentHash = hashContent(
|
|
682
|
-
JSON.stringify([
|
|
683
|
-
this.currentIncludeSourceComments,
|
|
684
|
-
graph.map((node) => [path.relative(promptsRoot, node.path), node.content]),
|
|
685
|
-
])
|
|
686
|
-
);
|
|
687
|
-
|
|
688
|
-
let allSucceeded = true;
|
|
689
|
-
|
|
690
|
-
const isTpl = entry.isTpl;
|
|
691
|
-
// Whether any file of this target renders as Liquid, which makes the
|
|
692
|
-
// output depend on its variables.
|
|
693
|
-
const rendersAnything = templates.length > 0;
|
|
694
|
-
|
|
695
|
-
for (const output of target.outputs) {
|
|
696
|
-
// Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
|
|
697
|
-
let destFile: string;
|
|
698
|
-
|
|
699
|
-
const resolvedDest = resolveOutputPath(target, output);
|
|
700
|
-
// Neither destinationFile nor destinationDir set — skip
|
|
701
|
-
if (resolvedDest === undefined) continue;
|
|
702
|
-
destFile = resolvedDest;
|
|
703
|
-
|
|
704
|
-
if (!assembled) {
|
|
705
|
-
this.reportNotWritten(destFile);
|
|
706
|
-
allSucceeded = false;
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// A rendered output depends on its variables as much as on its source: a
|
|
711
|
-
// changed answer or `_vars` value with the same template must re-render,
|
|
712
|
-
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
713
|
-
// verbatim copy hashes its content alone. The secret names are part of it
|
|
714
|
-
// too, because they decide what a scope-dumping tag hides.
|
|
715
|
-
const srcHash = rendersAnything && output.vars
|
|
716
|
-
? hashContent(
|
|
717
|
-
`${contentHash}\n${stableVarsFingerprint(output.vars)}\n` +
|
|
718
|
-
`secrets:${JSON.stringify([...this.secretVariables].sort())}`
|
|
719
|
-
)
|
|
720
|
-
: contentHash;
|
|
721
|
-
|
|
722
|
-
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
723
|
-
const existingEntry = state.files.find(f => f.dest === destFile);
|
|
724
|
-
if (
|
|
725
|
-
!this.rebuild &&
|
|
726
|
-
existingEntry?.srcHash === srcHash &&
|
|
727
|
-
fs.existsSync(destFile)
|
|
728
|
-
) {
|
|
729
|
-
log(` ⊘ ${destFile} (unchanged)`);
|
|
730
|
-
stateFileEntries.push(existingEntry);
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// Dry-run: report what would be written
|
|
735
|
-
if (this.dryRun) {
|
|
736
|
-
log(` ○ ${destFile} (would write)`);
|
|
737
|
-
continue;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// A `.tpl.` source with no variable scope never reaches LiquidJS, so its
|
|
741
|
-
// tags would ship verbatim. Record it; reported loudly after the run.
|
|
742
|
-
//
|
|
743
|
-
// NOTE: a config-driven build cannot reach this, because the settings
|
|
744
|
-
// resolver always sets `vars` on every output (at minimum the inherited
|
|
745
|
-
// scope). It is a real guard for callers that build a CompilationConfig
|
|
746
|
-
// directly, and it stays as a tripwire in case the resolver ever changes.
|
|
747
|
-
if (rendersAnything && !output.vars) {
|
|
748
|
-
for (const template of templates) {
|
|
749
|
-
this.unrenderedTemplates.push(`${template.path} → ${destFile}`);
|
|
750
|
-
}
|
|
751
|
-
// In strict mode this warning is an error, so the output is not written.
|
|
752
|
-
if (this.strict) {
|
|
753
|
-
this.reportNotWritten(destFile);
|
|
754
|
-
allSucceeded = false;
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
const errorsBeforeRender = this.errors.length;
|
|
760
|
-
const resolvedContent = await this.expand(
|
|
761
|
-
target.rootInputPath,
|
|
762
|
-
output.vars,
|
|
763
|
-
promptsRoot,
|
|
764
|
-
promptsRoot
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
// A template that failed to render is not written either.
|
|
768
|
-
if (this.errors.length > errorsBeforeRender) {
|
|
769
|
-
this.reportNotWritten(destFile);
|
|
770
|
-
allSucceeded = false;
|
|
771
|
-
continue;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const fileContent = resolvedContent;
|
|
775
|
-
const outputDir = path.dirname(destFile);
|
|
776
|
-
|
|
777
|
-
// Record all ancestor directories that Sous is about to create, from shallowest to deepest.
|
|
778
|
-
// mkdirSync({ recursive }) may create multiple levels; we must track each new one.
|
|
779
|
-
const dirsToCreate: string[] = [];
|
|
780
|
-
let walkDir = outputDir;
|
|
781
|
-
while (!fs.existsSync(walkDir)) {
|
|
782
|
-
dirsToCreate.unshift(walkDir);
|
|
783
|
-
const parent = path.dirname(walkDir);
|
|
784
|
-
if (parent === walkDir) break;
|
|
785
|
-
walkDir = parent;
|
|
786
|
-
}
|
|
787
|
-
if (dirsToCreate.length > 0) {
|
|
788
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
789
|
-
for (const dir of dirsToCreate) {
|
|
790
|
-
recordDirCreation(dir, state);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
try {
|
|
795
|
-
fs.writeFileSync(destFile, fileContent, "utf8");
|
|
796
|
-
|
|
797
|
-
if (destFile.endsWith(".sh")) {
|
|
798
|
-
fs.chmodSync(destFile, 0o755);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
if (!isTpl) {
|
|
802
|
-
try {
|
|
803
|
-
const srcStat = fs.statSync(target.rootInputPath);
|
|
804
|
-
fs.chmodSync(destFile, srcStat.mode);
|
|
805
|
-
} catch {
|
|
806
|
-
// Ignore chmod failures on unsupported platforms
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
const destHash = hashContent(fileContent);
|
|
811
|
-
const entry: StateFileEntry = {
|
|
812
|
-
dest: destFile,
|
|
813
|
-
srcHash,
|
|
814
|
-
destHash,
|
|
815
|
-
size: Buffer.byteLength(fileContent, "utf8"),
|
|
816
|
-
builtAt: new Date().toISOString(),
|
|
817
|
-
};
|
|
818
|
-
stateFileEntries.push(entry);
|
|
819
|
-
|
|
820
|
-
const tokenCount = this.encoder!.encode(fileContent).length;
|
|
821
|
-
const formattedTokenCount = this.numberFormatter.format(tokenCount);
|
|
822
|
-
log(` ✓ ${destFile} (~${formattedTokenCount} tokens)`);
|
|
823
|
-
} catch (error) {
|
|
824
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
825
|
-
this.handleError(`Failed to write ${destFile}: ${message}`);
|
|
826
|
-
allSucceeded = false;
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
return allSucceeded;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* Every file the last compile read, as real paths: the entry points and every
|
|
835
|
-
* file any include line of any target named, by any route (exact path, glob
|
|
836
|
-
* or view). Includes inside an unmet condition count, since they are read.
|
|
837
|
-
*/
|
|
838
|
-
includedFiles(): ReadonlySet<string> {
|
|
839
|
-
return this.includedFilesSeen;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* Compile all targets from the given config. Every target is compiled even
|
|
844
|
-
* after an error, so every error is listed; an output whose target had an
|
|
845
|
-
* error is not written and keeps its previous copy and its state entry.
|
|
846
|
-
*
|
|
847
|
-
* @returns True when nothing reported an error; false after any error.
|
|
848
|
-
*/
|
|
849
|
-
async compile(config: CompilationConfig, stateFilePath?: string): Promise<boolean> {
|
|
850
|
-
const stateService = new StateService();
|
|
851
|
-
let state: StateFile = stateFilePath
|
|
852
|
-
? ((await stateService.load(stateFilePath)) ?? {
|
|
853
|
-
lastBuild: "",
|
|
854
|
-
resolvedVars: {},
|
|
855
|
-
dirs: [],
|
|
856
|
-
files: [],
|
|
857
|
-
})
|
|
858
|
-
: { lastBuild: "", resolvedVars: {}, dirs: [], files: [] };
|
|
859
|
-
|
|
860
|
-
try {
|
|
861
|
-
// Only complain about a value that is actually present and wrong. The
|
|
862
|
-
// settings resolver always sets the key (to undefined when the config omits
|
|
863
|
-
// it), so a hasOwnProperty check alone fired on every single build.
|
|
864
|
-
if (
|
|
865
|
-
config.includeSourceComments !== undefined &&
|
|
866
|
-
typeof config.includeSourceComments !== "boolean"
|
|
867
|
-
) {
|
|
868
|
-
this.handleError("Config option 'includeSourceComments' must be a boolean");
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
this.includeSourceComments = config.includeSourceComments === true;
|
|
872
|
-
this.aliases = config.aliases ?? {};
|
|
873
|
-
this.views = config.views ?? {};
|
|
874
|
-
this.includedFilesSeen = new Set();
|
|
875
|
-
this.includeScope = config.includeScope ?? {};
|
|
876
|
-
this.secretVariables = config.secretVariables ?? [];
|
|
877
|
-
|
|
878
|
-
this.initializeEncoder();
|
|
879
|
-
|
|
880
|
-
let allSucceeded = true;
|
|
881
|
-
const stateFileEntries: StateFileEntry[] = [];
|
|
882
|
-
this.unrenderedTemplates = [];
|
|
883
|
-
|
|
884
|
-
for (const target of config.targets) {
|
|
885
|
-
const success = await this.compileTarget(target, state, stateFileEntries);
|
|
886
|
-
if (!success) allSucceeded = false;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
if (this.unrenderedTemplates.length > 0) {
|
|
890
|
-
const count = this.unrenderedTemplates.length;
|
|
891
|
-
const message =
|
|
892
|
-
`${count} TEMPLATE FILE(S) WERE COPIED WITHOUT BEING RENDERED.\n` +
|
|
893
|
-
`Each of these is a '.tpl.' source written to an output that has no vars, so\n` +
|
|
894
|
-
`LiquidJS never ran and the {{ tags }} are still in the output file:\n` +
|
|
895
|
-
this.unrenderedTemplates.map((entry) => ` - ${entry}`).join("\n") +
|
|
896
|
-
`\nFix: add a '_vars' block to the output (an empty '_vars: {}' is enough to\n` +
|
|
897
|
-
`enable rendering), or rename the source so it does not contain '.tpl.'.`;
|
|
898
|
-
|
|
899
|
-
this.handleWarning(message);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
if (this.errors.length > 0) {
|
|
903
|
-
subheading(`Done with ${this.errors.length} error(s).`, "⚠");
|
|
904
|
-
} else {
|
|
905
|
-
subheading("Done.", "✓");
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// Update state: fresh entries for everything compiled this pass, plus
|
|
909
|
-
// carried-forward entries for outputs this pass did not touch — other
|
|
910
|
-
// targets' outputs during a partial rebuild, and outputs dropped from the
|
|
911
|
-
// config, which must stay tracked so prune/clear can still find them.
|
|
912
|
-
// Entries whose files are gone from disk are released.
|
|
913
|
-
const writtenDests = new Set(stateFileEntries.map((e) => e.dest));
|
|
914
|
-
state.files = [
|
|
915
|
-
...stateFileEntries,
|
|
916
|
-
...state.files.filter((f) => !writtenDests.has(f.dest) && fs.existsSync(f.dest)),
|
|
917
|
-
];
|
|
918
|
-
state.lastBuild = new Date().toISOString();
|
|
919
|
-
|
|
920
|
-
if (stateFilePath && !this.dryRun) {
|
|
921
|
-
await stateService.save(stateFilePath, state);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Any error fails the compile, including one that belongs to no single
|
|
925
|
-
// target (a bad config option).
|
|
926
|
-
return allSucceeded && this.errors.length === 0;
|
|
927
|
-
} finally {
|
|
928
|
-
if (this.encoder) {
|
|
929
|
-
this.encoder.free();
|
|
930
|
-
this.encoder = null;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Backward-compat alias so existing imports of MarkdownCompiler keep working
|
|
937
|
-
export { CompilationService as MarkdownCompiler };
|
|
938
|
-
export type { CompilationServiceOptions as MarkdownCompilerOptions };
|
|
939
|
-
|
|
940
|
-
/** The path with symbolic links resolved, or the path itself when it cannot be read. */
|
|
941
|
-
function realPathOf(filePath: string): string {
|
|
942
|
-
try {
|
|
943
|
-
return fs.realpathSync(filePath);
|
|
944
|
-
} catch {
|
|
945
|
-
return filePath;
|
|
946
|
-
}
|
|
947
|
-
}
|