opencode-idea 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.js CHANGED
@@ -9,9 +9,9 @@
9
9
  // in IntelliJ IDEA (reusing a running instance), loads the IDE
10
10
  // capabilities immediately, and marks the project as an IDEA project;
11
11
  // `/close-in-idea` clears that mark;
12
- // 3. registers the `idea-run-config` skill (marked projects only), which
13
- // teaches the model how to manage `.run/*.run.xml` run configurations — a
14
- // capability the IDE MCP server does not expose.
12
+ // 3. registers the `idea-standalone` skill (marked projects only), which
13
+ // pins the project's local-run discipline: every app starts from an IDEA
14
+ // run configuration, and starting or restarting one is the user's call.
15
15
  //
16
16
  // A project that was never marked is left untouched. Everything is manual:
17
17
  // setup does NOT probe or connect the IDE. `/open-in-idea` probes, opens the
@@ -51,6 +51,8 @@ import {
51
51
  findIdePort,
52
52
  serverConfig,
53
53
  } from './mcp/idea.js';
54
+ import { servingRunLifecycleTools } from './mcp/enhance.js';
55
+ import { applyIdeaToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
54
56
  import { currentProjectPath } from './project.js';
55
57
  import {
56
58
  autoLaunchSupported,
@@ -117,18 +119,29 @@ const IDEA_TOOL_PREFIX = 'idea_';
117
119
  * - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
118
120
  * examples, run). Also IntelliJ Platform plugin-development only.
119
121
  * - Python Environment MCP: Python interpreter detection and configuration.
120
- * Hidden; project commands run through the IDE terminal instead, which is
121
- * already launched with the IDE's real environment.
122
- * - Database data sources: connections are configured by the user in the IDE,
123
- * so the AI only reads and queries them; create/edit stay hidden.
124
- *
125
- * NOT hidden: `idea_execute_terminal_command`. Project commands must run with
126
- * the SDKs the user configured in the IDE (JDK, Node, Python, ...), so the model
127
- * uses the IDE's integrated terminal for them. `executeInShell: true` runs the
128
- * command in the user's real shell (`zsh`/`bash`), which inherits the IDE
129
- * environment — including version managers such as fnm / SDKMAN whose selection
130
- * lives in shell init and cannot be reconstructed from a handful of variables.
131
- * Requires Brave Mode in the IDE; without it the IDE asks for confirmation.
122
+ * Hidden; a project's interpreter is chosen in the IDE's own settings, and the
123
+ * model has no business reconfiguring it.
124
+ * - Database: the connection tools (`create` / `edit`) stay hidden because the
125
+ * user configures data sources in the IDE. The query tools are hidden too —
126
+ * the IDE is not this plugin's idea of a database client, and a model that
127
+ * wants to inspect a database should be told to use a real one.
128
+ * - Plain file access: `idea_search_text` / `idea_search_regex` /
129
+ * `idea_search_file` / `idea_apply_patch` / `idea_create_new_file` each have a
130
+ * native tool that does the job (`grep` / `grep -E` / `glob` / `edit` /
131
+ * `write`); routing them through the IDE only adds a hop. `idea_read_file` is
132
+ * NOT hidden — it is the only way to read a file inside a jar (`<jar>!/<entry>`)
133
+ * or the JDK's `jrt://` image, so the guidance keeps it for exactly that and
134
+ * sends ordinary reads to the native tool.
135
+ * - IDE terminal: `idea_execute_terminal_command` does NOT inherit the project's
136
+ * environment. It was exposed on the assumption that it ran with the SDKs the
137
+ * user configured in the IDE (JDK, Node, Python, ...), but measured against a
138
+ * real IDE it brings no environment a native `shell` call does not already
139
+ * have — so it is a strictly worse shell, and hiding it removes an entire
140
+ * class of "why is it using an old JDK" confusion. Which tool to use instead
141
+ * is a routing decision, and it is made in `ide-guidance.js`: commands that
142
+ * need the project's environment go through a RUN CONFIGURATION (the IDE
143
+ * launches those with exactly the configured SDK), everything else goes
144
+ * through the native shell.
132
145
  */
133
146
  export const HIDDEN_IDEA_TOOLS = [
134
147
  // VCS
@@ -136,6 +149,8 @@ export const HIDDEN_IDEA_TOOLS = [
136
149
  'idea_get_repositories',
137
150
  // Router
138
151
  'idea_execute_tool',
152
+ // IDE terminal: no advantage over the native shell (see the note above).
153
+ 'idea_execute_terminal_command',
139
154
  // Debugger
140
155
  'idea_xdebug_control_session',
141
156
  'idea_xdebug_evaluate_expression',
@@ -165,10 +180,29 @@ export const HIDDEN_IDEA_TOOLS = [
165
180
  // Python Environment MCP
166
181
  'idea_configure_python_interpreter',
167
182
  'idea_get_python_environment',
168
- // Database data sources: the user configures them in the IDE, the AI only
169
- // reads and queries them.
183
+ // Database: connections are the user's, and the IDE is not this plugin's
184
+ // database client.
170
185
  'idea_create_database_connection',
171
186
  'idea_edit_database_connection',
187
+ 'idea_list_database_connections',
188
+ 'idea_test_database_connection',
189
+ 'idea_list_database_schemas',
190
+ 'idea_list_schema_object_kinds',
191
+ 'idea_list_schema_objects',
192
+ 'idea_introspect_schema',
193
+ 'idea_get_database_object_description',
194
+ 'idea_execute_sql_query',
195
+ 'idea_fetch_query_result',
196
+ 'idea_preview_table_data',
197
+ 'idea_list_recent_sql_queries',
198
+ 'idea_cancel_sql_query',
199
+ // Plain file access with a native equivalent (`idea_read_file` stays: only it
200
+ // can read inside a jar or the jrt:// image).
201
+ 'idea_search_text',
202
+ 'idea_search_regex',
203
+ 'idea_search_file',
204
+ 'idea_apply_patch',
205
+ 'idea_create_new_file',
172
206
  ];
173
207
 
174
208
  /**
@@ -415,10 +449,19 @@ export default {
415
449
  * tool?: {
416
450
  * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
417
451
  * reload?: () => Promise<void>,
452
+ * transform?: (cb: (editor: {
453
+ * list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
454
+ * get: (id: string) => ({ id?: string, input?: unknown } | undefined),
455
+ * add: (definition: unknown) => void,
456
+ * update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
457
+ * remove: (id: string) => void,
458
+ * }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
418
459
  * hook?: (name: string, cb: (event: {
419
460
  * tool?: string,
420
461
  * sessionID?: string,
462
+ * input?: Record<string, unknown>,
421
463
  * status?: string,
464
+ * result?: unknown,
422
465
  * error?: unknown,
423
466
  * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
424
467
  * },
@@ -464,13 +507,16 @@ export default {
464
507
  let sessionRegistration;
465
508
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
466
509
  let toolRegistration;
510
+ let toolBeforeRegistration;
511
+ let toolTransformRegistration;
467
512
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
468
513
  let promptRegistration;
469
514
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
470
515
  let commandRegistration;
471
516
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
472
517
  let skillRegistration;
473
- let skillsRegistered = false;
518
+ /** Ids of the skills currently registered, so a sync can tell "unchanged". */
519
+ let registeredSkillIds = [];
474
520
  let disposed = false;
475
521
 
476
522
  // Latches when a request showed the `idea_*` tools are gone. It is cleared
@@ -529,8 +575,7 @@ export default {
529
575
  // The skill lifecycle follows the mark: marking the project registers the
530
576
  // IDEA skills, clearing the mark removes them again. Same semantics as the
531
577
  // reconnect behavior, so the two can never disagree.
532
- if (marked) await ensureSkills();
533
- else await removeSkills();
578
+ await syncSkills();
534
579
  };
535
580
 
536
581
  // Resolve the mark once, so the request hooks can read it synchronously.
@@ -571,7 +616,11 @@ export default {
571
616
  // Without this, a session that already captured a snapshot keeps the old
572
617
  // tool list until the session is reopened.
573
618
  if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
574
- return waitForIdeaTools();
619
+ const ready = await waitForIdeaTools();
620
+ // The catalog decides which of the shipped skills may be offered, so this
621
+ // is the first moment that question has an answer; see `syncSkills`.
622
+ await syncSkills();
623
+ return ready;
575
624
  };
576
625
 
577
626
  const activateIde = async (port) => {
@@ -830,41 +879,101 @@ export default {
830
879
  }
831
880
  };
832
881
 
882
+ /**
883
+ * The IDEA tools this project's IDE is serving right now.
884
+ *
885
+ * Empty is the honest answer for "does the IDE serve X?" before anything has
886
+ * connected — and it fails that way on error too, because the alternative
887
+ * (assuming a tool exists) is the one mistake that cannot be walked back
888
+ * after the model has seen it.
889
+ *
890
+ * @returns {Promise<Set<string>>}
891
+ */
892
+ const servedIdeaToolNames = async () => {
893
+ if (typeof ctx.tool?.list !== 'function') return new Set();
894
+ try {
895
+ return new Set(toolIds(await ctx.tool.list()));
896
+ } catch {
897
+ return new Set();
898
+ }
899
+ };
900
+
901
+ /** Same ids in the same order — `readSkills()` sorts, so the order is stable. */
902
+ const isSameSkillSet = (skills, ids) =>
903
+ skills.length === ids.length && skills.every((skill, index) => skill.id === ids[index]);
904
+
833
905
  // The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
834
- // run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
835
- // schema from a real example, and how to verify a write. Registered only for
836
- // a project that has been marked as an IDEA project, so unrelated projects
837
- // never see an IDEA-specific skill.
906
+ // standalone-mode discipline (every app starts from an IDEA run
907
+ // configuration, and starting one is the user's call, so the skill has to be
908
+ // there to write the project's own rules). They pass two independent gates,
909
+ // because a skill can be wrong twice over:
910
+ //
911
+ // * the project mark — an unrelated project never sees an IDEA skill;
912
+ // * the tools the IDE actually serves — a skill that teaches a call the
913
+ // model cannot make is not knowledge, it is a failed call waiting to
914
+ // happen. `idea-standalone` drives `idea_rerun_run_configuration`, which
915
+ // only the companion plugin `idea-mcp-enhance` serves, so a stock IDE must
916
+ // not see it at all. Each skill declares its own dependency in its
917
+ // frontmatter (`requires:`), so this stays a data question.
918
+ //
919
+ // The catalog is unknown at setup: the mark is read from storage offline,
920
+ // before anything has connected to the IDE. So this is a reconcile rather
921
+ // than a one-shot — every catalog refresh re-runs it, and it offers exactly
922
+ // what is justified right now. Until the IDE has answered, the gated skills
923
+ // simply do not exist.
838
924
  //
839
925
  // Deliberately NOT wrapped in a catch: these are core features of the plugin,
840
926
  // so a rejected definition must fail loudly. Swallowing it would ship a
841
927
  // silently incomplete plugin — which is exactly how 0.0.6 lost
842
928
  // `/open-in-idea` without anyone noticing until the command was gone.
843
- const ensureSkills = async () => {
844
- if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
845
- skillsRegistered = true;
846
- // Read the documents before the transform: callbacks must stay synchronous.
847
- const skills = readSkills();
929
+ const syncSkillsOnce = async () => {
930
+ if (typeof ctx.skill?.transform !== 'function') return;
931
+
932
+ const served = await servedIdeaToolNames();
933
+ const wanted = (markCache === true ? readSkills() : [])
934
+ .filter(({ requires }) => requires.every((name) => served.has(name)))
935
+ .map(({ skill }) => skill);
936
+ if (isSameSkillSet(wanted, registeredSkillIds)) return;
937
+
938
+ // Tear down and register again, the same way the MCP server is
939
+ // re-registered: a transform that only ever adds would leave a stale skill
940
+ // behind, and one that only removes could never bring it back.
941
+ registeredSkillIds = [];
942
+ const previous = skillRegistration;
943
+ skillRegistration = undefined;
944
+ if (previous) {
945
+ try {
946
+ await previous.dispose();
947
+ } catch {
948
+ // best effort
949
+ }
950
+ }
951
+ if (wanted.length === 0) return;
848
952
  skillRegistration = await ctx.skill.transform((editor) => {
849
- for (const skill of skills) editor.add(skill);
953
+ for (const skill of wanted) editor.add(skill);
850
954
  });
955
+ registeredSkillIds = wanted.map((skill) => skill.id);
851
956
  };
852
- /** Remove the IDEA skills again when the project is unmarked. */
853
- const removeSkills = async () => {
854
- if (!skillsRegistered || !skillRegistration) return;
855
- skillsRegistered = false;
856
- const registration = skillRegistration;
857
- skillRegistration = undefined;
858
- try {
859
- await registration.dispose();
860
- } catch {
861
- // best effort
862
- }
957
+
958
+ // Serialized like the reconciler, and for the same reason: one call disposes
959
+ // a registration before it makes the next, so two overlapping calls would
960
+ // leak one of them. That overlap is easy to reach — `/close-in-idea` can land
961
+ // while a reconnect is still in flight. With the chain in place, callers may
962
+ // fire this without awaiting.
963
+ let skillSyncChain = Promise.resolve();
964
+ const syncSkills = () => {
965
+ const run = () => syncSkillsOnce();
966
+ const next = skillSyncChain.then(run, run);
967
+ skillSyncChain = next.then(
968
+ () => {},
969
+ () => {},
970
+ );
971
+ return next;
863
972
  };
864
- // A project marked in an earlier run gets its skills at setup; a project
865
- // marked for the first time gets them when `/open-in-idea` runs (both paths
866
- // go through `setMark` / this setup hook).
867
- if (markCache === true) await ensureSkills();
973
+ // A project marked in an earlier run gets its skills as soon as the IDE has
974
+ // answered; a project marked for the first time gets them when
975
+ // `/open-in-idea` runs (both paths go through `setMark` / this setup hook).
976
+ await syncSkills();
868
977
 
869
978
  // `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
870
979
  // capabilities immediately, and marks the project so later messages keep the
@@ -949,11 +1058,25 @@ export default {
949
1058
  if (markCache === true || serving) {
950
1059
  appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
951
1060
  }
1061
+ // A skill follows its tools in both directions, and this is the one place
1062
+ // that sees the answer change WITHOUT a reconnect: a request that has
1063
+ // lost the enhance tools must not keep the skill that drives them. Fired,
1064
+ // not awaited — the hook is synchronous, and the chain in `syncSkills`
1065
+ // makes that safe. `refreshIdeTools` covers the other direction.
1066
+ //
1067
+ // Skipped when there is nothing it could change, so a project that never
1068
+ // touched the IDE does not pay a catalog read on every request.
1069
+ if (markCache === true || registeredSkillIds.length > 0) void syncSkills();
952
1070
  if (!injectGuidance) return;
953
1071
  // Guidance promises tools the model can call, so it only goes out when
954
1072
  // they are actually in this request.
955
1073
  if (!serving) return;
956
- appendIdeGuidance(event?.system, projectPath);
1074
+ // Same rule for the run-lifecycle tools: they come from the companion
1075
+ // IDEA plugin `idea-mcp-enhance`, so a stock IDE has none of them and
1076
+ // naming them would promise calls that can only fail.
1077
+ appendIdeGuidance(event?.system, projectPath, {
1078
+ runLifecycleTools: servingRunLifecycleTools(tools),
1079
+ });
957
1080
  });
958
1081
 
959
1082
  // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
@@ -988,6 +1111,27 @@ export default {
988
1111
  if (registrations.length > 0) needsRebuild = false;
989
1112
  });
990
1113
 
1114
+ // The IDE's tool-catalog policy, in two halves that must stay in sync:
1115
+ // an argument this plugin always overwrites must not also be advertised,
1116
+ // or the model sets it, reasons about it, and is wrong. The schema half
1117
+ // runs first, so a request never sees a knob that is already decided; see
1118
+ // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1119
+ // narrow exception to "never touch tool definitions".
1120
+ //
1121
+ // Both halves are deliberately unconditional on the project mark: they are
1122
+ // properties of the tool call, not of whether this project is IDE-connected.
1123
+ if (typeof ctx.tool?.transform === 'function') {
1124
+ toolTransformRegistration = await ctx.tool.transform((editor) => {
1125
+ hideForcedArguments(editor);
1126
+ });
1127
+ }
1128
+ if (typeof ctx.tool?.hook === 'function') {
1129
+ toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1130
+ if (typeof event?.tool !== 'string') return;
1131
+ applyIdeaToolDefaults(event.tool, event.input);
1132
+ });
1133
+ }
1134
+
991
1135
  // The only self-initiated action: when an `idea_*` call fails in a way
