Skip to content

Angular: Read the story shapes that supply their own markup - #35797

Draft
valentinpalkovic wants to merge 8 commits into
valentin/angular-story-docs-snippetsfrom
valentin/angular-story-docs-2-story-shapes
Draft

Angular: Read the story shapes that supply their own markup#35797
valentinpalkovic wants to merge 8 commits into
valentin/angular-story-docs-snippetsfrom
valentin/angular-story-docs-2-story-shapes

Conversation

@valentinpalkovic

@valentinpalkovic valentinpalkovic commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

Stacked on #35807, which generates Angular story snippets on the server. That one builds the element from the component's metadata and the story's args; this one stops doing that for stories that already wrote their own markup.

What I did

The generator builds an element from the component's selector and the story's args. That is right for a plain { args: {...} } story and wrong for every story that supplies markup itself: either the markup the user wrote is replaced by a fabricated element, or the story is skipped and shows no snippet at all.

Both columns below are real buildStoryDocsPayload output over the same story file, on the base branch and on this one. The component is sb-button with a label and count input and a clicked output, under a meta carrying args: { label: 'meta' }.

Story shape Before After
{ template: '<sb-button emphasis>hi</sb-button>' } <sb-button [label]="'meta'" (clicked)="clicked($event)"></sb-button> <sb-button emphasis>hi</sb-button>
{ render: () => ({ template: '<sb-button rendered></sb-button>' }) } no snippet <sb-button rendered></sb-button>
() => ({ template: '<sb-button csf2></sb-button>' }) <sb-button [label]="'meta'" (clicked)="clicked($event)"></sb-button> <sb-button csf2></sb-button>
const S = { template: '…' }; export { S } <sb-button [label]="'meta'" (clicked)="clicked($event)"></sb-button> <sb-button reexported></sb-button>

The whole change is one predicate at the end of snippet generation. Markup that was read stands as written; everything else keeps the generated bindings:

const template = userTemplate(story, bindings);
return template?.kind === 'literal'
  ? template.markup
  : renderComponentSnippet({ selector: snippetContext.selector, ...bindings });

Only the markup reading is new

Two of these shapes look like they need a bespoke args reader and do not. CsfFile already records both: the export { X } specifier branch fills _storyAnnotations from the resolved initializer, and the ExpressionStatement branch folds X.args = {...} into the same record. Running the fixture against the base branch shows the args are already correct there, with no change from this PR:

ReExported          | <sb-button [label]="'reexported'" [count]="9" (clicked)="clicked($event)"></sb-button>
RenamedStory        | <sb-button [label]="'meta'" [count]="10" (clicked)="clicked($event)"></sb-button>
Csf 2 Assigned Args | <sb-button [label]="'assigned'" [count]="11" (clicked)="clicked($event)"></sb-button>

So this PR adds the markup reading and tests that pin the args behaviour so it cannot regress. Following a name to its declaration is a babel scope lookup rather than a resolver of its own.

Following names to what they hold

A template or render written as a name is markup the story really did write. Refusing to look through the name replaced it with a fabricated element, so a bare identifier is now followed back to its declaration in the same file:

const declaredValue = (story: StoryShape, node: t.Node | undefined): t.Node | undefined => {
  if (!t.isIdentifier(node)) {
    return node;
  }
  const program: NodePath<t.Program> = story.csf._file.path;
  const declaration = program.scope.getBinding(node.name)?.path.node;
  if (t.isVariableDeclarator(declaration)) {
    return declaration.init ?? node;
  }
  return t.isFunctionDeclaration(declaration) ? declaration : node;
};

An imported name binds to an ImportSpecifier, which has no initializer, so it falls through and stays an identifier. That is the distinction that matters: a story whose markup exists but is unreadable must not silently inherit the meta's markup, because that would print a snippet for code the story never runs. It falls back to the generated bindings instead.

const HOISTED_TEMPLATE = '<sb-button hoisted></sb-button>';
export const HoistedTemplate = { template: HOISTED_TEMPLATE };   // -> <sb-button hoisted></sb-button>

const renderFn = () => ({ template: '<sb-button via-fn></sb-button>' });
export const RenderIdentifier = { render: renderFn };            // -> <sb-button via-fn></sb-button>

import { IMPORTED_TEMPLATE } from './templates';
export const ImportedTemplate = { template: IMPORTED_TEMPLATE }; // -> generated bindings

argsToTemplate is expanded, not discarded

argsToTemplate(args) is the idiom every Angular docs example uses for a custom render, and it lives inside a template literal, which the generator rejected outright: the wrapper markup was thrown away and the story got no snippet.

It expands to exactly the bindings this generator already produces, so the template is now read in full:

render: (args) => ({
  props: args,
  template: `<div class="wrap"><sb-button ${argsToTemplate(args)}></sb-button></div>`,
})
<div class="wrap"><sb-button [label]="'Save'" [count]="7" (clicked)="clicked($event)"></sb-button></div>

Three decisions worth a look:

  • Values are inlined rather than referenced by name as the runtime helper does ([label]="'Save'", not [label]="label"). That drops the snippet's dependency on the story's props: args, so it stands alone.
  • include / exclude are honoured when written as array literals.
  • An interpolated arg used as slot content is substituted, which is what docs/_snippets/page-story-slots.md does:
    render: ({ footer, ...args }) => ({ template: `<sb-button ${argsToTemplate(args)}><span>${footer}</span></sb-button>` })

The binding list moved out of the element renderer into a shared bindingAttributes, because the plain-element path and the argsToTemplate expansion have to produce byte-identical output.

What still can't be read

  • an imported template or render, or one built from an expression that needs the story to run
  • a ${…} that is not argsToTemplate and not an arg holding a string, number or boolean

Both fall back to generated bindings. The next slice reports them through warning so the fallback is not silent.

The 15 recorded server-snippet-*.snapshot files are unchanged by this PR: none of the harness fixtures declares a template or a render, so none of them takes the new path.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

  1. Follow the sandbox setup in Angular: Generate story-docs snippets from the analyzer #35807 to get an Angular sandbox running with features.experimentalDocgenServer on.
  2. Add a story that writes its own markup:
    export const OwnTemplate = { template: '<storybook-button label="Hi"></storybook-button>' };
  3. Open its Docs page and press Show code. You should see that markup verbatim, not a generated element.
  4. Add a story using the argsToTemplate idiom:
    export const Wrapped = {
      args: { label: 'Save' },
      render: (args) => ({
        props: args,
        template: `<div class="wrap"><storybook-button ${argsToTemplate(args)}></storybook-button></div>`,
      }),
    };
    The snippet keeps the <div class="wrap"> wrapper and fills in the bindings.
  5. Move a template string into a const in the same file and reference it by name. The snippet still shows the markup.
  6. Move that same const into another file and import it. The snippet falls back to generated bindings rather than printing the variable name.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Declare whether manual QA will be needed for this PR during the next release, through qa:needed or qa:skip

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫

PR is not labeled with one of: ["cleanup","BREAKING CHANGE","feature request","bug","documentation","maintenance","build","dependencies"]

🚫

PR is not labeled with one of: ["ci:normal","ci:merged","ci:daily","ci:docs"]

🚫

PR is not labeled with one of: ["qa:needed","qa:skip","qa:success"]

Warnings
⚠️

This PR targets valentin/angular-story-docs-snippets. The default branch for contributions is next. Please make sure you are targeting the correct branch.

Generated by 🚫 dangerJS against aed8d8b

Comment thread code/frameworks/angular-vite/src/docgen/build-story-docs.ts Outdated
Comment thread code/frameworks/angular-vite/src/docgen/build-story-docs.ts Outdated
Comment thread code/frameworks/angular-vite/src/docgen/build-story-docs.ts Outdated
@valentinpalkovic
valentinpalkovic force-pushed the valentin/angular-story-docs-1-template-snippets branch from 7901b02 to 79d7bff Compare August 7, 2026 16:43
@valentinpalkovic
valentinpalkovic force-pushed the valentin/angular-story-docs-2-story-shapes branch from 056d892 to a431b81 Compare August 7, 2026 16:43
@valentinpalkovic
valentinpalkovic force-pushed the valentin/angular-story-docs-1-template-snippets branch from 79d7bff to 3341953 Compare August 7, 2026 16:49
@valentinpalkovic
valentinpalkovic force-pushed the valentin/angular-story-docs-2-story-shapes branch from a431b81 to 23fdf78 Compare August 7, 2026 16:49
The snippet generator built an element from the component's selector and the
story's args. That is right for a plain `{ args }` story and wrong for every
story that supplies markup itself, which was previously skipped outright.

A story's own `template`, the `{ template }` an inline `render` returns, and the
CSF2 function form are now read and shown as written. A name declared in the
same file is followed to what it holds; an imported one cannot be, so it falls
back to the generated bindings rather than printing the name as markup.

`argsToTemplate(args)` expands to exactly the bindings this generator emits, so
a template built around it is read in full and the wrapper markup survives.
@valentinpalkovic
valentinpalkovic force-pushed the valentin/angular-story-docs-2-story-shapes branch from 740865a to e46f5c9 Compare August 8, 2026 05:27
@valentinpalkovic
valentinpalkovic changed the base branch from valentin/angular-story-docs-1-template-snippets to valentin/angular-story-docs-snippets August 8, 2026 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant