You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A kit lives in symfony/ux (src/Toolkit/kits/<kit>/), but ux.symfony.comhardcodes every kit. Adding one means 8 hand-edits, plus 1-2 per recipe:
ToolkitKitId enum (case + color()), assets/icons/toolkit/<kit>.svg, assets/styles/toolkit-<kit>.css (Tailwind entry + theme tokens, 238 lines for Flowbite), assets/toolkit-<kit>.js (import + app.register() each controller), importmap.php, symfonycasts_tailwind.yaml + composer.json tailwind list (duplicated), composer.lock, and one templates/toolkit/docs/<kit>/<recipe>.md.twig per recipe.
Kit identity is declared twice, controllers listed three times, docs written 1:1 by hand. Community kits get none of this: no preview, no rendered docs.
Goal
Make a kit fully self-describing: metadata, theme, docs, and rendering assets all ship inside the kit (or inside Toolkit). ux.symfony.com becomes a generic renderer with zero per-kit code. Community-kit preview is out of scope here, but this is the first step toward it (a future bin/ux-toolkit-kit-preview).
Key insight
renderComponentDoc() renders .md.twig with Twig into a markdown string, then parses it with CommonMark. The .md.twig tangles three things:
For a first iteration these live in Toolkit (Symfony\UX\Toolkit\Markdown\), not a separate package; the site consumes them from the vendored symfony/ux-toolkit. league/commonmark is a dev-only Toolkit dependency (guarded by class_exists), so installing Toolkit to scaffold components never pulls it in; hosts that render docs provide it. Standalone League CommonMark extensions, added à la carte to your own converter (no all-in-one factory):
CodePreview is the live-render primitive (code + options, no kit knowledge). Its renderer points at a controller that accepts code and returns a Response; the host implements it (site = signed iframe; a future bin/ux-toolkit-kit-preview = local). No endpoint configured -> static highlighted block.
Rendered via overridable Twig templates (@UXToolkit/markdown/{alert,tabs,popover,code_preview}.html.twig, semantic HTML with Toolkit classes, not Shadcn). A host restyles by overriding a template.
Ships the portable frontend under src/Toolkit/assets/: a base CSS the templates reference + Stimulus controllers tabs / popover / clipboard (plain ES modules served through AssetMapper, no build). Rendering works standalone.
2. Toolkit: docs move into the kit
Each recipe gets a doc.md (narrative + example refs, no Twig):
### Borders
Add `border` to the items.
::: example Borders {"height": "300px"}
::: example <Name> is a single-line leaf directive (no closing :::). Toolkit reads examples/<Name>.html.twig and builds Tabs(CodePreview, FencedCode) directly in the AST (no markdown re-parse, so code containing ``` or ::: never breaks it).
The site's _base_component.md.twig moves into Toolkit as @UXToolkit/doc/recipe.md.twig. RecipeDocRenderer exposes renderAsHtml($kit, $recipe, $previewUrlGenerator) (Toolkit assembles the CommonMark converter from its own extensions plus the host's PreviewUrlGenerator) and renderAsMarkdown($kit, $recipe) (portable Markdown for .md/LLM consumption, with ::: example resolved to code fences).
The manifest gains color / icon (the theme is not a manifest field: theme.css is pulled in through the kit.js -> kit.css import chain). Controllers are auto-discovered by scanning assets/controllers/.
The kit also ships, at its root, kit.css (the Tailwind entry @import "tailwindcss"; @source "."; @import "./theme.css";) and kit.js (the preview entrypoint: import './kit.css'; then boots Stimulus and registers the kit's controllers via relative imports). These two are preview/demo artifacts, not shipped to end users by ux:install.
3. ux.symfony.com becomes a generic renderer
Consume Toolkit's Symfony\UX\Toolkit\Markdown\ extensions; delete the site's App\Service\CommonMark\* copies. Build a per-render converter (Toolkit extensions + ExampleExtension($recipe) + the site's signed PreviewUrlGenerator); optionally override @UXToolkit/markdown/* to keep the Shadcn look.
Drop ToolkitKitId -> auto-discover kits by scanning vendor/symfony/ux-toolkit/kits/*/manifest.json.
Drop the per-recipe .md.twig -> render via RecipeDocRenderer, the site keeps its shell (TOC, sidebar).
Wire front assets dynamically, nothing generated or committed. A KitDiscovery service + a single KitAssetsPass compiler pass (plus one decorator):
AssetMapper: append kit dirs to AssetMapperRepository's $paths (makes kit.js/kit.css/controllers servable).
Tailwind: append each kit's kit.css to symfonycasts/tailwind's input_css.
importmap: decorate ImportMapConfigReader to ->add()one entrypoint per kit pointing at kits/<kit>/kit.js. AssetMapper traces kit.js's relative imports (./kit.css + the controllers), so no per-controller entry and no ControllersMapGenerator hook.
Add a kit -> cache:clear -> it's wired. No importmap.php, toolkit-<kit>.{js,css}, or Tailwind-config edits.
For kit authors
Before: kit source in ux, plus up to 8 files on ux.symfony.com and a .md.twig per recipe.
After: the kit ships everything (manifest metadata, theme.css, doc.md, controllers), and the site picks it up automatically. No site edit to add, update, or theme a kit.
Implementation (2 PRs)
symfony/ux: Toolkit markdown extensions + overridable templates/CSS/JS (src/Toolkit/assets/), the CodePreview preview-controller contract, ::: example, RecipeDocRenderer (the relocated @UXToolkit/doc/recipe.md.twig), manifest color/icon, doc.md per recipe, migrate common/shadcn/flowbite-4.
symfony/ux.symfony.com: consume Toolkit's extensions, kit auto-discovery, KitAssetsPass + ImportMapConfigReader decorator, render via RecipeDocRenderer, delete the hand-written per-kit files.
Open questions
Preview transport.code reaches the preview controller. Signed GET (simple, caps size at the URL limit) vs POST (no cap, more signing/caching). Which default does Toolkit ship?
doc.md scope. Narrative-only, or allow a full-layout override for recipes the scaffolding can't express?
Tailwind on a vendored input. Confirm symfonycasts/tailwind compiles an input_css under vendor/ with a relative @source (Tailwind v4).
Full implementation plan (2 PRs, task-by-task)
Goal: make a UX Toolkit kit fully self-describing (docs + metadata + theme + rendering assets in the kit/Toolkit) so ux.symfony.com renders any kit with zero per-kit code.
Architecture: Two PRs. PR A (symfony/ux) puts the markdown extensions, their default rendering (overridable Twig templates + CSS + Stimulus JS), the doc-markdown generator, and the kit metadata into src/Toolkit. PR B (symfony/ux.symfony.com) makes the site a generic renderer that converts Toolkit's doc-markdown with Toolkit's extensions and wires kit assets dynamically via DI.
Tech Stack: PHP 8.4, league/commonmark ^2.4 (Toolkit dev-only, provided by hosts that render), Twig, Symfony FrameworkBundle, AssetMapper + importmap, symfony/stimulus-bundle, symfonycasts/tailwind, PHPUnit + spatie/phpunit-snapshot-assertions. Toolkit ships no-build (plain-JS) Stimulus controllers served via AssetMapper.
Global Constraints
PHP >=8.4. Classes final, promoted readonly where it fits, typed everything.
PHP CS @Symfony + @Symfony:risky; Symfony license header on every PHP file. PSR-4 Symfony\UX\Toolkit\ -> src/Toolkit/src.
league/commonmark is require-dev in src/Toolkit/composer.json, never require. Any Toolkit code path that instantiates a Markdown extension guards with class_exists(\League\CommonMark\Environment\Environment::class) and throws RuntimeException('Install league/commonmark to render Toolkit docs as HTML.').
New manifest.json fields are optional (no BC break).
Toolkit ships plain-JS Stimulus controllers (no build, no dist/), served via AssetMapper. oxfmt + oxlint clean on JS.
New PHP under src/Toolkit/src/Markdown/ and src/Toolkit/src/Doc/; new frontend under src/Toolkit/assets/; default templates under src/Toolkit/templates/markdown/. Port PHP from ux.symfony.com/src/Service/CommonMark/ (generalize, drop ToolkitKitId/site coupling). Toolkit renders each node through an overridable Twig template and ships base CSS + Stimulus controllers, so rendering is portable and hosts can override to restyle.
Task A1: league/commonmark dev dep + Markdown skeleton + guard
Produces: AlertExtension, TabsExtension, PopoverExtension (ExtensionInterface). Each renderer renders through its Twig template (@UXToolkit/markdown/*.html.twig), so a host overrides the template to restyle. Constructor guards via Assert::commonMarkAvailable().
Consumes: Twig (Environment), already a Toolkit dep.
What: port node/parser/renderer trios from ux.symfony.com/src/Service/CommonMark/Extension/{Alert,Tabs,Popover}; the site's renderers already delegate to Twig, keep that but ship the templates in Toolkit under a @UXToolkit namespace. Syntax unchanged (> [!NOTE], ::: tabs / :: tab).
Per extension: snapshot test (directive -> HTML from the default template), red, port, green, commit.
Task A3: FencedCode renderer + {"preview": true} inline form
Produces: ExampleExtension(Recipe $recipe), instantiated per render. Parser matches a single line ::: example <Name> {json} (no closing :::), reads <recipe>/examples/<Name>.html.twig, builds Tabs(CodePreview($code,$opts), FencedCode($code))directly in the AST in closeBlock() (reuse A2/A3/A4 nodes; no re-serialize).
Consumes: Recipe, A2/A3/A4 nodes.
Test against a fixture recipe: builds Tabs with the example code; code with ``` does not break; missing example throws. Red, implement, green, commit.
No build step (per decision): plain ES modules a host serves through AssetMapper. Consistent with the kit.js no-build approach.
Files:
Create: src/Toolkit/assets/controllers/{tabs,popover,clipboard}_controller.js (plain ESM, import { Controller } from '@hotwired/stimulus')
Create: src/Toolkit/assets/styles/toolkit.css (base stylesheet the default templates reference, via CSS custom properties for theming)
Modify: @UXToolkit/markdown/{tabs,popover,code_preview}.html.twig to wire the controllers (data-controller / -target / data-action, identifiers toolkit-tabs / toolkit-popover / toolkit-clipboard); update the A2 exact-HTML tests
Interfaces:
Produces: three Stimulus controllers (tab switching, popover toggle, copy-to-clipboard). No package.json / tsdown / dist. The host (PR B) adds src/Toolkit/assets/controllers to its Stimulus/AssetMapper paths and imports the CSS.
What: the portable interactivity for the default rendering. PHP tests already assert the wired HTML structure; browser tests can follow.
Produces: KitManifest::$color/$icon (?string), parsed optionally. (No theme field: theme.css is loaded via the kit.js -> kit.css import chain, so the manifest never needs its path.)
Test: manifest with/without the fields parses. Red, implement, green, commit.
Produces: RecipeDocRenderer::renderAsHtml(Kit, Recipe, PreviewUrlGenerator): string (Toolkit assembles the CommonMark converter from its own extensions + ExampleExtension($recipe) + the host's PreviewUrlGenerator; guarded by Assert::commonMarkAvailable()) and renderAsMarkdown(Kit, Recipe): string (portable Markdown, ::: example resolved to fences, no league/commonmark needed). Both render the @UXToolkit/doc/recipe.md.twig template (with a format flag) as the intermediate Markdown.
What: move ux.symfony.com/templates/toolkit/docs/_base_component.md.twig into Toolkit as @UXToolkit/doc/recipe.md.twig, keeping its section order (title/description, Demo, Installation Automatic/Manual, narrative, API-reference tables). Replace the site-only bits: toolkit_code_demo/usage/example(...) -> ::: example Demo / ::: example <Name> directives; the usage/examples blocks -> inject {{ doc }} (the recipe's doc.md); is_llm branch -> a format ('html' | 'markdown') flag. In markdown mode the template resolves ::: example to a plain ```twig fence (final output, not re-parsed; long-fence-escape code containing backticks).
Note: the site's _base_component.md.twig and every <kit>/<recipe>.md.twig are deleted in PR B (B4); their prose lives in each recipe's doc.md.
Snapshot test: markdown for a fixture recipe (llm true + false). Red, implement, green, update snapshots, commit.
Produces: convertMdTwig(string $mdTwig): string: strip {% extends/block/endblock %}; toolkit_code_example(kit, name, 'X', {opts}) -> ::: example X {json}; toolkit_code_demo(...) -> ::: example Demo {json}; toolkit_code_usage(...) -> ::: example Usage; {height:'300px'} -> {"height":"300px"}; keep prose/headings/alerts.
Unit-test the transform on button/alert/post-link snippets. Red, implement, green, commit.
Task A11: Migrate common, shadcn, flowbite-4
Files:
Create per recipe: src/Toolkit/kits/<kit>/<recipe>/doc.md
Create per kit: src/Toolkit/kits/<kit>/{theme.css,kit.css,kit.js,icon.svg} (kit.css = @import "tailwindcss"; @source "."; @import "./theme.css";; kit.js = import './kit.css'; + boots Stimulus + registers the kit's controllers via relative imports)
Modify per kit: src/Toolkit/kits/<kit>/manifest.json (color/icon); snapshots
What: run A10 over the site templates; extract theme.css from the site assets/styles/toolkit-<kit>.css@theme{} + INSTALL.md; copy icon.svg from ux.symfony.com/assets/icons/toolkit/<kit>.svg; color from ToolkitKitId::color(). Hand-review each doc.md.
Per kit: add files, update snapshots, review diffs, commit.
Task A12: PR A wrap-up
php-cs-fixer, twig-cs-fixer, oxfmt/oxlint, full phpunit + Vitest green; dist/ built.
src/Toolkit/CHANGELOG.md; open PR against symfony/ux (maintained branch; push upstream).
Depends on PR A available in the vendored symfony/ux-toolkit.
Task B1: Convert docs with Toolkit's extensions; delete site copies
Files:
Modify/Create: a dedicated toolkit-doc converter builder that, per render, assembles a CommonMarkConverter with Toolkit's Alert/Tabs/Popover/FencedCode/CodePreview extensions + ExampleExtension($recipe) + the site SignedPreviewUrlGenerator (B2)
Delete: src/Service/CommonMark/Extension/{Alert,Tabs,Popover,ToolkitPreview}/**, its FencedCode copy
Optional: override @UXToolkit/markdown/*.html.twig in templates/bundles/ to keep the Shadcn look (else use Toolkit defaults + Toolkit CSS)
Test: blog/general markdown still renders; a toolkit-doc string renders
Interfaces:
Consumes: RecipeDocRenderer::render() markdown (B4), Toolkit extensions, ExampleExtension (needs the Recipe).
Test: a doc-markdown with ::: example, ::: tabs, > [!NOTE] renders to HTML. Delete copies, commit.
Modify: src/Kernel.php (register pass + GlobResource on vendor/symfony/ux-toolkit/kits/*)
Test: tests/.../KitAssetsPassTest.php
Hooks (verified in vendor):
Append kit dirs to AssetMapperRepository$paths (makes kit.js/kit.css/controllers servable).
Append each kit kit.css to symfonycasts/tailwindinput_css (compiled).
Decorate ImportMapConfigReader::getEntries() -> parent + ->add(ImportMapEntry::createLocal('toolkit-<kit>', ..., 'kits/<kit>/kit.js', isEntrypoint: true)), one entry per kit. AssetMapper traces kit.js's relative imports (./kit.css + controllers), so no per-controller entry and no ControllersMapGenerator hook.
One-time (not per kit): import Toolkit's own markdown assets (@symfony/ux-toolkit tabs/popover/clipboard + CSS from A6).
Test: pass mutates the three args + decorator adds entries for a fixture kit. Commit.
Modify: drop per-kit lines from importmap.php, config/packages/symfonycasts_tailwind.yaml, composer.jsontailwind:build
Boot, load each kit page, styles + controllers resolve; commit.
Task B7: End-to-end verification
pnpm run fmt/lint clean; site suite green.
Manual: /toolkit/{common,shadcn,flowbite-4}/<recipe> render docs, API tables, live previews, tabs/alerts.
Throwaway-kit: drop a minimal kit into vendored kits/, cache:clear, confirm end-to-end with zero site edits and no committed generated files. Remove it.
Problem
A kit lives in
symfony/ux(src/Toolkit/kits/<kit>/), butux.symfony.comhardcodes every kit. Adding one means 8 hand-edits, plus 1-2 per recipe:ToolkitKitIdenum (case+color()),assets/icons/toolkit/<kit>.svg,assets/styles/toolkit-<kit>.css(Tailwind entry + theme tokens, 238 lines for Flowbite),assets/toolkit-<kit>.js(import +app.register()each controller),importmap.php,symfonycasts_tailwind.yaml+composer.jsontailwind list (duplicated),composer.lock, and onetemplates/toolkit/docs/<kit>/<recipe>.md.twigper recipe.Kit identity is declared twice, controllers listed three times, docs written 1:1 by hand. Community kits get none of this: no preview, no rendered docs.
Goal
Make a kit fully self-describing: metadata, theme, docs, and rendering assets all ship inside the kit (or inside Toolkit).
ux.symfony.combecomes a generic renderer with zero per-kit code. Community-kit preview is out of scope here, but this is the first step toward it (a futurebin/ux-toolkit-kit-preview).Key insight
renderComponentDoc()renders.md.twigwith Twig into a markdown string, then parses it with CommonMark. The.md.twigtangles three things:::: tabs,> [!NOTE], preview) -> portable, host-agnostic_base_component.md.twig: install tabs, API table) -> logic over theKit/Recipemodeltoolkit_code_example()is just sugar expanding to a::: tabsblock with a Preview and a Code tab. The fix is to separate these three.Design: 3 layers
1. Markdown extensions inside Toolkit (
src/Toolkit/src/Markdown/)For a first iteration these live in Toolkit (
Symfony\UX\Toolkit\Markdown\), not a separate package; the site consumes them from the vendoredsymfony/ux-toolkit.league/commonmarkis a dev-only Toolkit dependency (guarded byclass_exists), so installing Toolkit to scaffold components never pulls it in; hosts that render docs provide it. Standalone League CommonMark extensions, added à la carte to your own converter (no all-in-one factory):CodePreviewis the live-render primitive (code+options, no kit knowledge). Its renderer points at a controller that acceptscodeand returns aResponse; the host implements it (site = signed iframe; a futurebin/ux-toolkit-kit-preview= local). No endpoint configured -> static highlighted block.@UXToolkit/markdown/{alert,tabs,popover,code_preview}.html.twig, semantic HTML with Toolkit classes, not Shadcn). A host restyles by overriding a template.{"preview": true}.src/Toolkit/assets/: a base CSS the templates reference + Stimulus controllerstabs/popover/clipboard(plain ES modules served through AssetMapper, no build). Rendering works standalone.2. Toolkit: docs move into the kit
Each recipe gets a
doc.md(narrative + example refs, no Twig):::: example <Name>is a single-line leaf directive (no closing:::). Toolkit readsexamples/<Name>.html.twigand buildsTabs(CodePreview, FencedCode)directly in the AST (no markdown re-parse, so code containing ``` or:::never breaks it)._base_component.md.twigmoves into Toolkit as@UXToolkit/doc/recipe.md.twig.RecipeDocRendererexposesrenderAsHtml($kit, $recipe, $previewUrlGenerator)(Toolkit assembles the CommonMark converter from its own extensions plus the host'sPreviewUrlGenerator) andrenderAsMarkdown($kit, $recipe)(portable Markdown for.md/LLM consumption, with::: exampleresolved to code fences).color/icon(the theme is not a manifest field:theme.cssis pulled in through thekit.js->kit.cssimport chain). Controllers are auto-discovered by scanningassets/controllers/.{ "name": "shadcn", "color": "#000", "icon": "icon.svg" }The kit also ships, at its root,
kit.css(the Tailwind entry@import "tailwindcss"; @source "."; @import "./theme.css";) andkit.js(the preview entrypoint:import './kit.css';then boots Stimulus and registers the kit's controllers via relative imports). These two are preview/demo artifacts, not shipped to end users byux:install.3.
ux.symfony.combecomes a generic rendererConsume Toolkit's
Symfony\UX\Toolkit\Markdown\extensions; delete the site'sApp\Service\CommonMark\*copies. Build a per-render converter (Toolkit extensions +ExampleExtension($recipe)+ the site's signedPreviewUrlGenerator); optionally override@UXToolkit/markdown/*to keep the Shadcn look.Drop
ToolkitKitId-> auto-discover kits by scanningvendor/symfony/ux-toolkit/kits/*/manifest.json.Drop the per-recipe
.md.twig-> render viaRecipeDocRenderer, the site keeps its shell (TOC, sidebar).Wire front assets dynamically, nothing generated or committed. A
KitDiscoveryservice + a singleKitAssetsPasscompiler pass (plus one decorator):AssetMapperRepository's$paths(makeskit.js/kit.css/controllers servable).kit.csstosymfonycasts/tailwind'sinput_css.ImportMapConfigReaderto->add()one entrypoint per kit pointing atkits/<kit>/kit.js. AssetMapper traceskit.js's relative imports (./kit.css+ the controllers), so no per-controller entry and noControllersMapGeneratorhook.Add a kit ->
cache:clear-> it's wired. Noimportmap.php,toolkit-<kit>.{js,css}, or Tailwind-config edits.For kit authors
Before: kit source in
ux, plus up to 8 files onux.symfony.comand a.md.twigper recipe.After: the kit ships everything (manifest metadata,
theme.css,doc.md, controllers), and the site picks it up automatically. No site edit to add, update, or theme a kit.Implementation (2 PRs)
symfony/ux: Toolkit markdown extensions + overridable templates/CSS/JS (src/Toolkit/assets/), theCodePreviewpreview-controller contract,::: example,RecipeDocRenderer(the relocated@UXToolkit/doc/recipe.md.twig), manifestcolor/icon,doc.mdper recipe, migratecommon/shadcn/flowbite-4.symfony/ux.symfony.com: consume Toolkit's extensions, kit auto-discovery,KitAssetsPass+ImportMapConfigReaderdecorator, render viaRecipeDocRenderer, delete the hand-written per-kit files.Open questions
codereaches the preview controller. Signed GET (simple, caps size at the URL limit) vs POST (no cap, more signing/caching). Which default does Toolkit ship?doc.mdscope. Narrative-only, or allow a full-layout override for recipes the scaffolding can't express?symfonycasts/tailwindcompiles aninput_cssundervendor/with a relative@source(Tailwind v4).Full implementation plan (2 PRs, task-by-task)
Goal: make a UX Toolkit kit fully self-describing (docs + metadata + theme + rendering assets in the kit/Toolkit) so
ux.symfony.comrenders any kit with zero per-kit code.Architecture: Two PRs. PR A (
symfony/ux) puts the markdown extensions, their default rendering (overridable Twig templates + CSS + Stimulus JS), the doc-markdown generator, and the kit metadata intosrc/Toolkit. PR B (symfony/ux.symfony.com) makes the site a generic renderer that converts Toolkit's doc-markdown with Toolkit's extensions and wires kit assets dynamically via DI.Tech Stack: PHP 8.4,
league/commonmark^2.4 (Toolkit dev-only, provided by hosts that render), Twig, Symfony FrameworkBundle, AssetMapper + importmap,symfony/stimulus-bundle,symfonycasts/tailwind, PHPUnit +spatie/phpunit-snapshot-assertions. Toolkit ships no-build (plain-JS) Stimulus controllers served via AssetMapper.Global Constraints
>=8.4. Classesfinal, promotedreadonlywhere it fits, typed everything.@Symfony+@Symfony:risky; Symfony license header on every PHP file. PSR-4Symfony\UX\Toolkit\->src/Toolkit/src.league/commonmarkisrequire-devinsrc/Toolkit/composer.json, neverrequire. Any Toolkit code path that instantiates a Markdown extension guards withclass_exists(\League\CommonMark\Environment\Environment::class)and throwsRuntimeException('Install league/commonmark to render Toolkit docs as HTML.').manifest.jsonfields are optional (no BC break).dist/), served via AssetMapper. oxfmt + oxlint clean on JS.cd src/Toolkit && php vendor/bin/phpunit. Snapshots:php vendor/bin/simple-phpunit -d --update-snapshots.PR A:
symfony/ux(Toolkit markdown + rendering assets + doc model + metadata)New PHP under
src/Toolkit/src/Markdown/andsrc/Toolkit/src/Doc/; new frontend undersrc/Toolkit/assets/; default templates undersrc/Toolkit/templates/markdown/. Port PHP fromux.symfony.com/src/Service/CommonMark/(generalize, dropToolkitKitId/site coupling). Toolkit renders each node through an overridable Twig template and ships base CSS + Stimulus controllers, so rendering is portable and hosts can override to restyle.Task A1:
league/commonmarkdev dep + Markdown skeleton + guardFiles:
src/Toolkit/composer.json(require-dev:"league/commonmark": "^2.4")src/Toolkit/src/Assert.php(addAssert::commonMarkAvailable(): void)src/Toolkit/tests/Markdown/AssertCommonMarkTest.phpInterfaces:
Produces:
Assert::commonMarkAvailable()throwsRuntimeExceptionwhenleague/commonmarkis absent. Called by every extension constructor.No
MarkdownConverterFactory(dropped: hosts build their own converter and add Toolkit's extensions).Test: guard throws with a clear message when the class is missing (simulate via a wrapper). Implement, add dep, green, commit.
Task A2: Alert / Tabs / Popover extensions (render via overridable Twig templates)
Files:
src/Toolkit/src/Markdown/Extension/{Alert,Tabs,Popover}/{Node,Parser,Renderer,Extension}.phpsrc/Toolkit/templates/markdown/{alert,tabs,popover}.html.twig(semantic HTML + Toolkit classes, not Shadcn)src/Toolkit/tests/Markdown/{Alert,Tabs,Popover}Test.phpInterfaces:
AlertExtension,TabsExtension,PopoverExtension(ExtensionInterface). Each renderer renders through its Twig template (@UXToolkit/markdown/*.html.twig), so a host overrides the template to restyle. Constructor guards viaAssert::commonMarkAvailable().Environment), already a Toolkit dep.What: port node/parser/renderer trios from
ux.symfony.com/src/Service/CommonMark/Extension/{Alert,Tabs,Popover}; the site's renderers already delegate to Twig, keep that but ship the templates in Toolkit under a@UXToolkitnamespace. Syntax unchanged (> [!NOTE],::: tabs/:: tab).Task A3: FencedCode renderer +
{"preview": true}inline formFiles:
src/Toolkit/src/Markdown/Extension/FencedCode/FencedCodeRenderer.phpsrc/Toolkit/tests/Markdown/FencedCodeTest.phpInterfaces:
Produces:
FencedCodeRendererreading the fence info-string JSON tail; on{"preview": true}it buildsTabs(Preview: CodePreview(code), Code: FencedCode(code))(A5 nodes). Renders via@UXToolkit/markdown/fenced_code.html.twig.Test:
{"preview": true}fence -> Preview+Code tabs; plain fence -> normal code block. Red, implement, green, commit. (Sequence with A5.)Task A4:
CodePreviewnode +PreviewUrlGeneratorcontractFiles:
src/Toolkit/src/Markdown/Extension/CodePreview/{CodePreview,CodePreviewParser,CodePreviewRenderer,CodePreviewExtension}.phpsrc/Toolkit/src/Markdown/PreviewUrlGenerator.php(interface)src/Toolkit/templates/markdown/code_preview.html.twigsrc/Toolkit/tests/Markdown/CodePreviewTest.phpInterfaces:
interface PreviewUrlGenerator { public function generate(string $code, array $options): ?string; }(host-implemented).final class CodePreview extends AbstractBlockcarryingstring $code,array $options.CodePreviewExtension(?PreviewUrlGenerator $urlGenerator = null). Renderer: URL present -> iframe via template;null-> static highlighted block.What: port
.../ToolkitPreview/*, decouple fromToolkitKitId/UriSigner/routes (all behindPreviewUrlGenerator). Node has no kit knowledge.Task A5:
::: example <Name>leaf directive (recipe-aware)Files:
src/Toolkit/src/Markdown/Extension/Example/{ExampleParser,ExampleExtension}.phpsrc/Toolkit/tests/Markdown/ExampleDirectiveTest.phpInterfaces:
Produces:
ExampleExtension(Recipe $recipe), instantiated per render. Parser matches a single line::: example <Name> {json}(no closing:::), reads<recipe>/examples/<Name>.html.twig, buildsTabs(CodePreview($code,$opts), FencedCode($code))directly in the AST incloseBlock()(reuse A2/A3/A4 nodes; no re-serialize).Consumes:
Recipe, A2/A3/A4 nodes.Test against a fixture recipe: builds Tabs with the example code; code with ``` does not break; missing example throws. Red, implement, green, commit.
Task A6: Toolkit frontend assets (no-build Stimulus controllers + base CSS)
No build step (per decision): plain ES modules a host serves through AssetMapper. Consistent with the
kit.jsno-build approach.Files:
src/Toolkit/assets/controllers/{tabs,popover,clipboard}_controller.js(plain ESM,import { Controller } from '@hotwired/stimulus')src/Toolkit/assets/styles/toolkit.css(base stylesheet the default templates reference, via CSS custom properties for theming)@UXToolkit/markdown/{tabs,popover,code_preview}.html.twigto wire the controllers (data-controller/-target/data-action, identifierstoolkit-tabs/toolkit-popover/toolkit-clipboard); update the A2 exact-HTML testsInterfaces:
package.json/tsdown/dist. The host (PR B) addssrc/Toolkit/assets/controllersto its Stimulus/AssetMapper paths and imports the CSS.What: the portable interactivity for the default rendering. PHP tests already assert the wired HTML structure; browser tests can follow.
--filter 'Markdown|Doc',oxfmt/oxlint,twig-cs-fixergreen; commit.Task A7: Manifest
color/iconFiles:
src/Toolkit/schema-kit-v1.json,src/Toolkit/src/Kit/KitManifest.php,src/Toolkit/src/Kit/Kit.php(if it re-exposes)src/Toolkit/tests/Unit/Kit/KitManifestTest.phpInterfaces:
Produces:
KitManifest::$color/$icon(?string), parsed optionally. (Nothemefield:theme.cssis loaded via thekit.js->kit.cssimport chain, so the manifest never needs its path.)Test: manifest with/without the fields parses. Red, implement, green, commit.
Task A8:
RecipeDocRenderer(renderAsHtml / renderAsMarkdown) + relocated scaffolding templateFiles:
src/Toolkit/src/Doc/RecipeDocRenderer.phpsrc/Toolkit/templates/doc/recipe.md.twig(the relocated_base_component.md.twig, de-coupled from the site)src/Toolkit/tests/Functional/RecipeDocRendererTest.php(snapshot)Interfaces:
RecipeDocRenderer::renderAsHtml(Kit, Recipe, PreviewUrlGenerator): string(Toolkit assembles the CommonMark converter from its own extensions +ExampleExtension($recipe)+ the host'sPreviewUrlGenerator; guarded byAssert::commonMarkAvailable()) andrenderAsMarkdown(Kit, Recipe): string(portable Markdown,::: exampleresolved to fences, noleague/commonmarkneeded). Both render the@UXToolkit/doc/recipe.md.twigtemplate (with aformatflag) as the intermediate Markdown.Kit,Recipe,Installer/InstallationReport(files + deps),ComponentDocParser/ComponentDoc, the recipedoc.md(A9), Twig.What: move
ux.symfony.com/templates/toolkit/docs/_base_component.md.twiginto Toolkit as@UXToolkit/doc/recipe.md.twig, keeping its section order (title/description, Demo, Installation Automatic/Manual, narrative, API-reference tables). Replace the site-only bits:toolkit_code_demo/usage/example(...)->::: example Demo/::: example <Name>directives; theusage/examplesblocks -> inject{{ doc }}(the recipe'sdoc.md);is_llmbranch -> aformat('html' | 'markdown') flag. Inmarkdownmode the template resolves::: exampleto a plain ```twig fence (final output, not re-parsed; long-fence-escape code containing backticks).Note: the site's
_base_component.md.twigand every<kit>/<recipe>.md.twigare deleted in PR B (B4); their prose lives in each recipe'sdoc.md.Task A9:
doc.mdloading + linter checkFiles:
src/Toolkit/src/Recipe/Recipe.php(?string $doc),src/Toolkit/src/Recipe/RecipeSynchronizer.php(readdoc.md)src/Toolkit/src/Kit/Lint/Checker/DocChecker.php; register inKitLinter.phpsrc/Toolkit/tests/Unit/Recipe/RecipeSynchronizerTest.php,.../Lint/DocCheckerTest.phpInterfaces:
Produces:
Recipe::$doc;DocChecker(flags missingdoc.md). Followsrc/Toolkit/src/Kit/Lint/Checker/StimulusControllerChecker.php.Tests: recipe loads
doc.md; linter flags a missing one. Red, implement, green, commit.Task A10: One-shot
.md.twig->doc.mdconversion scriptFiles:
src/Toolkit/bin/convert-mdtwig-to-doc.phpsrc/Toolkit/tests/Unit/ConvertMdTwigTest.phpInterfaces:
Produces:
convertMdTwig(string $mdTwig): string: strip{% extends/block/endblock %};toolkit_code_example(kit, name, 'X', {opts})->::: example X {json};toolkit_code_demo(...)->::: example Demo {json};toolkit_code_usage(...)->::: example Usage;{height:'300px'}->{"height":"300px"}; keep prose/headings/alerts.Unit-test the transform on button/alert/post-link snippets. Red, implement, green, commit.
Task A11: Migrate
common,shadcn,flowbite-4Files:
src/Toolkit/kits/<kit>/<recipe>/doc.mdsrc/Toolkit/kits/<kit>/{theme.css,kit.css,kit.js,icon.svg}(kit.css=@import "tailwindcss"; @source "."; @import "./theme.css";;kit.js=import './kit.css';+ boots Stimulus + registers the kit's controllers via relative imports)src/Toolkit/kits/<kit>/manifest.json(color/icon); snapshotsWhat: run A10 over the site templates; extract
theme.cssfrom the siteassets/styles/toolkit-<kit>.css@theme{}+INSTALL.md; copyicon.svgfromux.symfony.com/assets/icons/toolkit/<kit>.svg;colorfromToolkitKitId::color(). Hand-review eachdoc.md.Task A12: PR A wrap-up
php-cs-fixer,twig-cs-fixer,oxfmt/oxlint, fullphpunit+ Vitest green;dist/built.src/Toolkit/CHANGELOG.md; open PR againstsymfony/ux(maintained branch; pushupstream).PR B:
symfony/ux.symfony.com(generic renderer + dynamic wiring)Depends on PR A available in the vendored
symfony/ux-toolkit.Task B1: Convert docs with Toolkit's extensions; delete site copies
Files:
CommonMarkConverterwith Toolkit'sAlert/Tabs/Popover/FencedCode/CodePreviewextensions +ExampleExtension($recipe)+ the siteSignedPreviewUrlGenerator(B2)src/Service/CommonMark/Extension/{Alert,Tabs,Popover,ToolkitPreview}/**, itsFencedCodecopy@UXToolkit/markdown/*.html.twigintemplates/bundles/to keep the Shadcn look (else use Toolkit defaults + Toolkit CSS)Interfaces:
Consumes:
RecipeDocRenderer::render()markdown (B4), Toolkit extensions,ExampleExtension(needs theRecipe).Test: a doc-markdown with
::: example,::: tabs,> [!NOTE]renders to HTML. Delete copies, commit.Task B2: Site
SignedPreviewUrlGeneratorFiles:
src/Service/Toolkit/SignedPreviewUrlGenerator.php implements Symfony\UX\Toolkit\Markdown\PreviewUrlGeneratorconfig/services.yamlInterfaces:
Consumes:
UriSigner,UrlGeneratorInterface(app_toolkit_component_preview), current kit id (via render context).Produces:
generate($code,$options): stringsigned URL (portToolkitPreviewRendererlogic).Test: URL is signed + points at the preview route. Commit.
Task B3:
KitDiscovery; dropToolkitKitIdFiles:
Create:
src/Service/Toolkit/KitDiscovery.php(scanvendor/symfony/ux-toolkit/kits/*/manifest.json)Delete:
src/Enum/ToolkitKitId.php; update all usages (ToolkitService,ToolkitRuntime::kitColor,ComponentsController,GenerateLlmsFilesCommand, listing templates) to string ids + manifestcolorTest:
tests/.../KitDiscoveryTest.phpTest discovery returns the three kits + colors; replace call sites; suite green; commit.
Task B4: Render docs via
RecipeDocRenderer; delete.md.twigFiles:
Modify:
src/Service/Toolkit/ToolkitService.php(renderComponentDoc->RecipeDocRenderer::render()then convert with the B1 converter;.mdLLM ->render(llm: true))Delete:
templates/toolkit/docs/**,src/Twig/Extension/{ToolkitExtension,ToolkitRuntime}.phptoolkit_code_*Test: functional test on a component page + a
.mdpageFunctional:
/toolkit/shadcn/buttonrenders (docs + preview);.mdrenders markdown. Delete templates/functions, commit.Task B5:
KitAssetsPass+ImportMapConfigReaderdecorator (dynamic wiring)Files:
src/DependencyInjection/Compiler/KitAssetsPass.php,src/AssetMapper/KitImportMapConfigReader.phpsrc/Kernel.php(register pass +GlobResourceonvendor/symfony/ux-toolkit/kits/*)tests/.../KitAssetsPassTest.phpHooks (verified in vendor):
Append kit dirs to
AssetMapperRepository$paths(makeskit.js/kit.css/controllers servable).Append each kit
kit.csstosymfonycasts/tailwindinput_css(compiled).Decorate
ImportMapConfigReader::getEntries()->parent+->add(ImportMapEntry::createLocal('toolkit-<kit>', ..., 'kits/<kit>/kit.js', isEntrypoint: true)), one entry per kit. AssetMapper traceskit.js's relative imports (./kit.css+ controllers), so no per-controller entry and noControllersMapGeneratorhook.One-time (not per kit): import Toolkit's own markdown assets (
@symfony/ux-toolkittabs/popover/clipboard + CSS from A6).Test: pass mutates the three args + decorator adds entries for a fixture kit. Commit.
Task B6: Delete hand-written per-kit files
Files:
Delete:
assets/toolkit-<kit>.js,assets/styles/toolkit-<kit>.css,assets/icons/toolkit/<kit>.svgModify: drop per-kit lines from
importmap.php,config/packages/symfonycasts_tailwind.yaml,composer.jsontailwind:buildBoot, load each kit page, styles + controllers resolve; commit.
Task B7: End-to-end verification
pnpm run fmt/lintclean; site suite green./toolkit/{common,shadcn,flowbite-4}/<recipe>render docs, API tables, live previews, tabs/alerts.kits/,cache:clear, confirm end-to-end with zero site edits and no committed generated files. Remove it.symfony/ux.symfony.com.Self-review notes
league/commonmarkdev-only +Assert::commonMarkAvailable()guard; (2) noMarkdownConverterFactory;RecipeDocRendererexposesrenderAsHtml(Toolkit assembles the converter from its extensions + the hostPreviewUrlGenerator) andrenderAsMarkdown(portable Markdown); (3) Toolkit ships overridable Twig templates + base CSS + no-build Stimulus controllers (A2/A4/A6), the site optionally overrides templates to keep Shadcn.@sourcevalidated early in A11/B5.