@astryxdesign/cli 0.4.5-canary.ee6d68d → 0.4.5-canary.fba7b40

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 (49) hide show
  1. package/api/docs/_adapter.d.mts +37 -8
  2. package/api/docs/_adapter.mjs +70 -41
  3. package/api/docs/detail/detail.d.mts +2 -0
  4. package/api/docs/detail/detail.mjs +12 -10
  5. package/api/docs/detail/section/section.d.mts +2 -0
  6. package/api/docs/detail/section/section.mjs +1 -0
  7. package/api/docs/docs.d.mts +2 -0
  8. package/api/docs/docs.doc.mjs +11 -2
  9. package/api/docs/docs.mjs +2 -1
  10. package/api/docs/docs.type.d.mts +15 -0
  11. package/api/docs/docs.type.mjs +6 -0
  12. package/api/docs/integrationDocs.test.mjs +208 -0
  13. package/api/docs/list/list.d.mts +5 -1
  14. package/api/docs/list/list.mjs +30 -14
  15. package/api/init/run/run.mjs +9 -4
  16. package/api/integration/validate-integration.mjs +2 -1
  17. package/api/integration/validate-integration.type.d.mts +1 -0
  18. package/api/integration/validate-integration.type.mjs +1 -0
  19. package/api/search/search.mjs +26 -15
  20. package/api/upgrade/_adapter.d.mts +2 -2
  21. package/api/upgrade/_adapter.mjs +7 -3
  22. package/api/upgrade/run/run.mjs +1 -1
  23. package/assets/docs/cli-integrations.doc.mjs +41 -3
  24. package/assets/docs/theme.doc.mjs +3 -3
  25. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +1 -1
  26. package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +1 -1
  27. package/authoring/doctypes/_schema.d.mts +2 -0
  28. package/authoring/doctypes/_schema.mjs +5 -0
  29. package/authoring/doctypes/reference/reference.doc.mjs +14 -0
  30. package/authoring/doctypes/reference/type.ts +12 -0
  31. package/authoring/integration/integration.doc.mjs +10 -1
  32. package/authoring/integration/parse.d.mts +1 -0
  33. package/authoring/integration/parse.mjs +1 -0
  34. package/authoring/integration/parse.test.mjs +1 -0
  35. package/authoring/integration/type.ts +5 -0
  36. package/clients/cli/commands/theme-build.doc.mjs +1 -1
  37. package/foundation/agent-docs/agent-docs.d.mts +19 -3
  38. package/foundation/agent-docs/agent-docs.mjs +30 -11
  39. package/foundation/agent-docs/agent-docs.test.mjs +38 -0
  40. package/foundation/config/project.d.mts +16 -0
  41. package/foundation/config/project.mjs +65 -4
  42. package/foundation/config/project.test.mjs +66 -0
  43. package/foundation/discovery/docs-discovery.d.mts +185 -0
  44. package/foundation/discovery/docs-discovery.mjs +544 -0
  45. package/foundation/discovery/docs-discovery.test.mjs +341 -0
  46. package/foundation/integrations/integrations.d.mts +8 -6
  47. package/foundation/integrations/integrations.mjs +6 -4
  48. package/foundation/integrations/validate-contributions.mjs +30 -2
  49. package/package.json +9 -9
@@ -2,9 +2,19 @@
2
2
  // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
3
 
4
4
  /**
5
- * @returns {Record<string, string>}
5
+ * The project's topics: the built-in ones plus whatever the configured
6
+ * integrations contribute.
7
+ *
8
+ * A docs read must not depend on a healthy project config. `astryx docs
9
+ * tokens` answered without loading anything before integrations could
10
+ * contribute topics, and it still answers when the config is unreadable — the
11
+ * built-in topics are the floor, and the integration issues surface on the
12
+ * commands that own them.
13
+ *
14
+ * @param {string} [cwd]
15
+ * @returns {Promise<DocsCatalog>}
6
16
  */
