@stonepandastudio/cairn 0.4.1 → 0.5.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 (68) hide show
  1. package/README.md +33 -22
  2. package/bin/cairn.js +5 -0
  3. package/lib/init.js +58 -14
  4. package/lib/render/cli.js +106 -0
  5. package/lib/render/engine.js +148 -0
  6. package/lib/render/index.js +210 -0
  7. package/package.json +2 -1
  8. package/presets/EXTRACTION.md +210 -0
  9. package/presets/README.md +86 -0
  10. package/presets/angular/code-guidelines.md +197 -0
  11. package/presets/angular/slots/architect-discussion-topics.md +10 -0
  12. package/presets/angular/slots/architect-mandatory-docs.md +5 -0
  13. package/presets/angular/slots/architect-references.md +4 -0
  14. package/presets/angular/slots/implementation-reference.md +34 -0
  15. package/presets/angular/slots/key-patterns.md +11 -0
  16. package/presets/angular/slots/plan-step-ordering.md +11 -0
  17. package/presets/angular/slots/review-checklist.md +16 -0
  18. package/presets/angular/variants/i18n-external-service.md +11 -0
  19. package/presets/angular/variants/i18n-glossr.md +63 -0
  20. package/presets/core/AGENTS.md +49 -0
  21. package/presets/core/README.md +35 -0
  22. package/presets/core/WORKFLOW.md +56 -0
  23. package/presets/core/agents/architect.md +269 -0
  24. package/presets/core/agents/developer.md +145 -0
  25. package/presets/core/agents/reviewer.md +167 -0
  26. package/presets/core/commands/_stub.md +7 -0
  27. package/presets/core/workflow.json +45 -0
  28. package/presets/drizzle/code-guidelines.md +33 -0
  29. package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
  30. package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
  31. package/presets/drizzle/slots/implementation-reference.md +17 -0
  32. package/presets/drizzle/slots/key-patterns.md +7 -0
  33. package/presets/drizzle/slots/review-checklist.md +10 -0
  34. package/presets/nestjs/code-guidelines.md +273 -0
  35. package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
  36. package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
  37. package/presets/nestjs/slots/architect-references.md +5 -0
  38. package/presets/nestjs/slots/implementation-reference.md +45 -0
  39. package/presets/nestjs/slots/key-patterns.md +11 -0
  40. package/presets/nestjs/slots/plan-step-ordering.md +12 -0
  41. package/presets/nestjs/slots/review-checklist.md +12 -0
  42. package/presets/nestjs/variants/validation-class-validator.md +120 -0
  43. package/presets/nestjs/variants/validation-zod.md +194 -0
  44. package/presets/nextjs/code-guidelines.md +45 -0
  45. package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
  46. package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
  47. package/presets/nextjs/slots/architect-references.md +6 -0
  48. package/presets/nextjs/slots/implementation-reference.md +24 -0
  49. package/presets/nextjs/slots/key-patterns.md +8 -0
  50. package/presets/nextjs/slots/plan-step-ordering.md +11 -0
  51. package/presets/nextjs/slots/review-checklist.md +11 -0
  52. package/presets/react/code-guidelines.md +46 -0
  53. package/presets/react/slots/architect-discussion-topics.md +5 -0
  54. package/presets/react/slots/architect-references.md +5 -0
  55. package/presets/react/slots/implementation-reference.md +26 -0
  56. package/presets/react/slots/key-patterns.md +8 -0
  57. package/presets/react/slots/plan-step-ordering.md +9 -0
  58. package/presets/react/slots/review-checklist.md +10 -0
  59. package/presets/tailwind/code-guidelines.md +28 -0
  60. package/presets/tailwind/slots/implementation-reference.md +8 -0
  61. package/presets/tailwind/slots/key-patterns.md +5 -0
  62. package/presets/tailwind/slots/review-checklist.md +8 -0
  63. package/presets/typeorm/code-guidelines.md +329 -0
  64. package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
  65. package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
  66. package/presets/typeorm/slots/implementation-reference.md +19 -0
  67. package/presets/typeorm/slots/key-patterns.md +8 -0
  68. package/presets/typeorm/slots/review-checklist.md +8 -0
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  Shared AI workflow scaffolding, kept in sync across several repos.
4
4
 
5
5
  Each repo carries an `ai/` folder and a `.claude/commands/` folder holding the same
