@readme/cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/git-format.js +80 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +54 -26
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2200 -2049
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +6 -2
  12. package/src/commands/oas-sync.js +271 -161
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +188 -165
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/containment.js +87 -0
  25. package/src/utils/eyes.js +143 -51
  26. package/src/utils/git.js +3 -3
  27. package/src/utils/lint.js +39 -38
  28. package/src/utils/llms.js +94 -91
  29. package/src/utils/markdown.js +256 -0
  30. package/src/utils/marketplace.js +12 -5
  31. package/src/utils/multi-spinner.js +5 -7
  32. package/src/utils/promise-pool.js +7 -5
  33. package/src/utils/reporter.js +88 -94
  34. package/src/utils/setup-templates.js +12 -6
  35. package/src/utils/styles.js +2 -1
  36. package/src/utils/tamagotchi.js +257 -91
  37. package/src/utils/tips.js +18 -9
  38. package/src/utils/url-segs.js +46 -41
  39. package/src/validators/components.js +55 -63
  40. package/src/validators/content.js +16 -14
  41. package/src/validators/duplicates.js +6 -2
  42. package/src/validators/frontmatter.js +21 -29
  43. package/src/validators/links.js +4 -8
  44. package/src/validators/oas-reference.js +25 -18
  45. package/src/validators/oas-schema.js +2 -0
  46. package/src/validators/ordering.js +5 -5
  47. package/src/validators/recipes.js +4 -12
  48. package/vendor/git-format/CLAUDE.md +352 -0
  49. package/vendor/git-format/frontmatter.schema.json +199 -0
  50. package/src/commands/import-oas.test.js +0 -277
  51. package/src/commands/import.classify.test.js +0 -172
  52. package/src/commands/import.landing.test.js +0 -217
  53. package/src/commands/import.slug-planner.test.js +0 -86
  54. package/src/commands/import.staging.test.js +0 -230
  55. package/src/commands/import.test.js +0 -382
  56. package/src/utils/llms.test.js +0 -302
  57. package/src/utils/url-segs.test.js +0 -239
