Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/deduplicate-parent-theme-targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
'@astryxdesign/cli': patch
---

[fix] Deduplicate parent-owned theming targets in CLI discovery while preserving each child component's direct documentation. (#5767)
[fix] Keep parent-owned theming targets in one source while projecting each member's exact anatomy and target into its direct CLI and docsite documentation. (#5767)

@cixzhang
24 changes: 21 additions & 3 deletions apps/docsite/scripts/generate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {
findParentOwnedMemberProjection,
projectParentOwnedMemberDoc,
stripMemberProjectionMetadata,
} from '../../../packages/cli/authoring/doctypes/component/member-projection.mjs';
import {resolveContentRoot} from './resolve-content-root.mjs';
import {expandWorkspaceDirs} from '../../../scripts/lib/workspace-globs.mjs';
import {
Expand Down Expand Up @@ -367,7 +372,7 @@ async function generateComponentRegistry() {
const mod = await import(pathToFileURL(dfPath).href);
const d = mod.docs;
if (d && (d.components || d.props) && !d.params && !d.subComponentOf) {
dirPrimaryDoc = d.name || null;
dirPrimaryDoc = d;
dirPrimaryMeta = {
name: d.name || null,
group: d.group || null,
Expand Down Expand Up @@ -398,6 +403,17 @@ async function generateComponentRegistry() {
continue;
}

let memberProjection = null;
if (doc.subComponentOf && dirPrimaryDoc) {
memberProjection = findParentOwnedMemberProjection(
dirPrimaryDoc,
doc.name,
);
doc = projectParentOwnedMemberDoc(dirPrimaryDoc, doc);
} else {
doc = stripMemberProjectionMetadata(doc);
}

const group = doc.group || null;
const category = doc.category || null;
const isHiddenFromOverview = doc.isHiddenFromOverview ?? false;
Expand Down Expand Up @@ -455,7 +471,9 @@ async function generateComponentRegistry() {
? null
: doc.theming
? sanitizeForJson(doc.theming)
: parentMeta.theming ?? null,
: memberProjection
? null
: parentMeta.theming ?? null,
params: isHookEntry
? Array.isArray(doc.params)
? sanitizeForJson(doc.params)
Expand Down Expand Up @@ -611,7 +629,7 @@ async function generateComponentRegistry() {
description: topDescription,
keywords,
hidden,
parentDoc: dirPrimaryDoc,
parentDoc: dirPrimaryDoc?.name ?? null,
props: [],
usage,
theming: null,
Expand Down
20 changes: 20 additions & 0 deletions apps/docsite/src/__tests__/component-detail-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ describe('component detail wiring', () => {
}
}
});
it('routes projected member anatomy through the existing Anatomy renderer', () => {
const entries = Object.values(components).flat();
for (const name of [
'DropdownMenuDivider',
'TableHeader',
'TableBody',
'TableFooter',
]) {
expect(
entries.find(entry => entry.name === name)?.usage?.anatomy,
).toHaveLength(1);
}

const source = fs.readFileSync(
path.join(DETAIL_DIR, 'ComponentDetailClient.tsx'),
'utf8',
);
expect(source).toContain('<Anatomy elements={comp.usage.anatomy} />');
});

it('carries component accessibility requirements into the registry', () => {
const button = Object.values(components)
.flat()
Expand Down
69 changes: 69 additions & 0 deletions apps/docsite/src/__tests__/data-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,75 @@ describe('componentRegistry', () => {
);
});

it('projects exact parent-owned anatomy and targets onto extracted members', () => {
const core = components['@astryxdesign/core'];
const expected = {
DropdownMenuDivider: {
anatomy: 'Pointer divider',
target: 'astryx-dropdown-menu-divider',
},
TableHeader: {
anatomy: 'Header section',
target: 'astryx-table-header',
},
TableBody: {
anatomy: 'Body section',
target: 'astryx-table-body',
},
TableFooter: {
anatomy: 'Footer section',
target: 'astryx-table-footer',
},
} as const;

for (const [name, contract] of Object.entries(expected)) {
const member = core.find(component => component.name === name);
expect(member, name).toBeDefined();
expect(
member!.usage?.anatomy?.map(part => part.name),
name,
).toEqual([contract.anatomy]);
expect(
member!.theming?.targets.map(target => target.className),
name,
).toEqual([contract.target]);
}

const dropdownDivider = core.find(
component => component.name === 'DropdownMenuDivider',
);
expect(
dropdownDivider!.usage?.anatomy?.map(part => part.name),
).not.toContain('Touch divider');
const tableHeader = core.find(
component => component.name === 'TableHeader',
);
expect(tableHeader!.usage?.anatomy?.map(part => part.name)).not.toContain(
'Body section',
);
});

it('keeps aggregate anatomy and targets on projection parents', () => {
const core = components['@astryxdesign/core'];
const table = core.find(component => component.name === 'Table');
expect(table!.usage?.anatomy).toHaveLength(16);
expect(table!.theming?.targets.map(target => target.className)).toEqual(
expect.arrayContaining([
'astryx-table-header',
'astryx-table-body',
'astryx-table-footer',
]),
);

const menu = core.find(component => component.name === 'DropdownMenu');
expect(menu!.usage?.anatomy?.map(part => part.name)).toEqual(
expect.arrayContaining(['Pointer divider', 'Touch divider']),
);
expect(menu!.theming?.targets.map(target => target.className)).toContain(
'astryx-dropdown-menu-divider',
);
});

it('sub-components can override inherited playground defaults', () => {
const core = components['@astryxdesign/core'];
const avatarGroupOverflow = core.find(
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/api/component/_adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import {
resolveImportPath,
} from '../../foundation/discovery/component-discovery.mjs';
import {Project} from '../../foundation/config/project.mjs';
import {loadDocs} from '../../foundation/discovery/component-loader.mjs';
import {
loadResolvedComponentDoc,
} from '../../foundation/discovery/component-loader.mjs';
import {searchComponents} from '../../foundation/text/string-utils.mjs';
import {AstryxError} from '../error.mjs';

Expand Down Expand Up @@ -337,17 +339,26 @@ export async function resolveUnscopedDoc(dirName, {coreDir, cwd, name}) {
}

/**
* Load a `.doc.mjs` through the shared loader, applying the API's doc-load
* options. Centralizes the `LoadedComponentDoc`/`LoadDocsOpts` casts so leaves
* get a typed doc without re-casting.
* Load a `.doc.mjs` through the resolved loader while preserving the component
* API's historical permissive loading for integration docs. Centralizes the
* `LoadedComponentDoc`/`LoadDocsOpts` casts so leaves get one resolved view
* without widening validation for unprojected consumers.
* @param {string} docPath
* @param {{zh?: boolean, dense?: boolean, lang?: string|null}} [opts]
* @returns {Promise<LoadedComponentDoc>}
*/
export async function loadComponentDoc(docPath, opts = {}) {
const {zh = false, dense = false, lang = null} = opts;
return /** @type {LoadedComponentDoc} */ (
await loadDocs(docPath, /** @type {LoadDocsOpts} */ ({zh, dense, lang}))
await loadResolvedComponentDoc(
docPath,
/** @type {LoadDocsOpts & {validate: false}} */ ({
zh,
dense,
lang,
validate: false,
}),
)
);
}

Expand Down
10 changes: 7 additions & 3 deletions packages/cli/api/discover/_adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
scanAllPackages,
findComponentInPackages,
} from './_package-scanner.mjs';
import {loadDocs} from '../../foundation/discovery/component-loader.mjs';
import {loadResolvedComponentDoc} from '../../foundation/discovery/component-loader.mjs';
import {AstryxError} from '../error.mjs';
import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';

Expand Down Expand Up @@ -136,9 +136,13 @@ export function findComponent(packages, name) {
export async function loadValidatedDoc(result, {lang, zh}) {
let docs;
try {
docs = await loadDocs(
docs = await loadResolvedComponentDoc(
result.docPath,
/** @type {{zh?: boolean, dense?: boolean, lang?: string}} */ ({zh, lang}),
/** @type {{zh?: boolean, dense?: boolean, lang?: string, validate?: boolean}} */ ({
zh,
lang,
validate: false,
}),
);
} catch (e) {
throw new AstryxError(
Expand Down
59 changes: 58 additions & 1 deletion packages/cli/api/discover/detail/doc/doc.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,46 @@ beforeAll(() => {
path.join(docsDir, 'Beta.doc.mjs'),
`export const docs = {name: 'Beta', usage: {description: 'Beta component'}, props: []};\n`,
);
fs.writeFileSync(
path.join(docsDir, 'Parent.doc.mjs'),
`export const docs = {
name: 'Parent', usage: {description: 'Parent component', anatomy: [
{name: 'Child part', required: false, description: 'Projected child part.'},
]}, props: [], theming: {targets: [{className: 'astryx-child-part'}]},
components: [{name: 'Child', projection: {anatomy: ['Child part'], targets: ['astryx-child-part']}}],
};\n`,
);
fs.writeFileSync(
path.join(docsDir, 'Child.doc.mjs'),
`export const docs = {name: 'Child', subComponentOf: 'Parent', usage: {description: 'Child component'}, props: []};\n`,
);
fs.writeFileSync(
path.join(docsDir, 'BadProjection.doc.mjs'),
`export const docs = {
name: 'BadProjection', usage: {description: 'Bad projection parent'}, props: [],
components: [{name: 'BadChild', projection: {targetz: ['astryx-missing']}}],
};\n`,
);
fs.writeFileSync(
path.join(docsDir, 'BadChild.doc.mjs'),
`export const docs = {name: 'BadChild', subComponentOf: 'BadProjection', usage: {description: 'Bad child'}, props: []};\n`,
);
pkg = {
name: '@acme/widgets',
category: '@acme/widgets',
version: '1.0.0',
dir: docsDir,
astryx: {},
docsDir,
components: ['Alpha', 'AlphaCard', 'Beta'],
components: [
'Alpha',
'AlphaCard',
'Beta',
'Parent',
'Child',
'BadProjection',
'BadChild',
],
};
});

Expand All @@ -57,6 +89,31 @@ describe('discover.detail.doc leaf', () => {
expect(res.data.name).toBe('Alpha');
});

it('projects child docs and strips projection metadata from parents', async () => {
const child = await doc([pkg], '@acme/widgets', 'Child', {});
expect(child.data.usage.anatomy).toEqual([
{
name: 'Child part',
required: false,
description: 'Projected child part.',
},
]);
expect(child.data.theming.targets).toEqual([
{className: 'astryx-child-part'},
]);

const parent = await doc([pkg], '@acme/widgets', 'Parent', {});
expect(parent.data.components).toEqual([{name: 'Child'}]);
});

it('rejects unknown projection fields instead of erasing them', async () => {
await expect(
doc([pkg], '@acme/widgets', 'BadChild', {}),
).rejects.toMatchObject({
code: 'ERR_INVALID_DOC',
});
});

it('throws ERR_UNKNOWN_PACKAGE for an unknown scope', async () => {
await expect(doc([pkg], '@acme/nope', 'Alpha', {})).rejects.toMatchObject({
code: 'ERR_UNKNOWN_PACKAGE',
Expand Down
15 changes: 10 additions & 5 deletions packages/cli/api/theme/targets/targets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,21 @@ describe('themeTargets (api/theme/targets)', () => {
]);
}, 60_000);

it.each(['table-header', 'table-body', 'table-footer'])(
'%s appears once under the Table owner',
async target => {
const {data} = await themeTargets('Table');
it.each([
['DropdownMenu', 'dropdown-menu-divider'],
['Table', 'table-header'],
['Table', 'table-body'],
['Table', 'table-footer'],
])(
'%s target %s appears once under its parent owner',
async (component, target) => {
const {data} = await themeTargets(component);
const matches = data.targets.filter(entry => entry.key === target);
expect(data.componentCount).toBe(1);
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({
key: target,
component: 'Table',
component,
});
},
60_000,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/authoring/doctypes/component/component.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const doc = {
name: 'components',
type: '(ComponentEntry | ComponentRef)[]',
description:
'MultiComponentDoc variant (required there): one entry per public component/hook exported from the directory. Each entry is a full ComponentEntry (inline: name, displayName, description, props | params+returns) or a name-only ComponentRef pointing at a sibling {Name}.doc.mjs.',
'MultiComponentDoc variant (required there): one entry per public component/hook exported from the directory. Each entry is a full ComponentEntry (inline: name, displayName, description, props | params+returns) or a ComponentRef cross-link pointing at a sibling {Name}.doc.mjs. A ref may add `projection: {anatomy?: string[], targets?: string[]}` to expose exact parent-owned anatomy names and target class names on that member’s direct docs. Projection selectors must each match exactly once, preserve parent order, and may not be re-authored by the child.',
},
{
name: 'subComponentOf',
Expand Down Expand Up @@ -239,7 +239,7 @@ export const docs = {
style: 'unordered',
items: [
'SingleComponentDoc: one primary component; put props directly on the doc via `props`. Use for Switch, Badge, Spinner, TextInput.',
'MultiComponentDoc: a directory exporting several components/hooks; list them in `components` (inline ComponentEntry or name-only ComponentRef). Use for Table, Dialog, TabList.',
'MultiComponentDoc: a directory exporting several components/hooks; list them in `components` (inline ComponentEntry or ComponentRef cross-link). A ref’s optional projection selects exact parent-owned anatomy names and target class names for the member’s direct read-only docs; stale, duplicate, or child-redeclared selections fail.',
'SubComponentDoc: a single sub-component in its own {Name}.doc.mjs inside the parent directory; set `subComponentOf` to the parent name. It inherits family fields and may omit `usage`.',
],
},
Expand Down
Loading
Loading