992
1136
  // that means the IDE MCP is not usable, run the open flow again. The
993
1137
  // failure is classified — a plain business error (a bad path, a missing
@@ -1035,6 +1179,20 @@ export default {
1035
1179
  // best effort
1036
1180
  }
1037
1181
  }
1182
+ if (toolBeforeRegistration) {
1183
+ try {
1184
+ await toolBeforeRegistration.dispose();
1185
+ } catch {
1186
+ // best effort
1187
+ }
1188
+ }
1189
+ if (toolTransformRegistration) {
1190
+ try {
1191
+ await toolTransformRegistration.dispose();
1192
+ } catch {
1193
+ // best effort
1194
+ }
1195
+ }
1038
1196
  if (promptRegistration) {
1039
1197
  try {
1040
1198
  await promptRegistration.dispose();
@@ -21,6 +21,12 @@
21
21
  //
22
22
  // The frontmatter reader below handles only the flat `key: value` form this
23
23
  // package writes. It is not a YAML engine and must not grow into one.
24
+ //
25
+ // One frontmatter key is the loader's own rather than OpenCode's: `requires`, a
26
+ // comma-separated list of the tools the skill is written around. It is read here
27
+ // and deliberately kept OUT of the registered definition — the caller filters on
28
+ // it instead (see `readSkills`), because a skill whose tools are absent must not
29
+ // be registered at all.
24
30
 
25
31
  import { readFileSync, readdirSync } from 'node:fs';
26
32
  import { fileURLToPath } from 'node:url';
@@ -67,6 +73,20 @@ export function parseSkillFrontmatter(text, source) {
67
73
  return fields;
68
74
  }
69
75
 
76
+ /**
77
+ * Split a frontmatter list value: `a, b` -> `['a', 'b']`.
78
+ *
79
+ * @param {string | undefined} value
80
+ * @returns {string[]}
81
+ */
82
+ function splitList(value) {
83
+ if (!value) return [];
84
+ return value
85
+ .split(',')
86
+ .map((entry) => entry.trim())
87
+ .filter((entry) => entry !== '');
88
+ }
89
+
70
90
  /**
71
91
  * Load one skill directory into a `Skill.Info`-shaped definition.
72
92
  *
@@ -74,6 +94,20 @@ export function parseSkillFrontmatter(text, source) {
74
94
  * @returns {{ id: string, name: string, description: string, path: string, content: string }}
75
95
  */
76
96
  export function readSkill(id) {
97
+ return readSkillPackage(id).skill;
98
+ }
99
+
100
+ /**
101
+ * A skill plus the tools it needs before it is worth offering.
102
+ *
103
+ * The two are kept apart on purpose: `skill` must satisfy `Skill.Info` exactly,
104
+ * while `requires` is this package's own metadata and must NOT be registered —
105
+ * it is what the caller filters on.
106
+ *
107
+ * @param {string} id Directory name, used as the skill id.
108
+ * @returns {{ skill: { id: string, name: string, description: string, path: string, content: string }, requires: string[] }}
109
+ */
110
+ export function readSkillPackage(id) {
77
111
  const file = new URL(`${id}/${SKILL_FILE_NAME}`, SKILLS_DIR);
78
112
  const source = `skills/${id}/${SKILL_FILE_NAME}`;
79
113
  const text = readFileSync(file, 'utf8');
@@ -83,23 +117,26 @@ export function readSkill(id) {
83
117
  throw new Error(`${source}: frontmatter needs a "description", otherwise the skill is never offered`);
84
118
  }
85
119
  return {
86
- id,
87
- name: fields.name || id,
88
- description,
89
- path: fileURLToPath(file),
90
- content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
120
+ skill: {
121
+ id,
122
+ name: fields.name || id,
123
+ description,
124
+ path: fileURLToPath(file),
125
+ content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
126
+ },
127
+ requires: splitList(fields.requires),
91
128
  };
92
129
  }
93
130
 
94
131
  /**
95
- * Every shipped skill, sorted by id.
132
+ * Every shipped skill, sorted by id, each with the tools it requires.
96
133
  *
97
- * @returns {Array<{ id: string, name: string, description: string, path: string, content: string }>}
134
+ * @returns {Array<{ skill: { id: string, name: string, description: string, path: string, content: string }, requires: string[] }>}
98
135
  */
99
136
  export function readSkills() {
100
137
  return readdirSync(SKILLS_DIR, { withFileTypes: true })
101
138
  .filter((entry) => entry.isDirectory())
102
139
  .map((entry) => entry.name)
103
140
  .sort()
104
- .map((id) => readSkill(id));
141
+ .map((id) => readSkillPackage(id));
105
142
  }
@@ -0,0 +1,94 @@
1
+ // The smallest template renderer that makes a prompt document reviewable.
2
+ //
3
+ // WHY NOT A REAL ENGINE (handlebars / ejs / nunjucks / velocity):
4
+ //
5
+ // * HTML escaping is the default in all of them, and it is WRONG here. This
6
+ // renders Markdown for a model: `\`code\``, `<jar>!/<entry>` and `j2k<` must
7
+ // come out byte-for-byte. Configuring that off is possible, but the default
8
+ // is the trap, and the failure is silent mangling of the text a model reads.
9
+ // * It would be the package's first runtime dependency. This plugin ships to
10
+ // npm and is otherwise dependency-free at runtime.
11
+ // * Each engine brings its own whitespace rules, and whitespace IS structure
12
+ // here: one blank line separates sections, and a conditional that renders
13
+ // empty must not leave a gap behind.
14
+ //
15
+ // What is actually needed is tiny: a variable, and if/else. So that is all this
16
+ // does, and the template file stays readable as the document it produces.
17
+ //
18
+ // DIRECTIVES MUST OCCUPY A WHOLE LINE. A directive line emits nothing at all, so
19
+ // "that line is there" and "that line is not there" is the only difference a
20
+ // flag makes — no stray blank line, no half-eaten indentation. A directive that
21
+ // is not alone on its line is a mistake, and this throws rather than guessing.
22
+
23
+ const DIRECTIVE = /^\{\{\s*(#if\s+([A-Za-z0-9_]+)|else|\/if)\s*\}\}$/;
24
+ // Any control tag anywhere, used to catch a directive that is not alone on its
25
+ // line: that line would otherwise be emitted verbatim, silently changing which
26
+ // text a model reads.
27
+ const CONTROL_TAG = /\{\{\s*(#[^}]*|\/[^}]*|else\s*)\}\}/;
28
+ const VARIABLE = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
29
+
30
+ /**
31
+ * Render a template against a data record.
32
+ *
33
+ * Supported: `{{name}}`, `{{#if name}}…{{/if}}`, `{{#if name}}…{{else}}…{{/if}}`.
34
+ * Nesting works. Unknown variables render as empty; unknown directives throw,
35
+ * because a typo in a directive silently changes which text a model sees.
36
+ *
37
+ * @param {string} template
38
+ * @param {Record<string, unknown>} data
39
+ * @returns {string}
40
+ */
41
+ export function renderTemplate(template, data) {
42
+ const out = [];
43
+ /** @type {Array<{ active: boolean, taken: boolean }>} */
44
+ const stack = [];
45
+ const emitting = () => stack.every((frame) => frame.active);
46
+
47
+ // A trailing newline in the file is not part of the document.
48
+ const lines = template.replace(/\n$/, '').split('\n');
49
+
50
+ for (const [index, line] of lines.entries()) {
51
+ const match = DIRECTIVE.exec(line.trim());
52
+
53
+ if (!match) {
54
+ if (CONTROL_TAG.test(line)) {
55
+ throw new Error(`template line ${index + 1}: a directive must occupy the whole line`);
56
+ }
57
+ if (emitting()) out.push(substitute(line, data));
58
+ continue;
59
+ }
60
+
61
+ const directive = match[1];
62
+ if (directive.startsWith('#if')) {
63
+ stack.push({ active: Boolean(data[match[2]]), taken: Boolean(data[match[2]]) });
64
+ continue;
65
+ }
66
+ if (directive === 'else') {
67
+ if (stack.length === 0) throw new Error(`template line ${index + 1}: {{else}} outside {{#if}}`);
68
+ const frame = stack[stack.length - 1];
69
+ // An `{{else}}` after a taken branch stays off: `if/else` is exclusive even
70
+ // when both sides are truthy.
71
+ frame.active = !frame.taken;
72
+ frame.taken = true;
73
+ continue;
74
+ }
75
+ // `{{/if}}`
76
+ if (stack.length === 0) throw new Error(`template line ${index + 1}: {{/if}} without {{#if}}`);
77
+ stack.pop();
78
+ }
79
+
80
+ if (stack.length > 0) throw new Error('template: {{#if}} was never closed');
81
+
82
+ return out.join('\n');
83
+ }
84
+
85
+ /**
86
+ * Replace `{{name}}` with its value. Values are inserted verbatim — see the file
87
+ * header for why nothing is escaped.
88
+ */
89
+ function substitute(line, data) {
90
+ return line.replace(VARIABLE, (_whole, name) => {
91
+ const value = data[name];
92
+ return value === undefined || value === null ? '' : String(value);
93
+ });
94
+ }