@astryxdesign/cli 0.7.2-canary.ebcc404 → 0.7.2-canary.f8f7cf1

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 (134) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +14 -0
  4. package/api/component/_adapter.mjs +48 -0
  5. package/api/component/component.mjs +10 -2
  6. package/api/component/integration-export-name.test.mjs +233 -8
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +2 -1
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +48 -3
  33. package/api/integration/pack-check.test.mjs +247 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.integration-check.test.mjs +24 -0
  40. package/api/search/search.mjs +20 -8
  41. package/api/theme/build/build.family.test.mjs +54 -6
  42. package/api/theme/build/build.mjs +114 -17
  43. package/api/theme/themeBuild.doc.mjs +4 -4
  44. package/assets/docs/README.md +10 -0
  45. package/assets/docs/author-a-theme.doc.mjs +1 -1
  46. package/assets/docs/getting-started.doc.mjs +3 -3
  47. package/assets/docs/theme.doc.dense.mjs +1 -1
  48. package/assets/docs/theme.doc.mjs +1 -1
  49. package/assets/docs/theme.doc.zh.mjs +1 -1
  50. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  51. package/assets/docs/tree/block-template.doc.mjs +2 -2
  52. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  53. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  54. package/assets/docs/tree/checks.doc.mjs +21 -13
  55. package/assets/docs/tree/component-family.doc.mjs +5 -1
  56. package/assets/docs/tree/component-imports.doc.mjs +1 -1
  57. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  58. package/assets/docs/tree/links.doc.mjs +2 -2
  59. package/assets/docs/tree/page-template.doc.mjs +1 -1
  60. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  61. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  62. package/assets/docs/tree/single-component.doc.mjs +3 -2
  63. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  64. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  65. package/assets/docs/tree/versioning.doc.mjs +1 -0
  66. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  67. package/assets/docs/use-a-theme.doc.mjs +2 -2
  68. package/assets/theme.template.ts +1 -1
  69. package/authoring/doctypes/component/component.doc.mjs +1 -1
  70. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  71. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  72. package/authoring/doctypes/template/parse.d.mts +6 -2
  73. package/authoring/doctypes/template/parse.mjs +3 -1
  74. package/authoring/doctypes/template/template.doc.mjs +1 -1
  75. package/authoring/doctypes/template/type.ts +22 -0
  76. package/authoring/index.d.ts +1 -0
  77. package/authoring/integration/integration.doc.mjs +8 -1
  78. package/authoring/integration/parse.test.mjs +10 -0
  79. package/authoring/integration/schema.d.mts +2 -0
  80. package/authoring/integration/schema.mjs +1 -0
  81. package/authoring/integration/type.ts +4 -0
  82. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  83. package/clients/cli/commands/build-theme.mjs +1 -1
  84. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  85. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  86. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  87. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  88. package/clients/cli/commands/doctor.mjs +190 -0
  89. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  90. package/clients/cli/commands/integration-check.test.mjs +267 -0
  91. package/clients/cli/commands/integration.doc.mjs +6 -2
  92. package/clients/cli/commands/integration.mjs +107 -1
  93. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  94. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  95. package/clients/cli/index.mjs +1 -0
  96. package/foundation/config/project.mjs +82 -0
  97. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  98. package/foundation/discovery/component-discovery.d.mts +108 -17
  99. package/foundation/discovery/component-discovery.export-names.test.mjs +469 -2
  100. package/foundation/discovery/component-discovery.mjs +380 -47
  101. package/foundation/discovery/component-replacement.test.mjs +20 -0
  102. package/foundation/discovery/doc-types.d.mts +157 -0
  103. package/foundation/discovery/doc-types.mjs +1224 -0
  104. package/foundation/discovery/doc-types.test.mjs +652 -0
  105. package/foundation/discovery/docs-discovery.d.mts +81 -0
  106. package/foundation/discovery/docs-discovery.mjs +57 -0
  107. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  108. package/foundation/discovery/integration-doc-files.mjs +231 -0
  109. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  110. package/foundation/discovery/module-exports.d.mts +6 -0
  111. package/foundation/discovery/module-exports.mjs +15 -2
  112. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  113. package/foundation/discovery/package-tsconfig.mjs +228 -0
  114. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  115. package/foundation/discovery/project-typescript.d.mts +23 -0
  116. package/foundation/discovery/project-typescript.mjs +61 -0
  117. package/foundation/discovery/prop-drift.d.mts +278 -0
  118. package/foundation/discovery/prop-drift.mjs +2263 -0
  119. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  120. package/foundation/discovery/type-match.d.mts +53 -0
  121. package/foundation/discovery/type-match.mjs +660 -0
  122. package/foundation/discovery/type-match.test.mjs +169 -0
  123. package/foundation/discovery/type-text.d.mts +68 -0
  124. package/foundation/discovery/type-text.mjs +658 -0
  125. package/foundation/discovery/type-text.test.mjs +196 -0
  126. package/foundation/discovery/unresolved-references.d.mts +62 -0
  127. package/foundation/discovery/unresolved-references.mjs +790 -0
  128. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  129. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  130. package/foundation/doc-compiler/links.d.mts +9 -5
  131. package/foundation/doc-compiler/links.mjs +4 -2
  132. package/foundation/integrations/contribution-inventory.mjs +6 -1
  133. package/foundation/response/response-types.doc.mjs +7 -2
  134. package/package.json +9 -9
