@doxbrix/doxloop 0.1.4 → 0.2.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 (357) hide show
  1. package/CHANGELOG.md +1153 -0
  2. package/README.md +422 -226
  3. package/assets/doxbrix-preview.css +77 -15
  4. package/contracts/agent-events-v1.schema.json +1 -0
  5. package/contracts/coverage-v1.schema.json +59 -0
  6. package/contracts/drift-v1.schema.json +1 -0
  7. package/contracts/evaluation-v1.schema.json +62 -0
  8. package/contracts/quality-codes-v1.json +1 -0
  9. package/contracts/quality-report-v1.schema.json +1 -0
  10. package/contracts/validation-v1.schema.json +1 -0
  11. package/dist/agent-failure.d.ts +36 -0
  12. package/dist/agent-failure.js +98 -0
  13. package/dist/agent-isolation.d.ts +17 -0
  14. package/dist/agent-isolation.js +94 -0
  15. package/dist/agent-log.d.ts +197 -0
  16. package/dist/agent-log.js +1094 -0
  17. package/dist/agent-process.d.ts +63 -0
  18. package/dist/agent-process.js +128 -0
  19. package/dist/agent-reply.d.ts +47 -0
  20. package/dist/agent-reply.js +315 -0
  21. package/dist/agents.d.ts +40 -4
  22. package/dist/agents.js +115 -11
  23. package/dist/api-coverage.d.ts +11 -0
  24. package/dist/api-coverage.js +72 -0
  25. package/dist/application-probe.d.ts +28 -0
  26. package/dist/application-probe.js +93 -0
  27. package/dist/application-url.d.ts +14 -0
  28. package/dist/application-url.js +26 -0
  29. package/dist/args.js +15 -0
  30. package/dist/artifact-deploy.d.ts +9 -0
  31. package/dist/artifact-deploy.js +28 -8
  32. package/dist/assets.d.ts +72 -0
  33. package/dist/assets.js +382 -0
  34. package/dist/auth.d.ts +10 -0
  35. package/dist/auth.js +38 -9
  36. package/dist/author.d.ts +206 -4
  37. package/dist/author.js +1650 -52
  38. package/dist/authoring-batches.d.ts +190 -0
  39. package/dist/authoring-batches.js +489 -0
  40. package/dist/authoring-postpass.d.ts +79 -0
  41. package/dist/authoring-postpass.js +1289 -0
  42. package/dist/authoring-progress.d.ts +95 -0
  43. package/dist/authoring-progress.js +269 -0
  44. package/dist/autosync.d.ts +56 -0
  45. package/dist/autosync.js +442 -0
  46. package/dist/batch-artifacts.d.ts +81 -0
  47. package/dist/batch-artifacts.js +412 -0
  48. package/dist/batch-limits.d.ts +37 -0
  49. package/dist/batch-limits.js +80 -0
  50. package/dist/branding.d.ts +41 -0
  51. package/dist/branding.js +229 -0
  52. package/dist/bundle-upload.d.ts +26 -0
  53. package/dist/bundle-upload.js +99 -0
  54. package/dist/capture-auth.d.ts +95 -0
  55. package/dist/capture-auth.js +204 -0
  56. package/dist/capture.d.ts +48 -0
  57. package/dist/capture.js +20 -1
  58. package/dist/cli.js +839 -32
  59. package/dist/content-links.d.ts +10 -0
  60. package/dist/content-links.js +49 -0
  61. package/dist/contract-validation.d.ts +4 -0
  62. package/dist/contract-validation.js +25 -0
  63. package/dist/coverage-actions.d.ts +8 -0
  64. package/dist/coverage-actions.js +71 -0
  65. package/dist/coverage-resolutions.d.ts +16 -0
  66. package/dist/coverage-resolutions.js +58 -0
  67. package/dist/db.d.ts +37 -0
  68. package/dist/db.js +288 -0
  69. package/dist/demo.d.ts +12 -0
  70. package/dist/demo.js +122 -0
  71. package/dist/deploy-credentials.d.ts +7 -0
  72. package/dist/deploy-credentials.js +76 -0
  73. package/dist/deploy-targets/github-pages.d.ts +3 -0
  74. package/dist/deploy-targets/github-pages.js +100 -0
  75. package/dist/deploy-targets/index.d.ts +8 -0
  76. package/dist/deploy-targets/index.js +78 -0
  77. package/dist/deploy-targets/netlify.d.ts +3 -0
  78. package/dist/deploy-targets/netlify.js +49 -0
  79. package/dist/deploy-targets/types.d.ts +32 -0
  80. package/dist/deploy-targets/types.js +2 -0
  81. package/dist/deploy-targets/vercel.d.ts +3 -0
  82. package/dist/deploy-targets/vercel.js +95 -0
  83. package/dist/deploy.d.ts +9 -0
  84. package/dist/deploy.js +87 -13
  85. package/dist/deterministic-capture.d.ts +90 -0
  86. package/dist/deterministic-capture.js +435 -0
  87. package/dist/direct-edit.d.ts +40 -0
  88. package/dist/direct-edit.js +159 -0
  89. package/dist/docs-crawl.d.ts +86 -0
  90. package/dist/docs-crawl.js +536 -0
  91. package/dist/docs-site.d.ts +49 -0
  92. package/dist/docs-site.js +233 -0
  93. package/dist/doctor.js +8 -0
  94. package/dist/documentation-collections.d.ts +23 -0
  95. package/dist/documentation-collections.js +191 -0
  96. package/dist/documentation-plan.d.ts +253 -0
  97. package/dist/documentation-plan.js +2563 -0
  98. package/dist/doxbrix-build.d.ts +19 -0
  99. package/dist/doxbrix-build.js +167 -0
  100. package/dist/doxbrix-markdown.d.ts +9 -0
  101. package/dist/doxbrix-markdown.js +115 -14
  102. package/dist/drift.d.ts +10 -0
  103. package/dist/drift.js +164 -0
  104. package/dist/evaluation.d.ts +46 -0
  105. package/dist/evaluation.js +113 -0
  106. package/dist/evidence-pack.d.ts +47 -0
  107. package/dist/evidence-pack.js +358 -0
  108. package/dist/evidence.d.ts +25 -0
  109. package/dist/evidence.js +175 -0
  110. package/dist/fs.d.ts +8 -2
  111. package/dist/fs.js +41 -11
  112. package/dist/generator-api.d.ts +89 -0
  113. package/dist/generator-preflight.d.ts +27 -0
  114. package/dist/generator-preflight.js +105 -0
  115. package/dist/generator-runtime.d.ts +7 -0
  116. package/dist/generator-runtime.js +17 -1
  117. package/dist/generators.d.ts +24 -2
  118. package/dist/generators.js +54 -1
  119. package/dist/git-delivery.d.ts +17 -0
  120. package/dist/git-delivery.js +123 -0
  121. package/dist/globs.d.ts +16 -0
  122. package/dist/globs.js +65 -0
  123. package/dist/glossary.d.ts +26 -0
  124. package/dist/glossary.js +179 -0
  125. package/dist/history.d.ts +106 -0
  126. package/dist/history.js +600 -0
  127. package/dist/html-markdown.d.ts +46 -0
  128. package/dist/html-markdown.js +423 -0
  129. package/dist/interactive.js +16 -15
  130. package/dist/job-events.d.ts +74 -0
  131. package/dist/job-events.js +377 -0
  132. package/dist/keep-awake.d.ts +50 -0
  133. package/dist/keep-awake.js +123 -0
  134. package/dist/local-source-snapshot.d.ts +20 -0
  135. package/dist/local-source-snapshot.js +61 -0
  136. package/dist/mintlify-detect.d.ts +3 -0
  137. package/dist/mintlify-detect.js +18 -0
  138. package/dist/mintlify-import.d.ts +75 -0
  139. package/dist/mintlify-import.js +190 -0
  140. package/dist/navigation.d.ts +98 -0
  141. package/dist/navigation.js +310 -0
  142. package/dist/openapi.d.ts +60 -0
  143. package/dist/openapi.js +439 -0
  144. package/dist/page-editor-bridge.d.ts +3 -0
  145. package/dist/page-editor-bridge.js +109 -0
  146. package/dist/page-editor-preview.d.ts +10 -0
  147. package/dist/page-editor-preview.js +55 -0
  148. package/dist/page-extension.d.ts +9 -0
  149. package/dist/page-extension.js +15 -0
  150. package/dist/page-metadata.d.ts +28 -0
  151. package/dist/page-metadata.js +166 -0
  152. package/dist/page-operations.d.ts +34 -0
  153. package/dist/page-operations.js +215 -0
  154. package/dist/page-routes.d.ts +4 -0
  155. package/dist/page-routes.js +61 -0
  156. package/dist/pages.d.ts +20 -0
  157. package/dist/pages.js +184 -0
  158. package/dist/plan-generator.d.ts +3 -0
  159. package/dist/plan-generator.js +21 -0
  160. package/dist/plan-navigation.d.ts +11 -0
  161. package/dist/plan-navigation.js +30 -0
  162. package/dist/planning-captures.d.ts +20 -0
  163. package/dist/planning-captures.js +143 -0
  164. package/dist/planning-research.d.ts +135 -0
  165. package/dist/planning-research.js +472 -0
  166. package/dist/planning-triage.d.ts +23 -0
  167. package/dist/planning-triage.js +131 -0
  168. package/dist/preview.d.ts +24 -0
  169. package/dist/preview.js +280 -29
  170. package/dist/project-detect.d.ts +36 -0
  171. package/dist/project-detect.js +251 -0
  172. package/dist/project-import.d.ts +54 -0
  173. package/dist/project-import.js +157 -0
  174. package/dist/project-lock.d.ts +6 -0
  175. package/dist/project-lock.js +96 -0
  176. package/dist/project-registry.d.ts +25 -0
  177. package/dist/project-registry.js +79 -0
  178. package/dist/project.d.ts +35 -3
  179. package/dist/project.js +388 -38
  180. package/dist/prompts.d.ts +9 -0
  181. package/dist/prompts.js +33 -4
  182. package/dist/proposal-replay.d.ts +32 -0
  183. package/dist/proposal-replay.js +99 -0
  184. package/dist/quality-claims.d.ts +8 -0
  185. package/dist/quality-claims.js +168 -0
  186. package/dist/quality-config.d.ts +5 -0
  187. package/dist/quality-config.js +84 -0
  188. package/dist/quality-contract.d.ts +37 -0
  189. package/dist/quality-contract.js +46 -0
  190. package/dist/quality-examples.d.ts +4 -0
  191. package/dist/quality-examples.js +233 -0
  192. package/dist/quality-gates.d.ts +16 -0
  193. package/dist/quality-gates.js +192 -0
  194. package/dist/quality-links.d.ts +7 -0
  195. package/dist/quality-links.js +149 -0
  196. package/dist/quality-lint.d.ts +6 -0
  197. package/dist/quality-lint.js +124 -0
  198. package/dist/quality-rendered.d.ts +11 -0
  199. package/dist/quality-rendered.js +222 -0
  200. package/dist/quality-schema.d.ts +3 -0
  201. package/dist/quality-schema.js +71 -0
  202. package/dist/release-notes.d.ts +44 -0
  203. package/dist/release-notes.js +183 -0
  204. package/dist/remote-monitor.d.ts +16 -0
  205. package/dist/remote-monitor.js +74 -0
  206. package/dist/remote-source.d.ts +34 -0
  207. package/dist/remote-source.js +426 -0
  208. package/dist/review-diff.d.ts +82 -0
  209. package/dist/review-diff.js +400 -0
  210. package/dist/review-learning.d.ts +11 -0
  211. package/dist/review-learning.js +60 -0
  212. package/dist/review-render.d.ts +38 -0
  213. package/dist/review-render.js +224 -0
  214. package/dist/review-report.d.ts +9 -0
  215. package/dist/review-report.js +89 -0
  216. package/dist/review-ui.d.ts +14 -0
  217. package/dist/review-ui.js +1248 -0
  218. package/dist/schedule.d.ts +78 -0
  219. package/dist/schedule.js +480 -0
  220. package/dist/screen-capture-provider.d.ts +52 -0
  221. package/dist/screen-capture-provider.js +218 -0
  222. package/dist/screenshot-workflow.d.ts +167 -0
  223. package/dist/screenshot-workflow.js +1237 -0
  224. package/dist/settings.d.ts +1 -1
  225. package/dist/settings.js +95 -7
  226. package/dist/site-export.d.ts +18 -0
  227. package/dist/site-export.js +87 -0
  228. package/dist/source-connectors.d.ts +33 -0
  229. package/dist/source-connectors.js +268 -0
  230. package/dist/source-discovery.d.ts +132 -0
  231. package/dist/source-discovery.js +823 -0
  232. package/dist/source-intelligence.d.ts +9 -0
  233. package/dist/source-intelligence.js +306 -0
  234. package/dist/sync-review.d.ts +28 -0
  235. package/dist/sync-review.js +264 -0
  236. package/dist/sync-runs.d.ts +192 -0
  237. package/dist/sync-runs.js +2244 -0
  238. package/dist/sync.d.ts +35 -0
  239. package/dist/sync.js +298 -32
  240. package/dist/text-diff.d.ts +9 -0
  241. package/dist/text-diff.js +59 -0
  242. package/dist/types.d.ts +946 -1
  243. package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
  244. package/dist/ui/assets/index-BHBYU2aG.css +1 -0
  245. package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
  246. package/dist/ui/index.html +18 -0
  247. package/dist/ui-server.d.ts +83 -0
  248. package/dist/ui-server.js +3532 -0
  249. package/dist/usage-budget.d.ts +28 -0
  250. package/dist/usage-budget.js +90 -0
  251. package/dist/validation.d.ts +25 -1
  252. package/dist/validation.js +312 -26
  253. package/dist/workspace-tools.d.ts +54 -0
  254. package/dist/workspace-tools.js +123 -0
  255. package/docs/agent-compatibility.md +50 -28
  256. package/docs/ci-and-automation.md +105 -66
  257. package/docs/doxbrix-http-api.md +8 -1
  258. package/docs/existing-documentation.md +80 -0
  259. package/docs/generation-performance.md +108 -0
  260. package/docs/generator-authoring.md +68 -5
  261. package/docs/generator-selection.md +50 -13
  262. package/docs/mintlify-import.md +71 -0
  263. package/docs/openapi-security.md +25 -0
  264. package/docs/project-format.md +331 -36
  265. package/docs/release-quality.md +158 -0
  266. package/docs/releasing.md +72 -0
  267. package/docs/review-workflows.md +51 -0
  268. package/docs/security-model.md +102 -39
  269. package/docs/troubleshooting.md +226 -93
  270. package/package.json +46 -15
  271. package/scripts/test-auto-screenshot.mjs +172 -0
  272. package/skills/doxloop-authoring/SKILL.md +242 -361
  273. package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
  274. package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
  275. package/skills/doxloop-authoring/references/page-depth.md +169 -0
  276. package/skills/doxloop-authoring/references/project-format.md +106 -7
  277. package/skills/doxloop-authoring/references/quality.md +10 -0
  278. package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
  279. package/skills/doxloop-authoring/references/screenshots.md +149 -235
  280. package/skills/doxloop-authoring/references/workflows.md +76 -0
  281. package/skills/doxloop-doxbrix/SKILL.md +38 -22
  282. package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
  283. package/skills/doxloop-doxbrix/references/components.md +30 -3
  284. package/skills/doxloop-doxbrix/references/manifest.md +3 -2
  285. package/vendor/doxbrix-import/LICENSE +202 -0
  286. package/vendor/doxbrix-import/README.md +17 -0
  287. package/vendor/doxbrix-import/UPSTREAM.json +47 -0
  288. package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
  289. package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
  290. package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
  291. package/vendor/doxbrix-import/dist/docs/import.js +147 -0
  292. package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
  293. package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
  294. package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
  295. package/vendor/doxbrix-import/dist/docs/project.js +77 -0
  296. package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
  297. package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
  298. package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
  299. package/vendor/doxbrix-import/dist/importer.js +1567 -0
  300. package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
  301. package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
  302. package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
  303. package/vendor/doxbrix-import/dist/safe-path.js +47 -0
  304. package/dist/agents.d.ts.map +0 -1
  305. package/dist/agents.js.map +0 -1
  306. package/dist/args.d.ts.map +0 -1
  307. package/dist/args.js.map +0 -1
  308. package/dist/artifact-deploy.d.ts.map +0 -1
  309. package/dist/artifact-deploy.js.map +0 -1
  310. package/dist/auth.d.ts.map +0 -1
  311. package/dist/auth.js.map +0 -1
  312. package/dist/author.d.ts.map +0 -1
  313. package/dist/author.js.map +0 -1
  314. package/dist/capture.d.ts.map +0 -1
  315. package/dist/capture.js.map +0 -1
  316. package/dist/cli.d.ts.map +0 -1
  317. package/dist/cli.js.map +0 -1
  318. package/dist/deploy.d.ts.map +0 -1
  319. package/dist/deploy.js.map +0 -1
  320. package/dist/deployment-visibility.d.ts.map +0 -1
  321. package/dist/deployment-visibility.js.map +0 -1
  322. package/dist/doctor.d.ts.map +0 -1
  323. package/dist/doctor.js.map +0 -1
  324. package/dist/doxbrix-markdown.d.ts.map +0 -1
  325. package/dist/doxbrix-markdown.js.map +0 -1
  326. package/dist/errors.d.ts.map +0 -1
  327. package/dist/errors.js.map +0 -1
  328. package/dist/fs.d.ts.map +0 -1
  329. package/dist/fs.js.map +0 -1
  330. package/dist/generator-api.d.ts.map +0 -1
  331. package/dist/generator-api.js.map +0 -1
  332. package/dist/generator-manager.d.ts.map +0 -1
  333. package/dist/generator-manager.js.map +0 -1
  334. package/dist/generator-runtime.d.ts.map +0 -1
  335. package/dist/generator-runtime.js.map +0 -1
  336. package/dist/generators.d.ts.map +0 -1
  337. package/dist/generators.js.map +0 -1
  338. package/dist/interactive.d.ts.map +0 -1
  339. package/dist/interactive.js.map +0 -1
  340. package/dist/preview.d.ts.map +0 -1
  341. package/dist/preview.js.map +0 -1
  342. package/dist/progress.d.ts.map +0 -1
  343. package/dist/progress.js.map +0 -1
  344. package/dist/project.d.ts.map +0 -1
  345. package/dist/project.js.map +0 -1
  346. package/dist/prompts.d.ts.map +0 -1
  347. package/dist/prompts.js.map +0 -1
  348. package/dist/settings.d.ts.map +0 -1
  349. package/dist/settings.js.map +0 -1
  350. package/dist/sync.d.ts.map +0 -1
  351. package/dist/sync.js.map +0 -1
  352. package/dist/types.d.ts.map +0 -1
  353. package/dist/types.js.map +0 -1
  354. package/dist/validation.d.ts.map +0 -1
  355. package/dist/validation.js.map +0 -1
  356. package/dist/version.d.ts.map +0 -1
  357. package/dist/version.js.map +0 -1