6
- workflow — brief → description → plan → execute → review → QA, with the steps
7
- mirrored into an issue tracker. Copied between repos by hand, they drift.
6
+ workflow — brief → description → plan → execute → review, with the steps optionally
7
+ mirrored into an issue tracker. Hand-copied between repos, they drift. cairn renders
8
+ them from one set of presets and reports where existing copies disagree.
8
9
 
9
10
  A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
10
11
  markers — a manifest of what it generated, and a report of where the copies stopped
@@ -15,33 +16,40 @@ npm i -D @stonepandastudio/cairn
15
16
  npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ --story-type Task
16
17
  ```
17
18
 
18
- ## What v1 does
19
+ ## What it does
19
20
 
20
21
  | Command | |
21
22
  |---|---|
22
23
  | `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
23
24
  | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
24
25
  | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
26
+ | `cairn render` | Scaffolds `ai/` + `.claude/commands/` from the bundled presets, from the repo's stack + workflow |
25
27
 
26
- Template rendering generating the command stubs, `WORKFLOW.md` and the agent role
27
- contracts from one workflow declaration is v2, and deliberately not in this release.
28
- v1 swaps the tracker client under the consuming repos; bundling prompt changes into
29
- the same release would make any regression impossible to attribute.
28
+ `cairn render` is **greenfield only**: it writes files that do not exist, records them
29
+ in the manifest, and leaves anything already on disk alone (reporting it). The
30
+ three-way merge that would re-render over local edits `cairn sync` is not built
31
+ yet; for a fresh repo there is nothing to merge.
30
32
 
31
33
  ## The two folders
32
34
 
33
35
  ```
34
36
  repo/
35
- cairn.config.json you edit this. cairn only ever reads it.
36
- _cairn/ cairn writes this. nobody hand-edits it.
37
- manifest.json what was generated, from which template, at which hash
38
- scripts/jira.js vendored shim
39
- ai/ project-owned. cairn never touches it.
40
- contexts/ progress/ tasks/ infrastructure/
37
+ cairn.config.json you edit this. cairn only ever reads it.
38
+ _cairn/ cairn writes this. nobody hand-edits it.
39
+ manifest.json what was generated, from which template, at which hash
40
+ scripts/jira.js vendored tracker shim
41
+ .claude/commands/ rendered from the workflow — one stub per step
42
+ ai/
43
+ agents/ rendered from presets/core + your stack's slots
44
+ AGENTS.md WORKFLOW.md rendered
45
+ infrastructure/code-guidelines.md rendered once as a seed — then it is yours
46
+ contexts/ tasks/ yours, never written by cairn
41
47
  ```
42
48
 
43
- The boundary is the whole design. Hand-edited config living inside a generated
44
- directory is how generated directories acquire state nobody dares regenerate.
49
+ `cairn render` seeds the rendered files once and records them in the manifest; it
50
+ never rewrites a file you have edited (that is `cairn sync`, not built yet). The
51
+ boundary is the whole design: hand-edited config living inside a generated directory
52
+ is how generated directories acquire state nobody dares regenerate.
45
53
 
46
54
  Generated files are **committed**, not gitignored. Claude Code discovers
47
55
  `.claude/commands/` and agent docs from disk, and a prompt change should show up in
@@ -88,7 +96,7 @@ environment wins over the file. If the repo's own app reads those same names, se
88
96
  `create-task`, `create-story`, `close-story` and friends are all still accepted, and
89
97
  output formats are byte-for-byte what the old scripts printed. Existing prose
90
98
  references name these strings and the agents parse the output, so they are kept
91
- until the v2 pass regenerates those documents.
99
+ until a render pass regenerates those documents from the presets.
92
100
 
93
101
  ## Doctor
94
102
 
@@ -124,11 +132,12 @@ match what is on disk?
124
132
  |---|---|
125
133
  | `MANAGED` | on-disk hash matches what cairn wrote |
126
134
  | `MODIFIED` | someone hand-edited a generated file |
127
- | `OUTDATED` | cairn has newer content for it — needs template hashes, wired in v2 |
135
+ | `OUTDATED` | cairn has newer content for it — needs template hashes; comes with `cairn sync` |
128
136
  | `DELETED` | it is gone; a future `cairn sync` will restore it |
129
137
 
