Skip to content
Merged
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
6 changes: 0 additions & 6 deletions .c8rc.json

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/thirty-houses-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@storybook/marko": minor
---

Add first-class support for change handlers
1,913 changes: 688 additions & 1,225 deletions package-lock.json

Large diffs are not rendered by default.

21 changes: 10 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@storybook/marko-root",
"private": true,
"type": "module",
"workspaces": [
"packages/frameworks/*",
"packages/renderers/*",
Expand All @@ -9,34 +10,31 @@
],
"scripts": {
"build": "npm run build:types && npm run build:js",
"build:js": "node -r ~preload ./scripts/build.ts",
"build:js": "node ./scripts/build.ts",
"build:types": "mtc -p tsconfig.build.json",
"change": "changeset add",
"ci:test": "c8 npm test",
"ci:test": "npm test",
"format": "npm run build:types && eslint -f unix --fix . && prettier . --write --log-level=error && sort-package-json --quiet ./{,packages/*/*/,tests/frameworks/*/}package.json",
"prepare": "husky",
"release": "npm run build && changeset publish",
"test": "npm run build:js && node -r '~preload' --test-reporter=spec --test ./tests/fixtures/*/test.ts",
"storybook:vite": "npm run build && npm run storybook --workspace=vite-tests",
"storybook:webpack": "npm run build && npm run storybook --workspace=webpack-tests",
"test": "npm run build:js && vitest",
"version": "changeset version && npm i --package-lock-only"
},
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.28.6",
"@changesets/changelog-github": "^0.6.0",
"@changesets/cli": "^2.30.0",
"@marko/compiler": "^5.39.62",
"@marko/testing-library": "^6.4.1",
"@marko/type-check": "^2.1.29",
"@marko/type-check": "^2.1.27",
"@marko/vite": "^6.0.1",
"@playwright/test": "^1.56.1",
"@testing-library/dom": "^10.4.1",
"@types/babel__register": "^7.17.3",
"@types/node": "^24.10.0",
"@types/resolve": "^1.20.6",
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"~preload": "file:scripts/preload.js",
"c8": "^11.0.0",
"esbuild": "^0.27.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
Expand All @@ -46,6 +44,7 @@
"marko": "^5.38.33",
"prettier": "^3.8.1",
"sort-package-json": "^3.6.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.1"
}
}
15 changes: 15 additions & 0 deletions packages/renderers/marko/src/attr-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
type Attrs = Record<PropertyKey, unknown>;
type AttrTag = Attrs & { [rest]: Attrs[] };
const empty: never[] = [];
const rest = Symbol("Attribute Tag");

export function attrTag(attrs: Attrs): AttrTag {
attrs[Symbol.iterator] = attrTagIterator;
attrs[rest] = empty;
return attrs as AttrTag;
}

function* attrTagIterator(this: AttrTag) {
yield this;
yield* this[rest];
}
113 changes: 113 additions & 0 deletions packages/renderers/marko/src/entry-preview.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,116 @@
import { normalizeStory } from "storybook/internal/preview-api";
import type {
Args,
ArgsEnhancer,
ArgTypesEnhancer,
StrictArgTypes,
StrictInputType,
} from "storybook/internal/types";

import type { MarkoRenderer } from "./types";

export { renderToCanvas, render } from "./render";

export const parameters = { renderer: "marko" };

/**
* Storybook does some normalization _before_ argTypesEnhancers (the one
* I found was `type: "foo"` => `type: { name: "foo" }`), so we need to
* re-normalize after updating stuff
*/
function normalizeArgTypes(
argTypes: StrictArgTypes,
id: string,
title: string,
): StrictArgTypes {
const normalized = normalizeStory(
"__argTypes__",
{ argTypes },
{ id, title },
);
return normalized.argTypes ?? argTypes;
}

function flattenAttrTags(
argTypes: StrictArgTypes,
args: Args | undefined,
prefix = "",
) {
const newArgTypes: StrictArgTypes = {};
const newArgs: Args | undefined = args ? {} : undefined;

for (const key in argTypes) {
if (key.startsWith("@")) continue;
const argType = argTypes[key];
const name = argType.name || key;
const table = prefix
? {
...argType.table,
category: prefix.substring(0, prefix.length - 3),
subcategory: argType.table?.subcategory || argType.table?.category,
}
: argType.table;

if (argType["@"]) {
newArgTypes[prefix + key] = {
...argType,
name: "@" + name,
control: { disable: true },
table,
};
newArgs && (newArgs[prefix + key] = null);

const [otherArgTypes, otherArgs] = flattenAttrTags(
argType["@"] as StrictInputType,
args?.[key],
prefix + "@" + key + " > ",
);

Object.assign(newArgTypes, otherArgTypes);
newArgs && Object.assign(newArgs, otherArgs);
} else {
newArgTypes[prefix + key] = { ...argType, name, table };
if (args && key in args) {
newArgs![prefix + key] = args[key];
}
}
}

return [newArgTypes, newArgs] as const;
}

function addControllableChangeHandlers(argTypes: StrictArgTypes) {
for (const key in argTypes) {
const argType = argTypes[key];

if (argType.controllable && !argTypes[key + "Change"]) {
argTypes[key].name =
(argType.name || key) + ", " + (argType.name || key) + "Change";
}
}
return argTypes;
}

