@astryxdesign/cli 0.6.3-canary.ea2f048 → 0.6.3-canary.ebaebc4

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 (188) hide show
  1. package/README.md +2 -1
  2. package/api/build/build.type.d.mts +2 -2
  3. package/api/build/build.type.mjs +2 -2
  4. package/api/component/component.type.d.mts +6 -6
  5. package/api/component/component.type.mjs +19 -19
  6. package/api/discover/discover.type.d.mts +4 -4
  7. package/api/discover/discover.type.mjs +10 -10
  8. package/api/docs/_adapter.d.mts +37 -24
  9. package/api/docs/_adapter.mjs +169 -83
  10. package/api/docs/compiled-topics.test.mjs +78 -0
  11. package/api/docs/detail/detail.mjs +14 -63
  12. package/api/docs/detail/section/section.d.mts +1 -1
  13. package/api/docs/detail/section/section.mjs +44 -20
  14. package/api/docs/detail/section/section.test.mjs +41 -0
  15. package/api/docs/docs.d.mts +7 -2
  16. package/api/docs/docs.doc.mjs +27 -10
  17. package/api/docs/docs.mjs +16 -9
  18. package/api/docs/docs.test.mjs +6 -0
  19. package/api/docs/docs.type.d.mts +40 -3
  20. package/api/docs/docs.type.mjs +36 -8
  21. package/api/docs/index/index.d.mts +18 -0
  22. package/api/docs/index/index.mjs +32 -0
  23. package/api/docs/index/index.test.mjs +62 -0
  24. package/api/docs/integrationDocs.test.mjs +106 -0
  25. package/api/doctor/doctor.d.mts +48 -0
  26. package/api/doctor/doctor.mjs +232 -0
  27. package/api/doctor/doctor.test.mjs +196 -0
  28. package/api/hook/hook.type.d.mts +3 -3
  29. package/api/hook/hook.type.mjs +11 -11
  30. package/api/hook/list/list.d.mts +1 -1
  31. package/api/integration/add-contribution.mjs +5 -3
  32. package/api/integration/add-contribution.test.mjs +4 -4
  33. package/api/integration/integration-authoring.type.d.mts +1 -1
  34. package/api/integration/pack-check.mjs +49 -7
  35. package/api/integration/pack-check.test.mjs +249 -0
  36. package/api/search/search.d.mts +1 -1
  37. package/api/search/search.mjs +5 -5
  38. package/api/search/search.type.d.mts +2 -2
  39. package/api/search/search.type.mjs +1 -1
  40. package/api/swizzle/swizzle.type.d.mts +2 -2
  41. package/api/swizzle/swizzle.type.mjs +2 -2
  42. package/api/template/template.d.mts +1 -1
  43. package/api/template/template.type.d.mts +6 -6
  44. package/api/template/template.type.mjs +12 -12
  45. package/api/theme/build/build.mjs +20 -6
  46. package/api/theme/build/build.test.mjs +127 -0
  47. package/api/theme/palette/generate/generate.mjs +1 -1
  48. package/api/theme/palette/generate/generator.d.mts +10 -13
  49. package/api/theme/palette/generate/generator.mjs +7 -3
  50. package/api/theme/theme.type.d.mts +170 -11
  51. package/api/theme/theme.type.mjs +94 -27
  52. package/api/upgrade/_adapter.mjs +71 -5
  53. package/api/upgrade/project-context.test.mjs +272 -0
  54. package/api/upgrade/upgrade.doc.mjs +4 -3
  55. package/api/upgrade/upgrade.type.d.mts +5 -5
  56. package/api/upgrade/upgrade.type.mjs +11 -11
  57. package/assets/codemods/integration-discovery.mjs +40 -2
  58. package/assets/codemods/integration-discovery.test.mjs +58 -0
  59. package/assets/codemods/transforms/v0.3.0/__tests__/unwrap-authoring-factories.test.mjs +27 -5
  60. package/assets/codemods/transforms/v0.3.0/unwrap-authoring-factories.mjs +20 -5
  61. package/assets/docs/README.md +9 -0
  62. package/assets/docs/authoring.doc.mjs +14 -0
  63. package/assets/docs/cli-integrations.doc.mjs +86 -15
  64. package/assets/docs/styling-libraries.doc.mjs +1 -1
  65. package/assets/docs/working-with-ai.doc.mjs +1 -1
  66. package/authoring/_shared/contract.ts +22 -0
  67. package/authoring/codemod/codemod.doc.mjs +6 -1
  68. package/authoring/codemod/parse.d.mts +8 -8
  69. package/authoring/codemod/parse.mjs +8 -6
  70. package/authoring/config/parse.d.mts +13 -13
  71. package/authoring/config/parse.mjs +8 -8
  72. package/authoring/config/type.ts +3 -3
  73. package/authoring/debug/parse.d.mts +5 -5
  74. package/authoring/debug/parse.mjs +3 -3
  75. package/authoring/doctypes/_schema.d.mts +788 -23
  76. package/authoring/doctypes/_schema.mjs +492 -39
  77. package/authoring/doctypes/base/graph-fields.doc.d.mts +9 -0
  78. package/authoring/doctypes/base/graph-fields.doc.mjs +62 -0
  79. package/authoring/doctypes/base/type.ts +40 -0
  80. package/authoring/doctypes/command/command.doc.mjs +3 -2
  81. package/authoring/doctypes/command/parse.d.mts +2 -2
  82. package/authoring/doctypes/command/parse.mjs +1 -1
  83. package/authoring/doctypes/command/type.ts +3 -2
  84. package/authoring/doctypes/component/component.doc.mjs +6 -3
  85. package/authoring/doctypes/component/parse.d.mts +2 -2
  86. package/authoring/doctypes/component/parse.mjs +1 -1
  87. package/authoring/doctypes/component/type.ts +4 -3
  88. package/authoring/doctypes/enum/parse.d.mts +2 -2
  89. package/authoring/doctypes/enum/parse.mjs +1 -1
  90. package/authoring/doctypes/enum/type.ts +3 -1
  91. package/authoring/doctypes/function/function.doc.mjs +4 -0
  92. package/authoring/doctypes/function/parse.d.mts +2 -2
  93. package/authoring/doctypes/function/parse.mjs +1 -1
  94. package/authoring/doctypes/function/type.ts +6 -2
  95. package/authoring/doctypes/hook/hook.doc.mjs +4 -0
  96. package/authoring/doctypes/hook/parse.d.mts +2 -2
  97. package/authoring/doctypes/hook/parse.mjs +1 -1
  98. package/authoring/doctypes/hook/type.ts +3 -2
  99. package/authoring/doctypes/legacy.d.mts +8 -6
  100. package/authoring/doctypes/legacy.mjs +5 -4
  101. package/authoring/doctypes/load-contract.test.mjs +207 -0
  102. package/authoring/doctypes/namespace/namespace.doc.d.mts +9 -0
  103. package/authoring/doctypes/namespace/namespace.doc.mjs +132 -0
  104. package/authoring/doctypes/namespace/parse.d.mts +12 -0
  105. package/authoring/doctypes/namespace/parse.mjs +25 -0
  106. package/authoring/doctypes/namespace/parse.test.mjs +165 -0
  107. package/authoring/doctypes/namespace/type.ts +71 -0
  108. package/authoring/doctypes/parse.d.mts +20 -18
  109. package/authoring/doctypes/parse.mjs +16 -10
  110. package/authoring/doctypes/parse.test.mjs +77 -3
  111. package/authoring/doctypes/reference/parse.d.mts +2 -2
  112. package/authoring/doctypes/reference/parse.mjs +8 -5
  113. package/authoring/doctypes/reference/reference.doc.mjs +17 -4
  114. package/authoring/doctypes/reference/type.ts +51 -5
  115. package/authoring/doctypes/schema/parse.d.mts +2 -2
  116. package/authoring/doctypes/schema/parse.mjs +1 -1
  117. package/authoring/doctypes/schema/type.ts +3 -2
  118. package/authoring/doctypes/template/parse.d.mts +92 -1
  119. package/authoring/doctypes/template/parse.mjs +36 -2
  120. package/authoring/doctypes/template/parse.test.mjs +8 -2
  121. package/authoring/doctypes/template/template.doc.mjs +4 -0
  122. package/authoring/doctypes/template/type.ts +5 -2
  123. package/authoring/doctypes/types.ts +10 -9
  124. package/authoring/gap-report/parse.d.mts +10 -10
  125. package/authoring/gap-report/parse.mjs +6 -6
  126. package/authoring/gap-report/type.ts +1 -1
  127. package/authoring/identity/identity.doc.d.mts +9 -0
  128. package/authoring/identity/identity.doc.mjs +61 -0
  129. package/authoring/identity/type.ts +132 -0
  130. package/authoring/index.d.mts +1 -0
  131. package/authoring/index.d.ts +49 -17
  132. package/authoring/index.mjs +1 -0
  133. package/authoring/integration/integration.doc.mjs +13 -6
  134. package/authoring/integration/parse.d.mts +2 -2
  135. package/authoring/integration/parse.mjs +1 -1
  136. package/authoring/integration/parse.test.mjs +10 -1
  137. package/authoring/integration/schema.d.mts +6 -4
  138. package/authoring/integration/schema.mjs +9 -3
  139. package/authoring/integration/type.ts +23 -6
  140. package/authoring/shadcn/receipt.d.mts +6 -6
  141. package/clients/cli/commands/docs.doc.mjs +13 -3
  142. package/clients/cli/commands/docs.mjs +121 -21
  143. package/clients/cli/commands/docs.test.mjs +88 -0
  144. package/clients/cli/commands/integration-authoring.test.mjs +13 -9
  145. package/clients/cli/commands/theme-palette-generate.doc.mjs +8 -4
  146. package/clients/cli/commands/upgrade.doc.mjs +2 -2
  147. package/clients/cli/formatters/index.mjs +162 -1
  148. package/clients/cli/formatters/index.test.mjs +91 -0
  149. package/clients/cli/lib/manifest.mjs +7 -2
  150. package/foundation/config/project.mjs +21 -6
  151. package/foundation/discovery/authoring-self-docs.d.mts +69 -0
  152. package/foundation/discovery/authoring-self-docs.mjs +214 -0
  153. package/foundation/discovery/authoring-self-docs.test.mjs +154 -0
  154. package/foundation/discovery/component-discovery.d.mts +1 -1
  155. package/foundation/discovery/component-discovery.mjs +2 -1
  156. package/foundation/discovery/docs-discovery.d.mts +11 -4
  157. package/foundation/discovery/docs-discovery.mjs +208 -88
  158. package/foundation/discovery/docs-discovery.test.mjs +279 -13
  159. package/foundation/discovery/docs-output-budget.d.mts +28 -0
  160. package/foundation/discovery/docs-output-budget.mjs +50 -0
  161. package/foundation/discovery/docs-section-key.d.mts +98 -0
  162. package/foundation/discovery/docs-section-key.mjs +221 -0
  163. package/foundation/discovery/docs-section-key.test.mjs +224 -0
  164. package/foundation/discovery/template-adapter.mjs +2 -1
  165. package/foundation/discovery/theming-targets.test.mjs +4 -0
  166. package/foundation/doc-compiler/compile.d.mts +162 -0
  167. package/foundation/doc-compiler/compile.mjs +262 -0
  168. package/foundation/doc-compiler/doc-compiler.test.mjs +687 -0
  169. package/foundation/doc-compiler/ir.d.mts +9 -0
  170. package/foundation/doc-compiler/ir.mjs +287 -0
  171. package/foundation/doc-compiler/lenses.d.mts +33 -0
  172. package/foundation/doc-compiler/lenses.mjs +127 -0
  173. package/foundation/identity/provider-identity.d.mts +90 -0
  174. package/foundation/identity/provider-identity.mjs +320 -0
  175. package/foundation/identity/provider-identity.test.mjs +254 -0
  176. package/foundation/identity/providers.d.mts +7 -0
  177. package/foundation/identity/providers.mjs +16 -0
  178. package/foundation/integrations/autolink.mjs +12 -5
  179. package/foundation/integrations/integration-warnings.mjs +6 -0
  180. package/foundation/integrations/integrations.d.mts +46 -2
  181. package/foundation/integrations/integrations.mjs +167 -8
  182. package/foundation/integrations/integrations.test.mjs +384 -1
  183. package/foundation/integrations/provider-conflicts.test.mjs +125 -0
  184. package/foundation/integrations/validate-contributions.d.mts +2 -0
  185. package/foundation/integrations/validate-contributions.mjs +10 -0
  186. package/foundation/response/json-contract.test.mjs +46 -17
  187. package/foundation/response/response-types.doc.mjs +6 -1
  188. package/package.json +9 -11