@@ -132,6 +132,35 @@ export const GRAPH_BLOCK_TYPES: Set<string>;
132
132
  * load; a guide the tree places may set `placement` (spec:AST-046).
133
133
  */
134
134
  export const GRAPH_ONLY_FIELDS: string[];
135
+ /**
136
+ * A component a link can name: its owner's provider id, package, and name,
137
+ * and the doc file that describes it.
138
+ * @typedef {object} LinkableComponent
139
+ * @property {string} providerId
140
+ * @property {string} package
141
+ * @property {string} name
142
+ * @property {string | null} docPath
143
+ * @property {string} [replaces] the Core component whose name it answers to
144
+ * in unqualified lookups (spec:AST-035 FR11)
145
+ */
146
+ /**
147
+ * A page or block template a link can name, by the id `astryx template` takes.
148
+ * @typedef {object} LinkableTemplate
149
+ * @property {string} providerId
150
+ * @property {string} package
151
+ * @property {'page' | 'block'} type
152
+ * @property {string} id
153
+ * @property {string} name
154
+ * @property {string} [displayName]
155
+ * @property {string} [description]
156
+ * @property {string} [replaces] the Core template id it answers to in
157
+ * unqualified lookups
158
+ */
159
+ /**
160
+ * The component and template docs links can name (spec:AST-047 FR9). No docs
161
+ * tree holds them, so a catalog carries the records the project reads.
162
+ * @typedef {{components: LinkableComponent[], templates: LinkableTemplate[]}} LinkableContributions
163
+ */
135
164
  /**
136
165
  * Every topic a project can read, and the relationships between them.
137
166
  *
@@ -181,6 +210,18 @@ export class DocsCatalog {
181
210
  namespaces: import("../doc-compiler/tree.mjs").TreeNamespaceInput[];
182
211
  guides: import("../doc-compiler/tree.mjs").TreeDocInput[];
183
212
  }>;
213
+ /**
214
+ * Say where links find component, page, and block docs: the records
215
+ * `astryx component` and `astryx template` read, loaded on first use.
216
+ * @param {() => Promise<LinkableContributions>} load
217
+ */
218
+ setContributions(load: () => Promise<LinkableContributions>): void;
219
+ /**
220
+ * The component and template docs links can name, or null when nothing set
221
+ * them, so no link to one resolves.
222
+ * @returns {Promise<LinkableContributions | null>}
223
+ */
224
+ contributions(): Promise<LinkableContributions | null>;
184
225
  /**
185
226
  * Add one integration-contributed doc, honoring what it declares. Returns
186
227
  * the issue it caused, or null when it applied cleanly — the caller owns
@@ -290,4 +331,44 @@ export type DocsTopicEntry = {
290
331
  */
291
332
  tree?: boolean | undefined;
292
333
  };
334
+ /**
335
+ * A component a link can name: its owner's provider id, package, and name,
336
+ * and the doc file that describes it.
337
+ */
338
+ export type LinkableComponent = {
339
+ providerId: string;
340
+ package: string;
341
+ name: string;
342
+ docPath: string | null;
343
+ /**
344
+ * the Core component whose name it answers to
345
+ * in unqualified lookups (spec:AST-035 FR11)
346
+ */
347
+ replaces?: string | undefined;
348
+ };
349
+ /**
350
+ * A page or block template a link can name, by the id `astryx template` takes.
351
+ */
352
+ export type LinkableTemplate = {
353
+ providerId: string;
354
+ package: string;
355
+ type: "page" | "block";
356
+ id: string;
357
+ name: string;
358
+ displayName?: string | undefined;
359
+ description?: string | undefined;
360
+ /**
361
+ * the Core template id it answers to in
362
+ * unqualified lookups
363
+ */
364
+ replaces?: string | undefined;
365
+ };
366
+ /**
367
+ * The component and template docs links can name (spec:AST-047 FR9). No docs
368
+ * tree holds them, so a catalog carries the records the project reads.
369
+ */
370
+ export type LinkableContributions = {
371
+ components: LinkableComponent[];
372
+ templates: LinkableTemplate[];
373
+ };
293
374
  import { withSourceTitle } from './docs-section-key.mjs';
