@sous-io/sous 0.1.0

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 (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/bin/run.js +17 -0
  4. package/bin/xcv +5 -0
  5. package/package.json +81 -0
  6. package/shared-prompts/_partials/resume-task.md +51 -0
  7. package/shared-prompts/_partials/sub-agent-delegation.md +32 -0
  8. package/shared-prompts/_partials/update-task-file.md +52 -0
  9. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +52 -0
  10. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +102 -0
  11. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +81 -0
  12. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +126 -0
  13. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +92 -0
  14. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +61 -0
  15. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +65 -0
  16. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +96 -0
  17. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +104 -0
  18. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +243 -0
  19. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +148 -0
  20. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +383 -0
  21. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +267 -0
  22. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +56 -0
  23. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +169 -0
  24. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +59 -0
  25. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +25 -0
  26. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +140 -0
  27. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +140 -0
  28. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +1 -0
  29. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +185 -0
  30. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +52 -0
  31. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +59 -0
  32. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +47 -0
  33. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +26 -0
  34. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +58 -0
  35. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +27 -0
  36. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +34 -0
  37. package/shared-prompts/skills/sous-skills/about-agent-skills/SKILL.tpl.md +177 -0
  38. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/about-something.md +45 -0
  39. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/do-something.md +33 -0
  40. package/shared-prompts/skills/sous-skills/about-agent-skills/references/advanced-patterns.md +87 -0
  41. package/shared-prompts/skills/sous-skills/about-agent-skills/references/commands.md +46 -0
  42. package/shared-prompts/skills/sous-skills/about-agent-skills/references/frontmatter.md +25 -0
  43. package/shared-prompts/skills/sous-skills/about-agent-skills/references/substitutions.md +50 -0
  44. package/shared-prompts/skills/sous-skills/about-liquid-templates/SKILL.tpl.md +268 -0
  45. package/shared-prompts/skills/sous-skills/about-liquid-templates/references/liquid-filters.md +82 -0
  46. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +51 -0
  47. package/shared-prompts/skills/sous-skills/create-skill/SKILL.tpl.md +114 -0
  48. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +122 -0
  49. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +80 -0
  50. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +14 -0
  51. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +13 -0
  52. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +93 -0
  53. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +14 -0
  54. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +13 -0
  55. package/src/base-command.ts +163 -0
  56. package/src/commands/build.ts +196 -0
  57. package/src/commands/clear.ts +71 -0
  58. package/src/commands/compile.ts +95 -0
  59. package/src/commands/launch.ts +111 -0
  60. package/src/commands/prune.ts +48 -0
  61. package/src/lib/build-service.ts +258 -0
  62. package/src/lib/config-discovery.ts +199 -0
  63. package/src/lib/env-local.ts +195 -0
  64. package/src/lib/include-resolver.ts +146 -0
  65. package/src/lib/markdown-compiler.ts +580 -0
  66. package/src/lib/pid-service.ts +88 -0
  67. package/src/lib/settings.ts +695 -0
  68. package/src/lib/state.ts +135 -0
  69. package/src/lib/watch-service.ts +115 -0
  70. package/src/templating/filters/bullet-list.ts +9 -0
  71. package/src/templating/filters/index.ts +8 -0
  72. package/src/templating/init-liquid-engine.ts +82 -0
  73. package/src/templating/lib/glob-files.ts +74 -0
  74. package/src/templating/lib/import-export.ts +32 -0
  75. package/src/templating/lib/tag-args.ts +19 -0
  76. package/src/templating/tags/exportScalarVarsJs.ts +43 -0
  77. package/src/templating/tags/getFiles.ts +89 -0
  78. package/src/templating/tags/index.ts +14 -0
  79. package/src/templating/tags/listFiles.ts +54 -0
  80. package/src/templating/tags/showVars.ts +22 -0
  81. package/src/utils/formatting.ts +338 -0
  82. package/src/utils/prompts.ts +19 -0
@@ -0,0 +1,135 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { VarScope } from "./settings.js";
5
+
6
+ // --- Types ---------------------------------------------------------------------------------------
7
+
8
+ /** A single file entry tracked by the state file. */
9
+ export type StateFileEntry = {
10
+ /** Absolute destination path of the output file. */
11
+ dest: string;
12
+ /** SHA-256 hex hash of the source content at build time. */
13
+ srcHash: string;
14
+ /** SHA-256 hex hash of the destination file content at build time. */
15
+ destHash: string;
16
+ /** File size in bytes. */
17
+ size: number;
18
+ /** ISO timestamp when this file was last written. */
19
+ builtAt: string;
20
+ };
21
+
22
+ /** The full state tracked for a project across builds. */
23
+ export type StateFile = {
24
+ /** ISO timestamp of the last successful build. */
25
+ lastBuild: string;
26
+ /** The resolved variable scope used during the last build. */
27
+ resolvedVars: VarScope;
28
+ /** Absolute paths of directories that Sous created (not pre-existing). */
29
+ dirs: string[];
30
+ /** All output files written by Sous for this project. */
31
+ files: StateFileEntry[];
32
+ };
33
+
34
+ // --- StateService --------------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Manages reading and writing the project state file.
38
+ * The state file tracks all files and directories Sous has written,
39
+ * enabling xcv prune and xcv clear to clean up precisely.
40
+ */
41
+ export class StateService {
42
+ /**
43
+ * Derives the state file path for a project.
44
+ *
45
+ * Precedence:
46
+ * 1. A `stateFilePath` variable in the PROJECT scope (explicit override).
47
+ * 2. `<sousDir>/sous.state.json` for a single-project config, or
48
+ * `<sousDir>/<key>.sous.state.json` when the config defines several
49
+ * projects (they must not share one state file).
50
+ * 3. `<cwd>/<key>.sous.state.json` as a last resort.
51
+ *
52
+ * @param projectKey - The project's key in the config's `projects` map.
53
+ * @param projectVars - The resolved PROJECT-scope variables. `sousDir` is
54
+ * auto-injected by buildAutoVars when a config has been discovered.
55
+ * @param projectCount - How many projects the active config defines. Defaults
56
+ * to 1 (the expected case: one `.sous/` per project).
57
+ */
58
+ getFilePath(projectKey: string, projectVars?: VarScope, projectCount = 1): string {
59
+ if (projectVars?.stateFilePath) {
60
+ return projectVars.stateFilePath;
61
+ }
62
+ if (projectVars?.sousDir) {
63
+ const fileName = projectCount > 1 ? `${projectKey}.sous.state.json` : "sous.state.json";
64
+ return path.join(projectVars.sousDir, fileName);
65
+ }
66
+ return path.join(process.cwd(), `${projectKey}.sous.state.json`);
67
+ }
68
+
69
+ /** Loads the state file for a project. Returns null if it does not exist. */
70
+ async load(filePath: string): Promise<StateFile | null> {
71
+ if (!fs.existsSync(filePath)) return null;
72
+ try {
73
+ const raw = fs.readFileSync(filePath, "utf8");
74
+ return JSON.parse(raw) as StateFile;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** Deletes all tracked files and removes any now-empty tracked directories. */
81
+ deleteTrackedFiles(files: StateFileEntry[], dirs: string[]): void {
82
+ for (const entry of files) {
83
+ if (fs.existsSync(entry.dest)) fs.rmSync(entry.dest);
84
+ }
85
+ const sortedDirs = [...dirs].sort(
86
+ (a, b) => b.split(path.sep).length - a.split(path.sep).length
87
+ );
88
+ for (const dir of sortedDirs) {
89
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
90
+ fs.rmdirSync(dir);
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Saves the state file to disk, creating parent directories as needed. */
96
+ async save(filePath: string, state: StateFile): Promise<void> {
97
+ const dir = path.dirname(filePath);
98
+ if (!fs.existsSync(dir)) {
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ }
101
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf8");
102
+ }
103
+ }
104
+
105
+ // --- Hash utilities ------------------------------------------------------------------------------
106
+
107
+ /** Computes a SHA-256 hex hash of a file's contents. */
108
+ export async function hashFile(filePath: string): Promise<string> {
109
+ const content = fs.readFileSync(filePath);
110
+ return crypto.createHash("sha256").update(content).digest("hex");
111
+ }
112
+
113
+ /** Computes a SHA-256 hex hash of an in-memory string. */
114
+ export function hashContent(content: string): string {
115
+ return crypto.createHash("sha256").update(content, "utf8").digest("hex");
116
+ }
117
+
118
+ // --- Directory tracking --------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Records that Sous created a directory.
122
+ * Only records if the directory is not already tracked.
123
+ */
124
+ export function recordDirCreation(dir: string, state: StateFile): void {
125
+ if (!state.dirs.includes(dir)) {
126
+ state.dirs.push(dir);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Returns true if Sous created this directory (i.e., it is tracked in the state file).
132
+ */
133
+ export function didSousCreateDir(dir: string, state: StateFile): boolean {
134
+ return state.dirs.includes(dir);
135
+ }
@@ -0,0 +1,115 @@
1
+ import chokidar from "chokidar";
2
+ import { minimatch } from "minimatch";
3
+ import path from "node:path";
4
+ import type { WatchConfig } from "./settings.js";
5
+ import { log } from "../utils/formatting.js";
6
+
7
+ /** Milliseconds to wait after a change before triggering a rebuild. */
8
+ const DEBOUNCE_MS = 300;
9
+
10
+ /** A partial rebuild triggered by a change to a specific source file. */
11
+ export type PartialRebuildEvent = {
12
+ type: "partial";
13
+ filePath: string;
14
+ };
15
+
16
+ /** A full rebuild triggered by a change to a high-impact path (config, templating, etc.). */
17
+ export type FullRebuildEvent = {
18
+ type: "full";
19
+ reason: string;
20
+ filePath: string;
21
+ };
22
+
23
+ export type WatchEvent = PartialRebuildEvent | FullRebuildEvent;
24
+
25
+ /** A handle returned by WatchService.watch() that allows the caller to stop the watcher. */
26
+ export type WatchHandle = {
27
+ stop(): Promise<void>;
28
+ };
29
+
30
+ /**
31
+ * Extracts the longest leading non-glob segment of a pattern as a watchable
32
+ * directory path. Chokidar v5 does not accept glob patterns directly.
33
+ *
34
+ * Examples:
35
+ * "/foo/bar/**\/*" → "/foo/bar"
36
+ * "/foo/bar/*.md" → "/foo/bar"
37
+ */
38
+ function globBaseDir(pattern: string): string {
39
+ const starIndex = pattern.indexOf("*");
40
+ if (starIndex === -1) return path.dirname(pattern);
41
+ const prefix = pattern.slice(0, starIndex);
42
+ return prefix.endsWith("/") || prefix.endsWith(path.sep)
43
+ ? prefix.slice(0, -1)
44
+ : path.dirname(prefix);
45
+ }
46
+
47
+ /**
48
+ * Watches exact files, glob-backed directories, and full-rebuild paths,
49
+ * calling the provided callback (debounced) when a relevant change is detected.
50
+ *
51
+ * - Changes to `files` or `globs` entries fire a partial rebuild event.
52
+ * - Changes to `fullRebuildPaths` entries fire a full rebuild event.
53
+ *
54
+ * Returns a WatchHandle with a `stop()` method to close the watcher.
55
+ */
56
+ export class WatchService {
57
+ watch(config: WatchConfig, onChange: (event: WatchEvent) => Promise<void>): WatchHandle {
58
+ const { files, globs, fullRebuildPaths = [] } = config;
59
+ const globBaseDirs = globs.map(globBaseDir);
60
+ const watchPaths = [...new Set([...files, ...globBaseDirs, ...fullRebuildPaths])];
61
+
62
+ const watcher = chokidar.watch(watchPaths, {
63
+ ignoreInitial: true,
64
+ persistent: true,
65
+ ignored: /sous\.state\.json$/,
66
+ });
67
+
68
+ let debounceTimer: NodeJS.Timeout | null = null;
69
+ let pendingEvent: WatchEvent | null = null;
70
+
71
+ const schedule = (event: WatchEvent) => {
72
+ pendingEvent = event;
73
+ if (debounceTimer) clearTimeout(debounceTimer);
74
+ debounceTimer = setTimeout(async () => {
75
+ debounceTimer = null;
76
+ const evt = pendingEvent!;
77
+ pendingEvent = null;
78
+ try {
79
+ await onChange(evt);
80
+ } catch (err) {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ log(` Watch error: ${message}`);
83
+ }
84
+ }, DEBOUNCE_MS);
85
+ };
86
+
87
+ watcher.on("all", (event, filePath) => {
88
+ if (!["add", "change", "unlink"].includes(event)) return;
89
+
90
+ // Full-rebuild paths take priority
91
+ const isFullRebuildPath = fullRebuildPaths.some(p =>
92
+ filePath === p || filePath.startsWith(p + path.sep) || filePath.startsWith(p + "/")
93
+ );
94
+
95
+ if (isFullRebuildPath) {
96
+ schedule({ type: "full", reason: filePath, filePath });
97
+ return;
98
+ }
99
+
100
+ const isExactFile = files.includes(filePath);
101
+ const matchesGlob = globs.some(g => minimatch(filePath, g));
102
+
103
+ if (isExactFile || matchesGlob) {
104
+ schedule({ type: "partial", filePath });
105
+ }
106
+ });
107
+
108
+ const totalPatterns = files.length + globs.length + fullRebuildPaths.length;
109
+ log(`\nWatching ${totalPatterns} pattern(s) for changes...\n`);
110
+
111
+ return {
112
+ stop: () => watcher.close(),
113
+ };
114
+ }
115
+ }
@@ -0,0 +1,9 @@
1
+ import type { Liquid } from "liquidjs";
2
+
3
+ /** Converts an array to a markdown bullet list. */
4
+ export function registerBulletListFilter(engine: Liquid): void {
5
+ engine.registerFilter("bulletList", (items: unknown) => {
6
+ if (!Array.isArray(items)) return String(items);
7
+ return items.map(i => `- ${String(i)}`).join("\n");
8
+ });
9
+ }
@@ -0,0 +1,8 @@
1
+ import type { Liquid } from "liquidjs";
2
+ import { registerBulletListFilter } from "./bullet-list.js";
3
+
4
+ const filterRegistrars: Array<(engine: Liquid) => void> = [
5
+ registerBulletListFilter,
6
+ ];
7
+
8
+ export default filterRegistrars;
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Liquid, type FS } from "liquidjs";
4
+ import filterRegistrars from "./filters/index.js";
5
+ import tagRegistrars from "./tags/index.js";
6
+ import { resolveIncludeCandidates, type AliasMap } from "../lib/include-resolver.js";
7
+
8
+ /** Options for alias-aware `{% render %}` path resolution. */
9
+ export type EngineAliasOptions = {
10
+ /** Resolved alias map (name → ordered base dirs). */
11
+ aliases?: AliasMap;
12
+ /** Variable scope for `${var}` substitution in render paths. */
13
+ scope?: Record<string, string>;
14
+ };
15
+
16
+ /**
17
+ * A node-backed LiquidJS FS that additionally understands `@`-prefixed render
18
+ * paths — `{% render "@~sous-shared/x.md" %}`, `{% render "@docs/y.md" %}`, or
19
+ * `{% render "@${var}/z.md" %}` — resolving them through the same alias/var/
20
+ * relative candidate logic as `@include`. Non-`@` paths use standard root-based
21
+ * resolution.
22
+ *
23
+ * @param opts - Alias map and variable scope.
24
+ * @returns A LiquidJS FS implementation.
25
+ */
26
+ function createAliasFS(opts: EngineAliasOptions): FS {
27
+ const aliases = opts.aliases ?? {};
28
+ const scope = opts.scope ?? {};
29
+
30
+ /** Resolve an `@`-path to its first existing candidate, or the first candidate. */
31
+ const resolveAt = (file: string, dir: string): string | null => {
32
+ if (!file.startsWith("@")) return null;
33
+ const candidates = resolveIncludeCandidates(file.slice(1), { aliases, scope, baseDir: dir });
34
+ return candidates.find((c) => fs.existsSync(c)) ?? candidates[0] ?? null;
35
+ };
36
+
37
+ return {
38
+ resolve(dir: string, file: string, ext: string): string {
39
+ const at = resolveAt(file, dir);
40
+ if (at) return at;
41
+ // Standard resolution: join against the root dir, applying ext if missing.
42
+ const joined = path.resolve(dir, file);
43
+ if (ext && !path.extname(joined)) return joined + ext;
44
+ return joined;
45
+ },
46
+ existsSync: (filepath: string) => fs.existsSync(filepath),
47
+ exists: async (filepath: string) => fs.existsSync(filepath),
48
+ readFileSync: (filepath: string) => fs.readFileSync(filepath, "utf8"),
49
+ readFile: async (filepath: string) => fs.promises.readFile(filepath, "utf8"),
50
+ dirname: (file: string) => path.dirname(file),
51
+ sep: path.sep,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Creates a configured LiquidJS engine instance for Sous template rendering.
57
+ * Registers built-in Sous filters and tags.
58
+ *
59
+ * @param roots - Filesystem root paths searched (in order) when resolving
60
+ * `{% render %}` partials (relative paths resolve against these).
61
+ * @param aliasOpts - Optional alias map + scope enabling `@alias/...` and
62
+ * `@${var}/...` paths in `{% render %}` (parity with `@include`).
63
+ */
64
+ export function createLiquidEngine(roots: string[], aliasOpts: EngineAliasOptions = {}): Liquid {
65
+ const engine = new Liquid({
66
+ root: roots,
67
+ extname: "",
68
+ strictVariables: false,
69
+ strictFilters: false,
70
+ fs: createAliasFS(aliasOpts),
71
+ });
72
+
73
+ for (const register of filterRegistrars) {
74
+ register(engine);
75
+ }
76
+
77
+ for (const register of tagRegistrars) {
78
+ register(engine);
79
+ }
80
+
81
+ return engine;
82
+ }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { glob } from "glob";
3
+
4
+ /** A single file discovered by {@link globFiles}. */
5
+ export interface GlobFile {
6
+ /** Absolute path to the file. */
7
+ path: string;
8
+ /** Absolute path to the file's directory. */
9
+ dir: string;
10
+ /** Path relative to the search root (POSIX separators). */
11
+ relPath: string;
12
+ /** File's basename (e.g. "get-thing.mjs"). */
13
+ name: string;
14
+ }
15
+
16
+ /** Options controlling a {@link globFiles} search. */
17
+ export interface GlobFilesOptions {
18
+ /** Absolute directory to search within. */
19
+ root: string;
20
+ /** Glob patterns to include. Defaults to everything (`**\/*`). */
21
+ include?: string[];
22
+ /** Glob patterns to exclude. */
23
+ exclude?: string[];
24
+ }
25
+
26
+ /**
27
+ * Find files under a root directory matching include globs and not matching
28
+ * exclude globs. Patterns are matched relative to `root`. Results are files
29
+ * only (no directories), sorted by relative path for deterministic output.
30
+ *
31
+ * @param options - The search root and include/exclude glob patterns.
32
+ * @returns A sorted array of matched files.
33
+ */
34
+ export async function globFiles(options: GlobFilesOptions): Promise<GlobFile[]> {
35
+ const { root } = options;
36
+ const include = options.include?.length ? options.include : ["**/*"];
37
+ const exclude = options.exclude ?? [];
38
+
39
+ const matches = await glob(include, {
40
+ cwd: root,
41
+ ignore: exclude,
42
+ nodir: true,
43
+ dot: true,
44
+ posix: true,
45
+ });
46
+
47
+ const unique = [...new Set(matches)].sort((a, b) => a.localeCompare(b));
48
+
49
+ return unique.map((relPath) => {
50
+ const absPath = path.resolve(root, relPath);
51
+ return {
52
+ path: absPath,
53
+ dir: path.dirname(absPath),
54
+ relPath,
55
+ name: path.basename(absPath),
56
+ };
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Parse a comma-separated glob attribute (e.g. `"*.mjs, !x.mjs"`) into a
62
+ * trimmed list of non-empty patterns. Returns an empty array for
63
+ * undefined/blank input.
64
+ *
65
+ * @param value - The raw attribute string, or undefined.
66
+ * @returns The parsed pattern list.
67
+ */
68
+ export function parseGlobList(value: string | undefined): string[] {
69
+ if (!value) return [];
70
+ return value
71
+ .split(",")
72
+ .map((p) => p.trim())
73
+ .filter((p) => p.length > 0);
74
+ }
@@ -0,0 +1,32 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ /**
4
+ * Dynamically import a single named export from a JavaScript/ESM file.
5
+ *
6
+ * Used to read declarative metadata (e.g. a script's `meta` export) at compile
7
+ * time. Files are expected to have NO top-level side effects — importing them
8
+ * runs module-level code.
9
+ *
10
+ * Failures are swallowed and reported as `undefined`: a file that fails to
11
+ * import, or that lacks the requested export, simply yields no value rather
12
+ * than aborting the whole template render. The optional `onError` callback
13
+ * receives the path and error for diagnostics.
14
+ *
15
+ * @param absPath - Absolute path to the file to import.
16
+ * @param exportName - The named export to read (e.g. "meta"). Use "default" for the default export.
17
+ * @param onError - Optional callback invoked when import or lookup fails.
18
+ * @returns The exported value, or undefined on any failure.
19
+ */
20
+ export async function importNamedExport(
21
+ absPath: string,
22
+ exportName: string,
23
+ onError?: (path: string, error: unknown) => void
24
+ ): Promise<unknown> {
25
+ try {
26
+ const mod = await import(pathToFileURL(absPath).href);
27
+ return mod[exportName];
28
+ } catch (error) {
29
+ onError?.(absPath, error);
30
+ return undefined;
31
+ }
32
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Split a tag's raw `args` string into an optional leading positional
3
+ * identifier and the remaining `key="value"` hash markup.
4
+ *
5
+ * A leading identifier is treated as the positional name ONLY when it is not
6
+ * immediately followed by `=` — so `getFiles tasks root="x"` yields
7
+ * `{ name: "tasks", rest: 'root="x"' }`, while `getFiles root="x"` yields
8
+ * `{ name: null, rest: 'root="x"' }` (the first token is the `root` attribute).
9
+ *
10
+ * @param args - The raw `TagToken.args` string.
11
+ * @returns The positional name (or null) and the remaining hash markup.
12
+ */
13
+ export function splitPositionalName(args: string): { name: string | null; rest: string } {
14
+ const lead = args.match(/^\s*([a-zA-Z_][\w]*)(\s*=)?/);
15
+ if (lead && !lead[2]) {
16
+ return { name: lead[1], rest: args.slice(lead[0].length).trim() };
17
+ }
18
+ return { name: null, rest: args.trim() };
19
+ }
@@ -0,0 +1,43 @@
1
+ import type { Liquid } from "liquidjs";
2
+ import type { Context } from "liquidjs/dist/context/context.js";
3
+ import { sortObjectKeys } from "../../utils/formatting.js";
4
+
5
+ /**
6
+ * A "scalar" for export purposes: the value types that can be safely embedded
7
+ * in a settings module and consumed at runtime without structural ambiguity.
8
+ */
9
+ function isScalar(value: unknown): value is string | number | boolean {
10
+ return (
11
+ typeof value === "string" ||
12
+ typeof value === "boolean" ||
13
+ (typeof value === "number" && Number.isFinite(value))
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Dumps all in-scope scalar variables as an ES module default export.
19
+ *
20
+ * Iterates every variable currently in the LiquidJS scope, keeps only scalars
21
+ * (strings, finite numbers, booleans), sorts them by key, and emits a valid
22
+ * `export default { ... };` block. Objects, arrays, functions, null, undefined,
23
+ * and non-finite numbers are skipped.
24
+ *
25
+ * Intended for compiling a `settings.tpl.mjs` that downstream runtime code
26
+ * (e.g. browser automation scripts) imports for project configuration.
27
+ */
28
+ export function registerExportScalarVarsJsTag(engine: Liquid): void {
29
+ engine.registerTag("exportScalarVarsJs", {
30
+ render(ctx: Context) {
31
+ const scope = ctx.getAll() as Record<string, unknown>;
32
+ const scalars: Record<string, string | number | boolean> = {};
33
+
34
+ for (const [key, value] of Object.entries(scope)) {
35
+ if (isScalar(value)) scalars[key] = value;
36
+ }
37
+
38
+ const sorted = sortObjectKeys(scalars);
39
+ const json = JSON.stringify(sorted, null, 2);
40
+ return `export default ${json};\n`;
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,89 @@
1
+ import { Hash, type Liquid } from "liquidjs";
2
+ import type { Context } from "liquidjs/dist/context/context.js";
3
+ import type { TagToken } from "liquidjs/dist/tokens/tag-token.js";
4
+ import { globFiles, parseGlobList, type GlobFile } from "../lib/glob-files.js";
5
+ import { splitPositionalName } from "../lib/tag-args.js";
6
+ import { importNamedExport } from "../lib/import-export.js";
7
+
8
+ /**
9
+ * Registers the `{% getFiles <varName> root="..." include="..." exclude="..." import="..." %}`
10
+ * tag. It globs files under `root` and assigns the resulting array to `<varName>`
11
+ * in the template scope. It renders no output — use a `{% for %}` loop to present
12
+ * the results.
13
+ *
14
+ * Each result has `{ path, dir, relPath, name }`. When the optional `import`
15
+ * attribute names an export (e.g. `import="meta"`), each file is dynamically
16
+ * imported and that export is attached under the same key (e.g. `file.meta`);
17
+ * files that fail to import or lack the export are omitted from the results.
18
+ *
19
+ * Attribute values may be quoted strings or scope variables (e.g. `root=tasksDir`).
20
+ * `include`/`exclude` are comma-separated glob patterns matched relative to `root`.
21
+ */
22
+ export function registerGetFilesTag(engine: Liquid): void {
23
+ engine.registerTag("getFiles", {
24
+ parse(token: TagToken) {
25
+ const { name, rest } = splitPositionalName(token.args);
26
+ this.varName = name;
27
+ this.hash = new Hash(rest, true);
28
+ },
29
+
30
+ *render(ctx: Context): Generator<unknown, string, Record<string, unknown>> {
31
+ const hash = yield this.hash.render(ctx);
32
+ const varName: string | null = this.varName;
33
+
34
+ const files = (yield resolveFiles(hash)) as unknown;
35
+
36
+ if (varName) {
37
+ (ctx.bottom() as Record<string, unknown>)[varName] = files;
38
+ }
39
+ return "";
40
+ },
41
+ });
42
+ }
43
+
44
+ /** A file record optionally carrying a dynamically-imported export. */
45
+ type ResolvedFile = GlobFile & Record<string, unknown>;
46
+
47
+ /**
48
+ * Resolve the glob attributes from a rendered hash into file records, applying
49
+ * the optional `import` export attachment.
50
+ *
51
+ * @param hash - The rendered tag attributes (`root`, `include`, `exclude`, `import`).
52
+ * @returns The matched files, each optionally carrying the imported export.
53
+ */
54
+ async function resolveFiles(hash: Record<string, unknown>): Promise<ResolvedFile[]> {
55
+ const root = hash.root != null ? String(hash.root) : "";
56
+ if (!root) {
57
+ throw new Error('getFiles: a "root" attribute is required');
58
+ }
59
+
60
+ const files = await globFiles({
61
+ root,
62
+ include: parseGlobList(hash.include != null ? String(hash.include) : undefined),
63
+ exclude: parseGlobList(hash.exclude != null ? String(hash.exclude) : undefined),
64
+ });
65
+
66
+ const importName = hash.import != null ? String(hash.import).trim() : "";
67
+ if (!importName) return files as ResolvedFile[];
68
+
69
+ return attachImports(files, importName);
70
+ }
71
+
72
+ /**
73
+ * For each file, dynamically import `importName` and attach it under that key.
74
+ * Files whose import fails or lacks the export are dropped from the result.
75
+ *
76
+ * @param files - The globbed file records.
77
+ * @param importName - The export to read from each file.
78
+ * @returns Files that successfully yielded the export, with it attached.
79
+ */
80
+ async function attachImports(files: GlobFile[], importName: string): Promise<ResolvedFile[]> {
81
+ const enriched = await Promise.all(
82
+ files.map(async (file) => {
83
+ const value = await importNamedExport(file.path, importName);
84
+ if (value === undefined) return null;
85
+ return { ...file, [importName]: value };
86
+ })
87
+ );
88
+ return enriched.filter((f): f is ResolvedFile => f !== null);
89
+ }
@@ -0,0 +1,14 @@
1
+ import type { Liquid } from "liquidjs";
2
+ import { registerShowVarsTag } from "./showVars.js";
3
+ import { registerExportScalarVarsJsTag } from "./exportScalarVarsJs.js";
4
+ import { registerGetFilesTag } from "./getFiles.js";
5
+ import { registerListFilesTag } from "./listFiles.js";
6
+
7
+ const tagRegistrars: Array<(engine: Liquid) => void> = [
8
+ registerShowVarsTag,
9
+ registerExportScalarVarsJsTag,
10
+ registerGetFilesTag,
11
+ registerListFilesTag,
12
+ ];
13
+
14
+ export default tagRegistrars;
@@ -0,0 +1,54 @@
1
+ import { Hash, type Liquid } from "liquidjs";
2
+ import type { Context } from "liquidjs/dist/context/context.js";
3
+ import type { TagToken } from "liquidjs/dist/tokens/tag-token.js";
4
+ import { globFiles, parseGlobList, type GlobFile } from "../lib/glob-files.js";
5
+
6
+ /**
7
+ * Registers the `{% listFiles root="..." include="..." exclude="..." %}` tag.
8
+ * It globs files under `root` and renders them directly as a simple markdown
9
+ * bullet list of file names — the convenience counterpart to `getFiles`.
10
+ *
11
+ * For custom presentation (or to read file metadata), use `getFiles` with a
12
+ * `{% for %}` loop instead. `listFiles` is intentionally glob-only and emits a
13
+ * fixed format.
14
+ *
15
+ * Attribute values may be quoted strings or scope variables. `include`/`exclude`
16
+ * are comma-separated glob patterns matched relative to `root`. An optional
17
+ * `relative="true"` renders relative paths instead of bare file names.
18
+ */
19
+ export function registerListFilesTag(engine: Liquid): void {
20
+ engine.registerTag("listFiles", {
21
+ parse(token: TagToken) {
22
+ this.hash = new Hash(token.args, true);
23
+ },
24
+
25
+ *render(ctx: Context): Generator<unknown, string, Record<string, unknown>> {
26
+ const hash = yield this.hash.render(ctx);
27
+ const files = (yield resolveFiles(hash)) as unknown as GlobFile[];
28
+ const useRelative = String(hash.relative ?? "") === "true";
29
+
30
+ if (files.length === 0) return "";
31
+ return files
32
+ .map((f) => `- ${useRelative ? f.relPath : f.name}`)
33
+ .join("\n");
34
+ },
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Resolve the glob attributes from a rendered hash into file records.
40
+ *
41
+ * @param hash - The rendered tag attributes (`root`, `include`, `exclude`).
42
+ * @returns The matched files.
43
+ */
44
+ async function resolveFiles(hash: Record<string, unknown>): Promise<GlobFile[]> {
45
+ const root = hash.root != null ? String(hash.root) : "";
46
+ if (!root) {
47
+ throw new Error('listFiles: a "root" attribute is required');
48
+ }
49
+ return globFiles({
50
+ root,
51
+ include: parseGlobList(hash.include != null ? String(hash.include) : undefined),
52
+ exclude: parseGlobList(hash.exclude != null ? String(hash.exclude) : undefined),
53
+ });
54
+ }