@@ -0,0 +1,154 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {
7
+ AUTHORING_SELF_DOCS,
8
+ auditAuthoringSelfDocs,
9
+ buildAuthoringTopic,
10
+ discoverAuthoringSelfDocSources,
11
+ } from './authoring-self-docs.mjs';
12
+ import {
13
+ GRAPH_BLOCK_TYPES,
14
+ GRAPH_ONLY_FIELDS,
15
+ problemsInTopic,
16
+ } from './docs-discovery.mjs';
17
+ import {doc as graphFieldsDoc} from '../../authoring/doctypes/base/graph-fields.doc.mjs';
18
+ import {doc as namespaceDoc} from '../../authoring/doctypes/namespace/namespace.doc.mjs';
19
+ import {doc as referenceDoc} from '../../authoring/doctypes/reference/reference.doc.mjs';
20
+ import {docs} from '../../api/docs/docs.mjs';
21
+
22
+ const SLOW = 60_000;
23
+
24
+ describe('authoring self-docs', () => {
25
+ it('lists every self-doc on disk exactly once', () => {
26
+ expect([...AUTHORING_SELF_DOCS].sort()).toEqual(
27
+ discoverAuthoringSelfDocSources(),
28
+ );
29
+ expect(new Set(AUTHORING_SELF_DOCS).size).toBe(AUTHORING_SELF_DOCS.length);
30
+ });
31
+
32
+ it('audits clean: every self-doc loads, is reachable, and fits one read', async () => {
33
+ expect(await auditAuthoringSelfDocs()).toEqual({
34
+ sections: AUTHORING_SELF_DOCS.length,
35
+ unreachable: [],
36
+ failed: [],
37
+ oversized: [],
38
+ });
39
+ });
40
+
41
+ it('builds a valid topic with one section per self-doc', async () => {
42
+ const topic = await buildAuthoringTopic();
43
+ expect(problemsInTopic(topic)).toEqual([]);
44
+ expect(topic.sections).toHaveLength(AUTHORING_SELF_DOCS.length);
45
+ });
46
+
47
+ it(
48
+ 'is readable progressively through the docs API',
49
+ async () => {
50
+ const index = await docs('authoring', undefined, {index: true});
51
+ expect(index.type).toBe('docs.index');
52
+ expect(index.data.sections.map(s => s.id)).toContain('integration');
53
+ const section = await docs('authoring', 'integration');
54
+ expect(section.data.title).toBe('Astryx Integration');
55
+ expect(section.data.content.some(block => block.type === 'table')).toBe(
56
+ true,
57
+ );
58
+ },
59
+ SLOW,
60
+ );
61
+ });
62
+
63
+ describe('what the authoring docs say about the unbuilt docs graph', () => {
64
+ it('marks exactly the fields topic loading rejects as not read yet', () => {
65
+ const notReadYet = graphFieldsDoc.fields
66
+ .filter(field => /Not read yet/.test(field.description))
67
+ .map(field => field.name);
68
+ expect(notReadYet.sort()).toEqual([...GRAPH_ONLY_FIELDS].sort());
69
+ for (const field of graphFieldsDoc.fields) {
70
+ if (notReadYet.includes(field.name)) {
71
+ expect(field.description).toMatch(/fails to load/);
72
+ }
73
+ }
74
+ expect(graphFieldsDoc.description).toMatch(/not built yet/);
75
+ });
76
+
77
+ it('says a topic using a graph block fails to load', () => {
78
+ const content = referenceDoc.fields
79
+ .flatMap(field => [field, ...(field.fields ?? [])])
80
+ .find(field => field.name === 'sections[].content');
81
+ for (const type of GRAPH_BLOCK_TYPES) {
82
+ expect(content.description).toContain(type);
83
+ }
84
+ expect(content.description).toMatch(/fails to load/);
85
+ });
86
+
87
+ it('says namespace docs are not loaded yet', () => {
88
+ expect(namespaceDoc.description).toMatch(/Not loaded yet/);
89
+ expect(
90
+ problemsInTopic({
91
+ ...namespaceDoc.examples?.[0],
92
+ type: 'namespace',
93
+ name: 'x',
94
+ }),
95
+ ).toEqual([
96
+ '"x" is a namespace doc. Only the docs graph reads namespace docs, and it is not built yet; remove this file from the docs directory.',
97
+ ]);
98
+ });
99
+ });
100
+
101
+ describe('auditAuthoringSelfDocs', () => {
102
+ let root;
103
+
104
+ beforeEach(() => {
105
+ root = fs.mkdtempSync(path.join(process.cwd(), '.astryx-self-docs-'));
106
+ fs.mkdirSync(path.join(root, 'kept'));
107
+ fs.writeFileSync(
108
+ path.join(root, 'kept', 'kept.doc.mjs'),
109
+ "export const doc = {type: 'schema', name: 'kept', displayName: 'Kept', description: 'Listed.', fields: []};\n",
110
+ );
111
+ });
112
+
113
+ afterEach(() => {
114
+ fs.rmSync(root, {recursive: true, force: true});
115
+ });
116
+
117
+ it('names a self-doc on disk that the list leaves out', async () => {
118
+ fs.writeFileSync(
119
+ path.join(root, 'kept', 'forgotten.doc.mjs'),
120
+ "export const doc = {name: 'forgotten', description: 'Not listed.'};\n",
121
+ );
122
+ const audit = await auditAuthoringSelfDocs({
123
+ root,
124
+ sources: ['kept/kept.doc.mjs'],
125
+ });
126
+ expect(audit.unreachable).toEqual(['kept/forgotten.doc.mjs']);
127
+ });
128
+
129
+ it('reports a self-doc that fails to load instead of throwing', async () => {
130
+ fs.writeFileSync(
131
+ path.join(root, 'kept', 'broken.doc.mjs'),
132
+ 'export const doc = {;\n',
133
+ );
134
+ const audit = await auditAuthoringSelfDocs({
135
+ root,
136
+ sources: ['kept/kept.doc.mjs', 'kept/broken.doc.mjs'],
137
+ });
138
+ expect(audit.failed.map(entry => entry.source)).toEqual([
139
+ 'kept/broken.doc.mjs',
140
+ ]);
141
+ expect(audit.sections).toBe(1);
142
+ });
143
+
144
+ it('names a section over the budget', async () => {
145
+ const audit = await auditAuthoringSelfDocs({
146
+ root,
147
+ sources: ['kept/kept.doc.mjs'],
148
+ budget: 10,
149
+ });
150
+ expect(audit.oversized).toEqual([
151
+ expect.objectContaining({key: 'kept', title: 'Kept'}),
152
+ ]);
153
+ });
154
+ });
@@ -175,4 +175,4 @@ export function discoverOwnedComponents(coreDir: string, loadedIntegrations?: Ar
175
175
  issuesUrl: string | undefined;
176
176
  }>;