7
- export function discoverTopics(): Record<string, string>;
17
+ export function loadDocsCatalog(cwd?: string): Promise<DocsCatalog>;
8
18
  /**
9
19
  * @param {string} docPath
10
20
  * @param {{lang?: string|null}} [opts]
@@ -14,18 +24,35 @@ export function loadReferenceDocs(docPath: string, { lang }?: {
14
24
  lang?: string | null;
15
25
  }): Promise<import("./docs.type.mjs").DocsDetailResponse["data"]>;
16
26
  /**
17
- * Discover topics, resolve `topic` to its doc file (throwing `ERR_UNKNOWN_TOPIC`
18
- * when unmatched), and load that topic's reference doc with any --dense/--zh
19
- * overlay applied. Shared by the detail and section leaves so topic normalization
20
- * and unknown-topic handling live in exactly one place.
27
+ * Load one catalog entry: its own doc, plus any extension an integration
28
+ * merged onto it, in configuration order.
29
+ *
30
+ * A localization overlay applies to each file before the extensions are
31
+ * merged, so an extension written in the base language stays readable under
32
+ * `--dense`/`--zh` (it replaces its own sections and leaves the rest
33
+ * translated) rather than being dropped.
34
+ *
35
+ * @param {import('../../foundation/discovery/docs-discovery.mjs').DocsTopicEntry} entry
36
+ * @param {{lang?: string|null}} [opts]
37
+ * @returns {Promise<import('./docs.type.mjs').DocsDetailResponse['data']>}
38
+ */
39
+ export function loadTopicDoc(entry: import("../../foundation/discovery/docs-discovery.mjs").DocsTopicEntry, { lang }?: {
40
+ lang?: string | null;
41
+ }): Promise<import("./docs.type.mjs").DocsDetailResponse["data"]>;
42
+ /**
43
+ * Resolve `topic` against the project's catalog (throwing `ERR_UNKNOWN_TOPIC`
44
+ * when unmatched), and load it with any --dense/--zh overlay and any
45
+ * integration extension applied. Shared by the detail and section leaves so
46
+ * topic normalization and unknown-topic handling live in exactly one place.
21
47
  *
22
48
  * @param {string} topic
23
49
  * @param {object} [options]
24
50
  * @param {string} [options.lang]
25
51
  * @param {boolean} [options.zh]
26
52
  * @param {boolean} [options.dense]
53
+ * @param {string} [options.cwd]
27
54
  * @returns {Promise<{
28
- * topics: Record<string, string>,
55
+ * catalog: DocsCatalog,
29
56
  * docsData: import('./docs.type.mjs').DocsDetailResponse['data'],
30
57
  * }>}
31
58
  */
@@ -33,7 +60,9 @@ export function resolveTopicDocs(topic: string, options?: {
33
60
  lang?: string | undefined;
34
61
  zh?: boolean | undefined;
35
62
  dense?: boolean | undefined;
63
+ cwd?: string | undefined;
36
64
  }): Promise<{
37
- topics: Record<string, string>;
65
+ catalog: DocsCatalog;
38
66
  docsData: import("./docs.type.mjs").DocsDetailResponse["data"];
39
67
  }>;
68
+ import { DocsCatalog } from '../../foundation/discovery/docs-discovery.mjs';
@@ -3,35 +3,50 @@
3
3
  /**
4
4
  * @file Shared doc-loading and topic-resolution helpers for the docs leaves.
5
5
  *
6
- * @input packages/cli/assets/docs/{topic}.doc.mjs and, when a --dense/--zh overlay is
7
- * requested, the sibling {topic}.doc.dense.mjs / {topic}.doc.zh.mjs.
8
- * @output Topic discovery map, overlay-merged reference-doc data, and a combined
9
- * resolve step ({topics, docsData}) that the detail and section leaves share.
6
+ * @input The project's doc catalog the CLI's own
7
+ * packages/cli/assets/docs/{topic}.doc.mjs plus every topic the configured
8
+ * integrations contribute and, when a --dense/--zh overlay is requested,
9
+ * the sibling {topic}.doc.dense.mjs / {topic}.doc.zh.mjs.
10
+ * @output Catalog access, overlay- and extension-merged reference-doc data,
11
+ * and a combined resolve step ({catalog, docsData}) that the detail and
12
+ * section leaves share.
10
13
  * @position Sits beside docs.mjs (api/docs/). Owns everything ≥2 leaves need so
11
- * no leaf re-implements discovery, overlay merging, or unknown-topic handling.
14
+ * no leaf re-implements resolution, overlay merging, or unknown-topic
15
+ * handling. Discovery itself lives in foundation/discovery/docs-discovery,
16
+ * which api/search and the agent-docs block read through the same catalog.
12
17
  */
13
18
 
14
19
  import * as fs from 'node:fs';
15
20
  import * as path from 'node:path';
16
21
  import {pathToFileURL} from 'node:url';
