pi-gsd 2.0.1 → 2.0.2

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 (65) hide show
  1. package/dist/pi-gsd-hooks.js +1532 -0
  2. package/package.json +3 -5
  3. package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
  4. package/src/cli.ts +0 -644
  5. package/src/commands/base.ts +0 -67
  6. package/src/commands/commit.ts +0 -22
  7. package/src/commands/config.ts +0 -71
  8. package/src/commands/frontmatter.ts +0 -51
  9. package/src/commands/index.ts +0 -76
  10. package/src/commands/init.ts +0 -43
  11. package/src/commands/milestone.ts +0 -37
  12. package/src/commands/phase.ts +0 -92
  13. package/src/commands/progress.ts +0 -71
  14. package/src/commands/roadmap.ts +0 -40
  15. package/src/commands/scaffold.ts +0 -19
  16. package/src/commands/state.ts +0 -102
  17. package/src/commands/template.ts +0 -52
  18. package/src/commands/verify.ts +0 -70
  19. package/src/commands/workstream.ts +0 -98
  20. package/src/commands/wxp.ts +0 -65
  21. package/src/lib/commands.ts +0 -1040
  22. package/src/lib/config.ts +0 -385
  23. package/src/lib/core.ts +0 -1167
  24. package/src/lib/frontmatter.ts +0 -462
  25. package/src/lib/init.ts +0 -517
  26. package/src/lib/milestone.ts +0 -290
  27. package/src/lib/model-profiles.ts +0 -272
  28. package/src/lib/phase.ts +0 -1012
  29. package/src/lib/profile-output.ts +0 -237
  30. package/src/lib/profile-pipeline.ts +0 -556
  31. package/src/lib/roadmap.ts +0 -378
  32. package/src/lib/schemas.ts +0 -290
  33. package/src/lib/security.ts +0 -176
  34. package/src/lib/state.ts +0 -1175
  35. package/src/lib/template.ts +0 -246
  36. package/src/lib/uat.ts +0 -289
  37. package/src/lib/verify.ts +0 -879
  38. package/src/lib/workstream.ts +0 -524
  39. package/src/output.ts +0 -45
  40. package/src/schemas/pi-gsd-settings.schema.json +0 -80
  41. package/src/schemas/wxp.xsd +0 -619
  42. package/src/schemas/wxp.zod.ts +0 -318
  43. package/src/wxp/__tests__/arguments.test.ts +0 -86
  44. package/src/wxp/__tests__/conditions.test.ts +0 -106
  45. package/src/wxp/__tests__/executor.test.ts +0 -95
  46. package/src/wxp/__tests__/helpers.ts +0 -26
  47. package/src/wxp/__tests__/integration.test.ts +0 -166
  48. package/src/wxp/__tests__/new-features.test.ts +0 -222
  49. package/src/wxp/__tests__/parser.test.ts +0 -159
  50. package/src/wxp/__tests__/paste.test.ts +0 -66
  51. package/src/wxp/__tests__/schema.test.ts +0 -120
  52. package/src/wxp/__tests__/security.test.ts +0 -87
  53. package/src/wxp/__tests__/shell.test.ts +0 -85
  54. package/src/wxp/__tests__/string-ops.test.ts +0 -25
  55. package/src/wxp/__tests__/variables.test.ts +0 -65
  56. package/src/wxp/arguments.ts +0 -89
  57. package/src/wxp/conditions.ts +0 -78
  58. package/src/wxp/executor.ts +0 -191
  59. package/src/wxp/index.ts +0 -191
  60. package/src/wxp/parser.ts +0 -198
  61. package/src/wxp/paste.ts +0 -51
  62. package/src/wxp/security.ts +0 -102
  63. package/src/wxp/shell.ts +0 -81
  64. package/src/wxp/string-ops.ts +0 -44
  65. package/src/wxp/variables.ts +0 -109