@@ -633,6 +633,38 @@ function findMergeTarget(sections, section) {
633
633
  return sections.findIndex(candidate => candidate.id === key);
634
634
  }
635
635
 
636
+ /**
637
+ * A component a link can name: its owner's provider id, package, and name,
638
+ * and the doc file that describes it.
639
+ * @typedef {object} LinkableComponent
640
+ * @property {string} providerId
641
+ * @property {string} package
642
+ * @property {string} name
643
+ * @property {string | null} docPath
644
+ * @property {string} [replaces] the Core component whose name it answers to
645
+ * in unqualified lookups (spec:AST-035 FR11)
646
+ */
647
+
648
+ /**
649
+ * A page or block template a link can name, by the id `astryx template` takes.
650
+ * @typedef {object} LinkableTemplate
651
+ * @property {string} providerId
652
+ * @property {string} package
653
+ * @property {'page' | 'block'} type
654
+ * @property {string} id
655
+ * @property {string} name
656
+ * @property {string} [displayName]
657
+ * @property {string} [description]
658
+ * @property {string} [replaces] the Core template id it answers to in
659
+ * unqualified lookups
660
+ */
661
+
662
+ /**
663
+ * The component and template docs links can name (spec:AST-047 FR9). No docs
664
+ * tree holds them, so a catalog carries the records the project reads.
665
+ * @typedef {{components: LinkableComponent[], templates: LinkableTemplate[]}} LinkableContributions
666
+ */
667
+
636
668
  /**
637
669
  * Every topic a project can read, and the relationships between them.
638
670
  *
@@ -644,6 +676,10 @@ function findMergeTarget(sections, section) {
644
676
  export class DocsCatalog {
645
677
  /** @type {Map<string, DocsTopicEntry>} */
646
678
  #topics = new Map();
679
+ /** @type {(() => Promise<LinkableContributions>) | null} */
680
+ #contributions = null;
681
+ /** @type {Promise<LinkableContributions> | null} */
682
+ #contributionsLoaded = null;
647
683
  /** @type {Map<string, string>} old topic name → the name that replaced it */
648
684
  #aliases = new Map();
649
685
  /** @type {Array<{namespaces: import('../doc-compiler/tree.mjs').TreeNamespaceInput[], guides: import('../doc-compiler/tree.mjs').TreeDocInput[]}>} */
@@ -687,6 +723,27 @@ export class DocsCatalog {
687
723
  return this.#treeInputs;
688
724
  }
689
725
 
726
+ /**
727
+ * Say where links find component, page, and block docs: the records
728
+ * `astryx component` and `astryx template` read, loaded on first use.
729
+ * @param {() => Promise<LinkableContributions>} load
730
+ */
731
+ setContributions(load) {
732
+ this.#contributions = load;
733
+ this.#contributionsLoaded = null;
734
+ }
735
+
736
+ /**
737
+ * The component and template docs links can name, or null when nothing set
738
+ * them, so no link to one resolves.
739
+ * @returns {Promise<LinkableContributions | null>}
740
+ */
741
+ contributions() {
742
+ if (!this.#contributions) return Promise.resolve(null);
743
+ this.#contributionsLoaded ??= this.#contributions();
744
+ return this.#contributionsLoaded;
745
+ }
746
+
690
747
  /**
691
748
  * Seed a catalog with the CLI's own topics.
692
749
  * @param {Record<string, string>} [builtins] topic name → absolute path
@@ -0,0 +1,22 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /** @typedef {import('./doc-types.mjs').DocFile} DocFile */
5
+ /** @typedef {import('./doc-types.mjs').DocRoot} DocRoot */
6
+ /**
7
+ * Every doc file an integration's roots hold, each assigned to the deepest
8
+ * root that holds it, each root a reader could not read, and how many test
9
+ * fixtures no reader serves.
10
+ * @param {any} integration a loaded integration
11
+ * @returns {Promise<{files: DocFile[], unread: Array<{path: string, reason: string}>, fixtures: number}>}
12
+ */
13
+ export function integrationDocFiles(integration: any): Promise<{
14
+ files: DocFile[];
15
+ unread: Array<{
16
+ path: string;
17
+ reason: string;
18
+ }>;
19
+ fixtures: number;
20
+ }>;
21
+ export type DocFile = import("./doc-types.mjs").DocFile;
22
+ export type DocRoot = import("./doc-types.mjs").DocRoot;
@@ -0,0 +1,231 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The doc files an integration package ships, by root: every doc its
5
+ * roots hold, so the doc type check covers each one an agent can open.
6
+ *
7
+ * Each root's reader names the docs it serves, with the kind it reads each
8
+ * as:
9
+ * - components: the doc each component discovery records;
10
+ * - templates: the doc of each template template discovery serves;
11
+ * - docs: the file of each topic, namespace and placed guide docs discovery
12
+ * serves, and each topic's and guide's `zh` and `dense` overlay that a docs
13
+ * read applies;
14
+ * - themes: each theme's descriptor, as theme discovery resolves it. A themes
15
+ * root that discovery cannot read serves no theme, and is listed with the
16
+ * reason.
17
+ * A doc no reader serves still ships: a `hidden` one, a second doc for one
18
+ * name, an overlay without its topic. It goes to its root's kind. A test
19
+ * fixture does not ship as a doc, and is only counted: a doc under
20
+ * `__tests__` in the components or docs root, which those readers skip.
21
+ * A file two roots hold goes to the deepest.
22
+ *
23
+ * @input A loaded integration and its resolved roots.
24
+ * @output DocFile entries for checkDocTypes, each with the root it belongs to,
25
+ * the roots a reader could not read, and how many test fixtures it left
26
+ * out.
27
+ * @position Read by `astryx doctor integration validate` before it checks
28
+ * each doc against its authoring type.
29
+ */
30
+
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import {OVERLAY_LANGUAGES, overlayPath} from '../doc-compiler/read.mjs';
34
+ import {discoverIntegrationComponents} from './component-discovery.mjs';
35
+ import {discoverIntegrationDocs} from './docs-discovery.mjs';
36
+ import {discoverIntegrationTemplatesForOne} from './template-adapter.mjs';
37
+ import {discoverThemeDirectory} from './theme-discovery.mjs';
38
+ import {rootProblem} from '../integrations/validate-contributions.mjs';
39
+
40
+ /** @typedef {import('./doc-types.mjs').DocFile} DocFile */
41
+ /** @typedef {import('./doc-types.mjs').DocRoot} DocRoot */
42
+
43
+ /**
44
+ * Every doc file an integration's roots hold, each assigned to the deepest
45
+ * root that holds it, each root a reader could not read, and how many test
46
+ * fixtures no reader serves.
47
+ * @param {any} integration a loaded integration
48
+ * @returns {Promise<{files: DocFile[], unread: Array<{path: string, reason: string}>, fixtures: number}>}
49
+ */
50
+ export async function integrationDocFiles(integration) {
51
+ const {components, templates, docs, themes} = integration ?? {};
52
+ /** @type {Array<[DocRoot, string, string[]]>} */
53
+ const read = [];
54
+ /** @type {Array<{path: string, reason: string}>} */
55
+ const unread = [];
56
+ // A root `validate` already reports (missing, a file, unreadable) is left
57
+ // to that issue. A reader that fails inside a root, such as on a folder it
58
+ // cannot read, leaves the root listed with the reason.
59
+ /** @type {(root: string | undefined, label: string, files: () => Promise<Array<[DocRoot, string[]]>>) => Promise<void>} */
60
+ const take = async (root, label, files) => {
61
+ if (!root || rootProblem(root)) return;
62
+ try {
63
+ for (const [kind, list] of await files()) read.push([kind, root, list]);
64
+ } catch (err) {
65
+ unread.push({
66
+ path: root,
67
+ reason: `the ${label} root does not load, so no doc in it was checked: ${err instanceof Error ? err.message : String(err)}`,
68
+ });
69
+ }
70
+ };
71
+ await take(components, 'components', async () => [
72
+ [
73
+ 'components',
74
+ discoverIntegrationComponents(integration).map(c => c.docPath),
75
+ ],
76
+ ]);
77
+ await take(templates, 'templates', async () => [
78
+ [
79
+ 'templates',
80
+ (await discoverIntegrationTemplatesForOne(integration)).templates.map(
81
+ t => t.docPath,
82
+ ),
83
+ ],
84
+ ]);
85
+ await take(docs, 'docs', async () => {
86
+ const found = await discoverIntegrationDocs(integration);
87
+ // Topics and placed guides are read with their overlays; a namespace
88
+ // names its file `<package name>/<path in the root>`.
89
+ const withOverlays = [
90
+ ...found.records.map(record => record.path),
91
+ ...found.guides.map(guide => guide.ref.topicFile),
92
+ ];
93
+ const prefix = `${integration.name}/`;
94
+ const namespaces = found.namespaces.map(entry =>
95
+ path.join(
96
+ /** @type {string} */ (docs),
97
+ ...(entry.source.startsWith(prefix)
98
+ ? entry.source.slice(prefix.length)
99
+ : entry.source
100
+ ).split('/'),
101
+ ),
102
+ );
103
+ return [
104
+ ['docs', [...withOverlays, ...namespaces]],
105
+ [
106
+ 'docs-overlay',
107
+ withOverlays.flatMap(file =>
108
+ OVERLAY_LANGUAGES.map(lang => overlayPath(file, lang)).filter(
109
+ overlay => fs.existsSync(overlay),
110
+ ),
111
+ ),
112
+ ],
113
+ ];
114
+ });
115
+ await take(themes, 'themes', async () => [
116
+ [
117
+ 'themes',
118
+ discoverThemeDirectory(
119
+ /** @type {string} */ (themes),
120
+ integration?.name ?? 'the package',
121
+ ).map(theme => theme.docPath),
122
+ ],
123
+ ]);
124
+ /** @type {Map<string, {root: DocRoot, dir: string}>} */
125
+ const owners = new Map();
126
+ /** @type {(table: Map<string, {root: DocRoot, dir: string}>, root: DocRoot, dir: string, file: string) => void} */
127
+ const own = (table, root, dir, file) => {
128
+ const owner = table.get(file);
129
+ if (!owner || dir.length > owner.dir.length) table.set(file, {root, dir});
130
+ };
131
+ for (const [root, dir, files] of read) {
132
+ for (const file of files) own(owners, root, dir, file);
133
+ }
134
+ // The docs no reader serves, in each root a reader could read.
135
+ /** @type {Map<string, {root: DocRoot, dir: string}>} */
136
+ const shipped = new Map();
137
+ const readable = new Set(read.map(([, dir]) => dir));
138
+ for (const [
139
+ kind,
140
+ dir,
141
+ ] of /** @type {Array<[DocRoot, string | undefined]>} */ ([
142
+ ['components', components],
143
+ ['templates', templates],
144
+ ['docs', docs],
145
+ ['themes', themes],
146
+ ])) {
147
+ if (!dir || !readable.has(dir)) continue;
148
+ for (const file of docFilesUnder(dir)) {
149
+ const overlay = OVERLAY_LANGUAGES.some(lang =>
150
+ file.endsWith(`.doc.${lang}.mjs`),
151
+ );
152
+ if (overlay && kind !== 'docs') continue;
153
+ own(shipped, overlay ? 'docs-overlay' : kind, dir, file);
154
+ }
155
+ }
156
+ let fixtures = 0;
157
+ for (const [file, owner] of shipped) {
158
+ if (owners.has(file)) continue;
159
+ if (isFixture(path.relative(owner.dir, file), owner.root)) fixtures++;
160
+ else owners.set(file, owner);
161
+ }
162
+ return {
163
+ files: [...owners]
164
+ .map(([file, {root}]) => ({path: file, root}))
165
+ .sort((a, b) => a.path.localeCompare(b.path)),
166
+ unread,
167
+ fixtures,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Whether a doc file is a test fixture: under `__tests__` in a root whose
173
+ * reader skips that folder (components and docs). The template reader reads
174
+ * it, so a template doc there is the package's like any other.
175
+ * @param {string} file its path relative to its root
176
+ * @param {DocRoot} root
177
+ */
178
+ function isFixture(file, root) {
179
+ return (
180
+ root !== 'templates' &&
181
+ root !== 'themes' &&
182
+ file.split(path.sep).includes('__tests__')
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Every doc file under a folder: a `.doc.ts`, `.doc.mjs` or `.doc.js` file,
188
+ * or a `zh` or `dense` overlay, through its folders and linked files, but not
189
+ * `node_modules`, `.git` or a linked folder.
190
+ * @param {string} dir
191
+ * @returns {string[]}
192
+ */
193
+ function docFilesUnder(dir) {
194
+ /** @type {string[]} */
195
+ const found = [];
196
+ /** @type {(folder: string) => void} */
197
+ const walk = folder => {
198
+ /** @type {fs.Dirent[]} */
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(folder, {withFileTypes: true});
202
+ } catch {
203
+ return;
204
+ }
205
+ for (const entry of entries) {
206
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
207
+ const full = path.join(folder, entry.name);
208
+ if (entry.isDirectory()) {
209
+ walk(full);
210
+ continue;
211
+ }
212
+ if (entry.isSymbolicLink()) {
213
+ try {
214
+ if (!fs.statSync(full).isFile()) continue;
215
+ } catch {
216
+ continue;
217
+ }
218
+ } else if (!entry.isFile()) {
219
+ continue;
220
+ }
221
+ if (
222
+ /\.doc\.(ts|mjs|js)$/.test(entry.name) ||
223
+ OVERLAY_LANGUAGES.some(lang => entry.name.endsWith(`.doc.${lang}.mjs`))
224
+ ) {
225
+ found.push(full);
226
+ }
227
+ }
228
+ };
229
+ walk(dir);
230
+ return found;
231
+ }
@@ -0,0 +1,246 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file integrationDocFiles(): exactly the docs each root's reader serves, a
5
+ * nested root's files given to that root, and a root its reader cannot read.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
12
+ import {discoverIntegrationComponents} from './component-discovery.mjs';
13
+ import {integrationDocFiles} from './integration-doc-files.mjs';
14
+
15
+ /** @type {string} */
16
+ let pkg;
17
+
18
+ beforeEach(() => {
19
+ pkg = fs.realpathSync(
20
+ fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-doc-files-')),
21
+ );
22
+ });
23
+
24
+ afterEach(() => {
25
+ fs.rmSync(pkg, {recursive: true, force: true});
26
+ });
27
+
28
+ /**
29
+ * @param {string} rel
30
+ * @param {string} text
31
+ */
32
+ function write(rel, text) {
33
+ const file = path.join(pkg, rel);
34
+ fs.mkdirSync(path.dirname(file), {recursive: true});
35
+ fs.writeFileSync(file, text);
36
+ return file;
37
+ }
38
+
39
+ /** @param {string} name */
40
+ const component = name =>
41
+ `export default {type: 'component', name: '${name}', displayName: '${name}', usage: {description: 'x'}, props: []};\n`;
42
+ /** @param {string} name */
43
+ const topic = name =>
44
+ `export default {type: 'generic', name: '${name}', title: 'T', description: 'D', sections: [{title: 'S', content: [{type: 'prose', text: 'x'}]}]};\n`;
45
+ /** @param {string} rel a template doc, with its same-stem source */
46
+ const template = rel => {
47
+ write(rel.replace(/\.doc\.mjs$/, '.tsx'), 'export default () => null;\n');
48
+ return write(
49
+ rel,
50
+ `export default {type: 'block', name: '${path.basename(rel, '.doc.mjs')}', description: 'D'};\n`,
51
+ );
52
+ };
53
+
54
+ /** @param {Awaited<ReturnType<typeof integrationDocFiles>>['files']} files */
55
+ const listed = files =>
56
+ files
57
+ .map(f => [path.relative(pkg, f.path).split(path.sep).join('/'), f.root])
58
+ .sort();
59
+
60
+ describe('integrationDocFiles', () => {
61
+ it('reads a fixture folder relative to its root, so a package under a `__tests__` folder is checked', async () => {
62
+ write(
63
+ '__tests__/acme/components/Hidden.doc.mjs',
64
+ `${component('Hidden')}\n hidden: true\n`,
65
+ );
66
+ write(
67
+ '__tests__/acme/components/__tests__/Tested.doc.mjs',
68
+ component('Tested'),
69
+ );
70
+
71
+ const {files, fixtures} = await integrationDocFiles({
72
+ name: '@acme/widgets',
73
+ components: path.join(pkg, '__tests__', 'acme', 'components'),
74
+ });
75
+
76
+ expect(listed(files)).toEqual([
77
+ ['__tests__/acme/components/Hidden.doc.mjs', 'components'],
78
+ ]);
79
+ expect(fixtures).toBe(1);
80
+ });
81
+
82
+ it("gives a nested root's files to that root", async () => {
83
+ write('src/Badge.doc.mjs', component('Badge'));
84
+ write('src/docs/guide.doc.mjs', topic('guide'));
85
+ write('src/docs/guide.doc.zh.mjs', 'export const docsZh = {};\n');
86
+ template('templates/hero.doc.mjs');
87
+
88
+ const {files, unread} = await integrationDocFiles({
89
+ name: '@acme/widgets',
90
+ components: path.join(pkg, 'src'),
91
+ docs: path.join(pkg, 'src', 'docs'),
92
+ templates: path.join(pkg, 'templates'),
93
+ });
94
+
95
+ expect(unread).toEqual([]);
96
+ expect(listed(files)).toEqual([
97
+ ['src/Badge.doc.mjs', 'components'],
98
+ ['src/docs/guide.doc.mjs', 'docs'],
99
+ ['src/docs/guide.doc.zh.mjs', 'docs-overlay'],
100
+ ['templates/hero.doc.mjs', 'templates'],
101
+ ]);
102
+ });
103
+
104
+ it('takes every doc the roots hold, served or not, and counts a test fixture no reader serves', async () => {
105
+ write('components/__fixtures__/Fixture.doc.mjs', component('Fixture'));
106
+ write('components/.drafts/Draft.doc.mjs', component('Draft'));
107
+ write('components/__tests__/Tested.doc.mjs', component('Tested'));
108
+ write(
109
+ 'components/Hidden.doc.mjs',
110
+ `${component('Hidden')}\n hidden: true\n`,
111
+ );
112
+ write('components/Twice.doc.mjs', component('Twice'));
113
+ write('components/again/Twice.doc.mjs', component('Twice'));
114
+ write('shared/Linked.doc.mjs', component('Linked'));
115
+ fs.symlinkSync(
116
+ path.join(pkg, 'shared', 'Linked.doc.mjs'),
117
+ path.join(pkg, 'components', 'Linked.doc.mjs'),
118
+ );
119
+ template('templates/__tests__/tested.doc.mjs');
120
+ write('templates/orphan.doc.mjs', "export default {type: 'block'};\n");
121
+ // The template reader reads `__fixtures__`: a template doc there without
122
+ // its source is the package's, so it is checked.
123
+ write(
124
+ 'templates/__fixtures__/bad.doc.mjs',
125
+ "export default {type: 'block'};\n",
126
+ );
127
+ write('docs/__tests__/sample.doc.mjs', topic('sample'));
128
+ write('docs/.drafts/topic.doc.mjs', topic('topic'));
129
+ write('docs/.drafts/topic.doc.zh.mjs', 'export const docsZh = {};\n');
130
+ write('docs/copy.doc.mjs', topic('topic'));
131
+ write('docs/orphan.doc.dense.mjs', 'export const docsDense = {};\n');
132
+ write('themes/oceanTheme.doc.mjs', 'export default {};\n');
133
+ const integration = {
134
+ name: '@acme/widgets',
135
+ components: path.join(pkg, 'components'),
136
+ templates: path.join(pkg, 'templates'),
137
+ docs: path.join(pkg, 'docs'),
138
+ themes: path.join(pkg, 'themes'),
139
+ };
140
+
141
+ const {files, unread, fixtures} = await integrationDocFiles(integration);
142
+
143
+ // Component discovery skips `__tests__` and a `hidden` doc, and serves the
144
+ // first doc of a name; a template without its source, a second topic of
145
+ // a name, and an overlay without its topic are not served either. All of
146
+ // them ship, so all are checked, but a test fixture no reader serves.
147
+ const served = discoverIntegrationComponents(integration).map(c =>
148
+ path.relative(pkg, c.docPath),
149
+ );
150
+ expect(served.filter(file => file.endsWith('Twice.doc.mjs'))).toHaveLength(
151
+ 1,
152
+ );
153
+ expect(listed(files)).toEqual(
154
+ [
155
+ ...served.map(file => [file, 'components']),
156
+ ['components/Hidden.doc.mjs', 'components'],
157
+ ['components/again/Twice.doc.mjs', 'components'],
158
+ ['docs/.drafts/topic.doc.mjs', 'docs'],
159
+ ['docs/.drafts/topic.doc.zh.mjs', 'docs-overlay'],
160
+ ['docs/copy.doc.mjs', 'docs'],
161
+ ['docs/orphan.doc.dense.mjs', 'docs-overlay'],
162
+ ['templates/__fixtures__/bad.doc.mjs', 'templates'],
163
+ ['templates/__tests__/tested.doc.mjs', 'templates'],
164
+ ['templates/orphan.doc.mjs', 'templates'],
165
+ ].sort(),
166
+ );
167
+ // `components/__tests__/Tested` and `docs/__tests__/sample`.
168
+ expect(fixtures).toBe(2);
169
+ expect(served.sort()).toEqual(
170
+ expect.arrayContaining([
171
+ 'components/.drafts/Draft.doc.mjs',
172
+ 'components/Linked.doc.mjs',
173
+ 'components/__fixtures__/Fixture.doc.mjs',
174
+ ]),
175
+ );
176
+ expect(served.some(file => /Tested|Hidden/.test(file))).toBe(false);
177
+ // Theme discovery serves no theme from this root, so none is checked,
178
+ // and the root says why.
179
+ expect(unread).toEqual([
180
+ {
181
+ path: integration.themes,
182
+ reason: expect.stringMatching(
183
+ /^the themes root does not load, so no doc in it was checked: .*lower-kebab/,
184
+ ),
185
+ },
186
+ ]);
187
+ });
188
+
189
+ it("takes a scoped package's namespace docs and placed guides where they are, with each guide's overlays", async () => {
190
+ write(
191
+ 'docs/guides/guides.doc.mjs',
192
+ `export const docs = ${JSON.stringify({type: 'namespace', name: 'guides', title: 'Guides', summary: 'Every guide.', slots: {guides: {title: 'Guides', accepts: {kinds: ['generic']}}}})};\n`,
193
+ );
194
+ write(
195
+ 'docs/guides/setup.doc.mjs',
196
+ `export const docs = ${JSON.stringify({type: 'generic', name: 'setup', title: 'Set up', description: 'D', placement: {parent: 'namespace:guides', slot: 'guides', order: 1}, sections: [{title: 'S', content: [{type: 'prose', text: 'x'}]}]})};\n`,
197
+ );
198
+ write('docs/guides/setup.doc.zh.mjs', 'export const docsZh = {};\n');
199
+ write('docs/guides/setup.doc.dense.mjs', 'export const docsDense = {};\n');
200
+
201
+ const {files, unread} = await integrationDocFiles({
202
+ name: '@acme/widgets',
203
+ docs: path.join(pkg, 'docs'),
204
+ });
205
+
206
+ expect(unread).toEqual([]);
207
+ expect(listed(files)).toEqual([
208
+ ['docs/guides/guides.doc.mjs', 'docs'],
209
+ ['docs/guides/setup.doc.dense.mjs', 'docs-overlay'],
210
+ ['docs/guides/setup.doc.mjs', 'docs'],
211
+ ['docs/guides/setup.doc.zh.mjs', 'docs-overlay'],
212
+ ]);
213
+ });
214
+
215
+ it('leaves a root validate reports to that issue, and lists a root whose reader fails inside it', async () => {
216
+ write('components/Badge.doc.mjs', component('Badge'));
217
+ write('components/locked/Inner.doc.mjs', component('Inner'));
218
+ write('docs/guide.doc.mjs', topic('guide'));
219
+ const locked = path.join(pkg, 'components', 'locked');
220
+ fs.chmodSync(path.join(pkg, 'docs'), 0o000);
221
+ fs.chmodSync(locked, 0o000);
222
+ try {
223
+ const {files, unread} = await integrationDocFiles({
224
+ name: '@acme/widgets',
225
+ components: path.join(pkg, 'components'),
226
+ docs: path.join(pkg, 'docs'),
227
+ templates: path.join(pkg, 'missing'),
228
+ });
229
+
230
+ // `validate` reports the unreadable docs root and the missing templates
231
+ // root itself; the components root reads until its locked folder.
232
+ expect(files).toEqual([]);
233
+ expect(unread).toEqual([
234
+ {
235
+ path: path.join(pkg, 'components'),
236
+ reason: expect.stringMatching(
237
+ /^the components root does not load, so no doc in it was checked: .*EACCES/,
238
+ ),
239
+ },
240
+ ]);
241
+ } finally {
242
+ fs.chmodSync(path.join(pkg, 'docs'), 0o755);
243
+ fs.chmodSync(locked, 0o755);
244
+ }
245
+ });
246
+ });
@@ -29,3 +29,9 @@ export function moduleExportSource(file: string, exportName: string, seen?: Set<
29
29
  * @returns {Record<string, unknown> | null}
30
30
  */
31
31
  export function staticExportObject(file: string, exportNames: readonly string[]): Record<string, unknown> | null;
32
+ /**
33
+ * Marks an object {@link staticExportObject} returns (or one inside it) whose
34
+ * source also has properties or spreads it could not read as literals. Not
35
+ * enumerable, so the object compares and serializes as its literal properties.
36
+ */
37
+ export const HAS_COMPUTED: unique symbol;