130
- v1 reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` and the `sync` that acts
131
- on these states arrive with the v2 renderer.
138
+ Today the doctor reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` (needs the
139
+ template hashes threaded through) and `cairn sync` (the three-way merge that acts on
140
+ these states) are still to come — `cairn render` is greenfield-only for now.
132
141
 
133
142
  A file can be perfectly in sync across repos while being hand-edited away from its
134
143
  template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
@@ -152,8 +161,8 @@ overwriting a hand edit is the destructive outcome.
152
161
  `ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
153
162
  it a per-project document, not a drifted template.
154
163
 
155
- v2 splits this flat file into a per-project `workspace.json` plus an opt-in hub that
156
- references the projects for cross-project comparison. See `DESIGN.md`.
164
+ A later release splits this flat file into a per-project `workspace.json` plus an
165
+ opt-in hub that references the projects for cross-project comparison. See `DESIGN.md`.
157
166
 
158
167
  ## Layout
159
168
 
@@ -164,10 +173,12 @@ lib/manifest.js generated-file hashing and state
164
173
  lib/paint.js ANSI + table rendering
165
174
  lib/tracker/ index (registry), cli, jira-server, youtrack, none
166
175
  lib/doctor/ index (analysis + report), scan, normalize, diff
176
+ lib/render/ engine (a small template engine), index (driver), cli
167
177
  lib/init.js cairn init
168
178
  templates/shims/ vendored shim source
179
+ presets/ core/ + framework/ORM/styling presets rendered by `cairn render`
169
180
  schema.json JSON Schema for cairn.config.json
170
181
  test/run.js dependency-free test runner
171
182
  ```
172
183
 
173
- No runtime dependencies. Node >= 18. `npm test` runs 54 tests.
184
+ No runtime dependencies. Node >= 18. `npm test` runs 61 tests.
package/bin/cairn.js CHANGED
@@ -19,6 +19,7 @@ Usage: cairn <command> [options]
19
19
  tracker <cmd> [...] issue tracker client (cairn tracker --help)
20
20
  doctor [options] cross-repo drift report (cairn doctor --help)
21
21
  init [options] make a repo cairn-managed (cairn init --help)
22
+ render [options] scaffold ai/ from the bundled presets (cairn render --help)
22
23
  version print version
23
24
 
24
25
  Run \`cairn <command> --help\` for command options.
@@ -40,6 +41,10 @@ function main() {
40
41
  process.exitCode = require('../lib/init').main(rest);
41
42
  return;
42
43
 
44
+ case 'render':
45
+ process.exitCode = require('../lib/render/cli').main(rest);
46
+ return;
47
+
43
48
  case 'version':
44
49
  case '--version':
45
50
  case '-v':
package/lib/init.js CHANGED
@@ -10,22 +10,25 @@ const {
10
10
  record,
11
11
  writeManifest,
12
12
  manifestPath,
13
+ hashContent,
13
14
  } = require('./manifest');
14
15
  const { listProviders } = require('./tracker');
15
16
  const { makePaint } = require('./paint');
16
17
 
17
18
  // `cairn init` — make a repo cairn-managed.
18
19
  //
19
- // It writes exactly three things:
20
+ // It writes:
20
21
  // cairn.config.json hand-editable from here on; cairn never rewrites it
22
+ // except with --force
21
23
  // _cairn/scripts/jira.js vendored shim, tracked in the manifest
22
24
  // _cairn/manifest.json the record of what was written
23
25
  //
24
- // Plus, when the repo still carries a copied ai/scripts/jira.js, that file is
25
- // replaced by a shim too. Replacing rather than deleting is deliberate: the five
26
- // repos have roughly forty prose references to that path, and breaking them all
27
- // in the same release that swaps the Jira client would make any failure
28
- // impossible to attribute.
26
+ // Plus, for every copied client the repo still carries (jira.js / youtrack.js /
27
+ // task-tracker.js under ai/scripts/), that file is replaced by the same shim
28
+ // replacing rather than deleting because dozens of prose references name the path
29
+ // and the shim reads the same argv. A file already holding the shim is skipped; a
30
+ // file hand-edited away from what cairn wrote is kept and reported unless
31
+ // --force-shims. cairn never invents one of these paths in a repo that lacked it.
29
32
 
30
33
  const SHIM_SOURCE = 'shims/jira.js';
31
34
  const SHIM_TARGET = '_cairn/scripts/jira.js';
@@ -55,6 +58,9 @@ Usage: cairn init [options]
55
58
  (only when the repo's app reads JIRA_*/YOUTRACK_* itself)
56
59
  --no-shim do not vendor or replace the ai/scripts/*.js client shims
57
60
  --force overwrite an existing cairn.config.json
61
+ --render also scaffold ai/ from the presets after init
62
+ --force-shims also replace an ai/scripts/*.js shim that was hand-edited
63
+ since cairn wrote it (kept untouched otherwise)
58
64
  --dry-run print what would be written, write nothing
59
65
  `;