@@ -1,191 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { executeShell, WxpShellError, resolveArgNode } from "./shell.js";
4
- import { executeStringOp } from "./string-ops.js";
5
- import { evaluateCondition, evaluateWhere, evaluateCondExprNode, CONDITION_OPS } from "./conditions.js";
6
- import { resolveTrustedEntry } from "./security.js";
7
- import type { XmlNode, WxpExecContext } from "../schemas/wxp.zod.js";
8
- import type { VariableStore } from "./variables.js";
9
-
10
- export class WxpExecutionError extends Error {
11
- constructor(
12
- public readonly cause: Error,
13
- public readonly variableSnapshot: Record<string, string>,
14
- message: string,
15
- ) {
16
- super(message);
17
- this.name = "WxpExecutionError";
18
- }
19
- }
20
-
21
- // ─── <display> ───────────────────────────────────────────────────────────────
22
-
23
- function execDisplay(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
24
- const msg = (node.attrs["msg"] ?? "").replace(
25
- /\{([^}]+)\}/g,
26
- (_, name: string) => vars.resolve(name) ?? "",
27
- );
28
- const level = node.attrs["level"];
29
- ctx.onDisplay(msg, level === "warning" || level === "error" ? level : "info");
30
- }
31
-
32
- // ─── <json-parse> ────────────────────────────────────────────────────────────
33
-
34
- function execJsonParse(node: XmlNode, vars: VariableStore): void {
35
- const src = node.attrs["src"] ?? "";
36
- const out = node.attrs["out"] ?? "";
37
- const pathStr = node.attrs["path"];
38
-
39
- const jsonStr = vars.get(src);
40
- if (jsonStr === undefined) throw new Error(`<json-parse>: source variable '${src}' is not defined`);
41
-
42
- let parsed: unknown;
43
- try { parsed = JSON.parse(jsonStr); } catch {
44
- throw new Error(`<json-parse>: '${src}' does not contain valid JSON`);
45
- }
46
-
47
- if (pathStr) {
48
- const parts = pathStr.replace(/^\$\.?/, "").split(".");
49
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON traversal
50
- let cur: any = parsed;
51
- for (const key of parts) {
52
- if (cur === null || typeof cur !== "object") throw new Error(`<json-parse>: path '${pathStr}' not found`);
53
- cur = cur[key];
54
- }
55
- parsed = cur;
56
- }
57
-
58
- if (Array.isArray(parsed)) {
59
- vars.setArray(out, parsed.map((item) => typeof item === "string" ? item : JSON.stringify(item)));
60
- } else if (parsed !== null && typeof parsed === "object") {
61
- vars.set(out, JSON.stringify(parsed), undefined);
62
- } else {
63
- vars.set(out, parsed === undefined || parsed === null ? "" : String(parsed), undefined);
64
- }
65
- }
66
-
67
- // ─── <read-file> ─────────────────────────────────────────────────────────────
68
-
69
- function execReadFile(node: XmlNode, vars: VariableStore): void {
70
- const filePath = node.attrs["path"] ?? "";
71
- const out = node.attrs["out"] ?? "";
72
- const content = fs.readFileSync(path.resolve(filePath), "utf8");
73
- vars.set(out, content, undefined);
74
- }
75
-
76
- // ─── <write-file> ────────────────────────────────────────────────────────────
77
-
78
- function execWriteFile(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
79
- const filePath = node.attrs["path"] ?? "";
80
- const src = node.attrs["src"] ?? "";
81
- const resolved = path.resolve(filePath);
82
-
83
- // Create-only: never overwrite
84
- if (fs.existsSync(resolved)) {
85
- throw new Error(`<write-file>: '${filePath}' already exists (create-only, never overwrites)`);
86
- }
87
-
88
- // Reject writes targeting trusted harness paths
89
- for (const entry of ctx.config.trustedPaths) {
90
- const abs = resolveTrustedEntry(entry, ctx.projectRoot, ctx.pkgRoot);
91
- if (resolved.startsWith(abs + path.sep) || resolved === abs) {
92
- throw new Error(`<write-file>: cannot write to trusted harness path '${filePath}'`);
93
- }
94
- }
95
-
96
- const content = vars.get(src) ?? "";
97
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
98
- fs.writeFileSync(resolved, content, "utf8");
99
- }
100
-
101
- // ─── <for-each> ──────────────────────────────────────────────────────────────
102
-
103
- function execForEach(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
104
- const varName = node.attrs["var"] ?? "";
105
- const itemName = node.attrs["item"] ?? "";
106
-
107
- const whereNode = node.children.find((c) => c.tag === "where");
108
- const sortByNode = node.children.find((c) => c.tag === "sort-by");
109
- const bodyNodes = node.children.filter((c) => c.tag !== "where" && c.tag !== "sort-by");
110
-
111
- let items = vars.getArray(varName);
112
- if (!items) return; // Missing array is not an error — may be conditional
113
-
114
- // Filter
115
- if (whereNode) {
116
- items = items.filter((itemJson) => {
117
- vars.set(itemName, itemJson, undefined);
118
- return evaluateWhere(whereNode, vars);
119
- });
120
- }
121
-
122
- // Sort
123
- if (sortByNode) {
124
- const key = sortByNode.attrs["key"] ?? "";
125
- const type = sortByNode.attrs["type"] ?? "string";
126
- const order = sortByNode.attrs["order"] ?? "asc";
127
-
128
- items = [...items].sort((aJson, bJson) => {
129
- vars.set(itemName, aJson, undefined);
130
- const aVal = vars.resolve(`${itemName}.${key}`) ?? vars.resolve(key) ?? "";
131
- vars.set(itemName, bJson, undefined);
132
- const bVal = vars.resolve(`${itemName}.${key}`) ?? vars.resolve(key) ?? "";
133
-
134
- const cmp = type === "number"
135
- ? Number(aVal) - Number(bVal)
136
- : aVal.localeCompare(bVal);
137
- return order === "desc" ? -cmp : cmp;
138
- });
139
- }
140
-
141
- for (const itemJson of items) {
142
- vars.set(itemName, itemJson, undefined);
143
- for (const child of bodyNodes) executeNode(child, vars, ctx);
144
- }
145
- }
146
-
147
- // ─── Dispatcher ───────────────────────────────────────────────────────────────
148
-
149
- export function executeNode(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
150
- switch (node.tag) {
151
- case "shell": executeShell(node, vars, ctx.config); break;
152
- case "string-op": executeStringOp(node, vars); break;
153
- case "json-parse": execJsonParse(node, vars); break;
154
- case "read-file": execReadFile(node, vars); break;
155
- case "write-file": execWriteFile(node, vars, ctx); break;
156
- case "display": execDisplay(node, vars, ctx); break;
157
- case "for-each": execForEach(node, vars, ctx); break;
158
-
159
- case "if": {
160
- const branch = evaluateCondition(node, vars);
161
- const thenNode = node.children.find((c) => c.tag === "then");
162
- const elseNode = node.children.find((c) => c.tag === "else");
163
- const taken = branch ? thenNode : elseNode;
164
- if (taken) for (const child of taken.children) executeNode(child, vars, ctx);
165
- break;
166
- }
167
-
168
- case "gsd-execute":
169
- executeBlock(node, vars, ctx);
170
- break;
171
-
172
- default:
173
- // paste, arguments, include, version — handled by resolution loop in index.ts
174
- break;
175
- }
176
- }
177
-
178
- /** Execute all children of a container node (<gsd-execute>, <then>, <else>). */
179
- export function executeBlock(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
180
- try {
181
- for (const child of node.children) executeNode(child, vars, ctx);
182
- } catch (err) {
183
- if (err instanceof WxpShellError || err instanceof Error) {
184
- throw new WxpExecutionError(err, vars.snapshot(), `Execution failed: ${err.message}`);
185
- }
186
- throw err;
187
- }
188
- }
189
-
190
- // Re-export for conditions used outside executor (index.ts for-each where clause)
191
- export { evaluateCondExprNode, CONDITION_OPS };
package/src/wxp/index.ts DELETED
@@ -1,191 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { extractWxpTags, spliceContent, extractCodeFenceRegions, inDeadZone } from "./parser.js";
4
- import { createVariableStore } from "./variables.js";
5
- import { parseArguments } from "./arguments.js";
6
- import { executeBlock } from "./executor.js";
7
- import { applyPaste, WxpPasteError } from "./paste.js";
8
- import { checkTrustedPath } from "./security.js";
9
- import type {
10
- WxpSecurityConfig,
11
- WxpExecContext,
12
- DisplayLevel,
13
- DisplayCallback,
14
- } from "../schemas/wxp.zod.js";
15
-
16
- export { WxpExecutionError } from "./executor.js";
17
- export { WxpShellError } from "./shell.js";
18
- export { WxpPasteError } from "./paste.js";
19
- export { WxpStringOpError } from "./string-ops.js";
20
- export { WxpArgumentsError } from "./arguments.js";
21
- export type { DisplayCallback, DisplayLevel, WxpExecContext };
22
-
23
- const MAX_ITERATIONS = 50;
24
- const NOOP_DISPLAY: DisplayCallback = () => {};
25
-
26
- export class WxpProcessingError extends Error {
27
- constructor(
28
- public readonly filePath: string,
29
- public readonly cause: Error,
30
- public readonly variableSnapshot: Record<string, string>,
31
- public readonly pendingOperations: string[],
32
- public readonly completedOperations: string[],
33
- ) {
34
- super(
35
- [
36
- `WXP Processing Error`,
37
- `File: ${filePath}`,
38
- `Error: ${cause.message}`,
39
- `Variable Namespace: ${JSON.stringify(variableSnapshot, null, 2)}`,
40
- `Pending Operations: [${pendingOperations.join(", ")}]`,
41
- `Completed Operations: [${completedOperations.join(", ")}]`,
42
- ].join("\n"),
43
- );
44
- this.name = "WxpProcessingError";
45
- }
46
- }
47
-
48
- export function processWxp(
49
- content: string,
50
- filePath: string,
51
- config: WxpSecurityConfig,
52
- projectRoot: string,
53
- pkgRoot: string,
54
- rawArguments = "",
55
- onDisplay: DisplayCallback = NOOP_DISPLAY,
56
- ): string {
57
- const pathCheck = checkTrustedPath(filePath, config, projectRoot, pkgRoot);
58
- if (!pathCheck.ok) {
59
- throw new WxpProcessingError(filePath, new Error(pathCheck.reason), {}, [], []);
60
- }
61
- return runLoop(content, filePath, config, projectRoot, pkgRoot, rawArguments, onDisplay);
62
- }
63
-
64
- export function processWxpTrustedContent(
65
- content: string,
66
- virtualFilePath: string,
67
- config: WxpSecurityConfig,
68
- projectRoot: string,
69
- pkgRoot: string,
70
- rawArguments = "",
71
- onDisplay: DisplayCallback = NOOP_DISPLAY,
72
- ): string {
73
- const trusted: WxpSecurityConfig = {
74
- ...config,
75
- trustedPaths: [
76
- ...config.trustedPaths,
77
- { position: "absolute", path: path.dirname(path.resolve(virtualFilePath)) },
78
- ],
79
- };
80
- return runLoop(content, virtualFilePath, trusted, projectRoot, pkgRoot, rawArguments, onDisplay);
81
- }
82
-
83
- function runLoop(
84
- content: string,
85
- filePath: string,
86
- config: WxpSecurityConfig,
87
- projectRoot: string,
88
- pkgRoot: string,
89
- rawArguments: string,
90
- onDisplay: DisplayCallback,
91
- ): string {
92
- const vars = createVariableStore();
93
- const done: string[] = [];
94
- let current = content;
95
-
96
- const ctx: WxpExecContext = { config, projectRoot, pkgRoot, onDisplay };
97
-
98
- for (let i = 0; i < MAX_ITERATIONS; i++) {
99
- const tags = extractWxpTags(current);
100
- const active = tags.filter((t) => t.node.tag !== "gsd-version");
101
- if (active.length === 0) break;
102
-
103
- const pending = active.map((t) => t.node.tag);
104
-
105
- try {
106
- let progress = false;
107
-
108
- // 1. <gsd-include>
109
- for (const tag of extractWxpTags(current)) {
110
- if (tag.node.tag !== "gsd-include") continue;
111
- if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
112
-
113
- const incPath = tag.node.attrs["path"];
114
- if (!incPath) continue;
115
-
116
- const abs = path.resolve(path.dirname(filePath), incPath);
117
- const check = checkTrustedPath(abs, config, projectRoot, pkgRoot);
118
- if (!check.ok) throw new Error(`Include rejected: ${check.reason}`);
119
-
120
- const included = fs.readFileSync(abs, "utf8");
121
- const stem = path.basename(abs, path.extname(abs));
122
-
123
- // INC-02: arg mappings from <gsd-arguments> child
124
- for (const child of tag.node.children) {
125
- if (child.tag !== "gsd-arguments") continue;
126
- for (const arg of child.children.filter((c) => c.tag === "arg")) {
127
- const from = arg.attrs["name"];
128
- const to = arg.attrs["as"];
129
- if (from && to) {
130
- const val = vars.get(from);
131
- if (val !== undefined) vars.set(to, val, stem);
132
- }
133
- }
134
- }
135
-
136
- const appendArgs = "include-arguments" in tag.node.attrs ? `\n${rawArguments}` : "";
137
- current = spliceContent(current, tag.start, tag.end, included + appendArgs);
138
- done.push("gsd-include");
139
- progress = true;
140
- break;
141
- }
142
- if (progress) continue;
143
-
144
- // 2. <gsd-arguments>
145
- for (const tag of extractWxpTags(current)) {
146
- if (tag.node.tag !== "gsd-arguments") continue;
147
- if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
148
-
149
- parseArguments(tag.node, rawArguments, vars);
150
- current = spliceContent(current, tag.start, tag.end, "");
151
- done.push("gsd-arguments");
152
- progress = true;
153
- break;
154
- }
155
- if (progress) continue;
156
-
157
- // 3. <gsd-execute>
158
- for (const tag of extractWxpTags(current)) {
159
- if (tag.node.tag !== "gsd-execute") continue;
160
- if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
161
-
162
- executeBlock(tag.node, vars, ctx);
163
- current = spliceContent(current, tag.start, tag.end, "");
164
- done.push("gsd-execute");
165
- progress = true;
166
- break;
167
- }
168
- if (progress) continue;
169
-
170
- // 4. <gsd-paste>
171
- const after = applyPaste(current, vars);
172
- if (after !== current) { current = after; done.push("gsd-paste"); continue; }
173
-
174
- break; // no progress
175
- } catch (err) {
176
- if (err instanceof WxpProcessingError) throw err;
177
- const e = err instanceof Error ? err : new Error(String(err));
178
- throw new WxpProcessingError(filePath, e, vars.snapshot(), pending, done);
179
- }
180
- }
181
-
182
- return current;
183
- }
184
-
185
- export function readWorkflowVersionTag(
186
- content: string,
187
- ): { version: string; doNotUpdate: boolean } | null {
188
- const m = /<gsd-version\s+v="([^"]+)"(\s+do-not-update)?\s*\/>/.exec(content);
189
- if (!m) return null;
190
- return { version: m[1], doNotUpdate: Boolean(m[2]) };
191
- }
package/src/wxp/parser.ts DELETED
@@ -1,198 +0,0 @@
1
- /**
2
- * parser.ts — Recursive-descent XML parser for WXP markdown documents.
3
- *
4
- * Parses WXP tags embedded in markdown. Code-fence regions are dead zones
5
- * where no tags are processed (WXP-01).
6
- */
7
-
8
- import type { XmlNode } from "../schemas/wxp.zod.js";
9
-
10
- // ─── Code-fence skip ──────────────────────────────────────────────────────────
11
-
12
- export function extractCodeFenceRegions(content: string): Array<[number, number]> {
13
- const regions: Array<[number, number]> = [];
14
- const re = /^```[^\n]*\n[\s\S]*?^```/gm;
15
- let m: RegExpExecArray | null;
16
- while ((m = re.exec(content)) !== null) {
17
- regions.push([m.index, m.index + m[0].length]);
18
- }
19
- return regions;
20
- }
21
-
22
- export function inDeadZone(pos: number, regions: Array<[number, number]>): boolean {
23
- return regions.some(([s, e]) => pos >= s && pos < e);
24
- }
25
-
26
- // ─── Attribute parser ─────────────────────────────────────────────────────────
27
-
28
- /**
29
- * Parse XML attribute string into a Record.
30
- * Handles: key="value", key='value', bare-key (boolean attribute)
31
- */
32
- export function parseAttrs(raw: string): Record<string, string> {
33
- const attrs: Record<string, string> = {};
34
- // Match key="val", key='val', or bare key
35
- const re = /([a-zA-Z0-9_:-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s/>]*)))?/g;
36
- let m: RegExpExecArray | null;
37
- while ((m = re.exec(raw)) !== null) {
38
- const key = m[1];
39
- const val = m[2] ?? m[3] ?? m[4] ?? ""; // empty string = boolean attribute
40
- attrs[key] = val;
41
- }
42
- return attrs;
43
- }
44
-
45
- // ─── Recursive XML tokeniser ──────────────────────────────────────────────────
46
-
47
- interface ParseResult {
48
- node: XmlNode;
49
- end: number; // index in content after closing tag
50
- }
51
-
52
- /**
53
- * Parse a single XML element starting at `pos` in `content`.
54
- * Uses a proper attribute-aware regex so self-closing `/>` is correctly detected.
55
- */
56
- function parseElement(content: string, pos: number): ParseResult | null {
57
- if (content[pos] !== "<") return null;
58
-
59
- // Proper attribute pattern: each attr is name or name=value (quoted or unquoted)
60
- // This ensures the `/` in `/>` is NOT consumed by the attrs group.
61
- const tagRe = /^<([a-zA-Z0-9_:-]+)((?:\s+[a-zA-Z0-9_:-]+(?:=(?:"[^"]*"|'[^']*'|[^\s/>]*))?)*)?\s*(\/??>)/;
62
- const slice = content.slice(pos);
63
- const m = tagRe.exec(slice);
64
- if (!m) return null;
65
-
66
- const tag = m[1];
67
- const rawAttrs = (m[2] ?? "").trim();
68
- const closing = m[3];
69
- const attrs = parseAttrs(rawAttrs);
70
-
71
- if (closing === "/>") {
72
- // Self-closing
73
- return {
74
- node: { tag, attrs, children: [], selfClosing: true },
75
- end: pos + m[0].length,
76
- };
77
- }
78
-
79
- // Opening tag — find matching closing tag, handling nesting
80
- let cursor = pos + m[0].length;
81
- const children: XmlNode[] = [];
82
- const closeTag = `</${tag}>`;
83
-
84
- while (cursor < content.length) {
85
- // Look for next < to check if it's a child element or closing tag
86
- const nextOpen = content.indexOf("<", cursor);
87
- if (nextOpen === -1) break;
88
-
89
- // Check for closing tag
90
- if (content.startsWith(closeTag, nextOpen)) {
91
- return {
92
- node: { tag, attrs, children, selfClosing: false },
93
- end: nextOpen + closeTag.length,
94
- };
95
- }
96
-
97
- // Check for comment <!-- ... -->
98
- if (content.startsWith("<!--", nextOpen)) {
99
- const commentEnd = content.indexOf("-->", nextOpen + 4);
100
- cursor = commentEnd !== -1 ? commentEnd + 3 : content.length;
101
- continue;
102
- }
103
-
104
- // Try to parse child element
105
- const child = parseElement(content, nextOpen);
106
- if (child) {
107
- children.push(child.node);
108
- cursor = child.end;
109
- } else {
110
- cursor = nextOpen + 1;
111
- }
112
- }
113
-
114
- // Unclosed tag — return what we have
115
- return {
116
- node: { tag, attrs, children, selfClosing: false },
117
- end: cursor,
118
- };
119
- }
120
-
121
- // ─── Top-level WXP tag extraction ────────────────────────────────────────────
122
-
123
- const WXP_TOP_TAGS = new Set([
124
- "gsd-execute",
125
- "gsd-arguments",
126
- "gsd-paste",
127
- "gsd-include",
128
- "gsd-version",
129
- ]);
130
-
131
- export interface WxpTagMatch {
132
- node: XmlNode;
133
- /** Start index in original content */
134
- start: number;
135
- /** End index (exclusive) in original content */
136
- end: number;
137
- }
138
-
139
- /**
140
- * Extract all top-level WXP tags from markdown content.
141
- * Skips code-fence regions.
142
- * Returns matches in document order.
143
- */
144
- export function extractWxpTags(content: string): WxpTagMatch[] {
145
- const deadZones = extractCodeFenceRegions(content);
146
- const matches: WxpTagMatch[] = [];
147
-
148
- // Scan for < characters that could start a WXP tag
149
- const tagStartRe = /<(gsd-[a-zA-Z0-9_-]+)/g;
150
- let m: RegExpExecArray | null;
151
-
152
- while ((m = tagStartRe.exec(content)) !== null) {
153
- const pos = m.index;
154
- if (inDeadZone(pos, deadZones)) continue;
155
-
156
- const tagName = m[1];
157
- if (!WXP_TOP_TAGS.has(tagName)) continue;
158
-
159
- const result = parseElement(content, pos);
160
- if (!result) continue;
161
-
162
- matches.push({ node: result.node, start: pos, end: result.end });
163
- // Advance regex past this element to avoid re-scanning its contents
164
- tagStartRe.lastIndex = result.end;
165
- }
166
-
167
- return matches;
168
- }
169
-
170
- // ─── Specific node extractors ─────────────────────────────────────────────────
171
-
172
- /** Replace a WXP tag span in content with a replacement string. */
173
- export function spliceContent(
174
- content: string,
175
- start: number,
176
- end: number,
177
- replacement: string,
178
- ): string {
179
- return content.slice(0, start) + replacement + content.slice(end);
180
- }
181
-
182
- /** Remove all unprocessed WXP tags from content (final strip step). */
183
- export function stripWxpTags(content: string): string {
184
- const deadZones = extractCodeFenceRegions(content);
185
- const tags = extractWxpTags(content);
186
-
187
- // Strip right-to-left to preserve indices
188
- let result = content;
189
- let offset = 0;
190
- for (const tag of tags) {
191
- if (inDeadZone(tag.start, deadZones)) continue;
192
- const adjustedStart = tag.start + offset;
193
- const adjustedEnd = tag.end + offset;
194
- result = result.slice(0, adjustedStart) + result.slice(adjustedEnd);
195
- offset += -(tag.end - tag.start);
196
- }
197
- return result;
198
- }
package/src/wxp/paste.ts DELETED
@@ -1,51 +0,0 @@
1
- import { extractCodeFenceRegions, inDeadZone } from "./parser.js";
2
- import type { VariableStore } from "./variables.js";
3
-
4
- export class WxpPasteError extends Error {
5
- constructor(
6
- public readonly variableName: string,
7
- public readonly variableSnapshot: Record<string, string>,
8
- ) {
9
- super(
10
- `<gsd-paste name="${variableName}" /> references undefined variable '${variableName}'`,
11
- );
12
- this.name = "WxpPasteError";
13
- }
14
- }
15
-
16
- /**
17
- * Replace all <gsd-paste name="X" /> tags in content with variable values.
18
- * - Tags inside code fences are NOT replaced.
19
- * - If any referenced variable is undefined → throws WxpPasteError (no partial output).
20
- * - Replacement is right-to-left to preserve indices.
21
- */
22
- export function applyPaste(content: string, vars: VariableStore): string {
23
- const deadZones = extractCodeFenceRegions(content);
24
- const pasteRe = /<gsd-paste\s+name="([^"]+)"\s*\/>/g;
25
-
26
- const matches: Array<{ index: number; full: string; name: string }> = [];
27
- let m: RegExpExecArray | null;
28
- while ((m = pasteRe.exec(content)) !== null) {
29
- if (!inDeadZone(m.index, deadZones)) {
30
- matches.push({ index: m.index, full: m[0], name: m[1] });
31
- }
32
- }
33
-
34
- // Validate all before replacing anything (atomic — no partial output)
35
- for (const match of matches) {
36
- if (vars.get(match.name) === undefined) {
37
- throw new WxpPasteError(match.name, vars.snapshot());
38
- }
39
- }
40
-
41
- // Replace right-to-left
42
- let result = content;
43
- for (let i = matches.length - 1; i >= 0; i--) {
44
- const match = matches[i];
45
- const value = vars.get(match.name) as string;
46
- result =
47
- result.slice(0, match.index) + value + result.slice(match.index + match.full.length);
48
- }
49
-
50
- return result;
51
- }