Skip to content
Open
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
159 changes: 93 additions & 66 deletions 2nd-gen/scaffolding/README.md
Original file line number Diff line number Diff line change
@@ -1,117 +1,144 @@
# 2nd-gen scaffolding

A [plop](https://plopjs.com/) generator that produces the deterministic file
skeleton for a 2nd-gen component: the `core` base layer, the `swc` concrete
layer, and the stories, docs, and test files that go with them.
A [plop](https://plopjs.com/) scaffolder that produces the deterministic file
skeletons for 2nd-gen components: the component itself, and the **test** and
**VRT** files that go with it.

The generator exists to take the mechanical, every-time-identical part of
Phase 2 (`migration-setup`) off the critical path. It writes the boilerplate so
a human or an agent only has to apply the decisions that actually differ between
components: the base-vs-concrete property split, the real Spectrum 2 tokens, the
variant and state API, and the prose in the docs page.
The generators exist to take the mechanical, every-time-identical part of
authoring off the critical path. They write the boilerplate so a human or an
agent only has to apply the decisions that actually differ between components:
the base-vs-concrete property split, the real Spectrum 2 tokens, the
variant/state API, the assertions, and the docs prose.

## Generators

| Generator | Scaffolds | Target |
| ----------- | ------------------------------------------------------- | --------------------------------- |
| `component` | Core base + SWC concrete + stories, docs, tests, VRT | new component (`core` + `swc`) |
| `test` | Unit (`*.test.ts`) + a11y (`*.a11y.spec.ts`) test files | **existing** component (retrofit) |
| `vrt` | A Chromatic VRT story (`test/vrt/<name>.vrt.ts`) | **existing** component (retrofit) |

`component` emits a baseline `test`, `a11y`, and `vrt` file as part of the
skeleton. Use the standalone `test` and `vrt` generators to add (or refresh)
that coverage on a component that already exists — for example, one migrated
before these generators landed.

## Usage

### Interactive (humans)

```bash
yarn plop component
yarn plop test
yarn plop vrt
```

You will be prompted for a component name. Enter it in any form: `action-button`,
`actionButton`, `Action Button`, or even `sp-action-button` (the `sp-`/`swc-`
prefix is stripped). The name is normalized by the built-in case helpers, so the
output is identical regardless of how you type it.
You are prompted for a name (enter it in any form: `action-button`,
`actionButton`, `Action Button`, or `sp-action-button` — the `sp-`/`swc-`
prefix is stripped and the case helpers normalize the rest).

### Headless (agents, scripts, CI)

Pass the name as a bypass argument and `--force` to skip the interactive
confirmation:
Pass the name as a positional bypass argument, plus `--force`:

```bash
yarn plop component "action-button" --force
yarn plop test "action-button" --force
yarn plop vrt "action-button" --force
```

## What it generates
Each generator emits a single fixed scaffold (a permutation-grid + forced-colors
story for `vrt`, and a play-function + a11y-snapshot pair for `test`). The output
is lint-clean and formatted; `TODO`s mark where the author fills in the
component-specific detail.

For a component named `action-button`:
## Retrofit contract (`test`, `vrt`)

```
2nd-gen/packages/core/components/action-button/
ActionButton.types.ts VALID_SIZES + Size type
ActionButton.base.ts abstract base class (SizedMixin + SpectrumElement)
index.ts re-exports base + types

2nd-gen/packages/swc/components/action-button/
ActionButton.ts concrete class, render(), styles getter
index.ts re-exports the concrete class
swc-action-button.ts defineElement registration + tag-name map
action-button.css :host + .swc-ActionButton block, token() scaffolding
action-button.mdx per-unit docs page (DocsHeader/Canvas/DocsFooter)
stories/action-button.stories.ts Playground/Overview/Anatomy/Sizes/States/Accessibility
test/action-button.test.ts Vitest play function reusing the Overview story
test/action-button.a11y.spec.ts Playwright ARIA-snapshot accessibility test
```
`test` and `vrt` add files to a component that **already exists**. They:

- **Guard first.** If `2nd-gen/packages/swc/components/<name>/` is missing, the
generator aborts with a clear message before writing anything (a typo cannot
silently scaffold tests for a non-existent component).
- **Never clobber by default.** The `add` actions use `skipIfExists`, so an
existing test/VRT file is left untouched. Pass `--force` to overwrite, or
delete the file first to regenerate it.
- **Stay scoped.** They format only the `test/` (or `test/vrt/`) subtree they
wrote, leaving the surrounding component files untouched.

It also wires the core package's `exports` map: the `swc` package uses wildcard
exports and needs no edit, but `@spectrum-web-components/core` uses explicit
per-component entries, so the generator adds `./components/action-button` and
`./components/action-button/index.js` and re-sorts the `exports` keys
alphabetically (a minimal, deterministic diff). Finally it runs Prettier on the
two new directories so the output lands pre-formatted.
## What gets wired automatically

## What it intentionally does NOT do
- **Core `exports` + `typesVersions`.** The SWC package (`@adobe/spectrum-wc`)
uses wildcard `exports`, so a new component needs no package.json edit there.
The core package (`@spectrum-web-components/core`) uses explicit per-component
entries, so the `component` generator adds both the `exports` and the matching
`typesVersions` entry for the new component and re-sorts the keys (a minimal,
deterministic diff). The `test`/`vrt` generators touch no package.json.
- **Formatting.** Each generator runs Prettier on the directories it wrote.

The skeleton is a starting point, not a finished component. After generating,
follow `migration-setup` and the later migration phases to:
## Custom-element registration

- Move properties, methods, and types from 1st-gen into the base and concrete
classes (Phase 3, `migration-api`).
- Implement semantics, ARIA, and keyboard support (Phase 4, `migration-a11y`).
- Replace the placeholder CSS with migrated Spectrum 2 tokens (Phase 5,
`migration-styling`).
- Flesh out the tests and stories (Phase 6, `migration-testing`).
- Write the docs prose and any `migration-guide.mdx` (Phase 7,
`migration-documentation`; the consumer guide is owned by the
`consumer-migration-guide` skill, so it is not scaffolded here).
Components are custom elements; the split matters:

Every generated file contains `TODO`/placeholder markers showing where this work
goes.
- `index.ts` re-exports the class only (no registration).
- `swc-<tag>.ts` is the side-effectful entry: it calls
`defineElement('swc-<tag>', Class)` (from `@adobe/spectrum-wc-core/element`)
and augments `HTMLElementTagNameMap`.

## What the generators intentionally do NOT do

The skeletons are starting points, not finished components. After generating,
follow `migration-setup` and the later migration phases (or the `vrt-authoring`
skill for VRT) to move properties/types into the classes, implement semantics and
ARIA, migrate CSS to Spectrum 2 tokens, and flesh out the tests, VRT grids, and
docs prose. Every generated file contains `TODO`/placeholder markers.

## Conventions the templates encode

These mirror the `badge` reference component and the project rules in `.ai/`:
These mirror the `badge` and `button` reference components and the project rules
in `.ai/`:

- **Two-layer architecture.** Shared, non-visual API lives on the core base
class; visual and version-specific API lives on the concrete `swc` class.
- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, so
size is styled with `:host([size="..."])` selectors, never a modifier class.
- **Public styling API.** Custom properties are exposed as `--swc-<name>-*` with
a `token()` fallback.
- **Sizing via host attribute.** `SizedMixin` reflects `size` to the host, styled
with `:host([size="..."])` selectors, never a modifier class.
- **Public styling API.** Custom properties exposed as `--swc-<name>-*` with a
`token()` fallback.
- **BEM-ish class block.** The render root carries `swc-<PascalCase>`.
- **Stories + per-unit MDX.** The Playground is tagged `['dev']` (not
`['autodocs', 'dev']`) because the generated `.mdx` is the docs page; each
- **Stories + per-unit MDX.** The Playground is tagged `['dev']`; each
section-tagged story is referenced from the MDX via `<Canvas of={...} />`.
- **Sentence-case titles.** Storybook titles are sentence case (`Action button`),
matching the project's title rule.

## Editing the templates

Templates live in `templates/component/` as Handlebars (`.hbs`) files. Name
derivations use plop's built-in case helpers plus two custom helpers defined in
Templates live under `templates/` as Handlebars (`.hbs`) files, grouped by
generator:

```
templates/
component/{core,swc}/*.hbs the component skeleton
test/*.hbs the standalone test + a11y files
vrt/vrt.ts.hbs the VRT story
```

Name derivations use plop's built-in case helpers plus custom helpers defined in
`plopfile.js`:

| Helper | `action-button` renders as |
| ----------------------- | -------------------------- |
| `{{dashCase name}}` | `action-button` |
| `{{pascalCase name}}` | `ActionButton` |
| `{{constantCase name}}` | `ACTION_BUTTON` |
| `{{titleName name}}` | `Action Button` (custom) |
| `{{titleName name}}` | `Action button` (custom) |
| `{{lb}}` / `{{rb}}` | `{` / `}` (custom) |

`titleName` produces the space-separated proper-noun title used for Storybook
`titleName` produces the space-separated sentence-case title used for Storybook
titles; plop's built-in `titleCase` keeps the dash (`Action-Button`) and must not
be used for titles. `lb`/`rb` emit literal braces in `.mdx` templates, where a
bare `{` next to a `{{helper}}` would otherwise be parsed as Handlebars.
`{` next to a `{{helper}}` would otherwise be parsed as Handlebars.

When you change the file layout, naming, or wiring, update both the templates and
the `add` actions in `plopfile.js`, then regenerate a throwaway component and run
`yarn lint` / `yarn lint:css` on it to confirm the output still passes.
the actions in `plopfile.js`, then regenerate a throwaway component and run
`yarn lint`, `yarn lint:css`, and `yarn lint:docs-pages` on it to confirm the
output still passes. Delete the throwaway component and revert
`2nd-gen/packages/core/package.json` afterward.
143 changes: 126 additions & 17 deletions 2nd-gen/scaffolding/plopfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,26 @@ const CORE_COMPONENTS = '2nd-gen/packages/core/components';
const SWC_COMPONENTS = '2nd-gen/packages/swc/components';

/**
* 2nd-gen component scaffolder.
* 2nd-gen scaffolder.
*
* Mirrors the file layout and conventions described by the migration skills
* (`migration-setup`, `stories-format`, `stories-documentation`) and the `badge`
* reference component. It produces the deterministic Phase 2 skeleton so the
* migration-setup skill only has to apply plan-specific architecture decisions
* (the base-vs-concrete split) rather than author every file by hand.
* (`migration-setup`, `stories-format`, `stories-documentation`, `vrt-authoring`)
* and the `badge` / `button` reference units. It produces the deterministic
* skeleton so a human or an agent only has to apply the decisions that actually
* differ between units (the base-vs-concrete split, the real API, the
* assertions) rather than author every file by hand.
*
* Generators:
* component — a custom-element component (core base + swc concrete + docs/tests)
* test — retrofit an existing component with unit + a11y test files
* vrt — retrofit an existing component with a Chromatic VRT story
*
* Built-in plop case helpers do most name derivation, plus the custom
* `titleName` helper for the space-separated proper-noun title:
* `titleName` helper for the space-separated sentence-case title:
* {{dashCase name}} -> action-button (kebab tag, dir, css, file names)
* {{pascalCase name}} -> ActionButton (class names, CSS BEM block)
* {{constantCase name}} -> ACTION_BUTTON (exported constant prefixes)
* {{titleName name}} -> Action Button (Storybook title, proper noun)
* {{titleName name}} -> Action button (Storybook title, sentence case)
*
* @param {import('plop').NodePlopAPI} plop
*/
Expand All @@ -54,19 +60,37 @@ export default function (plop) {
plop.setHelper('lb', () => '{');
plop.setHelper('rb', () => '}');

// Space-separated Title Case for human-facing labels (Storybook titles,
// describe blocks, prose). plop's built-in `titleCase` keeps the dash
// ("action-button" -> "Action-Button"), but Spectrum treats component names
// as proper nouns rendered with spaces ("Action Button").
plop.setHelper('titleName', (text) =>
String(text)
// Space-separated sentence-case label for human-facing text (Storybook
// titles, describe blocks, prose). Spectrum titles are sentence case:
// capitalize only the first word ("action-button" -> "Action button").
// Authors re-capitalize any acronyms or proper nouns the scaffold can't know.
// plop's built-in `titleCase` can't do this (it keeps the dash and caps every
// word).
plop.setHelper('titleName', (text) => {
const words = String(text)
.replace(/^(sp|swc)-/i, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // split camelCase
.split(/[-_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
);
.map((word) => word.toLowerCase());
if (words.length === 0) {
return '';
}
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
return words.join(' ');
});

// Shared prompt for the retrofit generators. A component name is entered in
// any form (`action-button`, `actionButton`, `Action Button`,
// `sp-action-button`); the case helpers normalize it.
const namePrompt = (message) => ({
type: 'input',
name: 'name',
message,
filter: (input) => input.trim().replace(/^(sp|swc)-/i, ''),
validate: (input) =>
input.trim().length > 0 || 'A name is required. You can rename it later.',
});

// ──────────────────────
// CUSTOM ACTIONS
Expand Down Expand Up @@ -142,8 +166,42 @@ export default function (plop) {
}
});

// Retrofit generators (`test`, `vrt`) only make sense for a component that
// already exists. Fail loudly and early — before any file is written — when
// the target directory is missing, so a typo does not silently scaffold tests
// for a non-existent component.
plop.setActionType('assert-component-exists', (answers) => {
const name = render('{{dashCase name}}', answers);
const dir = path.join(repoRoot, SWC_COMPONENTS, name);
if (!fs.existsSync(dir)) {
throw new Error(
`Component "${name}" does not exist at ${SWC_COMPONENTS}/${name}. ` +
`Scaffold it first with: yarn plop component "${name}"`
);
}
return `found component ${name}`;
});

// Format the directories a retrofit generator wrote (a `test/` or `test/vrt/`
// subtree), scoped so the surrounding component files are left untouched.
// Config: { paths: string[] } — handlebars-templated repo-relative dirs.
plop.setActionType('format-dir', (answers, config) => {
const targets = (config.paths ?? [])
.map((p) => render(p, answers))
.join(' ');
try {
execSync(`yarn prettier --write ${targets}`, {
cwd: repoRoot,
stdio: 'ignore',
});
return `formatted ${targets}`;
} catch {
return `skipped formatting (run "yarn lint" manually)`;
}
});

// ──────────────────
// GENERATOR
// GENERATORS
// ──────────────────

plop.setGenerator('component', {
Expand Down Expand Up @@ -226,10 +284,61 @@ export default function (plop) {
path: `${swcDir}/test/{{dashCase name}}.a11y.spec.ts`,
templateFile: t('swc/a11y.spec.ts.hbs'),
},
{
type: 'add',
path: `${swcDir}/test/vrt/{{dashCase name}}.vrt.ts`,
templateFile: path.join(here, 'templates/vrt/vrt.ts.hbs'),
},
// ── wiring + formatting ──────────────────────────────────
{ type: 'wire-core-export' },
{ type: 'format-component' },
];
},
});

plop.setGenerator('test', {
description: 'Retrofit an existing component with unit + a11y test files',
prompts: [namePrompt('Component to add tests for (e.g. action-button):')],
actions: () => {
const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`;

return [
{ type: 'assert-component-exists' },
{
type: 'add',
path: `${swcDir}/test/{{dashCase name}}.test.ts`,
templateFile: path.join(here, 'templates/test/test.ts.hbs'),
skipIfExists: true,
},
{
type: 'add',
path: `${swcDir}/test/{{dashCase name}}.a11y.spec.ts`,
templateFile: path.join(here, 'templates/test/a11y.spec.ts.hbs'),
skipIfExists: true,
},
{ type: 'format-dir', paths: [`${swcDir}/test`] },
];
},
});

plop.setGenerator('vrt', {
description: 'Retrofit an existing component with a Chromatic VRT story',
prompts: [
namePrompt('Component to add a VRT story for (e.g. action-button):'),
],
actions: () => {
const swcDir = `${SWC_COMPONENTS}/{{dashCase name}}`;

return [
{ type: 'assert-component-exists' },
{
type: 'add',
path: `${swcDir}/test/vrt/{{dashCase name}}.vrt.ts`,
templateFile: path.join(here, 'templates/vrt/vrt.ts.hbs'),
skipIfExists: true,
},
{ type: 'format-dir', paths: [`${swcDir}/test/vrt`] },
];
},
});
}
Loading
Loading