@sous-io/sous 0.2.37 → 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.
Files changed (244) hide show
  1. package/README.md +14 -256
  2. package/dist/bin.js +11 -0
  3. package/dist/sous.js +1093 -0
  4. package/docs/markdown/README.md +5 -4
  5. package/docs/markdown/_sidebar.md +21 -1
  6. package/docs/markdown/commands.md +294 -431
  7. package/docs/markdown/config-discovery.md +25 -14
  8. package/docs/markdown/config-inspection.md +53 -31
  9. package/docs/markdown/config-layers.md +168 -66
  10. package/docs/markdown/config-variables.md +44 -11
  11. package/docs/markdown/configuration.md +104 -17
  12. package/docs/markdown/design-principles.md +4 -1
  13. package/docs/markdown/repositories-authoring.md +30 -0
  14. package/docs/markdown/repositories-consuming.md +72 -14
  15. package/docs/markdown/repositories-file-formats.md +31 -13
  16. package/docs/markdown/repositories-providers.md +48 -57
  17. package/docs/markdown/repositories-quickstart.md +3 -2
  18. package/docs/markdown/repositories-troubleshooting.md +27 -1
  19. package/docs/markdown/repositories-variables.md +6 -1
  20. package/docs/markdown/repositories.md +18 -8
  21. package/package.json +48 -107
  22. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
  23. package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
  24. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
  25. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
  26. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
  27. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  28. package/sous.config.schema.json +351 -132
  29. package/bin/run.js +0 -51
  30. package/src/base-command.ts +0 -457
  31. package/src/commands/build.ts +0 -181
  32. package/src/commands/clear.ts +0 -79
  33. package/src/commands/compile.ts +0 -155
  34. package/src/commands/config/get.ts +0 -110
  35. package/src/commands/config/show.ts +0 -32
  36. package/src/commands/config/validate.ts +0 -53
  37. package/src/commands/help.ts +0 -46
  38. package/src/commands/init.ts +0 -289
  39. package/src/commands/launch.ts +0 -139
  40. package/src/commands/lock/rebuild.ts +0 -239
  41. package/src/commands/lock/show.ts +0 -115
  42. package/src/commands/namespace/list.ts +0 -138
  43. package/src/commands/namespace/show.ts +0 -130
  44. package/src/commands/prune.ts +0 -43
  45. package/src/commands/recipe/list.ts +0 -112
  46. package/src/commands/recipe/show.ts +0 -439
  47. package/src/commands/repo/add.ts +0 -145
  48. package/src/commands/repo/contribute.ts +0 -604
  49. package/src/commands/repo/gc.ts +0 -173
  50. package/src/commands/repo/init.ts +0 -136
  51. package/src/commands/repo/link.ts +0 -899
  52. package/src/commands/repo/list.ts +0 -236
  53. package/src/commands/repo/release.ts +0 -685
  54. package/src/commands/repo/remove.ts +0 -194
  55. package/src/commands/repo/search.ts +0 -243
  56. package/src/commands/repo/submit.ts +0 -341
  57. package/src/commands/repo/unlink.ts +0 -461
  58. package/src/commands/subscription/add.ts +0 -286
  59. package/src/commands/subscription/list.ts +0 -208
  60. package/src/commands/subscription/remove.ts +0 -182
  61. package/src/commands/subscription/update.ts +0 -216
  62. package/src/commands/update.ts +0 -178
  63. package/src/commands/vars/ask.ts +0 -417
  64. package/src/commands/vars/index.ts +0 -79
  65. package/src/commands/vars/list.ts +0 -67
  66. package/src/commands/vars/show.ts +0 -77
  67. package/src/config-command.ts +0 -30
  68. package/src/lib/build-preparation.ts +0 -128
  69. package/src/lib/build-service.ts +0 -580
  70. package/src/lib/config-discovery.ts +0 -378
  71. package/src/lib/config-inspect.ts +0 -145
  72. package/src/lib/config-kernel.mjs +0 -379
  73. package/src/lib/config-schema.ts +0 -401
  74. package/src/lib/env-file.ts +0 -328
  75. package/src/lib/env-local.ts +0 -212
  76. package/src/lib/errors.ts +0 -32
  77. package/src/lib/glob-pattern.ts +0 -24
  78. package/src/lib/include-resolver.ts +0 -523
  79. package/src/lib/interactive.ts +0 -165
  80. package/src/lib/markdown-compiler.ts +0 -947
  81. package/src/lib/package-info.ts +0 -25
  82. package/src/lib/pid-service.ts +0 -99
  83. package/src/lib/project-install.d.mts +0 -44
  84. package/src/lib/project-install.mjs +0 -234
  85. package/src/lib/project-scaffold/index.ts +0 -332
  86. package/src/lib/project-scaffold/templates.ts +0 -240
  87. package/src/lib/repos/catalog-display.ts +0 -215
  88. package/src/lib/repos/catalog-inputs.ts +0 -288
  89. package/src/lib/repos/catalog.ts +0 -842
  90. package/src/lib/repos/contribute.ts +0 -452
  91. package/src/lib/repos/core-recipe.ts +0 -105
  92. package/src/lib/repos/declarations.ts +0 -187
  93. package/src/lib/repos/defaults.ts +0 -175
  94. package/src/lib/repos/formats/common.ts +0 -440
  95. package/src/lib/repos/formats/index-file.ts +0 -253
  96. package/src/lib/repos/formats/links-map.ts +0 -98
  97. package/src/lib/repos/formats/lockfile.ts +0 -169
  98. package/src/lib/repos/formats/patterns.ts +0 -57
  99. package/src/lib/repos/formats/recipe-manifest.ts +0 -205
  100. package/src/lib/repos/formats/repo-manifest.ts +0 -97
  101. package/src/lib/repos/formats/store-entry.ts +0 -84
  102. package/src/lib/repos/formats/variable-definition.ts +0 -268
  103. package/src/lib/repos/freshness.ts +0 -264
  104. package/src/lib/repos/git-clone.ts +0 -850
  105. package/src/lib/repos/identity.ts +0 -89
  106. package/src/lib/repos/index.ts +0 -57
  107. package/src/lib/repos/links.ts +0 -356
  108. package/src/lib/repos/load-manifest.ts +0 -237
  109. package/src/lib/repos/lock-service.ts +0 -454
  110. package/src/lib/repos/locked-namespace-resolver.ts +0 -232
  111. package/src/lib/repos/locked-recipe-lookup.ts +0 -123
  112. package/src/lib/repos/locked-recipes.ts +0 -254
  113. package/src/lib/repos/managed-layer.ts +0 -422
  114. package/src/lib/repos/memories-hash-name.ts +0 -33
  115. package/src/lib/repos/namespace-resolver.ts +0 -527
  116. package/src/lib/repos/providers/base.ts +0 -268
  117. package/src/lib/repos/providers/git.ts +0 -245
  118. package/src/lib/repos/providers/github.ts +0 -589
  119. package/src/lib/repos/providers/gitlab.ts +0 -332
  120. package/src/lib/repos/providers/http.ts +0 -128
  121. package/src/lib/repos/providers/index-cache.ts +0 -421
  122. package/src/lib/repos/providers/index.ts +0 -106
  123. package/src/lib/repos/providers/local.ts +0 -410
  124. package/src/lib/repos/providers/provider.ts +0 -615
  125. package/src/lib/repos/recipe-config-layers.ts +0 -316
  126. package/src/lib/repos/recipe-key-matcher.ts +0 -62
  127. package/src/lib/repos/recipe-memories.ts +0 -178
  128. package/src/lib/repos/recipe-targets.ts +0 -236
  129. package/src/lib/repos/ref-search.ts +0 -46
  130. package/src/lib/repos/release/bump.ts +0 -161
  131. package/src/lib/repos/release/changelog.ts +0 -461
  132. package/src/lib/repos/release/git-state.ts +0 -460
  133. package/src/lib/repos/release/index-builder.ts +0 -1108
  134. package/src/lib/repos/release/index.ts +0 -19
  135. package/src/lib/repos/release/plan.ts +0 -521
  136. package/src/lib/repos/release/settle.ts +0 -154
  137. package/src/lib/repos/release/submissions.ts +0 -214
  138. package/src/lib/repos/release/submit-checkout.ts +0 -290
  139. package/src/lib/repos/release/submit-questions.ts +0 -153
  140. package/src/lib/repos/release/submit-service.ts +0 -898
  141. package/src/lib/repos/release/tags.ts +0 -239
  142. package/src/lib/repos/release/validate.ts +0 -463
  143. package/src/lib/repos/resolver.ts +0 -1028
  144. package/src/lib/repos/scaffold/index.ts +0 -245
  145. package/src/lib/repos/scaffold/templates.ts +0 -517
  146. package/src/lib/repos/seed.ts +0 -473
  147. package/src/lib/repos/store/contract.ts +0 -64
  148. package/src/lib/repos/store/hash.ts +0 -191
  149. package/src/lib/repos/store/recipe-store.ts +0 -599
  150. package/src/lib/repos/store/settings.ts +0 -58
  151. package/src/lib/repos/subscription-service.ts +0 -3859
  152. package/src/lib/repos/trust.ts +0 -447
  153. package/src/lib/repos/update-plan.ts +0 -235
  154. package/src/lib/self-update/index.ts +0 -14
  155. package/src/lib/self-update/installs.ts +0 -413
  156. package/src/lib/self-update/managers.ts +0 -126
  157. package/src/lib/self-update/plan.ts +0 -315
  158. package/src/lib/self-update/registry.ts +0 -99
  159. package/src/lib/self-update/run.ts +0 -506
  160. package/src/lib/self-update/versions.ts +0 -243
  161. package/src/lib/settings.ts +0 -1107
  162. package/src/lib/sous-home.ts +0 -104
  163. package/src/lib/state.ts +0 -167
  164. package/src/lib/vars/answers.ts +0 -192
  165. package/src/lib/vars/ask.ts +0 -1152
  166. package/src/lib/vars/definition-source.ts +0 -261
  167. package/src/lib/vars/display.ts +0 -231
  168. package/src/lib/vars/index.ts +0 -20
  169. package/src/lib/vars/ladder.ts +0 -282
  170. package/src/lib/vars/mappings.ts +0 -260
  171. package/src/lib/vars/names.ts +0 -94
  172. package/src/lib/vars/preanswers.ts +0 -395
  173. package/src/lib/vars/question-plan.ts +0 -241
  174. package/src/lib/vars/report.ts +0 -228
  175. package/src/lib/vars/safe-regex.ts +0 -235
  176. package/src/lib/vars/secrets.ts +0 -131
  177. package/src/lib/vars/validate.ts +0 -312
  178. package/src/lib/version-report.ts +0 -90
  179. package/src/lib/watch-loop.ts +0 -148
  180. package/src/lib/watch-service.ts +0 -116
  181. package/src/services/ref-resolver/container.ts +0 -107
  182. package/src/services/ref-resolver/dependency.ts +0 -60
  183. package/src/services/ref-resolver/format.ts +0 -197
  184. package/src/services/ref-resolver/glob.ts +0 -115
  185. package/src/services/ref-resolver/hash-names.ts +0 -155
  186. package/src/services/ref-resolver/index.ts +0 -55
  187. package/src/services/ref-resolver/injectable.ts +0 -36
  188. package/src/services/ref-resolver/location.ts +0 -62
  189. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
  190. package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
  191. package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
  192. package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
  193. package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
  194. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
  195. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
  196. package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
  197. package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
  198. package/src/services/ref-resolver/named-refs.ts +0 -144
  199. package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
  200. package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
  201. package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
  202. package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
  203. package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
  204. package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
  205. package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
  206. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
  207. package/src/services/ref-resolver/parts.ts +0 -115
  208. package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
  209. package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
  210. package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
  211. package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
  212. package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
  213. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
  214. package/src/services/ref-resolver/ref-helpers.ts +0 -62
  215. package/src/services/ref-resolver/ref-picker-service.ts +0 -204
  216. package/src/services/ref-resolver/ref-report.ts +0 -84
  217. package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
  218. package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
  219. package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
  220. package/src/services/ref-resolver/source.ts +0 -48
  221. package/src/services/ref-resolver/tokens.ts +0 -25
  222. package/src/services/ref-resolver/types.ts +0 -155
  223. package/src/templating/filters/bullet-list.ts +0 -9
  224. package/src/templating/filters/index.ts +0 -8
  225. package/src/templating/init-liquid-engine.ts +0 -139
  226. package/src/templating/lib/glob-files.ts +0 -74
  227. package/src/templating/lib/import-export.ts +0 -32
  228. package/src/templating/lib/secret-scope.ts +0 -41
  229. package/src/templating/lib/tag-args.ts +0 -19
  230. package/src/templating/tags/exportScalarVarsJs.ts +0 -46
  231. package/src/templating/tags/getFiles.ts +0 -89
  232. package/src/templating/tags/index.ts +0 -14
  233. package/src/templating/tags/listFiles.ts +0 -54
  234. package/src/templating/tags/showVars.ts +0 -28
  235. package/src/utils/choice-prompt.ts +0 -143
  236. package/src/utils/command-errors.ts +0 -186
  237. package/src/utils/command-help.ts +0 -45
  238. package/src/utils/confirm-prompt.ts +0 -110
  239. package/src/utils/flags.ts +0 -177
  240. package/src/utils/formatting.ts +0 -849
  241. package/src/utils/prompts.ts +0 -53
  242. package/src/utils/sous-directory.ts +0 -245
  243. package/src/utils/table.ts +0 -603
  244. 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
- }