@mfittko/repo-wiki 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.llmwiki/schema.md +107 -0
  2. package/AGENTS.md +42 -0
  3. package/CHANGELOG.md +91 -0
  4. package/LICENSE +21 -0
  5. package/README.md +254 -0
  6. package/dist/bin/repo-wiki.d.ts +2 -0
  7. package/dist/bin/repo-wiki.js +7 -0
  8. package/dist/bin/repo-wiki.js.map +1 -0
  9. package/dist/src/cli.d.ts +1 -0
  10. package/dist/src/cli.js +404 -0
  11. package/dist/src/cli.js.map +1 -0
  12. package/dist/src/compiler.d.ts +55 -0
  13. package/dist/src/compiler.js +2046 -0
  14. package/dist/src/compiler.js.map +1 -0
  15. package/dist/src/config.d.ts +63 -0
  16. package/dist/src/config.js +86 -0
  17. package/dist/src/config.js.map +1 -0
  18. package/dist/src/context-assembler.d.ts +68 -0
  19. package/dist/src/context-assembler.js +378 -0
  20. package/dist/src/context-assembler.js.map +1 -0
  21. package/dist/src/data-model-signals.d.ts +1 -0
  22. package/dist/src/data-model-signals.js +13 -0
  23. package/dist/src/data-model-signals.js.map +1 -0
  24. package/dist/src/docs-ingestor.d.ts +138 -0
  25. package/dist/src/docs-ingestor.js +844 -0
  26. package/dist/src/docs-ingestor.js.map +1 -0
  27. package/dist/src/docs-linter.d.ts +14 -0
  28. package/dist/src/docs-linter.js +164 -0
  29. package/dist/src/docs-linter.js.map +1 -0
  30. package/dist/src/docs-validation.d.ts +36 -0
  31. package/dist/src/docs-validation.js +297 -0
  32. package/dist/src/docs-validation.js.map +1 -0
  33. package/dist/src/extractors.d.ts +50 -0
  34. package/dist/src/extractors.js +2275 -0
  35. package/dist/src/extractors.js.map +1 -0
  36. package/dist/src/frontmatter.d.ts +46 -0
  37. package/dist/src/frontmatter.js +377 -0
  38. package/dist/src/frontmatter.js.map +1 -0
  39. package/dist/src/index.d.ts +26 -0
  40. package/dist/src/index.js +18 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/init.d.ts +12 -0
  43. package/dist/src/init.js +121 -0
  44. package/dist/src/init.js.map +1 -0
  45. package/dist/src/language.d.ts +2 -0
  46. package/dist/src/language.js +62 -0
  47. package/dist/src/language.js.map +1 -0
  48. package/dist/src/linter.d.ts +33 -0
  49. package/dist/src/linter.js +398 -0
  50. package/dist/src/linter.js.map +1 -0
  51. package/dist/src/llm-provider.d.ts +267 -0
  52. package/dist/src/llm-provider.js +474 -0
  53. package/dist/src/llm-provider.js.map +1 -0
  54. package/dist/src/page-ownership.d.ts +38 -0
  55. package/dist/src/page-ownership.js +96 -0
  56. package/dist/src/page-ownership.js.map +1 -0
  57. package/dist/src/planner.d.ts +55 -0
  58. package/dist/src/planner.js +422 -0
  59. package/dist/src/planner.js.map +1 -0
  60. package/dist/src/prompts.d.ts +103 -0
  61. package/dist/src/prompts.js +344 -0
  62. package/dist/src/prompts.js.map +1 -0
  63. package/dist/src/publisher.d.ts +68 -0
  64. package/dist/src/publisher.js +662 -0
  65. package/dist/src/publisher.js.map +1 -0
  66. package/dist/src/repository-analysis.d.ts +88 -0
  67. package/dist/src/repository-analysis.js +485 -0
  68. package/dist/src/repository-analysis.js.map +1 -0
  69. package/dist/src/scanner.d.ts +122 -0
  70. package/dist/src/scanner.js +309 -0
  71. package/dist/src/scanner.js.map +1 -0
  72. package/dist/src/search.d.ts +71 -0
  73. package/dist/src/search.js +410 -0
  74. package/dist/src/search.js.map +1 -0
  75. package/dist/src/secret-patterns.d.ts +3 -0
  76. package/dist/src/secret-patterns.js +14 -0
  77. package/dist/src/secret-patterns.js.map +1 -0
  78. package/dist/src/utils/args.d.ts +2 -0
  79. package/dist/src/utils/args.js +19 -0
  80. package/dist/src/utils/args.js.map +1 -0
  81. package/dist/src/utils/dotenv.d.ts +7 -0
  82. package/dist/src/utils/dotenv.js +73 -0
  83. package/dist/src/utils/dotenv.js.map +1 -0
  84. package/dist/src/utils/fs.d.ts +22 -0
  85. package/dist/src/utils/fs.js +83 -0
  86. package/dist/src/utils/fs.js.map +1 -0
  87. package/dist/src/utils/git.d.ts +13 -0
  88. package/dist/src/utils/git.js +39 -0
  89. package/dist/src/utils/git.js.map +1 -0
  90. package/dist/src/wiki-graph.d.ts +74 -0
  91. package/dist/src/wiki-graph.js +335 -0
  92. package/dist/src/wiki-graph.js.map +1 -0
  93. package/dist/src/wiki-patch.d.ts +152 -0
  94. package/dist/src/wiki-patch.js +489 -0
  95. package/dist/src/wiki-patch.js.map +1 -0
  96. package/dist/src/wiki-query.d.ts +63 -0
  97. package/dist/src/wiki-query.js +255 -0
  98. package/dist/src/wiki-query.js.map +1 -0
  99. package/dist/test/cli.test.d.ts +1 -0
  100. package/dist/test/cli.test.js +514 -0
  101. package/dist/test/cli.test.js.map +1 -0
  102. package/dist/test/compiler-eval.test.d.ts +1 -0
  103. package/dist/test/compiler-eval.test.js +234 -0
  104. package/dist/test/compiler-eval.test.js.map +1 -0
  105. package/dist/test/compiler.test.d.ts +1 -0
  106. package/dist/test/compiler.test.js +2537 -0
  107. package/dist/test/compiler.test.js.map +1 -0
  108. package/dist/test/context-assembler.test.d.ts +1 -0
  109. package/dist/test/context-assembler.test.js +379 -0
  110. package/dist/test/context-assembler.test.js.map +1 -0
  111. package/dist/test/docs-linter.test.d.ts +1 -0
  112. package/dist/test/docs-linter.test.js +900 -0
  113. package/dist/test/docs-linter.test.js.map +1 -0
  114. package/dist/test/dotenv.test.d.ts +1 -0
  115. package/dist/test/dotenv.test.js +77 -0
  116. package/dist/test/dotenv.test.js.map +1 -0
  117. package/dist/test/extractors-go.test.d.ts +1 -0
  118. package/dist/test/extractors-go.test.js +393 -0
  119. package/dist/test/extractors-go.test.js.map +1 -0
  120. package/dist/test/extractors-rust.test.d.ts +1 -0
  121. package/dist/test/extractors-rust.test.js +219 -0
  122. package/dist/test/extractors-rust.test.js.map +1 -0
  123. package/dist/test/extractors-utils.test.d.ts +1 -0
  124. package/dist/test/extractors-utils.test.js +786 -0
  125. package/dist/test/extractors-utils.test.js.map +1 -0
  126. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.d.ts +1 -0
  127. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js +4 -0
  128. package/dist/test/fixtures/compiler-e2e/basic-node-service/repo/infra/deploy.js.map +1 -0
  129. package/dist/test/frontmatter.test.d.ts +1 -0
  130. package/dist/test/frontmatter.test.js +287 -0
  131. package/dist/test/frontmatter.test.js.map +1 -0
  132. package/dist/test/init-planner.test.d.ts +1 -0
  133. package/dist/test/init-planner.test.js +688 -0
  134. package/dist/test/init-planner.test.js.map +1 -0
  135. package/dist/test/linter.test.d.ts +1 -0
  136. package/dist/test/linter.test.js +426 -0
  137. package/dist/test/linter.test.js.map +1 -0
  138. package/dist/test/llm-provider.test.d.ts +1 -0
  139. package/dist/test/llm-provider.test.js +783 -0
  140. package/dist/test/llm-provider.test.js.map +1 -0
  141. package/dist/test/page-ownership.test.d.ts +1 -0
  142. package/dist/test/page-ownership.test.js +247 -0
  143. package/dist/test/page-ownership.test.js.map +1 -0
  144. package/dist/test/publisher.test.d.ts +1 -0
  145. package/dist/test/publisher.test.js +1297 -0
  146. package/dist/test/publisher.test.js.map +1 -0
  147. package/dist/test/repository-analysis.test.d.ts +1 -0
  148. package/dist/test/repository-analysis.test.js +182 -0
  149. package/dist/test/repository-analysis.test.js.map +1 -0
  150. package/dist/test/run-compiled-tests.d.ts +1 -0
  151. package/dist/test/run-compiled-tests.js +48 -0
  152. package/dist/test/run-compiled-tests.js.map +1 -0
  153. package/dist/test/scanner.test.d.ts +1 -0
  154. package/dist/test/scanner.test.js +551 -0
  155. package/dist/test/scanner.test.js.map +1 -0
  156. package/dist/test/search.test.d.ts +1 -0
  157. package/dist/test/search.test.js +92 -0
  158. package/dist/test/search.test.js.map +1 -0
  159. package/dist/test/update-changelog.test.d.ts +1 -0
  160. package/dist/test/update-changelog.test.js +125 -0
  161. package/dist/test/update-changelog.test.js.map +1 -0
  162. package/dist/test/wiki-graph.test.d.ts +1 -0
  163. package/dist/test/wiki-graph.test.js +164 -0
  164. package/dist/test/wiki-graph.test.js.map +1 -0
  165. package/dist/test/wiki-patch.test.d.ts +1 -0
  166. package/dist/test/wiki-patch.test.js +610 -0
  167. package/dist/test/wiki-patch.test.js.map +1 -0
  168. package/dist/test/wiki-query.test.d.ts +1 -0
  169. package/dist/test/wiki-query.test.js +163 -0
  170. package/dist/test/wiki-query.test.js.map +1 -0
  171. package/docs/PLAN.md +993 -0
  172. package/docs/WHY.md +61 -0
  173. package/docs/plans/agent-integration.md +85 -0
  174. package/docs/plans/ci-publishing.md +111 -0
  175. package/docs/plans/doc-validation.md +92 -0
  176. package/docs/plans/github-action.md +113 -0
  177. package/docs/plans/incremental-mode.md +98 -0
  178. package/docs/plans/karpathy-llm-wiki-alignment.md +84 -0
  179. package/docs/plans/llm-compiler.md +160 -0
  180. package/docs/plans/production-scanner.md +104 -0
  181. package/docs/plans/query-and-file-back.md +103 -0
  182. package/docs/plans/search-index.md +118 -0
  183. package/docs/plans/trust-hardening.md +74 -0
  184. package/docs/plans/wiki-graph.md +183 -0
  185. package/docs/plans/wiki-health.md +76 -0
  186. package/package.json +83 -0
  187. package/prompts/compiler.md +16 -0
  188. package/prompts/lint.md +18 -0
  189. package/prompts/page-templates.md +25 -0
  190. package/skills/repo-wiki-cli/SKILL.md +139 -0
