Skip to content

Commit 67a3740

Browse files
feat(webapp): keep Storybook titles derived from the file tree
1 parent 4f82368 commit 67a3740

5 files changed

Lines changed: 129 additions & 0 deletions

File tree

.claude/skills/storybook-components/rules/story-titles.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Two conventions live side by side, and which one applies is a property of the co
55
- **Omit `title` by default.** `webapp/.storybook/main.ts` globs `../src/**` with no `titlePrefix`, so
66
a story with no title is filed by its path under `src``components/admin/ai/ModelPicker`. Most
77
stories belong here: the file tree *is* the grouping, and it cannot go stale.
8+
`hephaestus/prefer-auto-story-title` rejects an explicit title that only repeats this derived path,
9+
including copies that differ only in case or punctuation.
810

911
- **Declare a `title` when the file layout cannot express where a reader looks for the thing.** A
1012
product surface assembled from several directories, or one an admin knows by the screen it is on,

webapp/.oxlintrc.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,9 @@
660660
// so anything counted there is counted here. A literal cannot be shared across JSON and
661661
// TypeScript, so it is that relation `play-must-assert.test.ts` pins.
662662
"hephaestus/play-must-assert": "error",
663+
// The file path is the component tree's source of truth. An explicit title is only for a
664+
// deliberate reader-facing relocation, never a differently-cased copy of that path.
665+
"hephaestus/prefer-auto-story-title": "error",
663666
// One story quietly exempting itself leaves the whole suite green.
664667
"hephaestus/no-story-a11y-override": "error",
665668
"hephaestus/no-within-canvas-element": "error",

webapp/tools/oxlint/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { noRedundantInTheDocument } from "./rules/no-redundant-in-the-document.t
66
import { noStoryA11yOverride } from "./rules/no-story-a11y-override.ts";
77
import { noWithinCanvasElement } from "./rules/no-within-canvas-element.ts";
88
import { playMustAssert } from "./rules/play-must-assert.ts";
9+
import { preferAutoStoryTitle } from "./rules/prefer-auto-story-title.ts";
910
import { svgNeedsAccessibleName } from "./rules/svg-needs-accessible-name.ts";
1011
import { typedStoryMeta } from "./rules/typed-story-meta.ts";
1112