60
66
 
@@ -69,6 +75,8 @@ function parseArgs(argv) {
69
75
  envPrefix: '',
70
76
  shim: true,
71
77
  force: false,
78
+ forceShims: false,
79
+ render: false,
72
80
  dryRun: false,
73
81
  color: process.stdout.isTTY,
74
82
  };
@@ -83,6 +91,8 @@ function parseArgs(argv) {
83
91
  else if (a === '--env-prefix') args.envPrefix = argv[++i];
84
92
  else if (a === '--no-shim') args.shim = false;
85
93
  else if (a === '--force') args.force = true;
94
+ else if (a === '--force-shims') args.forceShims = args.force = true;
95
+ else if (a === '--render') args.render = true;
86
96
  else if (a === '--dry-run') args.dryRun = true;
87
97
  else if (a === '--no-color') args.color = false;
88
98
  else if (a === '-h' || a === '--help') args.help = true;
@@ -148,29 +158,52 @@ function main(argv = process.argv.slice(2)) {
148
158
  return 2;
149
159
  }
150
160
 
161
+ const manifest = readManifest(repoPath) || emptyManifest();
162
+
151
163
  const planned = [];
164
+ const kept = [];
152
165
  const config = buildConfig(args);
153
166
  planned.push([CONFIG_NAME, JSON.stringify(config, null, 2) + '\n', null]);
154
167
 
155
168
  const shim = readTemplate(SHIM_SOURCE);
156
169
  if (args.shim) {
157
170
  planned.push([SHIM_TARGET, shim, SHIM_SOURCE]);
158
- // Only replace a legacy path if a copied client is actually there creating
159
- // one in a repo that never had it would invent a path nothing references.
171
+ // Replace a legacy client only where one is actually present. A file already
172
+ // holding the shim is left alone; one hand-edited away from what cairn wrote
173
+ // is kept unless --force — never silently overwrite an edit.
160
174
  for (const target of LEGACY_SHIM_TARGETS) {
161
- if (fs.existsSync(path.join(repoPath, target))) {
162
- planned.push([target, shim, SHIM_SOURCE]);
175
+ const abs = path.join(repoPath, target);
176
+ if (!fs.existsSync(abs)) continue;
177
+ const current = fs.readFileSync(abs, 'utf8');
178
+ const entry = manifest.files[target];
179
+
180
+ // Already the shim and already tracked — nothing to do. If it holds the
181
+ // shim but isn't in the manifest (someone copied it by hand), fall through
182
+ // so this run records it.
183
+ if (current === shim && entry) continue;
184
+
185
+ const modified = entry && hashContent(current) !== entry.hash;
186
+ if (modified && !args.forceShims) {
187
+ kept.push(target);
188
+ continue;
163
189
  }
190
+ const note =
191
+ current === shim ? 'recording existing shim' : entry ? null : `replacing ${current.split('\n').length} lines`;
192
+ planned.push([target, shim, SHIM_SOURCE, note]);
164
193
  }
165
194
  }
166
195
 
167
196
  if (args.dryRun) {
168
197
  console.log(paint.bold(`cairn init --dry-run ${repoPath}`));
169
- for (const [rel] of planned) console.log(` would write ${rel}`);
198
+ for (const [rel, , , note] of planned) {
199
+ console.log(` would write ${rel}${note ? paint.dim(` (${note})`) : ''}`);
200
+ }
201
+ for (const rel of kept) {
202
+ console.log(paint.yellow(` would keep ${rel} (hand-edited — pass --force-shims to replace)`));
203
+ }
170
204
  return 0;
171
205
  }
172
206
 
173
- const manifest = readManifest(repoPath) || emptyManifest();
174
207
  for (const [rel, content, source] of planned) {
175
208
  const abs = path.join(repoPath, rel);
176
209
  fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -182,10 +215,14 @@ function main(argv = process.argv.slice(2)) {
182
215
  writeManifest(repoPath, manifest);
183
216
 
184
217
  console.log(paint.bold(`cairn init ${repoPath}`));
185
- for (const [rel, , source] of planned) {
186
- console.log(` ${paint.green('wrote')} ${rel}${source ? paint.dim(` (${source})`) : ''}`);
218
+ for (const [rel, , source, note] of planned) {
219
+ const tag = note || source;
220
+ console.log(` ${paint.green('wrote')} ${rel}${tag ? paint.dim(` (${tag})`) : ''}`);
187
221
  }
188
222
  console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
223
+ for (const rel of kept) {
224
+ console.log(paint.yellow(` kept ${rel} (hand-edited since cairn wrote it — pass --force-shims to replace)`));
225
+ }
189
226
 
190
227
  const gitignore = path.join(repoPath, '.gitignore');
191
228
  const hasIgnore = fs.existsSync(gitignore) && /^_cairn\b/m.test(fs.readFileSync(gitignore, 'utf8'));
@@ -199,6 +236,13 @@ function main(argv = process.argv.slice(2)) {
199
236
  console.log(paint.dim(`\n Next: ensure .env has ${envVars.join(', ')}, then run`));
200
237
  console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
201
238
  }
239
+
240
+ if (args.render) {
241
+ console.log('');
242
+ require('./render/cli').renderInto(repoPath, { paint });
243
+ } else {
244
+ console.log(paint.dim(`\n Next: \`cairn render\` to scaffold ai/ from the presets`));
245
+ }
202
246
  return 0;
203
247
  }
204
248
 
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
7
+ const { readManifest, emptyManifest, record, writeManifest, manifestPath } = require('../manifest');
8
+ const { makePaint } = require('../paint');
9
+ const { planRender } = require('./index');
10
+
11
+ const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
12
+
13
+ const HELP = `cairn render — scaffold a repo's ai/ from the bundled presets
14
+
15
+ Usage: cairn render [options]
16
+
17
+ --repo <path> repo to render into (default: the repo cairn.config.json is in)
18
+ --dry-run print what would be written, write nothing
19
+ --no-color disable ANSI colour
20
+
21
+ Reads the repo's cairn.config.json (run \`cairn init\` first) — the stack list,
22
+ tracker, and any agent overrides. Greenfield only: it writes files that do not
23
+ exist and records them in the manifest; anything already on disk is left alone.
24
+ `;
25
+
26
+ function parseArgs(argv) {
27
+ const args = { repo: null, dryRun: false, color: process.stdout.isTTY };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === '--repo') args.repo = argv[++i];
31
+ else if (a === '--dry-run') args.dryRun = true;
32
+ else if (a === '--no-color') args.color = false;
33
+ else if (a === '-h' || a === '--help') args.help = true;
34
+ else {
35
+ console.error(`Unknown argument: ${a}`);
36
+ return { error: 2 };
37
+ }
38
+ }
39
+ return args;
40
+ }
41
+
42
+ // Shared by `cairn render` and `cairn init --render`.
43
+ function renderInto(repoPath, { dryRun = false, paint } = {}) {
44
+ const config = loadRepoConfig(repoPath, { required: true });
45
+ const manifest = readManifest(repoPath) || emptyManifest();
46
+ paint = paint || makePaint(false);
47
+
48
+ const { planned, kept } = planRender({ config, presetsDir: PRESETS_DIR, manifest, repoPath });
49
+
50
+ if (dryRun) {
51
+ console.log(paint.bold(`cairn render --dry-run ${repoPath}`));
52
+ for (const [rel, , , note] of planned) {
53
+ console.log(` ${paint.green('would write')} ${rel}${note ? paint.dim(` (${note})`) : ''}`);
54
+ }
55
+ for (const k of kept) console.log(paint.yellow(` would keep ${k.rel} (${k.reason})`));
56
+ if (!planned.length && !kept.length) console.log(paint.dim(' nothing to render'));
57
+ return 0;
58
+ }
59
+
60
+ let wrote = 0;
61
+ for (const [rel, content, source] of planned) {
62
+ const abs = path.join(repoPath, rel);
63
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
64
+ fs.writeFileSync(abs, content, 'utf8');
65
+ record(manifest, rel, source, content);
66
+ console.log(` ${paint.green('wrote')} ${rel}`);
67
+ wrote++;
68
+ }
69
+ if (wrote) {
70
+ writeManifest(repoPath, manifest);
71
+ console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
72
+ }
73
+ for (const k of kept) console.log(paint.yellow(` kept ${k.rel} (${k.reason})`));
74
+ if (!wrote && !kept.length) console.log(paint.dim(' nothing to render — all current'));
75
+ return 0;
76
+ }
77
+
78
+ function main(argv = process.argv.slice(2)) {
79
+ const args = parseArgs(argv);
80
+ if (args.error) return args.error;
81
+ if (args.help) {
82
+ process.stdout.write(HELP);
83
+ return 0;
84
+ }
85
+
86
+ const start = args.repo ? path.resolve(args.repo) : process.cwd();
87
+ const repoPath = args.repo ? start : findRepoRoot(start);
88
+ if (!repoPath || !fs.existsSync(path.join(repoPath, 'cairn.config.json'))) {
89
+ console.error(`No cairn.config.json found — run \`cairn init\` first`);
90
+ return 2;
91
+ }
92
+
93
+ const paint = makePaint(args.color);
94
+ try {
95
+ if (!args.dryRun) console.log(paint.bold(`cairn render ${repoPath}`));
96
+ return renderInto(repoPath, { dryRun: args.dryRun, paint });
97
+ } catch (e) {
98
+ if (e instanceof ConfigError) {
99
+ console.error(e.message);
100
+ return 2;
101
+ }
102
+ throw e;
103
+ }
104
+ }
105
+
106
+ module.exports = { main, renderInto, HELP, PRESETS_DIR };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // A very small template engine — the subset of Handlebars the preset files use
4
+ // and nothing more. Pure: no fs, no cairn knowledge. The driver in ./index.js
5
+ // supplies the context and the `{{> stack/… }}` partial resolver.
6
+ //
7
+ // Supported:
8
+ // {{ path }} substitution, not HTML-escaped (output is markdown)
9
+ // {{ this.x }} `this` and dotted paths, plus @index / @index_1 / @first / @last inside #each
10
+ // {{#if path}}…{{/if}} with optional {{else}}
11
+ // {{#each path}}…{{/each}}
12
+ // {{> stack/name }} resolved by the caller-supplied resolvePartial(name, ctx)
13
+ // {{! comment }} dropped
14
+ // a leading <!-- cairn preset: … --> or <!-- cairn:… --> block is dropped
15
+
16
+ const TOKEN = /(\{\{!(?:(?!\}\})[\s\S])*\}\}|\{\{\{[^}]*\}\}\}|\{\{[#/>]?\s*[^}]*?\}\})/;
17
+
18
+ // Drop cairn's own editorial comments — `<!-- cairn preset: … -->` /
19
+ // `{{! … }}` — wherever they sit in a template. They document the preset for a
20
+ // human reading `presets/` and must not reach the rendered output. Then tidy the
21
+ // whitespace a block tag on its own line would otherwise leave behind (Mustache's
22
+ // "standalone" rule), and collapse the runs of blank lines that removing a
23
+ // comment block opens up.
24
+ function stripHeaderComment(src) {
25
+ return String(src)
26
+ .replace(/^/, '')
27
+ .replace(/[ \t]*<!--\s*cairn[:\s][\s\S]*?-->[ \t]*\n?/g, '')
28
+ .replace(/^[ \t]*\{\{!(?:(?!\}\})[\s\S])*\}\}[ \t]*\r?\n/gm, '')
29
+ .replace(/\{\{!(?:(?!\}\})[\s\S])*\}\}/g, '')
30
+ .replace(/^[ \t]*(\{\{[#/][^}]*\}\}|\{\{\s*else\s*\}\})[ \t]*\r?\n/gm, '$1')
31
+ .replace(/\n{3,}/g, '\n\n')
32
+ .replace(/^\n+/, '');
33
+ }
34
+
35
+ function tokenize(src) {
36
+ return src
37
+ .split(TOKEN)
38
+ .filter((s) => s !== '' && s !== undefined)
39
+ .map((s) => {
40
+ const m = s.match(/^\{\{([#/>!]?)\s*([\s\S]*?)\s*\}\}$/);
41
+ if (!m) return { t: 'text', v: s };
42
+ const [, sigil, body] = m;
43
+ if (sigil === '!') return { t: 'comment' };
44
+ if (sigil === '>') return { t: 'partial', v: body.trim() };
45
+ if (sigil === '#') {
46
+ const [kw, arg] = body.split(/\s+/, 2);
47
+ return { t: 'open', kw, arg };
48
+ }
49
+ if (sigil === '/') return { t: 'close', kw: body.trim() };
50
+ if (body === 'else') return { t: 'else' };
51
+ return { t: 'var', v: body.trim() };
52
+ });
53
+ }
54
+
55
+ // Build a nested node tree from the flat token list.
56
+ function parse(tokens) {
57
+ const root = { kw: 'root', body: [], else: null };
58
+ const stack = [root];
59
+ let target = 'body';
60
+
61
+ for (const tok of tokens) {
62
+ const top = stack[stack.length - 1];
63
+ const sink = target === 'else' ? top.else : top.body;
64
+
65
+ if (tok.t === 'comment') continue;
66
+ if (tok.t === 'text') sink.push({ t: 'text', v: tok.v });
67
+ else if (tok.t === 'var') sink.push({ t: 'var', v: tok.v });
68
+ else if (tok.t === 'partial') sink.push({ t: 'partial', v: tok.v });
69
+ else if (tok.t === 'open') {
70
+ const node = { t: 'block', kw: tok.kw, arg: tok.arg, body: [], else: [] };
71
+ sink.push(node);
72
+ stack.push(node);
73
+ target = 'body';
74
+ } else if (tok.t === 'else') {
75
+ target = 'else';
76
+ } else if (tok.t === 'close') {
77
+ if (stack.length < 2 || top.kw !== tok.kw) {
78
+ throw new Error(`template: unbalanced {{/${tok.kw}}}`);
79
+ }
80
+ stack.pop();
81
+ target = 'body';
82
+ }
83
+ }
84
+ if (stack.length !== 1) throw new Error(`template: unclosed {{#${stack[stack.length - 1].kw}}}`);
85
+ return root.body;
86
+ }
87
+
88
+ function lookup(ctx, path) {
89
+ if (path === 'this') return ctx.this;
90
+ if (path.startsWith('@')) return ctx['@'] ? ctx['@'][path] : undefined;
91
+ let cur = path.startsWith('this.') ? ctx.this : ctx;
92
+ for (const key of path.replace(/^this\./, '').split('.')) {
93
+ if (cur == null) return undefined;
94
+ cur = cur[key];
95
+ }
96
+ return cur;
97
+ }
98
+
99
+ function truthy(v) {
100
+ return Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false && v !== '';
101
+ }
102
+
103
+ function renderNodes(nodes, ctx, resolvePartial) {
104
+ let out = '';
105
+ for (const node of nodes) {
106
+ if (node.t === 'text') {
107
+ out += node.v;
108
+ } else if (node.t === 'var') {
109
+ const v = lookup(ctx, node.v);
110
+ out += v === undefined || v === null ? '' : String(v);
111
+ } else if (node.t === 'partial') {
112
+ const resolved = resolvePartial(node.v, ctx);
113
+ out += resolved == null ? '' : renderNodes(parse(tokenize(stripHeaderComment(resolved))), ctx, resolvePartial);
114
+ } else if (node.t === 'block' && node.kw === 'if') {
115
+ const branch = truthy(lookup(ctx, node.arg)) ? node.body : node.else;
116
+ out += renderNodes(branch, ctx, resolvePartial);
117
+ } else if (node.t === 'block' && node.kw === 'each') {
118
+ const list = lookup(ctx, node.arg);
119
+ if (Array.isArray(list)) {
120
+ list.forEach((item, i) => {
121
+ const child = {
122
+ ...ctx,
123
+ this: item,
124
+ '@': {
125
+ '@index': i,
126
+ '@index_1': i + 1,
127
+ '@first': i === 0,
128
+ '@last': i === list.length - 1,
129
+ },
130
+ };
131
+ out += renderNodes(node.body, child, resolvePartial);
132
+ });
133
+ } else if (node.else.length) {
134
+ out += renderNodes(node.else, ctx, resolvePartial);
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // Render `src` against `ctx`. `resolvePartial(name, ctx)` returns the raw text for
142
+ // a `{{> name }}` include, or null to emit nothing.
143
+ function render(src, ctx = {}, resolvePartial = () => null) {
144
+ const clean = stripHeaderComment(String(src));
145
+ return renderNodes(parse(tokenize(clean)), ctx, resolvePartial);
146
+ }
147
+
148
+ module.exports = { render, stripHeaderComment };