@@ -0,0 +1,1297 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { promises as fs } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { publishWiki, rewriteInternalWikiLinks } from '../src/publisher.js';
9
+ const execFileAsync = promisify(execFile);
10
+ async function git(args, cwd) {
11
+ return execFileAsync('git', args, cwd ? { cwd } : undefined);
12
+ }
13
+ async function fileExists(filePath) {
14
+ try {
15
+ await fs.access(filePath);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function legacyGeneratedPagesLayout() {
23
+ return `<!doctype html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1">
28
+ <title>{% if page.title %}{{ page.title | escape }}{% else %}{{ page.name | replace: '.md', '' | escape }}{% endif %}</title>
29
+ </head>
30
+ <body>
31
+ <main>
32
+ {{ content }}
33
+ </main>
34
+ <script type="module">
35
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
36
+ const blocks = document.querySelectorAll('pre > code.language-mermaid');
37
+ await mermaid.run({ nodes: document.querySelectorAll('.mermaid') });
38
+ </script>
39
+ </body>
40
+ </html>
41
+ `;
42
+ }
43
+ test('publishWiki redacts credential-bearing remotes in dry-run summaries', async () => {
44
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
45
+ const wikiDir = path.join(tempDir, 'wiki');
46
+ try {
47
+ await fs.mkdir(wikiDir, { recursive: true });
48
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
49
+ const result = await publishWiki({
50
+ wikiDir,
51
+ remote: 'https://x-access-token:super-secret@github.com/OWNER/REPO.wiki.git',
52
+ dryRun: true
53
+ });
54
+ assert.equal(result.summary.status, 'dry-run');
55
+ assert.equal(result.summary.target, 'github-wiki');
56
+ assert.equal(result.summary.path, '.');
57
+ assert.equal(result.summary.frontmatterPolicy, 'provenance');
58
+ assert.equal(result.summary.remote, 'https://***:***@github.com/OWNER/REPO.wiki.git');
59
+ const tokenOnlyResult = await publishWiki({
60
+ wikiDir,
61
+ remote: 'https://super-secret-token@github.com/OWNER/REPO.wiki.git',
62
+ dryRun: true
63
+ });
64
+ assert.equal(tokenOnlyResult.summary.remote, 'https://***@github.com/OWNER/REPO.wiki.git');
65
+ }
66
+ finally {
67
+ await fs.rm(tempDir, { recursive: true, force: true });
68
+ }
69
+ });
70
+ test('publishWiki rethrows non-fallback clone errors with credential-bearing remotes redacted', async () => {
71
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
72
+ const wikiDir = path.join(tempDir, 'wiki');
73
+ try {
74
+ await fs.mkdir(wikiDir, { recursive: true });
75
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
76
+ await assert.rejects(() => publishWiki({
77
+ wikiDir,
78
+ remote: 'https://super-secret-token@127.0.0.1:1/OWNER/REPO.wiki.git',
79
+ branch: 'master'
80
+ }), (error) => {
81
+ const serialized = JSON.stringify(error, Object.getOwnPropertyNames(error));
82
+ assert.equal(serialized.includes('super-secret-token'), false);
83
+ assert.equal(serialized.includes('https://***@127.0.0.1:1/OWNER/REPO.wiki.git'), true);
84
+ return true;
85
+ });
86
+ }
87
+ finally {
88
+ await fs.rm(tempDir, { recursive: true, force: true });
89
+ }
90
+ });
91
+ test('publishWiki strips frontmatter from top-level and nested markdown without changing local wiki files', async () => {
92
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
93
+ const wikiDir = path.join(tempDir, 'wiki');
94
+ const nestedDir = path.join(wikiDir, 'nested');
95
+ const remoteDir = path.join(tempDir, 'remote.git');
96
+ const checkoutDir = path.join(tempDir, 'checkout');
97
+ try {
98
+ await fs.mkdir(nestedDir, { recursive: true });
99
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\n---\n# Home\n', 'utf8');
100
+ await fs.writeFile(path.join(wikiDir, 'Page.md'), '---\nkind: page\n---\n# Top-level page\n', 'utf8');
101
+ await fs.writeFile(path.join(nestedDir, 'Page.md'), '---\nkind: nested\n---\n# Nested\n', 'utf8');
102
+ await fs.writeFile(path.join(wikiDir, 'asset.txt'), '---\nnot markdown\n---\n', 'utf8');
103
+ await fs.symlink('asset.txt', path.join(wikiDir, 'asset-link.txt'));
104
+ await git(['init', '--bare', remoteDir]);
105
+ const result = await publishWiki({
106
+ wikiDir,
107
+ remote: remoteDir,
108
+ branch: 'master',
109
+ message: 'Publish test wiki',
110
+ frontmatterPolicy: 'strip'
111
+ });
112
+ assert.equal(result.summary.status, 'published');
113
+ assert.equal(result.summary.target, 'github-wiki');
114
+ assert.equal(result.summary.frontmatterPolicy, 'strip');
115
+ assert.equal(result.summary.pages, 3);
116
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
117
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8'), '# Home\n');
118
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'Page.md'), 'utf8'), '# Top-level page\n');
119
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'nested', 'Page.md'), 'utf8'), '# Nested\n');
120
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'asset.txt'), 'utf8'), '---\nnot markdown\n---\n');
121
+ assert.equal(await fs.readlink(path.join(checkoutDir, 'asset-link.txt')), 'asset.txt');
122
+ assert.equal(await fs.readFile(path.join(wikiDir, 'Home.md'), 'utf8'), '---\nkind: home\n---\n# Home\n');
123
+ assert.equal(await fs.readFile(path.join(nestedDir, 'Page.md'), 'utf8'), '---\nkind: nested\n---\n# Nested\n');
124
+ }
125
+ finally {
126
+ await fs.rm(tempDir, { recursive: true, force: true });
127
+ }
128
+ });
129
+ test('publishWiki renders a provenance block for github-wiki by default', async () => {
130
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
131
+ const wikiDir = path.join(tempDir, 'wiki');
132
+ const remoteDir = path.join(tempDir, 'remote.git');
133
+ const checkoutDir = path.join(tempDir, 'checkout');
134
+ try {
135
+ await fs.mkdir(wikiDir, { recursive: true });
136
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), [
137
+ '---',
138
+ 'source_repo: "https://github.com/mfittko/repo-wiki.git"',
139
+ 'source_commit: "abc1234def5678"',
140
+ 'compiled_at: "2026-05-10T00:00:00.000Z"',
141
+ 'kind: "home"',
142
+ 'page_state: "generated"',
143
+ 'confidence: "medium"',
144
+ 'claim_status: "source-grounded"',
145
+ 'source_paths:',
146
+ ' - "src/publisher.ts"',
147
+ '---',
148
+ '# Home',
149
+ ''
150
+ ].join('\n'), 'utf8');
151
+ await git(['init', '--bare', remoteDir]);
152
+ const result = await publishWiki({
153
+ wikiDir,
154
+ remote: remoteDir,
155
+ branch: 'master',
156
+ message: 'Publish provenance wiki'
157
+ });
158
+ assert.equal(result.summary.status, 'published');
159
+ assert.equal(result.summary.frontmatterPolicy, 'provenance');
160
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
161
+ const published = await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8');
162
+ assert.match(published, /\*\*Generated from:\*\* `https:\/\/github\.com\/mfittko\/repo-wiki\.git`/);
163
+ assert.match(published, /\*\*Source commit:\*\* \[`abc1234`\]\(https:\/\/github\.com\/mfittko\/repo-wiki\/tree\/abc1234def5678\)/);
164
+ assert.match(published, /\*\*Compiled at:\*\* `2026-05-10T00:00:00\.000Z`/);
165
+ assert.match(published, /\*\*Page kind:\*\* `home`/);
166
+ assert.match(published, /\*\*Page state:\*\* `generated`/);
167
+ assert.match(published, /\*\*Confidence:\*\* `medium`/);
168
+ assert.match(published, /\*\*Claim status:\*\* `source-grounded`/);
169
+ assert.match(published, /\*\*Primary sources:\*\* \[src\/publisher\.ts\]\(https:\/\/github\.com\/mfittko\/repo-wiki\/blob\/abc1234def5678\/src\/publisher\.ts\)/);
170
+ assert.equal((await fs.readFile(path.join(wikiDir, 'Home.md'), 'utf8')).startsWith('---\n'), true);
171
+ }
172
+ finally {
173
+ await fs.rm(tempDir, { recursive: true, force: true });
174
+ }
175
+ });
176
+ test('publishWiki removes checkout files deleted from the local wiki', async () => {
177
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
178
+ const wikiDir = path.join(tempDir, 'wiki');
179
+ const remoteDir = path.join(tempDir, 'remote.git');
180
+ const checkoutDir = path.join(tempDir, 'checkout');
181
+ try {
182
+ await fs.mkdir(wikiDir, { recursive: true });
183
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
184
+ await fs.writeFile(path.join(wikiDir, 'Removed.md'), '# Remove me\n', 'utf8');
185
+ await git(['init', '--bare', remoteDir]);
186
+ const firstPublish = await publishWiki({
187
+ wikiDir,
188
+ remote: remoteDir,
189
+ branch: 'master',
190
+ message: 'Publish initial test wiki'
191
+ });
192
+ assert.equal(firstPublish.summary.status, 'published');
193
+ await fs.rm(path.join(wikiDir, 'Removed.md'));
194
+ const secondPublish = await publishWiki({
195
+ wikiDir,
196
+ remote: remoteDir,
197
+ branch: 'master',
198
+ message: 'Publish deleted test wiki page'
199
+ });
200
+ assert.equal(secondPublish.summary.status, 'published');
201
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
202
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8'), '# Home\n');
203
+ assert.equal(await fileExists(path.join(checkoutDir, 'Removed.md')), false);
204
+ }
205
+ finally {
206
+ await fs.rm(tempDir, { recursive: true, force: true });
207
+ }
208
+ });
209
+ test('publishWiki reports no changes when only stripped frontmatter changes', async () => {
210
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
211
+ const wikiDir = path.join(tempDir, 'wiki');
212
+ const remoteDir = path.join(tempDir, 'remote.git');
213
+ const checkoutDir = path.join(tempDir, 'checkout');
214
+ try {
215
+ await fs.mkdir(wikiDir, { recursive: true });
216
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\nsource_commit: abc123\n---\n# Home\n', 'utf8');
217
+ await git(['init', '--bare', remoteDir]);
218
+ const firstPublish = await publishWiki({
219
+ wikiDir,
220
+ remote: remoteDir,
221
+ branch: 'master',
222
+ message: 'Publish test wiki',
223
+ frontmatterPolicy: 'strip'
224
+ });
225
+ assert.equal(firstPublish.summary.status, 'published');
226
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\nsource_commit: def456\n---\n# Home\n', 'utf8');
227
+ const secondPublish = await publishWiki({
228
+ wikiDir,
229
+ remote: remoteDir,
230
+ branch: 'master',
231
+ message: 'Publish changed frontmatter only',
232
+ frontmatterPolicy: 'strip'
233
+ });
234
+ assert.equal(secondPublish.summary.status, 'no-changes');
235
+ assert.equal(secondPublish.summary.target, 'github-wiki');
236
+ assert.equal(secondPublish.summary.frontmatterPolicy, 'strip');
237
+ assert.equal(secondPublish.summary.pages, 1);
238
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
239
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8'), '# Home\n');
240
+ assert.equal(await fs.readFile(path.join(wikiDir, 'Home.md'), 'utf8'), '---\nkind: home\nsource_commit: def456\n---\n# Home\n');
241
+ }
242
+ finally {
243
+ await fs.rm(tempDir, { recursive: true, force: true });
244
+ }
245
+ });
246
+ test('publishWiki uses target-specific defaults in dry-run summaries', async () => {
247
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
248
+ const wikiDir = path.join(tempDir, 'wiki');
249
+ try {
250
+ await fs.mkdir(wikiDir, { recursive: true });
251
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\n---\n# Home\n', 'utf8');
252
+ const pagesResult = await publishWiki({
253
+ wikiDir,
254
+ target: 'github-pages',
255
+ dryRun: true
256
+ });
257
+ assert.equal(pagesResult.summary.status, 'dry-run');
258
+ assert.equal(pagesResult.summary.target, 'github-pages');
259
+ assert.equal(pagesResult.summary.branch, 'gh-pages');
260
+ assert.equal(pagesResult.summary.path, '.');
261
+ assert.equal(pagesResult.summary.frontmatterPolicy, 'preserve');
262
+ const wikiResult = await publishWiki({
263
+ wikiDir,
264
+ target: 'github-wiki',
265
+ dryRun: true
266
+ });
267
+ assert.equal(wikiResult.summary.status, 'dry-run');
268
+ assert.equal(wikiResult.summary.target, 'github-wiki');
269
+ assert.equal(wikiResult.summary.branch, 'master');
270
+ assert.equal(wikiResult.summary.path, '.');
271
+ assert.equal(wikiResult.summary.frontmatterPolicy, 'provenance');
272
+ }
273
+ finally {
274
+ await fs.rm(tempDir, { recursive: true, force: true });
275
+ }
276
+ });
277
+ test('publishWiki skipped-no-remote guidance mentions supported wiki remote environment variables', async () => {
278
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
279
+ const wikiDir = path.join(tempDir, 'wiki');
280
+ const previousPublishRemote = process.env.LLMWIKI_PUBLISH_REMOTE;
281
+ const previousWikiRemote = process.env.GITHUB_WIKI_REMOTE;
282
+ try {
283
+ await fs.mkdir(wikiDir, { recursive: true });
284
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
285
+ delete process.env.LLMWIKI_PUBLISH_REMOTE;
286
+ delete process.env.GITHUB_WIKI_REMOTE;
287
+ const result = await publishWiki({
288
+ wikiDir,
289
+ target: 'github-wiki'
290
+ });
291
+ assert.equal(result.summary.status, 'skipped-no-remote');
292
+ assert.match(result.summary.next_step, /LLMWIKI_PUBLISH_REMOTE/);
293
+ assert.match(result.summary.next_step, /GITHUB_WIKI_REMOTE/);
294
+ assert.match(result.summary.next_step, /--remote/);
295
+ }
296
+ finally {
297
+ if (previousPublishRemote === undefined) {
298
+ delete process.env.LLMWIKI_PUBLISH_REMOTE;
299
+ }
300
+ else {
301
+ process.env.LLMWIKI_PUBLISH_REMOTE = previousPublishRemote;
302
+ }
303
+ if (previousWikiRemote === undefined) {
304
+ delete process.env.GITHUB_WIKI_REMOTE;
305
+ }
306
+ else {
307
+ process.env.GITHUB_WIKI_REMOTE = previousWikiRemote;
308
+ }
309
+ await fs.rm(tempDir, { recursive: true, force: true });
310
+ }
311
+ });
312
+ test('publishWiki resolves target-specific remote environment variables', async () => {
313
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
314
+ const wikiDir = path.join(tempDir, 'wiki');
315
+ const previousPublishRemote = process.env.LLMWIKI_PUBLISH_REMOTE;
316
+ const previousWikiRemote = process.env.GITHUB_WIKI_REMOTE;
317
+ try {
318
+ await fs.mkdir(wikiDir, { recursive: true });
319
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
320
+ delete process.env.LLMWIKI_PUBLISH_REMOTE;
321
+ process.env.GITHUB_WIKI_REMOTE = 'https://github.com/OWNER/REPO.wiki.git';
322
+ const pagesWithoutPublishRemote = await publishWiki({
323
+ wikiDir,
324
+ target: 'github-pages',
325
+ dryRun: true
326
+ });
327
+ assert.equal(pagesWithoutPublishRemote.summary.remote, null);
328
+ const wikiResult = await publishWiki({
329
+ wikiDir,
330
+ target: 'github-wiki',
331
+ dryRun: true
332
+ });
333
+ assert.equal(wikiResult.summary.remote, 'https://github.com/OWNER/REPO.wiki.git');
334
+ process.env.LLMWIKI_PUBLISH_REMOTE = 'https://github.com/OWNER/REPO.git';
335
+ const pagesWithPublishRemote = await publishWiki({
336
+ wikiDir,
337
+ target: 'github-pages',
338
+ dryRun: true
339
+ });
340
+ assert.equal(pagesWithPublishRemote.summary.remote, 'https://github.com/OWNER/REPO.git');
341
+ }
342
+ finally {
343
+ if (previousPublishRemote === undefined) {
344
+ delete process.env.LLMWIKI_PUBLISH_REMOTE;
345
+ }
346
+ else {
347
+ process.env.LLMWIKI_PUBLISH_REMOTE = previousPublishRemote;
348
+ }
349
+ if (previousWikiRemote === undefined) {
350
+ delete process.env.GITHUB_WIKI_REMOTE;
351
+ }
352
+ else {
353
+ process.env.GITHUB_WIKI_REMOTE = previousWikiRemote;
354
+ }
355
+ await fs.rm(tempDir, { recursive: true, force: true });
356
+ }
357
+ });
358
+ test('publishWiki publishes github-pages output into configured path and preserves frontmatter by default', async () => {
359
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
360
+ const wikiDir = path.join(tempDir, 'wiki');
361
+ const remoteDir = path.join(tempDir, 'remote.git');
362
+ const checkoutDir = path.join(tempDir, 'checkout');
363
+ try {
364
+ await fs.mkdir(wikiDir, { recursive: true });
365
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\n---\n# Home\n', 'utf8');
366
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), [
367
+ '---',
368
+ 'kind: sidebar',
369
+ '---',
370
+ '# Navigation',
371
+ '',
372
+ '- [Home](Home)',
373
+ '- [Architecture](Architecture)',
374
+ '- [Index with extension](Index.md)',
375
+ '- [External](https://example.com/docs)',
376
+ '- [Anchor](#top)',
377
+ ''
378
+ ].join('\n'), 'utf8');
379
+ await git(['init', '--bare', remoteDir]);
380
+ const publishResult = await publishWiki({
381
+ wikiDir,
382
+ remote: remoteDir,
383
+ target: 'github-pages',
384
+ branch: 'gh-pages',
385
+ pagesPath: 'docs',
386
+ message: 'Publish pages wiki'
387
+ });
388
+ assert.equal(publishResult.summary.status, 'published');
389
+ assert.equal(publishResult.summary.target, 'github-pages');
390
+ assert.equal(publishResult.summary.path, 'docs');
391
+ assert.equal(publishResult.summary.frontmatterPolicy, 'preserve');
392
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
393
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'Home.md'), 'utf8'), '---\nkind: home\n---\n# Home\n');
394
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'index.md'), 'utf8'), '---\nkind: home\n---\n# Home\n');
395
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'Navigation.md'), 'utf8'), [
396
+ '---',
397
+ 'kind: sidebar',
398
+ '---',
399
+ '# Navigation',
400
+ '',
401
+ '- [Home](Home.html)',
402
+ '- [Architecture](Architecture.html)',
403
+ '- [Index with extension](Index.html)',
404
+ '- [External](https://example.com/docs)',
405
+ '- [Anchor](#top)',
406
+ ''
407
+ ].join('\n'));
408
+ const pagesConfig = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
409
+ assert.match(pagesConfig, /layout: "repo-wiki"/);
410
+ const pagesLayout = await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8');
411
+ assert.match(pagesLayout, /mermaid@11/);
412
+ assert.match(pagesLayout, /code\.language-mermaid/);
413
+ assert.equal(await fileExists(path.join(checkoutDir, 'Home.md')), false);
414
+ }
415
+ finally {
416
+ await fs.rm(tempDir, { recursive: true, force: true });
417
+ }
418
+ });
419
+ test('publishWiki preserves frontmatter for github-wiki when explicitly requested', async () => {
420
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
421
+ const wikiDir = path.join(tempDir, 'wiki');
422
+ const remoteDir = path.join(tempDir, 'remote.git');
423
+ const checkoutDir = path.join(tempDir, 'checkout');
424
+ try {
425
+ await fs.mkdir(wikiDir, { recursive: true });
426
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\nsource_commit: abc123\n---\n# Home\n', 'utf8');
427
+ await git(['init', '--bare', remoteDir]);
428
+ const result = await publishWiki({
429
+ wikiDir,
430
+ remote: remoteDir,
431
+ branch: 'master',
432
+ message: 'Publish preserved wiki',
433
+ frontmatterPolicy: 'preserve'
434
+ });
435
+ assert.equal(result.summary.status, 'published');
436
+ assert.equal(result.summary.frontmatterPolicy, 'preserve');
437
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
438
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8'), '---\nkind: home\nsource_commit: abc123\n---\n# Home\n');
439
+ }
440
+ finally {
441
+ await fs.rm(tempDir, { recursive: true, force: true });
442
+ }
443
+ });
444
+ test('publishWiki can render provenance blocks for github-pages when explicitly requested', async () => {
445
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
446
+ const wikiDir = path.join(tempDir, 'wiki');
447
+ const remoteDir = path.join(tempDir, 'remote.git');
448
+ const checkoutDir = path.join(tempDir, 'checkout');
449
+ try {
450
+ await fs.mkdir(wikiDir, { recursive: true });
451
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), [
452
+ '---',
453
+ 'source_repo: "https://github.com/mfittko/repo-wiki.git"',
454
+ 'source_commit: "abc1234def5678"',
455
+ 'source_paths: ["src/publisher.ts"]',
456
+ '---',
457
+ '# Home',
458
+ ''
459
+ ].join('\n'), 'utf8');
460
+ await git(['init', '--bare', remoteDir]);
461
+ const publishResult = await publishWiki({
462
+ wikiDir,
463
+ remote: remoteDir,
464
+ target: 'github-pages',
465
+ branch: 'gh-pages',
466
+ pagesPath: 'docs',
467
+ message: 'Publish pages provenance wiki',
468
+ frontmatterPolicy: 'provenance'
469
+ });
470
+ assert.equal(publishResult.summary.status, 'published');
471
+ assert.equal(publishResult.summary.frontmatterPolicy, 'provenance');
472
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
473
+ const published = await fs.readFile(path.join(checkoutDir, 'docs', 'Home.md'), 'utf8');
474
+ assert.match(published, /\*\*Generated from:\*\* `https:\/\/github\.com\/mfittko\/repo-wiki\.git`/);
475
+ assert.match(published, /\*\*Primary sources:\*\* \[src\/publisher\.ts\]\(https:\/\/github\.com\/mfittko\/repo-wiki\/blob\/abc1234def5678\/src\/publisher\.ts\)/);
476
+ }
477
+ finally {
478
+ await fs.rm(tempDir, { recursive: true, force: true });
479
+ }
480
+ });
481
+ test('publishWiki rejects unsafe git branch and remote arguments', async () => {
482
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
483
+ const wikiDir = path.join(tempDir, 'wiki');
484
+ try {
485
+ await fs.mkdir(wikiDir, { recursive: true });
486
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
487
+ await assert.rejects(() => publishWiki({
488
+ wikiDir,
489
+ branch: '--upload-pack=echo pwned',
490
+ dryRun: true
491
+ }), /must not start with whitespace or "-"/);
492
+ await assert.rejects(() => publishWiki({
493
+ wikiDir,
494
+ remote: '--upload-pack=echo pwned',
495
+ dryRun: true
496
+ }), /must not start with whitespace or "-"/);
497
+ await assert.rejects(() => publishWiki({
498
+ wikiDir,
499
+ remote: ' https://super-secret-token@github.com/OWNER/REPO.git',
500
+ dryRun: true
501
+ }), (error) => {
502
+ assert.ok(error instanceof Error);
503
+ assert.equal(error.message.includes('super-secret-token'), false);
504
+ assert.match(error.message, /Publish remote must not start with whitespace or "-"\./);
505
+ return true;
506
+ });
507
+ await assert.rejects(() => publishWiki({
508
+ wikiDir,
509
+ branch: 'main\ninjected',
510
+ dryRun: true
511
+ }), /contains unsupported control characters/);
512
+ }
513
+ finally {
514
+ await fs.rm(tempDir, { recursive: true, force: true });
515
+ }
516
+ });
517
+ test('publishWiki rejects unsafe github-pages publish paths', async () => {
518
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
519
+ const wikiDir = path.join(tempDir, 'wiki');
520
+ try {
521
+ await fs.mkdir(wikiDir, { recursive: true });
522
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
523
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '/absolute', dryRun: true }), /Publish path must be relative/);
524
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '\\foo', dryRun: true }), /Publish path must be relative/);
525
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '\\\\server\\share', dryRun: true }), /Publish path must be relative/);
526
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs\ninjected', dryRun: true }), /contains unsupported control characters/);
527
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs\rInjected', dryRun: true }), /contains unsupported control characters/);
528
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs\0injected', dryRun: true }), /contains unsupported control characters/);
529
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '../sibling', dryRun: true }), /must not contain "\.\." path segments/);
530
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs/../secret', dryRun: true }), /must not contain "\.\." path segments/);
531
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs/..', dryRun: true }), /must not contain "\.\." path segments/);
532
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '.git', dryRun: true }), /reserved \.git paths/);
533
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs/.git', dryRun: true }), /reserved \.git paths/);
534
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: '.GIT', dryRun: true }), /reserved \.git paths/);
535
+ await assert.rejects(() => publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs/.Git', dryRun: true }), /reserved \.git paths/);
536
+ const safeDotSegment = await publishWiki({ wikiDir, target: 'github-pages', pagesPath: 'docs/./site', dryRun: true });
537
+ assert.equal(safeDotSegment.summary.path, 'docs/site');
538
+ const safeSimilarSegment = await publishWiki({ wikiDir, target: 'github-pages', pagesPath: '.github/pages', dryRun: true });
539
+ assert.equal(safeSimilarSegment.summary.path, '.github/pages');
540
+ }
541
+ finally {
542
+ await fs.rm(tempDir, { recursive: true, force: true });
543
+ }
544
+ });
545
+ test('publishWiki preserves non-markdown files under github-pages publish path', async () => {
546
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
547
+ const wikiDir = path.join(tempDir, 'wiki');
548
+ const remoteDir = path.join(tempDir, 'remote.git');
549
+ const seedDir = path.join(tempDir, 'seed');
550
+ const checkoutDir = path.join(tempDir, 'checkout');
551
+ try {
552
+ await fs.mkdir(wikiDir, { recursive: true });
553
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# New home\n', 'utf8');
554
+ await git(['init', '--bare', remoteDir]);
555
+ await git(['clone', remoteDir, seedDir]);
556
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
557
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
558
+ await fs.mkdir(path.join(seedDir, 'docs', 'assets'), { recursive: true });
559
+ await fs.mkdir(path.join(seedDir, '_layouts'), { recursive: true });
560
+ await fs.writeFile(path.join(seedDir, 'docs', 'Old.md'), '# Old generated page\n', 'utf8');
561
+ await fs.writeFile(path.join(seedDir, 'docs', 'index.md'), '# Existing index\n', 'utf8');
562
+ await fs.writeFile(path.join(seedDir, 'docs', 'Navigation.md'), '# Existing navigation\n', 'utf8');
563
+ await fs.writeFile(path.join(seedDir, 'docs', 'assets', 'logo.txt'), 'keep asset\n', 'utf8');
564
+ await fs.writeFile(path.join(seedDir, '_config.yml'), 'title: Existing site\n', 'utf8');
565
+ await fs.writeFile(path.join(seedDir, '_layouts', 'repo-wiki.html'), '<main>{{ content }}</main>\n', 'utf8');
566
+ await fs.writeFile(path.join(seedDir, 'docs', 'CNAME'), 'example.com\n', 'utf8');
567
+ await git(['add', '.'], seedDir);
568
+ await git(['commit', '-m', 'Seed existing pages site'], seedDir);
569
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
570
+ const result = await publishWiki({
571
+ wikiDir,
572
+ remote: remoteDir,
573
+ target: 'github-pages',
574
+ branch: 'gh-pages',
575
+ pagesPath: 'docs',
576
+ message: 'Publish pages without deleting site assets'
577
+ });
578
+ assert.equal(result.summary.status, 'published');
579
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
580
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'Home.md'), 'utf8'), '# New home\n');
581
+ assert.equal(await fileExists(path.join(checkoutDir, 'docs', 'Old.md')), false);
582
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'index.md'), 'utf8'), '# Existing index\n');
583
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'Navigation.md'), 'utf8'), '# Existing navigation\n');
584
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'assets', 'logo.txt'), 'utf8'), 'keep asset\n');
585
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8'), 'title: Existing site\n');
586
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8'), '<main>{{ content }}</main>\n');
587
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'CNAME'), 'utf8'), 'example.com\n');
588
+ }
589
+ finally {
590
+ await fs.rm(tempDir, { recursive: true, force: true });
591
+ }
592
+ });
593
+ test('publishWiki accepts contained github-pages paths whose names begin with dot-dot', async () => {
594
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
595
+ const wikiDir = path.join(tempDir, 'wiki');
596
+ const remoteDir = path.join(tempDir, 'remote.git');
597
+ const checkoutDir = path.join(tempDir, 'checkout');
598
+ try {
599
+ await fs.mkdir(wikiDir, { recursive: true });
600
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
601
+ await git(['init', '--bare', remoteDir]);
602
+ const result = await publishWiki({
603
+ wikiDir,
604
+ remote: remoteDir,
605
+ target: 'github-pages',
606
+ branch: 'gh-pages',
607
+ pagesPath: '..docs',
608
+ message: 'Publish pages wiki into dot-dot-prefixed path'
609
+ });
610
+ assert.equal(result.summary.status, 'published');
611
+ assert.equal(result.summary.path, '..docs');
612
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
613
+ assert.equal(await fs.readFile(path.join(checkoutDir, '..docs', 'Home.md'), 'utf8'), '# Home\n');
614
+ }
615
+ finally {
616
+ await fs.rm(tempDir, { recursive: true, force: true });
617
+ }
618
+ });
619
+ test('publishWiki keeps existing pages entry and navigation files when already present', async () => {
620
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
621
+ const wikiDir = path.join(tempDir, 'wiki');
622
+ const remoteDir = path.join(tempDir, 'remote.git');
623
+ const checkoutDir = path.join(tempDir, 'checkout');
624
+ try {
625
+ await fs.mkdir(wikiDir, { recursive: true });
626
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '---\nkind: home\n---\n# Home\n', 'utf8');
627
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '---\nkind: sidebar\n---\n# Navigation\n', 'utf8');
628
+ await fs.writeFile(path.join(wikiDir, 'index.md'), '# Existing index\n', 'utf8');
629
+ await fs.writeFile(path.join(wikiDir, 'Navigation.md'), '# Existing navigation\n', 'utf8');
630
+ await git(['init', '--bare', remoteDir]);
631
+ await publishWiki({
632
+ wikiDir,
633
+ remote: remoteDir,
634
+ target: 'github-pages',
635
+ branch: 'gh-pages',
636
+ pagesPath: 'docs',
637
+ message: 'Publish pages wiki with existing navigation'
638
+ });
639
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
640
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'index.md'), 'utf8'), '# Existing index\n');
641
+ assert.equal(await fs.readFile(path.join(checkoutDir, 'docs', 'Navigation.md'), 'utf8'), '# Existing navigation\n');
642
+ }
643
+ finally {
644
+ await fs.rm(tempDir, { recursive: true, force: true });
645
+ }
646
+ });
647
+ test('publishWiki generates _includes/wiki_nav.html from _Sidebar.md for github-pages', async () => {
648
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
649
+ const wikiDir = path.join(tempDir, 'wiki');
650
+ const remoteDir = path.join(tempDir, 'remote.git');
651
+ const checkoutDir = path.join(tempDir, 'checkout');
652
+ try {
653
+ await fs.mkdir(wikiDir, { recursive: true });
654
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
655
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), [
656
+ '## Contents',
657
+ '- [Home](Home)',
658
+ '- [Index](Index)',
659
+ '- [External](HTTPS://example.com/docs)',
660
+ '- [Mail](MAILTO:test@example.com)',
661
+ '## Foundation',
662
+ '- [Architecture](Architecture)',
663
+ '- [Build, Test & Run](Build-Test-and-Run)',
664
+ ].join('\n') + '\n', 'utf8');
665
+ await git(['init', '--bare', remoteDir]);
666
+ const result = await publishWiki({
667
+ wikiDir,
668
+ remote: remoteDir,
669
+ target: 'github-pages',
670
+ branch: 'gh-pages',
671
+ pagesPath: 'docs',
672
+ message: 'Publish with sidebar'
673
+ });
674
+ assert.equal(result.summary.status, 'published');
675
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
676
+ const navHtml = await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8');
677
+ // The generated marker must be present
678
+ assert.match(navHtml, /repo-wiki-generated/);
679
+ // Section headings
680
+ assert.match(navHtml, /<h4 class="nav-section">Contents<\/h4>/);
681
+ assert.match(navHtml, /<h4 class="nav-section">Foundation<\/h4>/);
682
+ // Internal links use {{ _base }} prefix for depth-relative navigation
683
+ assert.ok(navHtml.includes('href="{{ _base }}Home.html"'), 'Home link should use {{ _base }} prefix');
684
+ assert.ok(navHtml.includes('href="{{ _base }}Index.html"'), 'Index link should use {{ _base }} prefix');
685
+ assert.ok(navHtml.includes('href="{{ _base }}Architecture.html"'), 'Architecture link should use {{ _base }} prefix');
686
+ assert.ok(navHtml.includes('href="{{ _base }}Build-Test-and-Run.html"'), 'Build link should use {{ _base }} prefix');
687
+ assert.ok(navHtml.includes('href="HTTPS://example.com/docs"'), 'uppercase HTTPS link should not use {{ _base }} prefix');
688
+ assert.ok(navHtml.includes('href="MAILTO:test@example.com"'), 'uppercase MAILTO link should not use {{ _base }} prefix');
689
+ // Link text is HTML-escaped
690
+ assert.match(navHtml, />Build, Test &amp; Run<\/a>/);
691
+ }
692
+ finally {
693
+ await fs.rm(tempDir, { recursive: true, force: true });
694
+ }
695
+ });
696
+ test('publishWiki generates _includes/wiki_nav.html from Navigation.md when _Sidebar.md is absent', async () => {
697
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
698
+ const wikiDir = path.join(tempDir, 'wiki');
699
+ const remoteDir = path.join(tempDir, 'remote.git');
700
+ const checkoutDir = path.join(tempDir, 'checkout');
701
+ try {
702
+ await fs.mkdir(wikiDir, { recursive: true });
703
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
704
+ await fs.writeFile(path.join(wikiDir, 'Navigation.md'), '## Nav\n- [Home](Home)\n', 'utf8');
705
+ await git(['init', '--bare', remoteDir]);
706
+ await publishWiki({
707
+ wikiDir,
708
+ remote: remoteDir,
709
+ target: 'github-pages',
710
+ branch: 'gh-pages',
711
+ message: 'Publish with nav'
712
+ });
713
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
714
+ const navHtml = await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8');
715
+ assert.ok(navHtml.includes('href="{{ _base }}Home.html"'), 'Navigation.md fallback should also use {{ _base }} prefix');
716
+ }
717
+ finally {
718
+ await fs.rm(tempDir, { recursive: true, force: true });
719
+ }
720
+ });
721
+ test('publishWiki writes empty _includes/wiki_nav.html when no sidebar content exists', async () => {
722
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
723
+ const wikiDir = path.join(tempDir, 'wiki');
724
+ const remoteDir = path.join(tempDir, 'remote.git');
725
+ const checkoutDir = path.join(tempDir, 'checkout');
726
+ try {
727
+ await fs.mkdir(wikiDir, { recursive: true });
728
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
729
+ await git(['init', '--bare', remoteDir]);
730
+ await publishWiki({
731
+ wikiDir,
732
+ remote: remoteDir,
733
+ target: 'github-pages',
734
+ branch: 'gh-pages',
735
+ message: 'Publish without sidebar'
736
+ });
737
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
738
+ const navHtml = await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8');
739
+ assert.ok(await fileExists(path.join(checkoutDir, '_includes', 'wiki_nav.html')), 'wiki_nav.html should always be written');
740
+ assert.match(navHtml, /repo-wiki-generated/, 'generated marker should be present even in empty nav');
741
+ }
742
+ finally {
743
+ await fs.rm(tempDir, { recursive: true, force: true });
744
+ }
745
+ });
746
+ test('publishWiki preserves existing _includes/wiki_nav.html without overwriting', async () => {
747
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
748
+ const wikiDir = path.join(tempDir, 'wiki');
749
+ const remoteDir = path.join(tempDir, 'remote.git');
750
+ const seedDir = path.join(tempDir, 'seed');
751
+ const checkoutDir = path.join(tempDir, 'checkout');
752
+ try {
753
+ await fs.mkdir(wikiDir, { recursive: true });
754
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
755
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n', 'utf8');
756
+ await git(['init', '--bare', remoteDir]);
757
+ await git(['clone', remoteDir, seedDir]);
758
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
759
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
760
+ await fs.mkdir(path.join(seedDir, '_includes'), { recursive: true });
761
+ await fs.writeFile(path.join(seedDir, '_includes', 'wiki_nav.html'), '<nav>custom nav</nav>\n', 'utf8');
762
+ await git(['add', '.'], seedDir);
763
+ await git(['commit', '-m', 'Seed custom nav include'], seedDir);
764
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
765
+ await publishWiki({
766
+ wikiDir,
767
+ remote: remoteDir,
768
+ target: 'github-pages',
769
+ branch: 'gh-pages',
770
+ message: 'Publish without overwriting custom nav'
771
+ });
772
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
773
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8'), '<nav>custom nav</nav>\n');
774
+ }
775
+ finally {
776
+ await fs.rm(tempDir, { recursive: true, force: true });
777
+ }
778
+ });
779
+ test('publishWiki preserves markerless generated-like custom nav include', async () => {
780
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
781
+ const wikiDir = path.join(tempDir, 'wiki');
782
+ const remoteDir = path.join(tempDir, 'remote.git');
783
+ const seedDir = path.join(tempDir, 'seed');
784
+ const checkoutDir = path.join(tempDir, 'checkout');
785
+ const customNav = [
786
+ '<h4 class="nav-section">Custom</h4>',
787
+ '<ul>',
788
+ '<li><a href="Custom.md">Custom</a></li>',
789
+ '<li><a href="https://example.com">External</a></li>',
790
+ '</ul>',
791
+ ''
792
+ ].join('\n');
793
+ try {
794
+ await fs.mkdir(wikiDir, { recursive: true });
795
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
796
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n- [Architecture](Architecture)\n', 'utf8');
797
+ await git(['init', '--bare', remoteDir]);
798
+ await git(['clone', remoteDir, seedDir]);
799
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
800
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
801
+ await fs.mkdir(path.join(seedDir, '_includes'), { recursive: true });
802
+ await fs.writeFile(path.join(seedDir, '_includes', 'wiki_nav.html'), customNav, 'utf8');
803
+ await git(['add', '.'], seedDir);
804
+ await git(['commit', '-m', 'Seed generated-like custom nav include'], seedDir);
805
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
806
+ await publishWiki({
807
+ wikiDir,
808
+ remote: remoteDir,
809
+ target: 'github-pages',
810
+ branch: 'gh-pages',
811
+ message: 'Publish without overwriting generated-like custom nav'
812
+ });
813
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
814
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8'), customNav);
815
+ }
816
+ finally {
817
+ await fs.rm(tempDir, { recursive: true, force: true });
818
+ }
819
+ });
820
+ test('publishWiki layout contains navigation sidebar, breadcrumbs, and page metadata elements', async () => {
821
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
822
+ const wikiDir = path.join(tempDir, 'wiki');
823
+ const remoteDir = path.join(tempDir, 'remote.git');
824
+ const checkoutDir = path.join(tempDir, 'checkout');
825
+ try {
826
+ await fs.mkdir(wikiDir, { recursive: true });
827
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
828
+ await git(['init', '--bare', remoteDir]);
829
+ await publishWiki({
830
+ wikiDir,
831
+ remote: remoteDir,
832
+ target: 'github-pages',
833
+ branch: 'gh-pages',
834
+ message: 'Publish for layout check'
835
+ });
836
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
837
+ const layout = await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8');
838
+ // Generated marker must be present
839
+ assert.match(layout, /repo-wiki-generated/);
840
+ assert.match(layout, /class="sidebar"/);
841
+ assert.match(layout, /class="site-nav"/);
842
+ assert.match(layout, /class="breadcrumb"/);
843
+ assert.match(layout, /class="page-metadata"/);
844
+ assert.match(layout, /Page metadata/);
845
+ assert.match(layout, /page\.page_state/);
846
+ assert.match(layout, /page\.kind/);
847
+ assert.match(layout, /page\.confidence/);
848
+ assert.match(layout, /page\.claim_status/);
849
+ assert.match(layout, /page\.source_repo/);
850
+ assert.match(layout, /page\.source_commit/);
851
+ assert.match(layout, /page\.compiled_at/);
852
+ assert.match(layout, /page\.source_paths/);
853
+ assert.match(layout, /source_path in page\.source_paths/);
854
+ assert.match(layout, /class="back-link"/);
855
+ assert.match(layout, /\{% include wiki_nav\.html %\}/);
856
+ assert.match(layout, /Home\.html/);
857
+ assert.match(layout, /Index\.html/);
858
+ assert.match(layout, /Architecture\.html/);
859
+ assert.match(layout, /Agent-Context-Pack\.html/);
860
+ assert.match(layout, /Build-Test-and-Run\.html/);
861
+ assert.match(layout, /Documentation-Debt-Report\.html/);
862
+ assert.match(layout, /_kind == 'module'/);
863
+ assert.match(layout, /_kind != 'home'/);
864
+ assert.match(layout, /Back to Index/);
865
+ assert.match(layout, /mermaid@11/);
866
+ assert.match(layout, /code\.language-mermaid/);
867
+ // Breadcrumb labels must use | escape to prevent HTML injection
868
+ assert.match(layout, /replace: '\.md', '' \| escape/);
869
+ }
870
+ finally {
871
+ await fs.rm(tempDir, { recursive: true, force: true });
872
+ }
873
+ });
874
+ test('publishWiki writes wiki_pages_dir to _config.yml when pagesPath is not root', async () => {
875
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
876
+ const wikiDir = path.join(tempDir, 'wiki');
877
+ const remoteDir = path.join(tempDir, 'remote.git');
878
+ const checkoutDir = path.join(tempDir, 'checkout');
879
+ try {
880
+ await fs.mkdir(wikiDir, { recursive: true });
881
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
882
+ await git(['init', '--bare', remoteDir]);
883
+ await publishWiki({
884
+ wikiDir,
885
+ remote: remoteDir,
886
+ target: 'github-pages',
887
+ branch: 'gh-pages',
888
+ pagesPath: 'smoke/pr-test',
889
+ message: 'Publish under nested path'
890
+ });
891
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
892
+ const config = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
893
+ assert.match(config, /repo-wiki-generated/, 'config should contain the generated marker');
894
+ assert.match(config, /layout: "repo-wiki"/);
895
+ assert.match(config, /wiki_pages_dir: "smoke\/pr-test"/);
896
+ assert.equal(await fileExists(path.join(checkoutDir, 'smoke', 'pr-test', 'Home.md')), true);
897
+ }
898
+ finally {
899
+ await fs.rm(tempDir, { recursive: true, force: true });
900
+ }
901
+ });
902
+ test('publishWiki does not add wiki_pages_dir to _config.yml when pagesPath is root', async () => {
903
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
904
+ const wikiDir = path.join(tempDir, 'wiki');
905
+ const remoteDir = path.join(tempDir, 'remote.git');
906
+ const checkoutDir = path.join(tempDir, 'checkout');
907
+ try {
908
+ await fs.mkdir(wikiDir, { recursive: true });
909
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
910
+ await git(['init', '--bare', remoteDir]);
911
+ await publishWiki({
912
+ wikiDir,
913
+ remote: remoteDir,
914
+ target: 'github-pages',
915
+ branch: 'gh-pages',
916
+ pagesPath: '.',
917
+ message: 'Publish at root'
918
+ });
919
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
920
+ const config = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
921
+ assert.match(config, /repo-wiki-generated/, 'config should contain the generated marker');
922
+ assert.match(config, /layout: "repo-wiki"/);
923
+ assert.equal(config.includes('wiki_pages_dir'), false);
924
+ }
925
+ finally {
926
+ await fs.rm(tempDir, { recursive: true, force: true });
927
+ }
928
+ });
929
+ test('publishWiki rewrites internal wiki links to use .html extension for github-pages output', async () => {
930
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
931
+ const wikiDir = path.join(tempDir, 'wiki');
932
+ const remoteDir = path.join(tempDir, 'remote.git');
933
+ const checkoutDir = path.join(tempDir, 'checkout');
934
+ try {
935
+ await fs.mkdir(wikiDir, { recursive: true });
936
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), [
937
+ '# Home',
938
+ '',
939
+ 'See [Architecture](Architecture) for the design.',
940
+ 'Also see [Build steps](./Build-Test-and-Run.md).',
941
+ 'And [Index](Index.md#overview) for section links.',
942
+ 'External: [GitHub](https://github.com)',
943
+ 'Anchor only: [Top](#top)',
944
+ 'Image: ![Logo](assets/logo.png)',
945
+ 'Asset: [Download](guide.pdf)',
946
+ ''
947
+ ].join('\n'), 'utf8');
948
+ await git(['init', '--bare', remoteDir]);
949
+ await publishWiki({
950
+ wikiDir,
951
+ remote: remoteDir,
952
+ target: 'github-pages',
953
+ branch: 'gh-pages',
954
+ message: 'Publish with link rewriting'
955
+ });
956
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
957
+ const published = await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8');
958
+ assert.match(published, /\[Architecture\]\(Architecture\.html\)/);
959
+ assert.match(published, /\[Build steps\]\(Build-Test-and-Run\.html\)/);
960
+ assert.match(published, /\[Index\]\(Index\.html#overview\)/);
961
+ assert.match(published, /\[GitHub\]\(https:\/\/github\.com\)/);
962
+ assert.match(published, /\[Top\]\(#top\)/);
963
+ assert.match(published, /!\[Logo\]\(assets\/logo\.png\)/);
964
+ assert.match(published, /\[Download\]\(guide\.pdf\)/);
965
+ }
966
+ finally {
967
+ await fs.rm(tempDir, { recursive: true, force: true });
968
+ }
969
+ });
970
+ test('publishWiki does not rewrite internal links for github-wiki target', async () => {
971
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
972
+ const wikiDir = path.join(tempDir, 'wiki');
973
+ const remoteDir = path.join(tempDir, 'remote.git');
974
+ const checkoutDir = path.join(tempDir, 'checkout');
975
+ try {
976
+ await fs.mkdir(wikiDir, { recursive: true });
977
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n\nSee [Architecture](Architecture).\n', 'utf8');
978
+ await git(['init', '--bare', remoteDir]);
979
+ await publishWiki({
980
+ wikiDir,
981
+ remote: remoteDir,
982
+ branch: 'master',
983
+ frontmatterPolicy: 'preserve',
984
+ message: 'Publish wiki without link rewriting'
985
+ });
986
+ await git(['clone', '--branch', 'master', remoteDir, checkoutDir]);
987
+ const published = await fs.readFile(path.join(checkoutDir, 'Home.md'), 'utf8');
988
+ assert.match(published, /\[Architecture\]\(Architecture\)/);
989
+ assert.equal(published.includes('Architecture.md'), false);
990
+ }
991
+ finally {
992
+ await fs.rm(tempDir, { recursive: true, force: true });
993
+ }
994
+ });
995
+ test('rewriteInternalWikiLinks normalizes bare page names, strips leading ./, and preserves special links', () => {
996
+ assert.equal(rewriteInternalWikiLinks('[Home](Home)'), '[Home](Home.html)');
997
+ assert.equal(rewriteInternalWikiLinks('[Arch](Architecture)'), '[Arch](Architecture.html)');
998
+ assert.equal(rewriteInternalWikiLinks('[Page](./Page.md)'), '[Page](Page.html)');
999
+ assert.equal(rewriteInternalWikiLinks('[Page](./Page)'), '[Page](Page.html)');
1000
+ assert.equal(rewriteInternalWikiLinks('[Index](Index.md#section)'), '[Index](Index.html#section)');
1001
+ assert.equal(rewriteInternalWikiLinks('[Index](Index#section)'), '[Index](Index.html#section)');
1002
+ assert.equal(rewriteInternalWikiLinks('[GitHub](https://github.com)'), '[GitHub](https://github.com)');
1003
+ assert.equal(rewriteInternalWikiLinks('[GitHub](HTTPS://github.com)'), '[GitHub](HTTPS://github.com)');
1004
+ assert.equal(rewriteInternalWikiLinks('[Mail](mailto:test@example.com)'), '[Mail](mailto:test@example.com)');
1005
+ assert.equal(rewriteInternalWikiLinks('[Mail](MAILTO:test@example.com)'), '[Mail](MAILTO:test@example.com)');
1006
+ assert.equal(rewriteInternalWikiLinks('[FTP](ftp://example.com)'), '[FTP](ftp://example.com)');
1007
+ assert.equal(rewriteInternalWikiLinks('[FTP](FTP://example.com)'), '[FTP](FTP://example.com)');
1008
+ assert.equal(rewriteInternalWikiLinks('[Proto](//example.com)'), '[Proto](//example.com)');
1009
+ assert.equal(rewriteInternalWikiLinks('[Top](#top)'), '[Top](#top)');
1010
+ assert.equal(rewriteInternalWikiLinks('![Logo](logo.png)'), '![Logo](logo.png)');
1011
+ assert.equal(rewriteInternalWikiLinks('![Img](photo.jpg)'), '![Img](photo.jpg)');
1012
+ assert.equal(rewriteInternalWikiLinks('![Diagram](diagram.svg)'), '![Diagram](diagram.svg)');
1013
+ assert.equal(rewriteInternalWikiLinks('![Diagram](Architecture)'), '![Diagram](Architecture)');
1014
+ assert.equal(rewriteInternalWikiLinks('[PDF](guide.pdf)'), '[PDF](guide.pdf)');
1015
+ assert.equal(rewriteInternalWikiLinks('[JSON](data.json)'), '[JSON](data.json)');
1016
+ assert.equal(rewriteInternalWikiLinks('[YAML](config.yml)'), '[YAML](config.yml)');
1017
+ const result = rewriteInternalWikiLinks('[Home](Home) and [Arch](Architecture) and [GitHub](https://github.com)');
1018
+ assert.equal(result, '[Home](Home.html) and [Arch](Architecture.html) and [GitHub](https://github.com)');
1019
+ });
1020
+ test('rewriteInternalWikiLinks rejects unsafe URI schemes and preserves query strings', () => {
1021
+ // Unsafe scheme rejection — URLs without inner parens so the regex captures them cleanly
1022
+ assert.equal(rewriteInternalWikiLinks('[XSS-lower](javascript:void)'), '[XSS-lower](#)');
1023
+ assert.equal(rewriteInternalWikiLinks('[XSS-upper](JAVASCRIPT:void)'), '[XSS-upper](#)');
1024
+ assert.equal(rewriteInternalWikiLinks('[Data](data:text/html,evil)'), '[Data](#)');
1025
+ assert.equal(rewriteInternalWikiLinks('[VBS](vbscript:msgbox)'), '[VBS](#)');
1026
+ assert.equal(rewriteInternalWikiLinks('[Blob](blob:https://example.com/id)'), '[Blob](#)');
1027
+ assert.equal(rewriteInternalWikiLinks('[About](about:blank)'), '[About](#)');
1028
+ assert.equal(rewriteInternalWikiLinks('[XSS-space]( javascript:evil)'), '[XSS-space](#)');
1029
+ assert.equal(rewriteInternalWikiLinks('[XSS-tab](\tJAVASCRIPT:evil)'), '[XSS-tab](#)');
1030
+ assert.equal(rewriteInternalWikiLinks('[XSS-trailing]( JAVASCRIPT:evil )'), '[XSS-trailing](#)');
1031
+ // Query strings preserved
1032
+ assert.equal(rewriteInternalWikiLinks('[Raw](logo.png?raw=1)'), '[Raw](logo.png?raw=1)');
1033
+ assert.equal(rewriteInternalWikiLinks('[Page](Page?x=1)'), '[Page](Page.html?x=1)');
1034
+ assert.equal(rewriteInternalWikiLinks('[Page](Page?x=1#section)'), '[Page](Page.html?x=1#section)');
1035
+ });
1036
+ test('publishWiki regenerates repo-wiki-generated support files on subsequent publish', async () => {
1037
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1038
+ const wikiDir = path.join(tempDir, 'wiki');
1039
+ const remoteDir = path.join(tempDir, 'remote.git');
1040
+ const checkoutDir = path.join(tempDir, 'checkout');
1041
+ try {
1042
+ await fs.mkdir(wikiDir, { recursive: true });
1043
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1044
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n', 'utf8');
1045
+ await git(['init', '--bare', remoteDir]);
1046
+ // First publish
1047
+ await publishWiki({
1048
+ wikiDir,
1049
+ remote: remoteDir,
1050
+ target: 'github-pages',
1051
+ branch: 'gh-pages',
1052
+ message: 'First publish'
1053
+ });
1054
+ // Update sidebar
1055
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n- [Architecture](Architecture)\n', 'utf8');
1056
+ // Second publish — generated nav should reflect the updated sidebar
1057
+ await publishWiki({
1058
+ wikiDir,
1059
+ remote: remoteDir,
1060
+ target: 'github-pages',
1061
+ branch: 'gh-pages',
1062
+ message: 'Second publish with updated sidebar'
1063
+ });
1064
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1065
+ const navHtml = await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8');
1066
+ assert.match(navHtml, /repo-wiki-generated/, 'generated marker present after second publish');
1067
+ assert.ok(navHtml.includes('href="{{ _base }}Architecture.html"'), 'updated Architecture link present after second publish');
1068
+ }
1069
+ finally {
1070
+ await fs.rm(tempDir, { recursive: true, force: true });
1071
+ }
1072
+ });
1073
+ test('publishWiki upgrades markerless legacy generated pages layout and config', async () => {
1074
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1075
+ const wikiDir = path.join(tempDir, 'wiki');
1076
+ const remoteDir = path.join(tempDir, 'remote.git');
1077
+ const seedDir = path.join(tempDir, 'seed');
1078
+ const checkoutDir = path.join(tempDir, 'checkout');
1079
+ try {
1080
+ await fs.mkdir(wikiDir, { recursive: true });
1081
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1082
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n', 'utf8');
1083
+ await git(['init', '--bare', remoteDir]);
1084
+ await git(['clone', remoteDir, seedDir]);
1085
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
1086
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
1087
+ await fs.mkdir(path.join(seedDir, '_layouts'), { recursive: true });
1088
+ await fs.writeFile(path.join(seedDir, '_config.yml'), 'defaults:\n - scope:\n path: ""\n values:\n layout: "repo-wiki"\n', 'utf8');
1089
+ await fs.writeFile(path.join(seedDir, '_layouts', 'repo-wiki.html'), legacyGeneratedPagesLayout(), 'utf8');
1090
+ await git(['add', '.'], seedDir);
1091
+ await git(['commit', '-m', 'Seed legacy generated support files'], seedDir);
1092
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
1093
+ await publishWiki({
1094
+ wikiDir,
1095
+ remote: remoteDir,
1096
+ target: 'github-pages',
1097
+ branch: 'gh-pages',
1098
+ message: 'Upgrade legacy support files'
1099
+ });
1100
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1101
+ const config = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
1102
+ const layout = await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8');
1103
+ assert.match(config, /repo-wiki-generated/);
1104
+ assert.match(layout, /repo-wiki-generated/);
1105
+ assert.match(layout, /class="page-metadata"/);
1106
+ assert.match(layout, /\{% include wiki_nav\.html %\}/);
1107
+ }
1108
+ finally {
1109
+ await fs.rm(tempDir, { recursive: true, force: true });
1110
+ }
1111
+ });
1112
+ test('publishWiki preserves markerless custom pages layout and config', async () => {
1113
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1114
+ const wikiDir = path.join(tempDir, 'wiki');
1115
+ const remoteDir = path.join(tempDir, 'remote.git');
1116
+ const seedDir = path.join(tempDir, 'seed');
1117
+ const checkoutDir = path.join(tempDir, 'checkout');
1118
+ try {
1119
+ await fs.mkdir(wikiDir, { recursive: true });
1120
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1121
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), '## Nav\n- [Home](Home)\n', 'utf8');
1122
+ await git(['init', '--bare', remoteDir]);
1123
+ await git(['clone', remoteDir, seedDir]);
1124
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
1125
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
1126
+ // Seed custom support files without the generated marker
1127
+ await fs.mkdir(path.join(seedDir, '_includes'), { recursive: true });
1128
+ await fs.mkdir(path.join(seedDir, '_layouts'), { recursive: true });
1129
+ await fs.writeFile(path.join(seedDir, '_includes', 'wiki_nav.html'), '<nav>custom nav</nav>\n', 'utf8');
1130
+ await fs.writeFile(path.join(seedDir, '_layouts', 'repo-wiki.html'), '<main>{{ content }}</main>\n', 'utf8');
1131
+ await fs.writeFile(path.join(seedDir, '_config.yml'), 'title: Custom site\nlayout: custom\n', 'utf8');
1132
+ await git(['add', '.'], seedDir);
1133
+ await git(['commit', '-m', 'Seed custom support files'], seedDir);
1134
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
1135
+ // Publish — custom files should be preserved
1136
+ await publishWiki({
1137
+ wikiDir,
1138
+ remote: remoteDir,
1139
+ target: 'github-pages',
1140
+ branch: 'gh-pages',
1141
+ message: 'Publish should not overwrite custom files'
1142
+ });
1143
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1144
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8'), '<nav>custom nav</nav>\n');
1145
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8'), '<main>{{ content }}</main>\n');
1146
+ assert.equal(await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8'), 'title: Custom site\nlayout: custom\n');
1147
+ }
1148
+ finally {
1149
+ await fs.rm(tempDir, { recursive: true, force: true });
1150
+ }
1151
+ });
1152
+ test('publishWiki refreshes marked generated pages layout and config', async () => {
1153
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1154
+ const wikiDir = path.join(tempDir, 'wiki');
1155
+ const remoteDir = path.join(tempDir, 'remote.git');
1156
+ const seedDir = path.join(tempDir, 'seed');
1157
+ const checkoutDir = path.join(tempDir, 'checkout');
1158
+ try {
1159
+ await fs.mkdir(wikiDir, { recursive: true });
1160
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1161
+ await git(['init', '--bare', remoteDir]);
1162
+ await git(['clone', remoteDir, seedDir]);
1163
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
1164
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
1165
+ await fs.mkdir(path.join(seedDir, '_layouts'), { recursive: true });
1166
+ await fs.writeFile(path.join(seedDir, '_config.yml'), '# repo-wiki-generated: regenerated on each publish\ndefaults: []\n', 'utf8');
1167
+ await fs.writeFile(path.join(seedDir, '_layouts', 'repo-wiki.html'), '<!-- repo-wiki-generated: regenerated on each publish -->\n<main>old</main>\n', 'utf8');
1168
+ await git(['add', '.'], seedDir);
1169
+ await git(['commit', '-m', 'Seed marked support files'], seedDir);
1170
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
1171
+ await publishWiki({
1172
+ wikiDir,
1173
+ remote: remoteDir,
1174
+ target: 'github-pages',
1175
+ branch: 'gh-pages',
1176
+ message: 'Refresh marked support files'
1177
+ });
1178
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1179
+ const config = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
1180
+ const layout = await fs.readFile(path.join(checkoutDir, '_layouts', 'repo-wiki.html'), 'utf8');
1181
+ assert.match(config, /layout: "repo-wiki"/);
1182
+ assert.doesNotMatch(config, /defaults: \[\]/);
1183
+ assert.match(layout, /class="page-metadata"/);
1184
+ assert.doesNotMatch(layout, /<main>old<\/main>/);
1185
+ }
1186
+ finally {
1187
+ await fs.rm(tempDir, { recursive: true, force: true });
1188
+ }
1189
+ });
1190
+ test('publishWiki updates wiki_pages_dir when upgrading legacy generated config', async () => {
1191
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1192
+ const wikiDir = path.join(tempDir, 'wiki');
1193
+ const remoteDir = path.join(tempDir, 'remote.git');
1194
+ const seedDir = path.join(tempDir, 'seed');
1195
+ const checkoutDir = path.join(tempDir, 'checkout');
1196
+ try {
1197
+ await fs.mkdir(wikiDir, { recursive: true });
1198
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1199
+ await git(['init', '--bare', remoteDir]);
1200
+ await git(['clone', remoteDir, seedDir]);
1201
+ await git(['config', 'user.name', 'repo-wiki-test'], seedDir);
1202
+ await git(['config', 'user.email', 'repo-wiki-test@example.com'], seedDir);
1203
+ await fs.writeFile(path.join(seedDir, '_config.yml'), 'defaults:\n - scope:\n path: ""\n values:\n layout: "repo-wiki"\n', 'utf8');
1204
+ await git(['add', '.'], seedDir);
1205
+ await git(['commit', '-m', 'Seed legacy generated config'], seedDir);
1206
+ await git(['push', 'origin', 'HEAD:gh-pages'], seedDir);
1207
+ await publishWiki({
1208
+ wikiDir,
1209
+ remote: remoteDir,
1210
+ target: 'github-pages',
1211
+ branch: 'gh-pages',
1212
+ pagesPath: 'nested/pages',
1213
+ message: 'Upgrade legacy config with nested path'
1214
+ });
1215
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1216
+ const config = await fs.readFile(path.join(checkoutDir, '_config.yml'), 'utf8');
1217
+ assert.match(config, /repo-wiki-generated/);
1218
+ assert.match(config, /wiki_pages_dir: "nested\/pages"/);
1219
+ assert.equal(await fileExists(path.join(checkoutDir, 'nested', 'pages', 'Home.md')), true);
1220
+ }
1221
+ finally {
1222
+ await fs.rm(tempDir, { recursive: true, force: true });
1223
+ }
1224
+ });
1225
+ test('publishWiki does not mutate source wiki files when publishing to github-pages', async () => {
1226
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1227
+ const wikiDir = path.join(tempDir, 'wiki');
1228
+ const remoteDir = path.join(tempDir, 'remote.git');
1229
+ try {
1230
+ await fs.mkdir(wikiDir, { recursive: true });
1231
+ const sourceContent = [
1232
+ '---',
1233
+ 'kind: module',
1234
+ '---',
1235
+ '# Module',
1236
+ '',
1237
+ 'See [Architecture](Architecture) for context.',
1238
+ ''
1239
+ ].join('\n');
1240
+ await fs.writeFile(path.join(wikiDir, 'Module.md'), sourceContent, 'utf8');
1241
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1242
+ await git(['init', '--bare', remoteDir]);
1243
+ await publishWiki({
1244
+ wikiDir,
1245
+ remote: remoteDir,
1246
+ target: 'github-pages',
1247
+ branch: 'gh-pages',
1248
+ message: 'Publish should not touch source files'
1249
+ });
1250
+ // Source files must be byte-for-byte unchanged
1251
+ assert.equal(await fs.readFile(path.join(wikiDir, 'Module.md'), 'utf8'), sourceContent);
1252
+ assert.equal(await fs.readFile(path.join(wikiDir, 'Home.md'), 'utf8'), '# Home\n');
1253
+ }
1254
+ finally {
1255
+ await fs.rm(tempDir, { recursive: true, force: true });
1256
+ }
1257
+ });
1258
+ test('publishWiki unsafe sidebar href is sanitized to # in wiki_nav.html', async () => {
1259
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-wiki-publisher-test-'));
1260
+ const wikiDir = path.join(tempDir, 'wiki');
1261
+ const remoteDir = path.join(tempDir, 'remote.git');
1262
+ const checkoutDir = path.join(tempDir, 'checkout');
1263
+ try {
1264
+ await fs.mkdir(wikiDir, { recursive: true });
1265
+ await fs.writeFile(path.join(wikiDir, 'Home.md'), '# Home\n', 'utf8');
1266
+ await fs.writeFile(path.join(wikiDir, '_Sidebar.md'), [
1267
+ '## Nav',
1268
+ '- [Safe](Home)',
1269
+ '- [XSS](javascript:alert(1))',
1270
+ '- [Spaced XSS]( javascript:evil)',
1271
+ '- [Tabbed XSS](\tJAVASCRIPT:evil)',
1272
+ '- [Data](data:text/html,evil)',
1273
+ ].join('\n') + '\n', 'utf8');
1274
+ await git(['init', '--bare', remoteDir]);
1275
+ await publishWiki({
1276
+ wikiDir,
1277
+ remote: remoteDir,
1278
+ target: 'github-pages',
1279
+ branch: 'gh-pages',
1280
+ message: 'Publish with unsafe sidebar links'
1281
+ });
1282
+ await git(['clone', '--branch', 'gh-pages', remoteDir, checkoutDir]);
1283
+ const navHtml = await fs.readFile(path.join(checkoutDir, '_includes', 'wiki_nav.html'), 'utf8');
1284
+ // Safe link should be present with {{ _base }} prefix
1285
+ assert.ok(navHtml.includes('href="{{ _base }}Home.html"'), 'safe link present');
1286
+ // Unsafe schemes must be replaced with #
1287
+ assert.ok(!navHtml.includes('javascript:'), 'javascript: scheme must not appear in output');
1288
+ assert.ok(!navHtml.includes('JAVASCRIPT:'), 'uppercase javascript: scheme must not appear in output');
1289
+ assert.ok(!navHtml.includes('data:'), 'data: scheme must not appear in output');
1290
+ assert.match(navHtml, /<a href="#">Spaced XSS<\/a>/);
1291
+ assert.match(navHtml, /<a href="#">Tabbed XSS<\/a>/);
1292
+ }
1293
+ finally {
1294
+ await fs.rm(tempDir, { recursive: true, force: true });
1295
+ }
1296
+ });
1297
+ //# sourceMappingURL=publisher.test.js.map