@@ -2,14 +2,10 @@ import { execSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import readline from 'node:readline';
5
- import * as styles from '../utils/styles.js';
6
- import {
7
- PLATFORMS,
8
- detectExistingCi,
9
- detectPlatform,
10
- usesBlacksmith,
11
- } from '../utils/git.js';
5
+
6
+ import { PLATFORMS, detectExistingCi, detectPlatform, usesBlacksmith } from '../utils/git.js';
12
7
  import { templateFor } from '../utils/setup-templates.js';
8
+ import * as styles from '../utils/styles.js';
13
9
 
14
10
  // Detect the user's CI platform at module load so the top-level `--help`
15
11
  // can advertise the right `setup:<platform>` form (e.g. `setup:github` for
@@ -43,12 +39,8 @@ export const helpHint = _hasExistingCi
43
39
  // Hidden aliases cover every other form: bare `setup` (with optional positional),
44
40
  // the generic `setup:ci`, and one entry per platform. Whichever form is the
45
41
  // primary above is filtered out so it isn't double-registered.
46
- const ALL_FORMS = [
47
- 'setup [platform]',
48
- 'setup:ci',
49
- ...Object.keys(PLATFORMS).map((k) => `setup:${k}`),
50
- ];
51
- export const aliases = ALL_FORMS.filter((a) => a.split(' ')[0] !== _primaryName);
42
+ const ALL_FORMS = ['setup [platform]', 'setup:ci', ...Object.keys(PLATFORMS).map(k => `setup:${k}`)];
43
+ export const aliases = ALL_FORMS.filter(a => a.split(' ')[0] !== _primaryName);
52
44
 
53
45
  export function args(cmd) {
54
46
  cmd.option('--blacksmith', 'Use Blacksmith runners (GitHub Actions only)');
@@ -56,9 +48,9 @@ export function args(cmd) {
56
48
 
57
49
  cmd.addHelpText('after', () => {
58
50
  const bin = styles.binName();
59
- const cmdStrs = Object.keys(PLATFORMS).map((k) => `${bin} setup:${k}`);
60
- const cmdWidth = Math.max(...cmdStrs.map((s) => s.length));
61
- const labelWidth = Math.max(...Object.values(PLATFORMS).map((d) => d.label.length));
51
+ const cmdStrs = Object.keys(PLATFORMS).map(k => `${bin} setup:${k}`);
52
+ const cmdWidth = Math.max(...cmdStrs.map(s => s.length));
53
+ const labelWidth = Math.max(...Object.values(PLATFORMS).map(d => d.label.length));
62
54
  const rows = Object.entries(PLATFORMS).map(([key, def], i) => {
63
55
  const cmdStr = cmdStrs[i].padEnd(cmdWidth);
64
56
  const label = def.label.padEnd(labelWidth);
@@ -153,12 +145,20 @@ export async function run(...callArgs) {
153
145
 
154
146
  console.log(` This will create a workflow that runs on every pull request:`);
155
147
  console.log();
156
- console.log(` ${styles.success('✔')} ${styles.bold('Lint docs')} — runs ${styles.orange(`${styles.binName()} lint`)} and posts results as a PR comment`);
148
+ console.log(
149
+ ` ${styles.success('✔')} ${styles.bold('Lint docs')} — runs ${styles.orange(`${styles.binName()} lint`)} and posts results as a PR comment`,
150
+ );
157
151
  if (platform === 'github') {
158
- console.log(` ${styles.success('✔')} ${styles.bold('OAS change detection')} — flags modified OpenAPI spec files and suggests syncing`);
159
- console.log(` ${styles.success('✔')} ${styles.bold('Auto-fix base branch')} — redirects PRs from ${styles.orange('main')} to the correct version branch`);
152
+ console.log(
153
+ ` ${styles.success('✔')} ${styles.bold('OAS change detection')} — flags modified OpenAPI spec files and suggests syncing`,
154
+ );
155
+ console.log(
156
+ ` ${styles.success('✔')} ${styles.bold('Auto-fix base branch')} — redirects PRs from ${styles.orange('main')} to the correct version branch`,
157
+ );
160
158
  if (useBlacksmith) {
161
- console.log(` ${styles.success('✔')} ${styles.bold('Blacksmith runner')} — uses ${styles.orange('blacksmith-2vcpu-ubuntu-2404')} instead of ${styles.orange('ubuntu-latest')}`);
159
+ console.log(
160
+ ` ${styles.success('✔')} ${styles.bold('Blacksmith runner')} — uses ${styles.orange('blacksmith-2vcpu-ubuntu-2404')} instead of ${styles.orange('ubuntu-latest')}`,
161
+ );
162
162
  }
163
163
  }
164
164
  console.log();
@@ -169,9 +169,7 @@ export async function run(...callArgs) {
169
169
 
170
170
  // 4. Confirm.
171
171
  if (!opts.yes) {
172
- const prompt = isOverwrite
173
- ? 'This will overwrite the existing workflow. Continue?'
174
- : 'Set up this workflow?';
172
+ const prompt = isOverwrite ? 'This will overwrite the existing workflow. Continue?' : 'Set up this workflow?';
175
173
  const confirmed = await confirm(prompt);
176
174
  if (!confirmed) {
177
175
  styles.info('Setup cancelled.');
@@ -203,7 +201,7 @@ function normalizePlatform(input) {
203
201
  function printDetectionSummary(detection, chosen) {
204
202
  const lines = [];
205
203
  if (detection.existing.length > 0) {
206
- const labels = detection.existing.map((k) => PLATFORMS[k].label).join(', ');
204
+ const labels = detection.existing.map(k => PLATFORMS[k].label).join(', ');
207
205
  lines.push(`${styles.dim('Detected existing CI:')} ${labels}`);
208
206
  }
209
207
  if (detection.remote) {
@@ -246,8 +244,8 @@ function confirm(message) {
246
244
  output: process.stdout,
247
245
  });
248
246
 
249
- return new Promise((resolve) => {
250
- rl.question(`${styles.brand('?')} ${message} ${styles.dim('(Y/n)')} `, (answer) => {
247
+ return new Promise(resolve => {
248
+ rl.question(`${styles.brand('?')} ${message} ${styles.dim('(Y/n)')} `, answer => {
251
249
  rl.close();
252
250
  const val = answer.trim().toLowerCase();
253
251
  resolve(val === '' || val === 'y' || val === 'yes');
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
+
2
3
  import * as styles from '../utils/styles.js';
3
4
 
4
5
  export const command = 'versions';
@@ -18,7 +19,10 @@ export function run(_options, _cmd, ctx) {
18
19
  stdio: ['pipe', 'pipe', 'pipe'],
19
20
  }).trim();
20
21
 
21
- const branches = raw.split('\n').map((b) => b.trim()).filter(Boolean);
22
+ const branches = raw
23
+ .split('\n')
24
+ .map(b => b.trim())
25
+ .filter(Boolean);
22
26
 
23
27
  // Strip "origin/" prefix and deduplicate.
24
28
  const seen = new Set();
@@ -73,7 +77,7 @@ export function run(_options, _cmd, ctx) {
73
77
  for (let i = 0; i < subs.length; i++) {
74
78
  const isLast = i === subs.length - 1;
75
79
  const connector = isLast ? '└─' : '├─';
76
- console.log(` ${styles.dim(connector)} ${styles.dim(version + '_')}${subs[i].name} ${styles.dim('(branch)')}`);
80
+ console.log(` ${styles.dim(connector)} ${styles.dim(`${version}_`)}${subs[i].name} ${styles.dim('(branch)')}`);
77
81
  }
78
82
  }
79
83
 
@@ -1,4 +1,5 @@
1
1
  import { redirect } from 'next/navigation';
2
+
2
3
  import { getPage, collectSidebar } from '../../lib/docs';
3
4
 
4
5
  export const dynamic = 'force-dynamic';
@@ -1,7 +1,9 @@
1
- import { collectSidebar } from '../lib/docs';
2
- import { SidebarNav, SidebarPanel } from './Sidebar';
3
1
  import '@readme/markdown/dist/main.css';
2
+
3
+ import { collectSidebar } from '../lib/docs';
4
+
4
5
  import './globals.css';
6
+ import { SidebarNav, SidebarPanel } from './Sidebar';
5
7
 
6
8
  export const metadata = {
7
9
  title: {
@@ -21,10 +23,13 @@ export default async function RootLayout({ children }) {
21
23
  </head>
22
24
  <body className="bg-[#000343] text-gray-900 antialiased">
23
25
  {/* Top Navigation */}
24
- <header className="fixed top-0 inset-x-0 h-[48px] bg-[#000343] flex items-center px-5 z-50" style={{
25
- backgroundImage: `linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)`,
26
- backgroundSize: '20px 20px',
27
- }}>
26
+ <header
27
+ className="fixed top-0 inset-x-0 h-[48px] bg-[#000343] flex items-center px-5 z-50"
28
+ style={{
29
+ backgroundImage: `linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)`,
30
+ backgroundSize: '20px 20px',
31
+ }}
32
+ >
28
33
  <div className="flex items-center gap-3 mr-8 shrink-0">
29
34
  <div className="flex items-center gap-2">
30
35
  <i className="fa-brands fa-readme text-[#018ef5] text-[18px] relative top-[1px]" />
@@ -50,17 +55,19 @@ export default async function RootLayout({ children }) {
50
55
 
51
56
  {/* Content */}
52
57
  <main className="ml-[300px] flex-1 min-w-0 bg-white rounded-tr-xl">
53
- <div className="max-w-[820px] py-10 px-16">
54
- {children}
55
- </div>
58
+ <div className="max-w-[820px] py-10 px-16">{children}</div>
56
59
  </main>
57
60
  </div>
58
61
 
59
- <style dangerouslySetInnerHTML={{ __html: `
62
+ <style
63
+ dangerouslySetInnerHTML={{
64
+ __html: `
60
65
  [data-sidebar-group][data-expanded="false"] .sidebar-children { display: none; }
61
66
  [data-sidebar-group][data-expanded="true"] .sidebar-children { display: block; }
62
67
  [data-sidebar-group][data-expanded="true"] .sidebar-chevron { transform: rotate(90deg); }
63
- `}} />
68
+ `,
69
+ }}
70
+ />
64
71
  <script
65
72
  dangerouslySetInnerHTML={{
66
73
  __html: `(function(){var es=new EventSource('/__reload');es.onmessage=function(e){if(e.data==='reload')location.reload()};})();
@@ -1,4 +1,5 @@
1
1
  import { redirect } from 'next/navigation';
2
+
2
3
  import { getFirstPageHref } from '../lib/docs';
3
4
 
4
5
  export const dynamic = 'force-dynamic';
@@ -1,9 +1,11 @@
1
- import { createRequire } from 'node:module';
2
1
  import fs from 'fs';
2
+ import { createRequire } from 'node:module';
3
3
  import path from 'path';
4
- import matter from 'gray-matter';
4
+
5
5
  import { compile, run } from '@readme/markdown';
6
- import yaml from 'js-yaml';
6
+ import { load as loadYAML } from 'js-yaml';
7
+
8
+ import { parseFrontmatter } from '../../utils/markdown.js';
7
9
 
8
10
  // Use createRequire to load react-dom/server at runtime,
9
11
  // bypassing Next.js App Router's static import restriction.
@@ -36,7 +38,7 @@ function walkDir(dirPath, sectionDir, depth = 0) {
36
38
  let order = [];
37
39
  const orderPath = path.join(dirPath, '_order.yaml');
38
40
  if (fs.existsSync(orderPath)) {
39
- order = yaml.load(fs.readFileSync(orderPath, 'utf-8')) || [];
41
+ order = loadYAML(fs.readFileSync(orderPath, 'utf-8')) || [];
40
42
  }
41
43
 
42
44
  // Get all items (exclude dotfiles and _-prefixed files)
@@ -90,16 +92,14 @@ function walkDir(dirPath, sectionDir, depth = 0) {
90
92
  // If either exists, treat this as a clickable parent page.
91
93
  const indexPath = path.join(dirPath, name, 'index.md');
92
94
  const siblingMdPath = path.join(dirPath, `${name}.md`);
93
- const parentPagePath = fs.existsSync(indexPath)
94
- ? indexPath
95
- : fs.existsSync(siblingMdPath) ? siblingMdPath : null;
95
+ const parentPagePath = fs.existsSync(indexPath) ? indexPath : fs.existsSync(siblingMdPath) ? siblingMdPath : null;
96
96
 
97
97
  let title = name;
98
98
  let href = null;
99
99
  let hidden = false;
100
100
  let icon = null;
101
101
  if (parentPagePath) {
102
- const { data } = matter(fs.readFileSync(parentPagePath, 'utf-8'));
102
+ const { data } = parseFrontmatter(fs.readFileSync(parentPagePath, 'utf-8'));
103
103
  title = data.title || name;
104
104
  href = `/${sectionDir}/${encodeURIComponent(name)}`;
105
105
  hidden = !!data.hidden;
@@ -112,7 +112,7 @@ function walkDir(dirPath, sectionDir, depth = 0) {
112
112
  mdFiles.delete(name);
113
113
  } else if (isMd) {
114
114
  const filePath = path.join(dirPath, `${name}.md`);
115
- const { data } = matter(fs.readFileSync(filePath, 'utf-8'));
115
+ const { data } = parseFrontmatter(fs.readFileSync(filePath, 'utf-8'));
116
116
  const linkUrl = data.link?.url;
117
117
  entries.push({
118
118
  title: data.title || name,
@@ -152,7 +152,7 @@ function loadCustomBlocks(docsRoot) {
152
152
 
153
153
  for (const file of files) {
154
154
  const raw = fs.readFileSync(path.join(blocksDir, file), 'utf-8');
155
- const { data, content } = matter(raw);
155
+ const { data, content } = parseFrontmatter(raw);
156
156
  const name = data.name || path.basename(file, path.extname(file));
157
157
  sources[name] = content;
158
158
  modules[name] = run(compile(content));
@@ -202,7 +202,7 @@ export function getPage(slugArray) {
202
202
  if (!filePath) return null;
203
203
 
204
204
  const raw = fs.readFileSync(filePath, 'utf-8');
205
- const { data, content } = matter(raw);
205
+ const { data, content } = parseFrontmatter(raw);
206
206
 
207
207
  let html;
208
208
 
@@ -254,7 +254,7 @@ function renderOasOperationPreview(specFile, operationId) {
254
254
  let spec;
255
255
  try {
256
256
  const rawSpec = fs.readFileSync(specPath, 'utf-8');
257
- spec = specFile.toLowerCase().endsWith('.json') ? JSON.parse(rawSpec) : yaml.load(rawSpec);
257
+ spec = specFile.toLowerCase().endsWith('.json') ? JSON.parse(rawSpec) : loadYAML(rawSpec);
258
258
  } catch (e) {
259
259
  return `<p><em>Couldn't parse <code>reference/${specFile}</code>: ${escapeHtml(e.message)}</em></p>`;
260
260
  }
@@ -272,16 +272,21 @@ function renderOasOperationPreview(specFile, operationId) {
272
272
  }
273
273
 
274
274
  const { method, path: opPath, op } = found;
275
- const methodColor = {
276
- GET: '#22c55e', POST: '#3b82f6', PUT: '#f59e0b', PATCH: '#a855f7', DELETE: '#ef4444',
277
- }[method] || '#6b7280';
275
+ const methodColor =
276
+ {
277
+ GET: '#22c55e',
278
+ POST: '#3b82f6',
279
+ PUT: '#f59e0b',
280
+ PATCH: '#a855f7',
281
+ DELETE: '#ef4444',
282
+ }[method] || '#6b7280';
278
283
 
279
284
  const parts = [];
280
285
  parts.push(
281
286
  `<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem;">` +
282
- `<span style="background: ${methodColor}; color: white; padding: 0.15rem 0.5rem; border-radius: 0.25rem; font-family: monospace; font-weight: 600; font-size: 0.85rem;">${method}</span>` +
283
- `<code style="font-size: 1rem;">${escapeHtml(opPath)}</code>` +
284
- `</div>`,
287
+ `<span style="background: ${methodColor}; color: white; padding: 0.15rem 0.5rem; border-radius: 0.25rem; font-family: monospace; font-weight: 600; font-size: 0.85rem;">${method}</span>` +
288
+ `<code style="font-size: 1rem;">${escapeHtml(opPath)}</code>` +
289
+ `</div>`,
285
290
  );
286
291
  if (op.description) parts.push(`<p>${escapeHtml(op.description)}</p>`);
287
292
 
@@ -317,7 +322,10 @@ function renderOasOperationPreview(specFile, operationId) {
317
322
  }
318
323
 
319
324
  function escapeHtml(s) {
320
- return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
325
+ return String(s).replace(
326
+ /[&<>"']/g,
327
+ c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
328
+ );
321
329
  }
322
330
 
323
331
  function findFirstHref(items) {
@@ -10,8 +10,8 @@ export default {
10
10
  // Next's SWC loader excludes `node_modules` from JSX transformation by
11
11
  // default. Marking our own package as a transpile target bypasses that.
12
12
  transpilePackages: ['@readme/cli'],
13
- serverExternalPackages: ['gray-matter', 'js-yaml', '@readme/markdown'],
14
- webpack: (config) => {
13
+ serverExternalPackages: ['js-yaml', '@readme/markdown'],
14
+ webpack: config => {
15
15
  // Suppress noisy cache serialization warnings from large @readme/markdown bundle
16
16
  config.infrastructureLogging = {
17
17
  ...config.infrastructureLogging,
package/src/gha.js CHANGED
@@ -17,8 +17,8 @@
17
17
  // let it do what it already does, exit code included.
18
18
 
19
19
  import * as lint from './commands/lint.js';
20
- import * as oasValidate from './commands/oas-validate.js';
21
20
  import * as oasSync from './commands/oas-sync.js';
21
+ import * as oasValidate from './commands/oas-validate.js';
22
22
  import { writeGithubActionsOutputs } from './utils/gha-output.js';
23
23
 
24
24
  // Each command's run(options, cmd, ctx) only ever reads specific boolean
@@ -26,8 +26,8 @@ import { writeGithubActionsOutputs } from './utils/gha-output.js';
26
26
  // (the commander Command instance normal CLI invocation would pass) — so a
27
27
  // plain options object and a null cmd are enough here.
28
28
  const COMMANDS = {
29
- 'lint': { run: lint.run, flags: ['json', 'github', 'fix'] },
30
- 'validate': { run: lint.run, flags: ['json', 'github', 'fix'] }, // alias, matches src/commands/lint.js
29
+ lint: { run: lint.run, flags: ['json', 'github', 'fix'] },
30
+ validate: { run: lint.run, flags: ['json', 'github', 'fix'] }, // alias, matches src/commands/lint.js
31
31
  'oas:validate': { run: oasValidate.run, flags: ['dereference'] },
32
32
  'oas:sync': { run: oasSync.run, flags: [] },
33
33
  };
@@ -46,7 +46,7 @@ export async function run() {
46
46
  if (!entry) {
47
47
  console.error(
48
48
  `::error::Unknown or unsupported command "${command || '(empty)'}" for the \`readme\` input. ` +
49
- `Supported: ${[...new Set(Object.keys(COMMANDS).filter((k) => k !== 'validate'))].join(', ')}.`,
49
+ `Supported: ${[...new Set(Object.keys(COMMANDS).filter(k => k !== 'validate'))].join(', ')}.`,
50
50
  );
51
51
  writeGithubActionsOutputs({ 'has-errors': 'true' });
52
52
  process.exit(1);
@@ -72,8 +72,8 @@ export async function run() {
72
72
 
73
73
  if (badTokens.length > 0) {
74
74
  console.error(
75
- `::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map((t) => `"${t}"`).join(', ')} for "${command}". ` +
76
- `Supported: ${entry.flags.length > 0 ? entry.flags.map((f) => `--${f}`).join(', ') : '(none)'}.`,
75
+ `::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map(t => `"${t}"`).join(', ')} for "${command}". ` +
76
+ `Supported: ${entry.flags.length > 0 ? entry.flags.map(f => `--${f}`).join(', ') : '(none)'}.`,
77
77
  );
78
78
  writeGithubActionsOutputs({ 'has-errors': 'true' });
79
79
  process.exit(1);
@@ -89,7 +89,7 @@ export async function run() {
89
89
  await entry.run(options, null, { gitRoot });
90
90
  }
91
91
 
92
- run().catch((err) => {
92
+ run().catch(err => {
93
93
  console.error(err);
94
94
  // A thrown error (as opposed to a command's own controlled process.exit)
95
95
  // means whatever run() was doing never reached its own
@@ -25,7 +25,7 @@ export const slotOrphansSystemPrompt = [
25
25
  'Example output: {"assignments": [0, 2, 0, 1, -1, 3]}',
26
26
  '',
27
27
  'Guidance:',
28
- '- Choose the best semantic fit based on the orphan\'s title and URL path.',
28
+ "- Choose the best semantic fit based on the orphan's title and URL path.",
29
29
  '- Only use -1 when no existing category is plausible.',
30
30
  ].join('\n');
31
31
 
@@ -54,13 +54,18 @@ export const slotOrphansOutputSchema = {
54
54
  */
55
55
  export function buildSlotOrphansUserPrompt({ categories, orphans }) {
56
56
  const catList = categories.map((c, i) => {
57
- const sample = (c.pages || []).slice(0, 3).map((p) => p.title).join(', ');
57
+ const sample = (c.pages || [])
58
+ .slice(0, 3)
59
+ .map(p => p.title)
60
+ .join(', ');
58
61
  return `${i}. ${c.title}${sample ? ` (e.g. ${sample})` : ''}`;
59
62
  });
60
63
 
61
64
  const orphanList = orphans.map((p, i) => {
62
65
  let relPath = p.url;
63
- try { relPath = new URL(p.url).pathname; } catch {}
66
+ try {
67
+ relPath = new URL(p.url).pathname;
68
+ } catch {}
64
69
  return `${i}. ${p.title} — ${relPath}`;
65
70
  });
66
71
 
@@ -264,13 +269,17 @@ export const organizeFromScratchOutputSchema = {
264
269
  export function buildOrganizeFromScratchUserPrompt({ siteTitle, items }) {
265
270
  const compactLines = items.map((it, idx) => {
266
271
  let relPath = it.url;
267
- try { relPath = new URL(it.url).pathname + new URL(it.url).search; } catch {}
272
+ try {
273
+ relPath = new URL(it.url).pathname + new URL(it.url).search;
274
+ } catch {}
268
275
  return `${idx}\t${it.title}\t${relPath}`;
269
276
  });
270
277
 
271
278
  let origin = '(unknown)';
272
279
  if (items.length > 0) {
273
- try { origin = new URL(items[0].url).origin; } catch {}
280
+ try {
281
+ origin = new URL(items[0].url).origin;
282
+ } catch {}
274
283
  }
275
284
 
276
285
  return [
@@ -507,7 +516,8 @@ export function prettifyPagePrompt(input) {
507
516
  * @returns {string}
508
517
  */
509
518
  export function stripCodeFences(text) {
510
- return String(text).trim()
519
+ return String(text)
520
+ .trim()
511
521
  .replace(/^```json\s*/i, '')
512
522
  .replace(/^```\s*/i, '')
513
523
  .replace(/\s*```$/, '')
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * A path that would leave `baseDir`, including by following a symlink. `target` is the refused
6
+ * path (the symlink itself when that is what was hit).
7
+ */
8
+ export class PathEscapeError extends Error {
9
+ constructor(target, reason) {
10
+ super(`${target}: ${reason}`);
11
+ this.name = 'PathEscapeError';
12
+ this.target = target;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Guard for "this path stays inside `baseDir`" that does not trust the lexical path.
18
+ *
19
+ * `path.relative` only compares strings. A directory symlink committed under the base (git records
20
+ * it as mode 120000, so a clone recreates it) still looks inside the base, and `mkdir`,
21
+ * `writeFile`, `rename`, `unlink`, and `rm` all follow it. Walk each segment with `lstat` so a
22
+ * symlink is refused even when its target is also inside the base, and `realpath` the nearest
23
+ * existing ancestor so a link that points out of the base cannot hide behind a not-yet-created
24
+ * child.
25
+ *
26
+ * Paths that do not exist yet are allowed when that ancestor is a real directory inside the base —
27
+ * callers are about to create them.
28
+ *
29
+ * @param {string} baseDir Existing directory that bounds every path.
30
+ * @param {{ label?: string }} [opts] Noun used in the error (`reference directory`).
31
+ * @returns {(target: string) => void}
32
+ */
33
+ export function makeContainment(baseDir, { label = 'reference directory' } = {}) {
34
+ const baseReal = fs.realpathSync(baseDir);
35
+ const baseResolved = path.resolve(baseDir);
36
+
37
+ return function assertContained(target) {
38
+ const resolved = path.resolve(target);
39
+ const rel = path.relative(baseResolved, resolved);
40
+
41
+ // '' is the base itself. Callers pass a child, so deleting or writing the base is not a
42
+ // contained operation.
43
+ if (rel === '' || escapes(rel)) {
44
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
45
+ }
46
+
47
+ let cursor = baseReal;
48
+ for (const segment of rel.split(path.sep)) {
49
+ if (segment === '' || segment === '.' || segment === '..') {
50
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
51
+ }
52
+
53
+ cursor = path.join(cursor, segment);
54
+
55
+ let st;
56
+ try {
57
+ st = fs.lstatSync(cursor);
58
+ } catch (err) {
59
+ if (err.code !== 'ENOENT') throw err;
60
+
61
+ // `cursor` is the first missing segment. Its parent is either the base (relative path '')
62
+ // or a segment just lstat'd as a real directory. realpath it anyway so a link above the
63
+ // base cannot hide behind a not-yet-created child.
64
+ const parentRel = path.relative(baseReal, fs.realpathSync(path.dirname(cursor)));
65
+ if (escapes(parentRel)) {
66
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
67
+ }
68
+
69
+ return;
70
+ }
71
+
72
+ if (st.isSymbolicLink()) {
73
+ throw new PathEscapeError(cursor, `symbolic links inside the ${label} are not followed`);
74
+ }
75
+ }
76
+
77
+ const realRel = path.relative(baseReal, fs.realpathSync(cursor));
78
+ if (realRel === '' || escapes(realRel)) {
79
+ throw new PathEscapeError(resolved, `path escapes the ${label}`);
80
+ }
81
+ };
82
+ }
83
+
84
+ /** True when `path.relative(base, candidate)` climbed out of `base`. '' did not. */
85
+ function escapes(rel) {
86
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
87
+ }