@@ -19,6 +20,7 @@ export default definePlugin({
1920
"no-story-a11y-override": noStoryA11yOverride,
2021
"no-within-canvas-element": noWithinCanvasElement,
2122
"play-must-assert": playMustAssert,
23+
"prefer-auto-story-title": preferAutoStoryTitle,
2224
"svg-needs-accessible-name": svgNeedsAccessibleName,
2325
"typed-story-meta": typedStoryMeta,
2426
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ruleTester } from "../rule-tester.ts";
2+
import { preferAutoStoryTitle } from "./prefer-auto-story-title.ts";
3+
4+
const component = "const Button = () => null;";
5+
6+
ruleTester.run("prefer-auto-story-title", preferAutoStoryTitle, {
7+
valid: [
8+
{
9+
code: `${component}\nconst meta = { component: Button } satisfies Meta<typeof Button>;`,
10+
filename: "webapp/src/components/ui/Button.stories.tsx",
11+
},
12+
{
13+
// Dropping the implementation-only `components` segment is a real sidebar relocation.
14+
code: `${component}\nconst meta = { title: "UI primitives/Button", component: Button } satisfies Meta<typeof Button>;`,
15+
filename: "webapp/src/components/ui/Button.stories.tsx",
16+
},
17+
{
18+
// Product surfaces can cut across the source layout.
19+
code: `${component}\nconst meta = { title: "Workspace admin/Practices/Review/Overview", component: Button } satisfies Meta<typeof Button>;`,
20+
filename: "webapp/src/components/admin/practices/review/ReviewPage.stories.tsx",
21+
},
22+
{
23+
// A computed title is owned by check:story-sort, which gives the more precise diagnostic.
24+
code: `${component}\nconst meta = { title: prefix + "/Button", component: Button } satisfies Meta<typeof Button>;`,
25+
filename: "webapp/src/components/ui/Button.stories.tsx",
26+
},
27+
],
28+
invalid: [
29+
{
30+
code: `${component}\nconst meta = { title: "components/ui/Button", component: Button } satisfies Meta<typeof Button>;`,
31+
filename: "webapp/src/components/ui/Button.stories.tsx",
32+
errors: [{ messageId: "redundant", data: { automatic: "components/ui/Button" } }],
33+
},
34+
{
35+
// Sentence case and punctuation do not make the same Storybook path meaningful metadata.
36+
code: `${component}\nconst meta = { title: "components/UI/button", component: Button } satisfies Meta<typeof Button>;`,
37+
filename: "/repo/webapp/src/components/ui/Button.stories.tsx",
38+
cwd: "/repo",
39+
errors: [{ messageId: "redundant" }],
40+
},
41+
],
42+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { relative } from "node:path";
2+
import { defineRule, type ESTree } from "@oxlint/plugins";
3+
import { propertyName } from "../property.ts";
4+
5+
const STORY_SUFFIX = /\.stories\.[cm]?[jt]sx?$/;
6+
const PATH_SEPARATOR = /[\\/]/;
7+
8+
/** Storybook ignores case and punctuation when it turns a path or title into an id. */
9+
const normalizedSegments = (segments: string[]) =>
10+
segments.map((segment) => segment.replaceAll(/[^a-zA-Z0-9]/g, "").toLowerCase());
11+
12+
/** The title Storybook derives from this file when `meta.title` is absent. */
13+
function automaticTitle(filename: string, cwd: string): string[] | undefined {
14+
const segments = relative(cwd, filename).split(PATH_SEPARATOR);
15+
const src = segments.findIndex(
16+
(segment, index) => segment === "src" && (index === 0 || segments[index - 1] === "webapp"),
17+
);
18+
if (src === -1) return undefined;
19+
const owned = segments.slice(src + 1);
20+
const file = owned.at(-1);
21+
if (file === undefined || !STORY_SUFFIX.test(file)) return undefined;
22+
owned[owned.length - 1] = file.replace(STORY_SUFFIX, "");
23+
return owned;
24+
}
25+
26+
function objectExpression(expression: ESTree.Expression | null | undefined) {
27+
let current = expression;
28+
while (
29+
current?.type === "TSSatisfiesExpression" ||
30+
current?.type === "TSAsExpression" ||
31+
current?.type === "TSInstantiationExpression"
32+
) {
33+
current = current.expression;
34+
}
35+
return current?.type === "ObjectExpression" ? current : undefined;
36+
}
37+
38+
export const preferAutoStoryTitle = defineRule({
39+
meta: {
40+
type: "suggestion",
41+
docs: {
42+
description:
43+
"Omit a Storybook meta title when it only restates the title Storybook derives from the story file. Explicit titles are reserved for a reader-facing hierarchy the source path cannot express.",
44+
},
45+
messages: {
46+
redundant:
47+
"This title restates `{{automatic}}`, which Storybook already derives from the file path. Delete `title`; keep an explicit title only when it deliberately relocates the component in the reader-facing tree.",
48+
},
49+
},
50+
create(context) {
51+
const automatic = automaticTitle(context.filename, context.cwd);
52+
if (automatic === undefined) return {};
53+
return {
54+
VariableDeclarator(node) {
55+
if (node.id.type !== "Identifier" || node.id.name !== "meta") return;
56+
const meta = objectExpression(node.init);
57+
if (meta === undefined) return;
58+
const title = meta.properties.find(
59+
(property) => property.type === "Property" && propertyName(property) === "title",
60+
);
61+
if (
62+
title?.type !== "Property" ||
63+
title.value.type !== "Literal" ||
64+
typeof title.value.value !== "string"
65+
) {
66+
return;
67+
}
68+
const declared = title.value.value.split("/");
69+
if (normalizedSegments(declared).join("/") !== normalizedSegments(automatic).join("/")) {
70+
return;
71+
}
72+
context.report({
73+
node: title,
74+
messageId: "redundant",
75+
data: { automatic: automatic.join("/") },
76+
});
77+
},
78+
};
79+
},
80+
});

0 commit comments

Comments
 (0)