function addBodyContentSummary(argTypes: StrictArgTypes) {
for (const key in argTypes) {
if (argTypes[key].bodyContent) {
argTypes[key].table = {
...argTypes[key].table,
type: argTypes[key].table?.type || { summary: "Marko.Body" },
};
}
}
return argTypes;
}

export const argTypesEnhancers: ArgTypesEnhancer<MarkoRenderer>[] = [
({ argTypes, initialArgs }) => flattenAttrTags(argTypes, initialArgs)[0],
({ argTypes }) => addControllableChangeHandlers(argTypes),
({ argTypes }) => addBodyContentSummary(argTypes),
({ argTypes, id, title }) => normalizeArgTypes(argTypes, id, title),
];

export const argsEnhancers: ArgsEnhancer<MarkoRenderer>[] = [
({ initialArgs, argTypes }) =>
flattenAttrTags(argTypes, initialArgs)[1] || {},
];
63 changes: 58 additions & 5 deletions packages/renderers/marko/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { ArgsStoryFn, RenderContext } from "storybook/internal/types";
import type {
Args,
ArgsStoryFn,
RenderContext,
StoryContext,
} from "storybook/internal/types";
import { addons } from "storybook/preview-api";
import type { MarkoRenderer } from "./types";
import { UPDATE_STORY_ARGS } from "storybook/internal/core-events";
import { attrTag } from "./attr-tag";

type Subscriptions = Record<string, (...args: unknown[]) => void>;
const instanceByCanvasElement = new WeakMap<
Expand All @@ -24,10 +32,11 @@ export function renderToCanvas(
cleanup(canvasElement);
}

const input = processInput(config.input || {}, ctx.storyContext, true);
if (instance) {
(instance as any as Marko.MountedTemplate).update(config.input || {});
(instance as any as Marko.MountedTemplate).update(input);
} else {
instance = template.mount(config.input || {}, canvasElement) as any;
instance = template.mount(input, canvasElement) as any;
}
} else {
if (instance && (ctx.forceRemount || !instance.state)) {
Expand Down Expand Up @@ -70,7 +79,7 @@ export function renderToCanvas(
}
} else {
instance = template
.renderSync(input)
.renderSync(processInput(input, ctx.storyContext, false))
.replaceChildrenOf(canvasElement)
.getComponent();

Expand All @@ -91,7 +100,8 @@ export function renderToCanvas(
export const render: ArgsStoryFn<MarkoRenderer> = (args, ctx) => {
const { component } = ctx;
assertHasTemplate(component, ctx);
return { component, input: args };

return { component, input: processInput(args, ctx, isTagsAPI(component)) };
};

function isTagsAPI(template: Marko.Template) {
Expand Down Expand Up @@ -130,3 +140,46 @@ function cleanup(canvasElement: MarkoRenderer["canvasElement"]) {
instanceByCanvasElement.delete(canvasElement);
subscriptionsByInstance.delete(component);
}

/**
* Un-flatten attr tags and add change handlers
*/
function processInput(args: Args, ctx: StoryContext, tagsApi: boolean) {
const input = {} as typeof args;

for (const key in args) {
let path = key;
let obj = input;
// Attr tag nested attributes have been converted to `@foo > @bar > baz` by `entry-preview.ts`
while (path.startsWith("@")) {
const i = path.indexOf(" > ");
obj = obj[path.substring(1, i)] ??= attrTag({});
path = path.substring(i + 3);
}

// Normalize body content
if (ctx.argTypes[key]?.bodyContent) {
const type = ctx.argTypes[key]?.bodyContent;
if (tagsApi) {
obj[path] = /* TODO */ args[key];
} else {
if (type === "html") obj[path] = (out: any) => out.html(args[key]);
else obj[path] = (out: any) => out.text(args[key]);
}
} else {
obj[path] = args[key];
}

// Add controllable change handlers
if (ctx.argTypes[key]?.controllable && !args[key + "Change"]) {
obj[path + "Change"] = (v: unknown) => {
addons.getChannel().emit(UPDATE_STORY_ARGS, {
storyId: ctx.id,
updatedArgs: { [key]: v },
});
};
}
}

return input;
}
30 changes: 30 additions & 0 deletions packages/renderers/marko/src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,33 @@ declare const LOGLEVEL:
| "error"
| "silent"
| undefined;

// Empty import makes this file a module so "declare module" augments instead of replacing
import type {} from "storybook/internal/csf";

declare module "storybook/internal/csf" {
interface InputType {
/**
* **_[Marko]_**
*
* Indicates that this arg is [controllable](https://markojs.com/docs/explanation/controllable-components#the-controllable-pattern) via a `_Change` handler.
*
* For further customization, add a `_Change` argType manually
*/
controllable?: true;
/**
* **_[Marko]_**
*
* This arg may be passed as an [attribute tag](https://markojs.com/docs/reference/language#attribute-tags).
*
* The value acts as `argTypes` for this attribute tag.
*/
"@"?: Record<string, InputType>;
/**
* **_[Marko]_**
*
* Pass control text as [content](https://markojs.com/docs/reference/language#tag-content).
*/
bodyContent?: true | "text" | "html";
}
}
Loading
Loading