17
- import {CLI_ROOT} from '../../foundation/fs/paths.mjs';
22
+ import {Project} from '../../foundation/config/project.mjs';
23
+ import {
24
+ DocsCatalog,
25
+ mergeTopic,
26
+ } from '../../foundation/discovery/docs-discovery.mjs';
18
27
  import {AstryxError} from '../error.mjs';
19
28
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
20
29
 
21
- const DOCS_DIR = path.join(CLI_ROOT, 'assets', 'docs');
22
-
23
30
  /**
24
- * @returns {Record<string, string>}
31
+ * The project's topics: the built-in ones plus whatever the configured
32
+ * integrations contribute.
33
+ *
34
+ * A docs read must not depend on a healthy project config. `astryx docs
35
+ * tokens` answered without loading anything before integrations could
36
+ * contribute topics, and it still answers when the config is unreadable — the
37
+ * built-in topics are the floor, and the integration issues surface on the
38
+ * commands that own them.
39
+ *
40
+ * @param {string} [cwd]
41
+ * @returns {Promise<DocsCatalog>}
25
42
  */
26
- export function discoverTopics() {
27
- /** @type {Record<string, string>} */
28
- const topics = Object.create(null);
29
- if (!fs.existsSync(DOCS_DIR)) return topics;
30
- for (const file of fs.readdirSync(DOCS_DIR)) {
31
- const match = file.match(/^([\w-]+)\.doc\.mjs$/);
32
- if (match) topics[match[1]] = path.join(DOCS_DIR, file);
43
+ export async function loadDocsCatalog(cwd = process.cwd()) {
44
+ try {
45
+ const project = await Project.load(cwd);
46
+ return await project.docs();
47
+ } catch {
48
+ return DocsCatalog.fromBuiltins();
33
49
  }
34
- return topics;
35
50
  }
36
51
 
37
52
  /**
@@ -41,7 +56,7 @@ export function discoverTopics() {
41
56
  */
42
57
  export async function loadReferenceDocs(docPath, {lang} = {}) {
43
58
  const mod = await import(pathToFileURL(docPath).href);
44
- const docs = mod.docs;
59
+ const docs = mod.docs ?? mod.default;
45
60
  if (!lang || lang === 'en') return docs;
46
61
 
47
62
  const dir = path.dirname(docPath);
@@ -96,46 +111,60 @@ export async function loadReferenceDocs(docPath, {lang} = {}) {
96
111
  }
97
112
 
98
113
  /**
99
- * Discover topics, resolve `topic` to its doc file (throwing `ERR_UNKNOWN_TOPIC`
100
- * when unmatched), and load that topic's reference doc with any --dense/--zh
101
- * overlay applied. Shared by the detail and section leaves so topic normalization
102
- * and unknown-topic handling live in exactly one place.
114
+ * Load one catalog entry: its own doc, plus any extension an integration
115
+ * merged onto it, in configuration order.
116
+ *
117
+ * A localization overlay applies to each file before the extensions are
118
+ * merged, so an extension written in the base language stays readable under
119
+ * `--dense`/`--zh` (it replaces its own sections and leaves the rest
120
+ * translated) rather than being dropped.
121
+ *
122
+ * @param {import('../../foundation/discovery/docs-discovery.mjs').DocsTopicEntry} entry
123
+ * @param {{lang?: string|null}} [opts]
124
+ * @returns {Promise<import('./docs.type.mjs').DocsDetailResponse['data']>}
125
+ */
126
+ export async function loadTopicDoc(entry, {lang} = {}) {
127
+ let doc = await loadReferenceDocs(entry.path, {lang});
128
+ for (const extension of entry.extensions) {
129
+ doc = mergeTopic(doc, await loadReferenceDocs(extension.path, {lang}));
130
+ }
131
+ return doc;
132
+ }
133
+
134
+ /**
135
+ * Resolve `topic` against the project's catalog (throwing `ERR_UNKNOWN_TOPIC`
136
+ * when unmatched), and load it with any --dense/--zh overlay and any
137
+ * integration extension applied. Shared by the detail and section leaves so
138
+ * topic normalization and unknown-topic handling live in exactly one place.
103
139
  *
104
140
  * @param {string} topic
105
141
  * @param {object} [options]
106
142
  * @param {string} [options.lang]
107
143
  * @param {boolean} [options.zh]
108
144
  * @param {boolean} [options.dense]
145
+ * @param {string} [options.cwd]
109
146
  * @returns {Promise<{
110
- * topics: Record<string, string>,
147
+ * catalog: DocsCatalog,
111
148
  * docsData: import('./docs.type.mjs').DocsDetailResponse['data'],
112
149
  * }>}
113
150
  */
114
151
  export async function resolveTopicDocs(topic, options = {}) {
115
- const {lang = null, zh = false, dense = false} = options;
152
+ const {lang = null, zh = false, dense = false, cwd} = options;
116
153
  const effectiveLang = lang || (dense ? 'dense' : zh ? 'zh' : null);
117
- const topics = discoverTopics();
154
+ const catalog = await loadDocsCatalog(cwd);
118
155
 
119
- // A public API caller could pass a non-string topic; `.toLowerCase()` below
120
- // would throw a raw TypeError (no ERR_* code downgrades to ERR_UNKNOWN).
121
- // Surface the same stable code the unknown-topic path uses.
122
- if (typeof topic !== 'string') {
156
+ // A public API caller could pass a non-string topic; `resolve` answers
157
+ // undefined for one, which lands on the same stable code as an unknown name
158
+ // rather than a raw TypeError (which downgrades to ERR_UNKNOWN).
159
+ const entry = catalog.resolve(topic);
160
+ if (!entry) {
123
161
  throw new AstryxError(
124
162
  `Unknown topic "${String(topic)}"`,
125
- Object.keys(topics).map(t => ({name: t, reason: 'available topic'})),
126
- ERROR_CODES.ERR_UNKNOWN_TOPIC,
127
- );
128
- }
129
-
130
- const normalized = topic.toLowerCase();
131
- if (!topics[normalized]) {
132
- throw new AstryxError(
133
- `Unknown topic "${topic}"`,
134
- Object.keys(topics).map(t => ({name: t, reason: 'available topic'})),
163
+ catalog.names().map(t => ({name: t, reason: 'available topic'})),
135
164
  ERROR_CODES.ERR_UNKNOWN_TOPIC,
136
165
  );
137
166
  }
138
167
 
139
- const docsData = await loadReferenceDocs(topics[normalized], {lang: effectiveLang});
140
- return {topics, docsData};
168
+ const docsData = await loadTopicDoc(entry, {lang: effectiveLang});
169
+ return {catalog, docsData};
141
170
  }
@@ -7,10 +7,12 @@
7
7
  * @param {string} [options.lang]
8
8
  * @param {boolean} [options.zh]
9
9
  * @param {boolean} [options.dense]
10
+ * @param {string} [options.cwd]
10
11
  * @returns {Promise<import('../docs.type.mjs').DocsDetailResponse>}
11
12
  */
12
13
  export function detail(topic: string, options?: {
13
14
  lang?: string | undefined;
14
15
  zh?: boolean | undefined;
15
16
  dense?: boolean | undefined;
17
+ cwd?: string | undefined;
16
18
  }): Promise<import("../docs.type.mjs").DocsDetailResponse>;
@@ -11,17 +11,19 @@
11
11
  * discovery/loading/topic-resolution with the section leaf via _adapter.mjs.
12
12
  */
13
13
 
14
- import {pathToFileURL} from 'node:url';
15
- import {resolveTopicDocs} from '../_adapter.mjs';
14
+ import {loadTopicDoc, resolveTopicDocs} from '../_adapter.mjs';
16
15
 
17
16
  /**
18
17
  * Resolve token-ref blocks by inlining the referenced section's table.
19
18
  * This allows section docs to reference token tables without duplicating data.
19
+ *
20
+ * The reference is resolved through the catalog, so a topic may point at one
21
+ * an integration contributed (or replaced) rather than only at a built-in.
20
22
  * @param {import('../docs.type.mjs').DocsDetailResponse['data']} docsData
21
- * @param {Record<string, string>} topics
23
+ * @param {import('../../../foundation/discovery/docs-discovery.mjs').DocsCatalog} catalog
22
24
  * @returns {Promise<import('../docs.type.mjs').DocsDetailResponse['data']>}
23
25
  */
24
- async function resolveTokenRefs(docsData, topics) {
26
+ async function resolveTokenRefs(docsData, catalog) {
25
27
  const resolved = {...docsData, sections: [...docsData.sections]};
26
28
  for (let si = 0; si < resolved.sections.length; si++) {
27
29
  const section = resolved.sections[si];
@@ -29,13 +31,12 @@ async function resolveTokenRefs(docsData, topics) {
29
31
  const newContent = [];
30
32
  for (const block of section.content) {
31
33
  if (block.type === 'token-ref') {
32
- const refPath = topics[block.topic];
33
- if (!refPath) {
34
+ const refEntry = catalog.resolve(block.topic);
35
+ if (!refEntry) {
34
36
  newContent.push({type: 'prose', text: `[token-ref: unknown topic "${block.topic}"]`});
35
37
  continue;
36
38
  }
37
- const refMod = await import(pathToFileURL(refPath).href);
38
- const refDocs = refMod.docs;
39
+ const refDocs = await loadTopicDoc(refEntry);
39
40
  const refSection = refDocs.sections.find(
40
41
  (/** @type {import('@astryxdesign/cli/authoring').ReferenceSection} */ s) =>
41
42
  s.title.toLowerCase() === block.section.toLowerCase(),
@@ -72,10 +73,11 @@ async function resolveTokenRefs(docsData, topics) {
72
73
  * @param {string} [options.lang]
73
74
  * @param {boolean} [options.zh]
74
75
  * @param {boolean} [options.dense]
76
+ * @param {string} [options.cwd]
75
77
  * @returns {Promise<import('../docs.type.mjs').DocsDetailResponse>}
76
78
  */
77
79
  export async function detail(topic, options = {}) {
78
- const {topics, docsData} = await resolveTopicDocs(topic, options);
79
- const resolved = await resolveTokenRefs(docsData, topics);
80
+ const {catalog, docsData} = await resolveTopicDocs(topic, options);
81
+ const resolved = await resolveTokenRefs(docsData, catalog);
80
82
  return {type: 'docs.detail', data: resolved};
81
83
  }
@@ -8,10 +8,12 @@
8
8
  * @param {string} [options.lang]
9
9
  * @param {boolean} [options.zh]
10
10
  * @param {boolean} [options.dense]
11
+ * @param {string} [options.cwd]
11
12
  * @returns {Promise<import('../../docs.type.mjs').DocsDetailSectionResponse>}
12
13
  */
13
14
  export function section(topic: string, sectionName: string, options?: {
14
15
  lang?: string | undefined;
15
16
  zh?: boolean | undefined;
16
17
  dense?: boolean | undefined;
18
+ cwd?: string | undefined;
17
19
  }): Promise<import("../../docs.type.mjs").DocsDetailSectionResponse>;
@@ -24,6 +24,7 @@ import {resolveTopicDocs} from '../../_adapter.mjs';
24
24
  * @param {string} [options.lang]
25
25
  * @param {boolean} [options.zh]
26
26
  * @param {boolean} [options.dense]
27
+ * @param {string} [options.cwd]
27
28
  * @returns {Promise<import('../../docs.type.mjs').DocsDetailSectionResponse>}
28
29
  */
29
30
  export async function section(topic, sectionName, options = {}) {
@@ -8,6 +8,7 @@
8
8
  * @param {string} [options.lang]
9
9
  * @param {boolean} [options.zh]
10
10
  * @param {boolean} [options.dense]
11
+ * @param {string} [options.cwd]
11
12
  * @returns {Promise<
12
13
  * import('./docs.type.mjs').DocsListResponse |
13
14
  * import('./docs.type.mjs').DocsDetailResponse |
@@ -18,6 +19,7 @@ export function docs(topic?: string, section?: string, options?: {
18
19
  lang?: string | undefined;
19
20
  zh?: boolean | undefined;
20
21
  dense?: boolean | undefined;
22
+ cwd?: string | undefined;
21
23
  }): Promise<import("./docs.type.mjs").DocsListResponse | import("./docs.type.mjs").DocsDetailResponse | import("./docs.type.mjs").DocsDetailSectionResponse>;
22
24
  import { list } from './list/list.mjs';
23
25
  import { detail } from './detail/detail.mjs';
@@ -18,7 +18,10 @@ export const doc = {
18
18
  'Routes on its arguments: no topic lists every reference-doc topic; a topic ' +
19
19
  'returns that full ReferenceDoc (with token-ref blocks inlined); a topic ' +
20
20
  'plus a section returns the first section whose title contains the ' +
21
- '(case-insensitive) query. Overlay options select localized or dense variants.',
21
+ '(case-insensitive) query. The topic set is the CLI\'s own docs plus the ' +
22
+ 'ones the project\'s configured integrations contribute — including any ' +
23
+ 'topic an integration replaces or extends — so it depends on the cwd. ' +
24
+ 'Overlay options select localized or dense variants.',
22
25
  importPath: '@astryxdesign/cli/api',
23
26
  signature:
24
27
  'docs(topic?: string, section?: string, options?: DocsOptions): Promise<DocsListResponse | DocsDetailResponse | DocsDetailSectionResponse>',
@@ -60,12 +63,18 @@ export const doc = {
60
63
  type: 'boolean',
61
64
  description: 'Return the token-efficient dense doc variant.',
62
65
  },
66
+ {
67
+ name: 'options.cwd',
68
+ type: 'string',
69
+ description:
70
+ "Project directory whose configured integrations contribute topics. Defaults to process.cwd(); an unreadable config falls back to the CLI's own topics.",
71
+ },
63
72
  ],
64
73
  returns: [
65
74
  {
66
75
  type: 'docs.list',
67
76
  description:
68
- 'All available reference-doc topics as DocsListEntry[] ({topic, description}), in discovery order.',
77
+ 'All available reference-doc topics as DocsListEntry[] ({topic, description, package, replaces?}), in read order.',
69
78
  },
70
79
  {
71
80
  type: 'docs.detail',
package/api/docs/docs.mjs CHANGED
@@ -29,6 +29,7 @@ export {list, detail, sectionLeaf as section};
29
29
  * @param {string} [options.lang]
30
30
  * @param {boolean} [options.zh]
31
31
  * @param {boolean} [options.dense]
32
+ * @param {string} [options.cwd]
32
33
  * @returns {Promise<
33
34
  * import('./docs.type.mjs').DocsListResponse |
34
35
  * import('./docs.type.mjs').DocsDetailResponse |
@@ -36,7 +37,7 @@ export {list, detail, sectionLeaf as section};
36
37
  * >}
37
38
  */
38
39
  export async function docs(topic, section, options = {}) {
39
- if (!topic) return list();
40
+ if (!topic) return list(options);
40
41
  if (section) return sectionLeaf(topic, section, options);
41
42
  return detail(topic, options);
42
43
  }
@@ -11,6 +11,16 @@ export type DocsListResponse = {
11
11
  export type DocsListEntry = {
12
12
  topic: string;
13
13
  description: string;
14
+ /**
15
+ * the package that owns this topic —
16
+ * '@astryxdesign/cli' for a built-in one, else the contributing integration
17
+ */
18
+ package: string;
19
+ /**
20
+ * the topic this one took the place of, when it
21
+ * was contributed as a replacement
22
+ */
23
+ replaces?: string | undefined;
14
24
  };
15
25
  /**
16
26
  * xds --json docs <topic>
@@ -33,4 +43,9 @@ export type DocsOptions = {
33
43
  lang?: string | undefined;
34
44
  zh?: boolean | undefined;
35
45
  dense?: boolean | undefined;
46
+ /**
47
+ * project directory whose configured integrations
48
+ * contribute topics; defaults to process.cwd()
49
+ */
50
+ cwd?: string | undefined;
36
51
  };
@@ -23,6 +23,10 @@
23
23
  * @typedef {object} DocsListEntry
24
24
  * @property {string} topic
25
25
  * @property {string} description
26
+ * @property {string} package the package that owns this topic —
27
+ * '@astryxdesign/cli' for a built-in one, else the contributing integration
28
+ * @property {string} [replaces] the topic this one took the place of, when it
29
+ * was contributed as a replacement
26
30
  */
27
31
 
28
32
  /**
@@ -45,6 +49,8 @@
45
49
  * @property {string} [lang]
46
50
  * @property {boolean} [zh]
47
51
  * @property {boolean} [dense]
52
+ * @property {string} [cwd] project directory whose configured integrations
53
+ * contribute topics; defaults to process.cwd()
48
54
  */
49
55
 
50
56
  export {};
@@ -0,0 +1,208 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file End-to-end tests for integration-contributed doc topics: a scaffolded
5
+ * consumer project whose configured integration ships a `docs` root, read back
6
+ * through the public surfaces — `docs()`, `search()`, and the agent-docs block.
7
+ *
8
+ * The unit-level rules (what a docs root contributes, what the catalog does
9
+ * with `replaces`/`extends`) are covered in
10
+ * foundation/discovery/docs-discovery.test.mjs. What is pinned here is that a
11
+ * contributed topic is indistinguishable from a built-in one at the surfaces
12
+ * an agent actually reads.
13
+ *
14
+ * Fixtures live under a repo-local temp dir, not /tmp, because Vite refuses to
15
+ * dynamically import a module from outside the project root.
16
+ */
17
+
18
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import {docs} from './docs.mjs';
22
+ import {search} from '../search/search.mjs';
23
+ import {loadDocsCatalog} from './_adapter.mjs';
24
+ import {AstryxError} from '../error.mjs';
25
+
26
+ const SLOW = 30_000;
27
+
28
+ let tmpDir;
29
+
30
+ /** A minimal, valid topic. */
31
+ function topic(fields) {
32
+ return {
33
+ type: 'generic',
34
+ name: 'deploying',
35
+ title: 'Deploying',
36
+ description: 'How to ship an app built with Acme widgets.',
37
+ category: 'guide',
38
+ sections: [
39
+ {title: 'Overview', content: [{type: 'prose', text: 'Push the button.'}]},
40
+ ],
41
+ ...fields,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * A consumer project that configures one integration, optionally with a docs
47
+ * root holding the given topics.
48
+ * @param {Record<string, object|string>} [topics] file name → doc
49
+ * @param {{config?: string}} [options]
50
+ */
51
+ function scaffold(topics, {config} = {}) {
52
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({name: 'consumer'}));
53
+ fs.writeFileSync(
54
+ path.join(tmpDir, 'astryx.config.mjs'),
55
+ config ?? "export default {integrations: ['@acme/widgets']};\n",
56
+ );
57
+
58
+ const pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
59
+ fs.mkdirSync(pkgDir, {recursive: true});
60
+ fs.writeFileSync(
61
+ path.join(pkgDir, 'package.json'),
62
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
63
+ );
64
+ fs.writeFileSync(
65
+ path.join(pkgDir, 'astryx.integration.mjs'),
66
+ `export default ${JSON.stringify(topics ? {docs: './docs'} : {})};\n`,
67
+ );
68
+
69
+ if (topics) {
70
+ const docsDir = path.join(pkgDir, 'docs');
71
+ fs.mkdirSync(docsDir, {recursive: true});
72
+ for (const [file, doc] of Object.entries(topics)) {
73
+ fs.writeFileSync(
74
+ path.join(docsDir, file),
75
+ typeof doc === 'string'
76
+ ? doc
77
+ : `export const docs = ${JSON.stringify(doc, null, 2)};\n`,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ beforeEach(() => {
84
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-integration-docs-test-'));
85
+ });
86
+
87
+ afterEach(() => {
88
+ fs.rmSync(tmpDir, {recursive: true, force: true});
89
+ });
90
+
91
+ describe('integration-contributed topics', () => {
92
+ it('lists and reads like a built-in topic, naming its owner', async () => {
93
+ scaffold({'deploying.doc.mjs': topic()});
94
+
95
+ const listed = await docs(undefined, undefined, {cwd: tmpDir});
96
+ const entry = listed.data.find(t => t.topic === 'deploying');
97
+ expect(entry).toMatchObject({
98
+ topic: 'deploying',
99
+ description: 'How to ship an app built with Acme widgets.',
100
+ package: '@acme/widgets',
101
+ });
102
+ // The built-in topics keep their own owner.
103
+ expect(listed.data.find(t => t.topic === 'tokens').package).toBe('@astryxdesign/cli');
104
+
105
+ const detail = await docs('deploying', undefined, {cwd: tmpDir});
106
+ expect(detail.type).toBe('docs.detail');
107
+ expect(detail.data.sections[0].content[0].text).toBe('Push the button.');
108
+
109
+ const section = await docs('deploying', 'overview', {cwd: tmpDir});
110
+ expect(section.data.title).toBe('Overview');
111
+ }, SLOW);
112
+
113
+ it('is invisible to a project that does not configure the integration', async () => {
114
+ scaffold({'deploying.doc.mjs': topic()}, {config: 'export default {};\n'});
115
+ const listed = await docs(undefined, undefined, {cwd: tmpDir});
116
+ expect(listed.data.some(t => t.topic === 'deploying')).toBe(false);
117
+ }, SLOW);
118
+
119
+ it('serves the replacement of a built-in topic, and says what it replaced', async () => {
120
+ scaffold({
121
+ 'getting-started.doc.mjs': topic({
122
+ name: 'getting-started',
123
+ replaces: 'getting-started',
124
+ title: 'Getting started',
125
+ description: 'Install Acme widgets.',
126
+ sections: [
127
+ {title: 'Install', content: [{type: 'prose', text: 'yarn add @acme/widgets'}]},
128
+ ],
129
+ }),
130
+ });
131
+
132
+ const detail = await docs('getting-started', undefined, {cwd: tmpDir});
133
+ expect(detail.data.sections.map(s => s.title)).toEqual(['Install']);
134
+ expect(detail.data.sections[0].content[0].text).toBe('yarn add @acme/widgets');
135
+
136
+ const listed = await docs(undefined, undefined, {cwd: tmpDir});
137
+ const entries = listed.data.filter(t => t.topic === 'getting-started');
138
+ expect(entries).toHaveLength(1);
139
+ expect(entries[0]).toMatchObject({
140
+ package: '@acme/widgets',
141
+ replaces: 'getting-started',
142
+ });
143
+ }, SLOW);
144
+
145
+ it('keeps the replaced name resolving when the replacement renames it', async () => {
146
+ scaffold({
147
+ 'setup.doc.mjs': topic({name: 'setup', replaces: 'getting-started'}),
148
+ });
149
+ const byOldName = await docs('getting-started', undefined, {cwd: tmpDir});
150
+ const byNewName = await docs('setup', undefined, {cwd: tmpDir});
151
+ expect(byOldName.data.name).toBe('setup');
152
+ expect(byNewName.data.name).toBe('setup');
153
+ }, SLOW);
154
+
155
+ it('merges an extension into the topic it extends', async () => {
156
+ const builtin = await docs('theme');
157
+ const baseSectionTitle = builtin.data.sections[0].title;
158
+ scaffold({
159
+ 'theme-internal.doc.mjs': topic({
160
+ name: 'theme-internal',
161
+ extends: 'theme',
162
+ sections: [
163
+ {title: baseSectionTitle, content: [{type: 'prose', text: 'Use the Acme theme.'}]},
164
+ {title: 'Acme themes', content: [{type: 'prose', text: 'Three of them.'}]},
165
+ ],
166
+ }),
167
+ });
168
+
169
+ const extended = await docs('theme', undefined, {cwd: tmpDir});
170
+ // The extension is not a topic of its own.
171
+ expect((await docs(undefined, undefined, {cwd: tmpDir})).data.some(
172
+ t => t.topic === 'theme-internal',
173
+ )).toBe(false);
174
+ expect(extended.data.sections[0].content[0].text).toBe('Use the Acme theme.');
175
+ expect(extended.data.sections.at(-1).title).toBe('Acme themes');
176
+ // Everything the extension did not name is still the base doc's.
177
+ expect(extended.data.sections.length).toBe(builtin.data.sections.length + 1);
178
+ }, SLOW);
179
+
180
+ it('offers the contributed topics as suggestions on an unknown one', async () => {
181
+ scaffold({'deploying.doc.mjs': topic()});
182
+ await expect(docs('nope-not-a-topic', undefined, {cwd: tmpDir})).rejects.toBeInstanceOf(
183
+ AstryxError,
184
+ );
185
+ try {
186
+ await docs('nope-not-a-topic', undefined, {cwd: tmpDir});
187
+ } catch (err) {
188
+ expect(err.suggestions.map(s => s.name)).toContain('deploying');
189
+ }
190
+ }, SLOW);
191
+
192
+ it('indexes a contributed topic in search', async () => {
193
+ scaffold({'deploying.doc.mjs': topic()});
194
+ const {data} = await search('deploying', {cwd: tmpDir, type: 'doc'});
195
+ expect(data.results[0]).toMatchObject({
196
+ domain: 'doc',
197
+ name: 'deploying',
198
+ command: 'astryx docs deploying',
199
+ });
200
+ }, SLOW);
201
+
202
+ it("falls back to the CLI's own topics when the project config is unreadable", async () => {
203
+ scaffold({'deploying.doc.mjs': topic()}, {config: 'export default {integrations: 42};\n'});
204
+ const catalog = await loadDocsCatalog(tmpDir);
205
+ expect(catalog.resolve('tokens')).toBeTruthy();
206
+ expect(catalog.resolve('deploying')).toBeUndefined();
207
+ }, SLOW);
208
+ });
@@ -2,6 +2,10 @@
2
2
  // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
3
 
4
4
  /**
5
+ * @param {object} [options]
6
+ * @param {string} [options.cwd]
5
7
  * @returns {Promise<import('../docs.type.mjs').DocsListResponse>}
6
8
  */
7
- export function list(): Promise<import("../docs.type.mjs").DocsListResponse>;
9
+ export function list({ cwd }?: {
10
+ cwd?: string | undefined;
11
+ }): Promise<import("../docs.type.mjs").DocsListResponse>;