package/dist/assets.js ADDED
@@ -0,0 +1,382 @@
1
+ import { statSync } from 'node:fs';
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, extname, join, relative, resolve, sep } from 'node:path';
4
+ import { applyDirectEdit } from './direct-edit.js';
5
+ import { DoxloopError } from './errors.js';
6
+ import { assertInside, listFiles, pathExists, resolveContainedDirectory } from './fs.js';
7
+ import { loadGeneratorAdapter } from './generators.js';
8
+ import { loadPages, loadProject, relativePath, ROOT_CONTENT_IGNORED_DIRECTORIES } from './project.js';
9
+ import { runWorkspace } from './sync-runs.js';
10
+ /**
11
+ * Images and downloadable files that documentation pages reference. Uploads
12
+ * land in the generator's conventional asset directory and are referenced by
13
+ * a public path pages can use directly; the library lists every asset with the
14
+ * pages that embed it so alt text can be edited and unused files deleted.
15
+ */
16
+ export const ASSET_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.ico']);
17
+ export const ASSET_FILE_EXTENSIONS = new Set(['.pdf', '.mp4', '.webm', '.mov', '.zip']);
18
+ export const MAX_ASSET_BYTES = 10 * 1024 * 1024;
19
+ const IGNORED_DIRECTORIES = new Set([...ROOT_CONTENT_IGNORED_DIRECTORIES, 'node_modules', '.doxloop', '.git', 'build', 'dist', 'site', '_site', '.vitepress', '.astro', '.next', 'venv', '.venv']);
20
+ const DEFAULT_ASSET_LOCATIONS = {
21
+ doxbrix: (contentDir) => ({ directory: portable(join(contentDir, 'assets')), publicPrefix: '/assets' }),
22
+ docusaurus: () => ({ directory: 'static/img', publicPrefix: '/img' }),
23
+ mkdocs: (contentDir) => ({ directory: portable(join(contentDir, 'assets')), publicPrefix: '/assets' }),
24
+ sphinx: (contentDir) => ({ directory: portable(join(contentDir, '_static')), publicPrefix: '/_static' }),
25
+ hugo: () => ({ directory: 'static/images', publicPrefix: '/images' }),
26
+ vitepress: (contentDir) => ({ directory: portable(join(contentDir, 'public')), publicPrefix: '/' }),
27
+ starlight: () => ({ directory: 'public', publicPrefix: '/' }),
28
+ nextra: () => ({ directory: 'public', publicPrefix: '/' }),
29
+ markdoc: () => ({ directory: 'public', publicPrefix: '/' }),
30
+ jekyll: () => ({ directory: 'assets/images', publicPrefix: '/assets/images' }),
31
+ static: () => ({ directory: 'assets', publicPrefix: '/assets' }),
32
+ };
33
+ export function assetLocation(project, adapter) {
34
+ if (adapter?.project.assets)
35
+ return adapter.project.assets;
36
+ const defaults = DEFAULT_ASSET_LOCATIONS[project.generator] ?? DEFAULT_ASSET_LOCATIONS.doxbrix;
37
+ return defaults(project.contentDir);
38
+ }
39
+ export async function readAssetLibrary(root) {
40
+ const project = await loadProject(root);
41
+ const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
42
+ const location = assetLocation(project, adapter);
43
+ return { ...location, maxBytes: MAX_ASSET_BYTES, assets: await listAssets(root, project, adapter) };
44
+ }
45
+ export async function listAssets(root, loaded, loadedAdapter) {
46
+ const project = loaded ?? (await loadProject(root));
47
+ const adapter = loadedAdapter ?? (project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project));
48
+ const contentRoot = await contentDirectory(root, project);
49
+ const location = assetLocation(project, adapter);
50
+ const extensions = new Set([...ASSET_IMAGE_EXTENSIONS, ...ASSET_FILE_EXTENSIONS]);
51
+ const roots = new Set([contentRoot]);
52
+ const assetRoot = resolve(root, location.directory);
53
+ if (containedBy(root, assetRoot) && !containedBy(contentRoot, assetRoot) && (await pathExists(assetRoot)))
54
+ roots.add(assetRoot);
55
+ const files = new Set();
56
+ for (const directory of roots) {
57
+ if (!(await pathExists(directory)))
58
+ continue;
59
+ for (const file of await listFiles(directory, extensions, { ignoredDirectories: IGNORED_DIRECTORIES }))
60
+ files.add(file);
61
+ }
62
+ const references = await collectReferences(root, project, adapter, contentRoot);
63
+ const entries = [];
64
+ for (const absolute of [...files].sort()) {
65
+ const path = relativePath(root, absolute);
66
+ const info = await stat(absolute);
67
+ entries.push({
68
+ path,
69
+ name: basename(absolute),
70
+ publicPath: publicPathFor(path, contentRoot, root, location),
71
+ kind: ASSET_IMAGE_EXTENSIONS.has(extname(absolute).toLowerCase()) ? 'image' : 'file',
72
+ bytes: info.size,
73
+ modifiedAt: info.mtime.toISOString(),
74
+ references: references.get(path) ?? [],
75
+ });
76
+ }
77
+ return entries;
78
+ }
79
+ export async function uploadAsset(root, raw) {
80
+ const input = parseUpload(raw);
81
+ const project = await loadProject(root);
82
+ const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
83
+ const contentRoot = await contentDirectory(root, project);
84
+ const location = assetLocation(project, adapter);
85
+ const name = safeAssetName(input.name);
86
+ const bytes = decodeUpload(input.data, name);
87
+ const directory = input.directory ? assetDirectory(root, contentRoot, location, input.directory) : resolve(root, location.directory);
88
+ const target = assertInside(root, join(directory, name));
89
+ const path = relativePath(root, target);
90
+ if ((await pathExists(target)) && !input.replace) {
91
+ throw new DoxloopError(`"${path}" already exists. Choose another name or replace the existing file.`);
92
+ }
93
+ await applyDirectEdit(root, {
94
+ kind: 'asset',
95
+ requestText: `${input.replace ? 'Replaced' : 'Uploaded'} ${path}`,
96
+ files: [path],
97
+ apply: async () => {
98
+ await mkdir(directory, { recursive: true });
99
+ await writeFile(target, bytes);
100
+ },
101
+ });
102
+ const entry = (await listAssets(root, project, adapter)).find((asset) => asset.path === path);
103
+ if (!entry)
104
+ throw new DoxloopError(`"${path}" was written but is not listed as an asset.`);
105
+ return entry;
106
+ }
107
+ export async function deleteAsset(root, raw) {
108
+ const body = record(raw);
109
+ const path = normalizedPath(body.path);
110
+ const project = await loadProject(root);
111
+ const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
112
+ const asset = (await listAssets(root, project, adapter)).find((entry) => entry.path === path);
113
+ if (!asset)
114
+ throw new DoxloopError(`"${path}" is not an asset in this project.`, 2);
115
+ if (asset.references.length > 0) {
116
+ // Validation treats a missing link target as an error, so a forced delete
117
+ // would only be rolled back; the pages have to drop the image first.
118
+ const pages = [...new Set(asset.references.map((reference) => reference.page))];
119
+ throw new DoxloopError(`"${asset.name}" is still used by ${pages.join(', ')}. Ask the agent to remove or replace the image on ${pages.length === 1 ? 'that page' : 'those pages'} first.`);
120
+ }
121
+ await applyDirectEdit(root, {
122
+ kind: 'asset',
123
+ requestText: `Deleted ${path}`,
124
+ files: [path],
125
+ apply: () => rm(join(root, path), { force: true }),
126
+ });
127
+ return { removed: path };
128
+ }
129
+ /** Rewrite the alt text of every reference to one asset across the pages that embed it. */
130
+ export async function updateAssetAlt(root, raw) {
131
+ const body = record(raw);
132
+ const path = normalizedPath(body.path);
133
+ if (typeof body.alt !== 'string')
134
+ throw new DoxloopError('Alt text must be a string.', 2);
135
+ const alt = body.alt.trim().replace(/\s+/g, ' ');
136
+ if (alt.length > 300)
137
+ throw new DoxloopError('Alt text must be 300 characters or fewer.', 2);
138
+ if (/[[\]()<>"]/.test(alt))
139
+ throw new DoxloopError('Alt text cannot contain brackets, parentheses, angle brackets, or quotes.', 2);
140
+ const project = await loadProject(root);
141
+ const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
142
+ const contentRoot = await contentDirectory(root, project);
143
+ const asset = (await listAssets(root, project, adapter)).find((entry) => entry.path === path);
144
+ if (!asset)
145
+ throw new DoxloopError(`"${path}" is not an asset in this project.`, 2);
146
+ if (asset.references.length === 0)
147
+ throw new DoxloopError(`"${asset.name}" is not embedded in any page, so there is no alt text to change.`, 2);
148
+ const pages = [...new Set(asset.references.map((reference) => reference.page))];
149
+ const rewrites = new Map();
150
+ for (const page of pages) {
151
+ const source = await readFile(join(root, page), 'utf8');
152
+ const next = rewriteAlt(source, alt, (reference) => resolveReference(root, contentRoot, page, reference, adapter) === path);
153
+ if (next !== source)
154
+ rewrites.set(page, next);
155
+ }
156
+ await applyDirectEdit(root, {
157
+ kind: 'asset',
158
+ requestText: `Set alt text for ${asset.name}`,
159
+ files: [...rewrites.keys()],
160
+ pagesChanged: rewrites.size,
161
+ apply: async () => {
162
+ for (const [page, content] of rewrites)
163
+ await writeFile(join(root, page), content, 'utf8');
164
+ },
165
+ });
166
+ const updated = (await listAssets(root, project, adapter)).find((entry) => entry.path === path);
167
+ return updated ?? asset;
168
+ }
169
+ /** Replace one screenshot in a proposal workspace before the proposal is accepted. */
170
+ export async function replaceRunCapture(root, raw) {
171
+ const body = record(raw);
172
+ const runId = typeof body.run === 'string' ? body.run.trim() : '';
173
+ if (!/^[a-z0-9-]+$/.test(runId))
174
+ throw new DoxloopError('A capture replacement needs a run id.', 2);
175
+ const file = normalizedPath(body.path);
176
+ if (extname(file).toLowerCase() !== '.png')
177
+ throw new DoxloopError('Only PNG screenshots can be replaced.', 2);
178
+ const workspace = runWorkspace(root, runId);
179
+ const target = assertInside(workspace, resolve(workspace, file));
180
+ if (!(await pathExists(target)))
181
+ throw new DoxloopError('That capture no longer exists.', 2);
182
+ const bytes = decodeUpload(typeof body.data === 'string' ? body.data : '', basename(file));
183
+ await writeFile(target, bytes);
184
+ return { run: runId, path: file, bytes: bytes.length };
185
+ }
186
+ /**
187
+ * Markdown image references and HTML `img`/`source` tags with their alt text.
188
+ * Exposed so the page service and the library agree on what counts as a
189
+ * reference.
190
+ */
191
+ export function pageAssetReferences(raw) {
192
+ const output = [];
193
+ for (const match of raw.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+[^)]*)?\)/g)) {
194
+ output.push({ reference: match[2].trim(), alt: match[1].trim() });
195
+ }
196
+ for (const match of raw.matchAll(/<(?:img|source|Image|Frame)\b([^>]*)>/g)) {
197
+ const attributes = match[1];
198
+ const src = /\bsrc=["']([^"']+)["']/.exec(attributes)?.[1]?.trim();
199
+ if (!src)
200
+ continue;
201
+ output.push({ reference: src, alt: /\balt=["']([^"']*)["']/.exec(attributes)?.[1]?.trim() ?? '' });
202
+ }
203
+ return output.filter((entry) => !/^(?:https?:|data:|#|mailto:)/i.test(entry.reference));
204
+ }
205
+ function rewriteAlt(source, alt, matches) {
206
+ let output = source.replace(/!\[([^\]]*)\]\(([^)\s]+)((?:\s+[^)]*)?)\)/g, (whole, _old, reference, rest) => (matches(reference.trim()) ? `![${alt}](${reference}${rest})` : whole));
207
+ output = output.replace(/<(img|source|Image|Frame)\b([^>]*)>/g, (whole, tag, attributes) => {
208
+ const src = /\bsrc=["']([^"']+)["']/.exec(attributes)?.[1]?.trim();
209
+ if (!src || !matches(src))
210
+ return whole;
211
+ const escaped = alt.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
212
+ if (/\balt=["'][^"']*["']/.test(attributes))
213
+ return `<${tag}${attributes.replace(/\balt=["'][^"']*["']/, `alt="${escaped}"`)}>`;
214
+ const selfClosing = /\/\s*$/.test(attributes);
215
+ const body = selfClosing ? attributes.replace(/\s*\/\s*$/, '') : attributes.replace(/\s+$/, '');
216
+ return `<${tag}${body} alt="${escaped}"${selfClosing ? ' /' : ''}>`;
217
+ });
218
+ return output;
219
+ }
220
+ async function collectReferences(root, project, adapter, contentRoot) {
221
+ const references = new Map();
222
+ let pages = [];
223
+ try {
224
+ pages = await loadPages(root, project);
225
+ }
226
+ catch {
227
+ return references;
228
+ }
229
+ for (const absolute of pages) {
230
+ const page = relativePath(root, absolute);
231
+ const raw = await readFile(absolute, 'utf8');
232
+ for (const { reference, alt } of pageAssetReferences(raw)) {
233
+ const resolved = resolveReference(root, contentRoot, page, reference, adapter);
234
+ if (!resolved)
235
+ continue;
236
+ const list = references.get(resolved) ?? [];
237
+ list.push({ page, alt });
238
+ references.set(resolved, list);
239
+ }
240
+ }
241
+ return references;
242
+ }
243
+ function resolveReference(root, contentRoot, page, reference, adapter) {
244
+ const clean = reference.split(/[?#]/)[0];
245
+ if (!clean)
246
+ return undefined;
247
+ const candidates = [
248
+ adapter?.resolveLocalAsset?.({ root, contentRoot, pagePath: join(root, page), reference: clean }),
249
+ clean.startsWith('/') ? resolve(contentRoot, clean.slice(1)) : resolve(join(root, page, '..'), clean),
250
+ clean.startsWith('/') ? resolve(root, clean.slice(1)) : undefined,
251
+ clean.startsWith('/') ? resolve(root, 'static', clean.slice(1)) : undefined,
252
+ clean.startsWith('/') ? resolve(root, 'public', clean.slice(1)) : undefined,
253
+ ].filter((value) => Boolean(value));
254
+ for (const candidate of candidates) {
255
+ if (!containedBy(root, candidate) || !isFile(candidate))
256
+ continue;
257
+ const rel = relativePath(root, candidate);
258
+ if (rel)
259
+ return rel;
260
+ }
261
+ return undefined;
262
+ }
263
+ function isFile(candidate) {
264
+ try {
265
+ return statSync(candidate).isFile();
266
+ }
267
+ catch {
268
+ return false;
269
+ }
270
+ }
271
+ function publicPathFor(path, contentRoot, root, location) {
272
+ const absolute = resolve(root, path);
273
+ const assetRoot = resolve(root, location.directory);
274
+ if (containedBy(assetRoot, absolute)) {
275
+ const rel = portable(relative(assetRoot, absolute));
276
+ return `${location.publicPrefix.replace(/\/$/, '')}/${rel}`.replace(/\/{2,}/g, '/');
277
+ }
278
+ if (containedBy(contentRoot, absolute))
279
+ return `/${portable(relative(contentRoot, absolute))}`;
280
+ return `/${path}`;
281
+ }
282
+ function assetDirectory(root, contentRoot, location, requested) {
283
+ const candidate = assertInside(root, resolve(root, requested));
284
+ const assetRoot = resolve(root, location.directory);
285
+ if (!containedBy(contentRoot, candidate) && !containedBy(assetRoot, candidate)) {
286
+ throw new DoxloopError(`Uploads must stay inside ${location.directory} or the documentation content directory.`, 2);
287
+ }
288
+ if (candidate.split(sep).some((part) => IGNORED_DIRECTORIES.has(part)))
289
+ throw new DoxloopError('That directory is not part of the documentation.', 2);
290
+ return candidate;
291
+ }
292
+ function parseUpload(raw) {
293
+ const body = record(raw);
294
+ if (typeof body.name !== 'string' || !body.name.trim())
295
+ throw new DoxloopError('An upload needs a file name.', 2);
296
+ if (typeof body.data !== 'string' || !body.data)
297
+ throw new DoxloopError('An upload needs file content.', 2);
298
+ return {
299
+ name: body.name,
300
+ data: body.data,
301
+ ...(typeof body.directory === 'string' && body.directory.trim() ? { directory: body.directory.trim() } : {}),
302
+ replace: body.replace === true,
303
+ };
304
+ }
305
+ export function safeAssetName(raw) {
306
+ const name = basename(raw.trim()).replace(/\s+/g, '-').replace(/[^A-Za-z0-9._-]/g, '').replace(/^\.+/, '');
307
+ const extension = extname(name).toLowerCase();
308
+ if (!name || name === extension)
309
+ throw new DoxloopError('File names may use letters, numbers, dots, hyphens, and underscores.', 2);
310
+ if (!ASSET_IMAGE_EXTENSIONS.has(extension) && !ASSET_FILE_EXTENSIONS.has(extension)) {
311
+ throw new DoxloopError(`"${extension || 'files without an extension'}" is not an accepted asset type. Upload PNG, JPEG, GIF, WebP, SVG, AVIF, ICO, PDF, MP4, WebM, MOV, or ZIP files.`, 2);
312
+ }
313
+ if (name.length > 120)
314
+ throw new DoxloopError('File names must be 120 characters or fewer.', 2);
315
+ return name;
316
+ }
317
+ export function decodeUpload(data, name) {
318
+ const encoded = data.replace(/^data:[^;]+;base64,/, '').replace(/\s+/g, '');
319
+ if (!encoded || !/^[A-Za-z0-9+/]+=*$/.test(encoded))
320
+ throw new DoxloopError('File content must be base64 encoded.', 2);
321
+ if ((encoded.length * 3) / 4 > MAX_ASSET_BYTES + 4)
322
+ throw new DoxloopError(`Files must be ${Math.round(MAX_ASSET_BYTES / (1024 * 1024))} MB or smaller.`, 2);
323
+ const bytes = Buffer.from(encoded, 'base64');
324
+ if (bytes.length === 0)
325
+ throw new DoxloopError('The uploaded file is empty.', 2);
326
+ if (bytes.length > MAX_ASSET_BYTES)
327
+ throw new DoxloopError(`Files must be ${Math.round(MAX_ASSET_BYTES / (1024 * 1024))} MB or smaller.`, 2);
328
+ assertContentMatchesExtension(bytes, extname(name).toLowerCase());
329
+ return bytes;
330
+ }
331
+ function assertContentMatchesExtension(bytes, extension) {
332
+ const head = bytes.subarray(0, 16);
333
+ const startsWith = (...signature) => signature.every((byte, index) => head[index] === byte);
334
+ const text = bytes.subarray(0, 4096).toString('utf8');
335
+ const ok = (() => {
336
+ switch (extension) {
337
+ case '.png': return startsWith(0x89, 0x50, 0x4e, 0x47);
338
+ case '.jpg':
339
+ case '.jpeg': return startsWith(0xff, 0xd8, 0xff);
340
+ case '.gif': return startsWith(0x47, 0x49, 0x46, 0x38);
341
+ case '.webp': return startsWith(0x52, 0x49, 0x46, 0x46) && head.subarray(8, 12).toString('ascii') === 'WEBP';
342
+ case '.avif': return head.subarray(4, 8).toString('ascii') === 'ftyp';
343
+ case '.ico': return startsWith(0x00, 0x00, 0x01, 0x00);
344
+ case '.pdf': return startsWith(0x25, 0x50, 0x44, 0x46);
345
+ case '.zip': return startsWith(0x50, 0x4b);
346
+ case '.mp4':
347
+ case '.mov': return head.subarray(4, 8).toString('ascii') === 'ftyp';
348
+ case '.webm': return startsWith(0x1a, 0x45, 0xdf, 0xa3);
349
+ case '.svg': return /<svg[\s>]/i.test(text) && !/<script[\s>]|\bon[a-z]+\s*=|javascript:/i.test(bytes.toString('utf8'));
350
+ default: return false;
351
+ }
352
+ })();
353
+ if (!ok) {
354
+ throw new DoxloopError(extension === '.svg'
355
+ ? 'SVG files must contain an <svg> element and no scripts or event handlers.'
356
+ : `The file content does not look like a ${extension.slice(1).toUpperCase()} file.`, 2);
357
+ }
358
+ }
359
+ function normalizedPath(value) {
360
+ if (typeof value !== 'string' || !value.trim())
361
+ throw new DoxloopError('An asset request needs a project-relative path.', 2);
362
+ const path = portable(value.trim()).replace(/^\.?\//, '');
363
+ if (!path || path.split('/').some((part) => part === '..' || part === ''))
364
+ throw new DoxloopError('Asset paths cannot leave the project.', 2);
365
+ return path;
366
+ }
367
+ function record(value) {
368
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
369
+ }
370
+ async function contentDirectory(root, project) {
371
+ return resolveContainedDirectory(root, project.contentDir, 'Documentation content directory', {
372
+ allowRoot: project.generator === 'doxbrix',
373
+ });
374
+ }
375
+ function containedBy(root, candidate) {
376
+ const rel = relative(resolve(root), resolve(candidate));
377
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith('/'));
378
+ }
379
+ function portable(path) {
380
+ return sep === '/' ? path : path.split(sep).join('/');
381
+ }
382
+ //# sourceMappingURL=assets.js.map
package/dist/auth.d.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  interface UserConfig {
2
2
  apiUrl: string;
3
3
  token?: string;
4
+ /**
5
+ * One token per Doxbrix server. Signing in to a local development server
6
+ * must not replace the app.doxbrix.com sign-in, and a token is only ever
7
+ * sent to the server that issued it.
8
+ */
9
+ tokens?: Record<string, string>;
4
10
  }
5
11
  export declare function apiUrl(override?: string): string;
6
12
  export declare function loadUserConfig(): Promise<UserConfig>;
13
+ /** The token issued by `baseUrl`, never one issued by another server. */
14
+ export declare function tokenFor(config: UserConfig, baseUrl: string): string | undefined;
15
+ /** Servers this computer holds a sign-in for, for "signed in elsewhere" hints. */
16
+ export declare function signedInServers(config: UserConfig): string[];
7
17
  export declare function saveToken(token: string, baseUrl: string): Promise<void>;
8
18
  export declare function logout(): Promise<void>;
9
19
  export declare function login(options: {
package/dist/auth.js CHANGED
@@ -31,16 +31,39 @@ export async function loadUserConfig() {
31
31
  return { apiUrl: apiUrl() };
32
32
  try {
33
33
  const parsed = JSON.parse(await readFile(path, 'utf8'));
34
- return { apiUrl: apiUrl(parsed.apiUrl), ...(parsed.token ? { token: parsed.token } : {}) };
34
+ const current = apiUrl(parsed.apiUrl);
35
+ const tokens = {};
36
+ for (const [url, token] of Object.entries(parsed.tokens ?? {})) {
37
+ if (typeof token === 'string' && token)
38
+ tokens[apiUrl(url)] = token;
39
+ }
40
+ // Files written before per-server tokens hold one token for `apiUrl`.
41
+ if (parsed.token && !tokens[current])
42
+ tokens[current] = parsed.token;
43
+ return { apiUrl: current, ...(tokens[current] ? { token: tokens[current] } : {}), tokens };
35
44
  }
36
45
  catch {
37
46
  throw new DoxloopError(`Cannot read Doxloop credentials at ${path}.`);
38
47
  }
39
48
  }
49
+ /** The token issued by `baseUrl`, never one issued by another server. */
50
+ export function tokenFor(config, baseUrl) {
51
+ const environment = process.env.DOXLOOP_TOKEN || process.env.DOXBRIX_TOKEN;
52
+ if (environment)
53
+ return environment;
54
+ const url = apiUrl(baseUrl);
55
+ return config.tokens?.[url] ?? (config.apiUrl === url ? config.token : undefined);
56
+ }
57
+ /** Servers this computer holds a sign-in for, for "signed in elsewhere" hints. */
58
+ export function signedInServers(config) {
59
+ return Object.keys(config.tokens ?? (config.token ? { [config.apiUrl]: config.token } : {}));
60
+ }
40
61
  export async function saveToken(token, baseUrl) {
41
62
  const path = configPath();
42
63
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
43
- await writeFile(path, `${JSON.stringify({ apiUrl: baseUrl, token }, null, 2)}\n`, {
64
+ const existing = await pathExists(path) ? await loadUserConfig().catch(() => undefined) : undefined;
65
+ const tokens = { ...(existing?.tokens ?? {}), [apiUrl(baseUrl)]: token };
66
+ await writeFile(path, `${JSON.stringify({ apiUrl: baseUrl, token, tokens }, null, 2)}\n`, {
44
67
  encoding: 'utf8',
45
68
  mode: 0o600,
46
69
  });
@@ -77,7 +100,8 @@ export async function login(options) {
77
100
  await verifyAndSaveToken(baseUrl, await readSecret('Token: '));
78
101
  return;
79
102
  }
80
- process.stdout.write(`Open ${start.verificationUri}\nEnter code: ${start.userCode}\n`);
103
+ const minutes = Math.max(1, Math.round(start.expiresIn / 60));
104
+ process.stdout.write(`Open ${start.verificationUri}\nEnter code: ${start.userCode}\nThe code expires in ${minutes} minute${minutes === 1 ? '' : 's'}.\n`);
81
105
  openBrowser(`${start.verificationUri}?code=${encodeURIComponent(start.userCode)}`);
82
106
  const deadline = Date.now() + start.expiresIn * 1000;
83
107
  while (Date.now() < deadline) {
@@ -159,10 +183,11 @@ async function verifyToken(baseUrl, token) {
159
183
  }
160
184
  export async function whoami(override) {
161
185
  const config = await loadUserConfig();
162
- const token = config.token ?? process.env.DOXLOOP_TOKEN ?? process.env.DOXBRIX_TOKEN;
186
+ const destination = apiUrl(override ?? config.apiUrl);
187
+ const token = tokenFor(config, destination);
163
188
  if (!token)
164
- throw new DoxloopError('Not signed in. Run `doxloop login`.');
165
- const account = await requestJson(`${apiUrl(override ?? config.apiUrl)}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } });
189
+ throw new DoxloopError(`Not signed in to ${new URL(destination).host}. Run \`doxloop login\`.`);
190
+ const account = await requestJson(`${destination}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } });
166
191
  process.stdout.write(`${account.email}${account.name ? ` (${account.name})` : ''}\n`);
167
192
  }
168
193
  export async function authenticatedRequest(path, init, override) {
@@ -174,12 +199,13 @@ export async function authenticatedRequestOptional(path, init, override) {
174
199
  }
175
200
  async function authenticatedRequestInternal(path, init, override, allowNotFound) {
176
201
  const config = await loadUserConfig();
177
- const token = config.token ?? process.env.DOXLOOP_TOKEN ?? process.env.DOXBRIX_TOKEN;
202
+ const destination = apiUrl(override ?? config.apiUrl);
203
+ const token = tokenFor(config, destination);
178
204
  if (!token)
179
- throw new DoxloopError('Not signed in. Run `doxloop login` first.');
205
+ throw new DoxloopError(`Not signed in to ${new URL(destination).host}. Sign in with Doxbrix first.`);
180
206
  const headers = new Headers(init.headers);
181
207
  headers.set('Authorization', `Bearer ${token}`);
182
- const url = `${apiUrl(override ?? config.apiUrl)}${path}`;
208
+ const url = `${destination}${path}`;
183
209
  let response;
184
210
  try {
185
211
  response = await fetch(url, { ...init, headers, redirect: 'error' });
@@ -277,6 +303,9 @@ function configPath() {
277
303
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'doxloop', 'config.json');
278
304
  }
279
305
  function openBrowser(url) {
306
+ // Headless machines, SSH sessions, and recordings print the link instead.
307
+ if (process.env.DOXLOOP_NO_BROWSER === '1')
308
+ return;
280
309
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
281
310
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
282
311
  const child = spawn(command, args, { detached: true, stdio: 'ignore' });