177
177
  /** The owner package name for built-in (core) components. */
178
- export const CORE_PACKAGE: "@astryxdesign/core";
178
+ export const CORE_PACKAGE: import("../../authoring/index.js").ProviderId;
@@ -7,11 +7,12 @@
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import {existsCaseExact} from '../fs/paths.mjs';
10
+ import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
10
11
 
11
12
  const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
12
13
 
13
14
  /** The owner package name for built-in (core) components. */
14
- export const CORE_PACKAGE = '@astryxdesign/core';
15
+ export const CORE_PACKAGE = CORE_PROVIDER_ID;
15
16
 
16
17
  /** Conventional doc-file suffixes for integration components (same-stem). */
17
18
  const INTEGRATION_DOC_SUFFIXES = ['.doc.ts', '.doc.mjs', '.doc.js'];
@@ -72,9 +72,10 @@ export function discoverIntegrationDocs(integration: {
72
72
  errors: Error[];
73
73
  }>;
74
74
  /**
75
- * Merge an extension onto a base topic: a section whose title matches one in
76
- * the base replaces it, a section the base does not have is appended, and the
77
- * title/description are taken from the extension when it states them.
75
+ * Merge an extension onto a base topic: a section with a stable `id` replaces
76
+ * the base section with the same `id`; legacy sections without IDs fall back to
77
+ * title matching. A section with no match is appended, and title/description
78
+ * are taken from the extension when it states them.
78
79
  *
79
80
  * Keyed by section TITLE rather than by position, the way the localization
80
81
  * overlays are — position keying grafts an overlay onto whichever section
@@ -86,12 +87,17 @@ export function discoverIntegrationDocs(integration: {
86
87
  * @returns {any} a new doc; neither input is mutated
87
88
  */
88
89
  export function mergeTopic(base: any, overlay: any): any;
90
+ export { withSourceTitle };
89
91
  /**
90
92
  * Owner package recorded for the built-in topics. They ship inside the CLI
91
93
  * (assets/docs), not in @astryxdesign/core, so this is the CLI's own name —
92
94
  * unlike component discovery, whose built-ins belong to core.
93
95
  */
94
- export const BUILTIN_DOCS_PACKAGE: "@astryxdesign/cli";
96
+ export const BUILTIN_DOCS_PACKAGE: import("../../authoring/index.js").ProviderId;
97
+ /** Blocks that are valid authoring but require the compiled graph renderer. */
98
+ export const GRAPH_BLOCK_TYPES: Set<string>;
99
+ /** Doc fields only the docs graph reads; a topic that sets one fails to load. */
100
+ export const GRAPH_ONLY_FIELDS: string[];
95
101
  /**
96
102
  * Every topic a project can read, and the relationships between them.
97
103
  *
@@ -183,3 +189,4 @@ export type DocsTopicEntry = {
183
189
  path: string;
184
190
  }>;
185
191
  };
192
+ import { withSourceTitle } from './docs-section-key.mjs';
@@ -33,7 +33,16 @@ import * as fs from 'node:fs';
33
33
  import * as path from 'node:path';
34
34
  import {CLI_ROOT} from '../fs/paths.mjs';
35
35
  import {importUserModule} from '../fs/module-loader.mjs';
36
+ import {CLI_PROVIDER_ID} from '../identity/providers.mjs';
36
37
  import {parseDoc} from '../../authoring/doctypes/parse.mjs';
38
+ import {
39
+ sectionKey,
40
+ sectionKeyProblems,
41
+ sourceTitle,
42
+ withSourceTitle,
43
+ } from './docs-section-key.mjs';
44
+
45
+ export {withSourceTitle};
37
46
 
38
47
  /** Where the CLI's own topics live. */
39
48
  const BUILTIN_DOCS_DIR = path.join(CLI_ROOT, 'assets', 'docs');
@@ -43,7 +52,7 @@ const BUILTIN_DOCS_DIR = path.join(CLI_ROOT, 'assets', 'docs');
43
52
  * (assets/docs), not in @astryxdesign/core, so this is the CLI's own name —
44
53
  * unlike component discovery, whose built-ins belong to core.
45
54
  */
46
- export const BUILTIN_DOCS_PACKAGE = '@astryxdesign/cli';
55
+ export const BUILTIN_DOCS_PACKAGE = CLI_PROVIDER_ID;
47
56
 
48
57
  /**
49
58
  * A built-in topic file: `{topic}.doc.mjs`. Anchored at both ends so a
@@ -128,12 +137,25 @@ const BLOCK_FIELDS = {
128
137
  'token-ref': ['topic', 'section'],
129
138
  };
130
139
 
140
+ /** Blocks that are valid authoring but require the compiled graph renderer. */
141
+ export const GRAPH_BLOCK_TYPES = new Set([
142
+ 'workflow',
143
+ 'collection',
144
+ 'reference',
145
+ ]);
146
+
147
+ /** Doc fields only the docs graph reads; a topic that sets one fails to load. */
148
+ export const GRAPH_ONLY_FIELDS = ['placement', 'aliases', 'audience'];
149
+
131
150
  /**
132
151
  * Fields a block kind may carry but does not need. Kept per kind rather than
133
152
  * globally: only a code block renders a `label`, so allowing it everywhere
134
153
  * would wave through the misspellings this check exists to catch.
135
154
  */
136
- const OPTIONAL_BLOCK_FIELDS = {code: ['label']};
155
+ /** @type {Record<string, string[]>} */
156
+ const OPTIONAL_BLOCK_FIELDS = {
157
+ code: ['label'],
158
+ };
137
159
 
138
160
  /**
139
161
  * Fields whose value has to be one of a set, because the renderer indexes on
@@ -147,7 +169,7 @@ const BLOCK_FIELD_VALUES = {
147
169
  };
148
170
 
149
171
  /** Keys a section may carry. */
150
- const SECTION_FIELDS = ['title', 'category', 'content', 'previewType'];
172
+ const SECTION_FIELDS = ['id', 'title', 'category', 'content', 'previewType'];
151
173
 
152
174
  /**
153
175
  * Check the fields the docs surfaces actually read. `parseDoc` is the outer
@@ -161,6 +183,13 @@ const SECTION_FIELDS = ['title', 'category', 'content', 'previewType'];
161
183
  * @returns {string[]} problems, each already pointed at a place in the doc
162
184
  */
163
185
  export function problemsInTopic(doc) {
186
+ // A namespace doc is valid authoring that only the docs graph reads. Said
187
+ // plainly, instead of as the topic fields it does not have.
188
+ if (doc?.type === 'namespace') {
189
+ return [
190
+ `"${doc.name}" is a namespace doc. Only the docs graph reads namespace docs, and it is not built yet; remove this file from the docs directory.`,
191
+ ];
192
+ }
164
193
  /** @type {string[]} */
165
194
  const problems = [];
166
195
  for (const field of ['name', 'title', 'description']) {
@@ -173,83 +202,119 @@ export function problemsInTopic(doc) {
173
202
  `name: "${doc.name}" is not URL-safe. A topic name is its CLI argument and its docsite path, so it may hold only letters, digits, "_" and "-".`,
174
203
  );
175
204
  }
205
+ for (const field of GRAPH_ONLY_FIELDS) {
206
+ if (doc?.[field] != null) {
207
+ problems.push(
208
+ `${field}: requires the compiled graph reader and is not supported by legacy topic readers`,
209
+ );
210
+ }
211
+ }
176
212
  if (!Array.isArray(doc?.sections) || doc.sections.length === 0) {
177
213
  problems.push('sections: expected at least one section');
178
214
  return problems;
179
215
  }
180
216
 
181
- doc.sections.forEach((/** @type {any} */ section, /** @type {number} */ s) => {
182
- const at = `sections[${s}]`;
183
- if (typeof section?.title !== 'string' || section.title === '') {
184
- problems.push(`${at}.title: expected a non-empty string`);
185
- }
186
- for (const key of Object.keys(section ?? {})) {
187
- if (!SECTION_FIELDS.includes(key)) {
188
- problems.push(`${at}.${key}: not a field of a section`);
217
+ doc.sections.forEach(
218
+ (/** @type {any} */ section, /** @type {number} */ s) => {
219
+ const at = `sections[${s}]`;
220
+ if (typeof section?.title !== 'string' || section.title === '') {
221
+ problems.push(`${at}.title: expected a non-empty string`);
189
222
  }
190
- }
191
- if (!Array.isArray(section?.content)) {
192
- problems.push(`${at}.content: expected an array of blocks`);
193
- return;
194
- }
195
- section.content.forEach((/** @type {any} */ block, /** @type {number} */ b) => {
196
- const blockAt = `${at}.content[${b}]`;
197
- const fields = /** @type {Record<string, string[]>} */ (BLOCK_FIELDS)[block?.type];
198
- if (fields == null) {
199
- problems.push(
200
- `${blockAt}.type: ${JSON.stringify(block?.type)} is not one of ${Object.keys(BLOCK_FIELDS).join(', ')}`,
201
- );
202
- return;
203
- }
204
- for (const field of fields) {
205
- const value = block[field];
206
- // Empty counts as missing, the way it does for the doc's own title: a
207
- // block whose text is '' passes every other check and renders as a gap.
208
- if (value == null) {
209
- problems.push(`${blockAt}.${field}: required for a ${block.type} block`);
210
- } else if (typeof value === 'string' && value.trim() === '') {
211
- problems.push(`${blockAt}.${field}: expected a non-empty string`);
212
- } else if (Array.isArray(value) && value.length === 0) {
213
- problems.push(`${blockAt}.${field}: expected a non-empty array`);
223
+ for (const key of Object.keys(section ?? {})) {
224
+ if (!SECTION_FIELDS.includes(key)) {
225
+ problems.push(`${at}.${key}: not a field of a section`);
214
226
  }
215
227
  }
216
- const allowedValues =
217
- /** @type {Record<string, Record<string, unknown[]>>} */ (BLOCK_FIELD_VALUES)[block.type] ?? {};
218
- for (const [field, values] of Object.entries(allowedValues)) {
219
- const value = block[field];
220
- if (value != null && !values.includes(value)) {
221
- problems.push(
222
- `${blockAt}.${field}: ${JSON.stringify(value)} is not one of ${values.join(', ')}`,
223
- );
224
- }
228
+ if (!Array.isArray(section?.content)) {
229
+ problems.push(`${at}.content: expected an array of blocks`);
230
+ return;
225
231
  }
226
- // A table's cells are read by column index, so a short row renders blank
227
- // cells and a long one drops its tail — both silently.
228
- if (block.type === 'table' && Array.isArray(block.headers) && Array.isArray(block.rows)) {
229
- block.rows.forEach((/** @type {any} */ row, /** @type {number} */ r) => {
230
- if (!Array.isArray(row)) {
231
- problems.push(`${blockAt}.rows[${r}]: expected an array of cells`);
232
- } else if (row.length !== block.headers.length) {
233
- problems.push(
234
- `${blockAt}.rows[${r}]: has ${row.length} cells but the table has ${block.headers.length} headers`,
232
+ section.content.forEach(
233
+ (/** @type {any} */ block, /** @type {number} */ b) => {
234
+ const blockAt = `${at}.content[${b}]`;
235
+ const fields = /** @type {Record<string, string[]>} */ (BLOCK_FIELDS)[
236
+ block?.type
237
+ ];
238
+ if (fields == null) {
239
+ if (GRAPH_BLOCK_TYPES.has(block?.type)) {
240
+ problems.push(
241
+ `${blockAt}.type: ${JSON.stringify(block.type)} requires the compiled graph renderer and is not supported by legacy topic readers`,
242
+ );
243
+ } else {
244
+ problems.push(
245
+ `${blockAt}.type: ${JSON.stringify(block?.type)} is not one of ${Object.keys(BLOCK_FIELDS).join(', ')}`,
246
+ );
247
+ }
248
+ return;
249
+ }
250
+ for (const field of fields) {
251
+ const value = block[field];
252
+ // Empty counts as missing, the way it does for the doc's own title: a
253
+ // block whose text is '' passes every other check and renders as a gap.
254
+ if (value == null) {
255
+ problems.push(
256
+ `${blockAt}.${field}: required for a ${block.type} block`,
257
+ );
258
+ } else if (typeof value === 'string' && value.trim() === '') {
259
+ problems.push(`${blockAt}.${field}: expected a non-empty string`);
260
+ } else if (Array.isArray(value) && value.length === 0) {
261
+ problems.push(`${blockAt}.${field}: expected a non-empty array`);
262
+ }
263
+ }
264
+ const allowedValues =
265
+ /** @type {Record<string, Record<string, unknown[]>>} */ (
266
+ BLOCK_FIELD_VALUES
267
+ )[block.type] ?? {};
268
+ for (const [field, values] of Object.entries(allowedValues)) {
269
+ const value = block[field];
270
+ if (value != null && !values.includes(value)) {
271
+ problems.push(
272
+ `${blockAt}.${field}: ${JSON.stringify(value)} is not one of ${values.join(', ')}`,
273
+ );
274
+ }
275
+ }
276
+ // A table's cells are read by column index, so a short row renders blank
277
+ // cells and a long one drops its tail — both silently.
278
+ if (
279
+ block.type === 'table' &&
280
+ Array.isArray(block.headers) &&
281
+ Array.isArray(block.rows)
282
+ ) {
283
+ block.rows.forEach(
284
+ (/** @type {any} */ row, /** @type {number} */ r) => {
285
+ if (!Array.isArray(row)) {
286
+ problems.push(
287
+ `${blockAt}.rows[${r}]: expected an array of cells`,
288
+ );
289
+ } else if (row.length !== block.headers.length) {
290
+ problems.push(
291
+ `${blockAt}.rows[${r}]: has ${row.length} cells but the table has ${block.headers.length} headers`,
292
+ );
293
+ }
294
+ },
235
295
  );
236
296
  }
237
- });
238
- }
239
- // An unknown key is almost always a misspelled required one, and it
240
- // would otherwise reach a reader as a block that renders nothing.
241
- const allowed = [
242
- 'type',
243
- ...fields,
244
- ...(/** @type {Record<string, string[]>} */ (OPTIONAL_BLOCK_FIELDS)[block.type] ?? []),
245
- ];
246
- for (const key of Object.keys(block)) {
247
- if (!allowed.includes(key)) {
248
- problems.push(`${blockAt}.${key}: not a field of a ${block.type} block`);
249
- }
250
- }
251
- });
252
- });
297
+ // An unknown key is almost always a misspelled required one, and it
298
+ // would otherwise reach a reader as a block that renders nothing.
299
+ const allowed = [
300
+ 'type',
301
+ ...fields,
302
+ ...(OPTIONAL_BLOCK_FIELDS[block.type] ?? []),
303
+ ];
304
+ for (const key of Object.keys(block)) {
305
+ if (!allowed.includes(key)) {
306
+ problems.push(
307
+ `${blockAt}.${key}: not a field of a ${block.type} block`,
308
+ );
309
+ }
310
+ }
311
+ },
312
+ );
313
+ },
314
+ );
315
+ // Readers address a section by its key, so two sections sharing one would
316
+ // make one of them unreachable.
317
+ problems.push(...sectionKeyProblems(doc.sections));
253
318
  return problems;
254
319
  }
255
320
 
@@ -283,7 +348,9 @@ export async function discoverIntegrationDocs(integration) {
283
348
  const full = path.join(dirPath, entry.name);
284
349
  if (entry.isDirectory()) {
285
350
  scanDir(full);
286
- } else if (INTEGRATION_DOC_SUFFIXES.some(suffix => entry.name.endsWith(suffix))) {
351
+ } else if (
352
+ INTEGRATION_DOC_SUFFIXES.some(suffix => entry.name.endsWith(suffix))
353
+ ) {
287
354
  files.push(full);
288
355
  }
289
356
  }
@@ -298,7 +365,11 @@ export async function discoverIntegrationDocs(integration) {
298
365
  try {
299
366
  doc = parseDoc(await loadTopicModule(file), path.basename(file));
300
367
  } catch (err) {
301
- errors.push(new Error(`${path.relative(docsDir, file)}: ${/** @type {any} */ (err).message}`));
368
+ errors.push(
369
+ new Error(
370
+ `${path.relative(docsDir, file)}: ${/** @type {any} */ (err).message}`,
371
+ ),
372
+ );
302
373
  continue;
303
374
  }
304
375
  const problems = problemsInTopic(doc);
@@ -315,7 +386,8 @@ export async function discoverIntegrationDocs(integration) {
315
386
  const parsed = /** @type {any} */ (doc);
316
387
  // Two files claiming one name would collapse into a single entry, and the
317
388
  // one that lost would never be reachable. Named here, where both files are.
318
- const previous = seen.get(parsed.name);
389
+ const topicKey = parsed.name.toLowerCase();
390
+ const previous = seen.get(topicKey);
319
391
  if (previous) {
320
392
  errors.push(
321
393
  new Error(
@@ -324,7 +396,7 @@ export async function discoverIntegrationDocs(integration) {
324
396
  );
325
397
  continue;
326
398
  }
327
- seen.set(parsed.name, path.relative(docsDir, file));
399
+ seen.set(topicKey, path.relative(docsDir, file));
328
400
  if (parsed.replaces != null && parsed.extends != null) {
329
401
  errors.push(
330
402
  new Error(
@@ -349,9 +421,10 @@ export async function discoverIntegrationDocs(integration) {
349
421
  }
350
422
 
351
423
  /**
352
- * Merge an extension onto a base topic: a section whose title matches one in
353
- * the base replaces it, a section the base does not have is appended, and the
354
- * title/description are taken from the extension when it states them.
424
+ * Merge an extension onto a base topic: a section with a stable `id` replaces
425
+ * the base section with the same `id`; legacy sections without IDs fall back to
426
+ * title matching. A section with no match is appended, and title/description
427
+ * are taken from the extension when it states them.
355
428
  *
356
429
  * Keyed by section TITLE rather than by position, the way the localization
357
430
  * overlays are — position keying grafts an overlay onto whichever section
@@ -365,9 +438,18 @@ export async function discoverIntegrationDocs(integration) {
365
438
  export function mergeTopic(base, overlay) {
366
439
  const sections = [...(base.sections ?? [])];
367
440
  for (const section of overlay.sections ?? []) {
368
- const at = sections.findIndex((/** @type {any} */ s) => s.title === section.title);
369
- if (at === -1) sections.push(section);
370
- else sections[at] = section;
441
+ const at = findMergeTarget(sections, section);
442
+ if (at === -1) {
443
+ sections.push(section);
444
+ } else {
445
+ // A legacy extension that replaces a section which has since gained a
446
+ // stable ID keeps that ID, so readers addressing it keep working.
447
+ const replaced = sections[at];
448
+ sections[at] =
449
+ section.id == null && replaced.id != null
450
+ ? withSourceTitle({...section, id: replaced.id}, sourceTitle(section))
451
+ : section;
452
+ }
371
453
  }
372
454
  return {
373
455
  ...base,
@@ -377,6 +459,41 @@ export function mergeTopic(base, overlay) {
377
459
  };
378
460
  }
379
461
 
462
+ /**
463
+ * The base section an extension section replaces. A stable ID matches first.
464
+ * Otherwise the exact title matches when at least one side has no ID: the
465
+ * migration window in which the base or the extension adopts stable IDs
466
+ * before the other does. Two different authored IDs stay distinct even under
467
+ * one title.
468
+ *
469
+ * @param {any[]} sections
470
+ * @param {any} section
471
+ * @returns {number}
472
+ */
473
+ function findMergeTarget(sections, section) {
474
+ const title = sourceTitle(section);
475
+ const key = sectionKey(section);
476
+ // A section is addressed by its key: an authored id, or the key its title
477
+ // derives, which is the key the topic's index shows. Matching on it means an
478
+ // extension never appends a second section under a key already in use.
479
+ const byKey = () =>
480
+ sections.findIndex(candidate => sectionKey(candidate) === key);
481
+ const legacyTitleMatch = () =>
482
+ sections.findIndex(
483
+ candidate => candidate.id == null && sourceTitle(candidate) === title,
484
+ );
485
+ if (section.id != null) {
486
+ const byId = byKey();
487
+ return byId === -1 ? legacyTitleMatch() : byId;
488
+ }
489
+ const legacy = legacyTitleMatch();
490
+ if (legacy !== -1) return legacy;
491
+ const sameTitle = sections.findIndex(
492
+ candidate => sourceTitle(candidate) === title,
493
+ );
494
+ return sameTitle === -1 ? byKey() : sameTitle;
495
+ }
496
+
380
497
  /**
381
498
  * Every topic a project can read, and the relationships between them.
382
499
  *
@@ -399,7 +516,7 @@ export class DocsCatalog {
399
516
  static fromBuiltins(builtins = discoverBuiltinTopics()) {
400
517
  const catalog = new DocsCatalog();
401
518
  for (const [name, file] of Object.entries(builtins)) {
402
- catalog.#topics.set(name, {
519
+ catalog.#topics.set(name.toLowerCase(), {
403
520
  name,
404
521
  package: BUILTIN_DOCS_PACKAGE,
405
522
  path: file,
@@ -455,7 +572,9 @@ export class DocsCatalog {
455
572
  // The replacement takes the base topic's slot, so a reader that opens
456
573
  // the first topic (or the nth) sees the same one it did before.
457
574
  const replaced = target.name;
458
- this.#replaceAt(replaced, {
575
+ const replacedKey = replaced.toLowerCase();
576
+ const replacementKey = record.name.toLowerCase();
577
+ this.#replaceAt(replacedKey, {
459
578
  name: record.name,
460
579
  package: record.package,
461
580
  path: record.path,
@@ -466,17 +585,18 @@ export class DocsCatalog {
466
585
  // Extensions were authored against the content that just went away.
467
586
  extensions: [],
468
587
  });
469
- if (record.name !== replaced) {
470
- this.#aliases.set(replaced, record.name);
588
+ if (replacementKey !== replacedKey) {
589
+ this.#aliases.set(replacedKey, replacementKey);
471
590
  // A topic renamed twice keeps every name it has ever answered to.
472
591
  for (const [from, to] of this.#aliases) {
473
- if (to === replaced) this.#aliases.set(from, record.name);
592
+ if (to === replacedKey) this.#aliases.set(from, replacementKey);
474
593
  }
475
594
  }
476
595
  return warning;
477
596
  }
478
597
 
479
- const existing = this.#topics.get(record.name);
598
+ const topicKey = record.name.toLowerCase();
599
+ const existing = this.#topics.get(topicKey);
480
600
  if (existing) {
481
601
  return {
482
602
  code: 'invalid_doc',
@@ -484,7 +604,7 @@ export class DocsCatalog {
484
604
  message: `Topic "${record.name}" is already provided by ${existing.package}. Give it another name, or declare \`replaces: '${record.name}'\` to take its place.`,
485
605
  };
486
606
  }
487
- this.#topics.set(record.name, {
607
+ this.#topics.set(topicKey, {
488
608
  name: record.name,
489
609
  package: record.package,
490
610
  path: record.path,
@@ -519,7 +639,7 @@ export class DocsCatalog {
519
639
 
520
640
  /** @returns {string[]} every topic name, in read order */
521
641
  names() {
522
- return [...this.#topics.keys()];
642
+ return [...this.#topics.values()].map(entry => entry.name);
523
643
  }
524
644
 
525
645
  /** @returns {DocsTopicEntry[]} every topic, in read order */
@@ -536,7 +656,7 @@ export class DocsCatalog {
536
656
  /** @type {Map<string, DocsTopicEntry>} */
537
657
  const next = new Map();
538
658
  for (const [key, value] of this.#topics) {
539
- if (key === name) next.set(entry.name, entry);
659
+ if (key === name.toLowerCase()) next.set(entry.name.toLowerCase(), entry);
540
660
  else next.set(key, value);
541
661
  }
542
662
  this.#topics = next;