Skip to content

[Toolkit] Make kits self-describing so ux.symfony.com renders them with zero per-kit code #3711

Description

@Kocal

Problem

A kit lives in symfony/ux (src/Toolkit/kits/<kit>/), but ux.symfony.com hardcodes 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:

  • directives (::: tabs, > [!NOTE], preview) -> portable, host-agnostic
  • scaffolding (_base_component.md.twig: install tabs, API table) -> logic over the Kit/Recipe model
  • host rendering (the signed preview iframe) -> the only genuinely site-specific bit

toolkit_code_example() is just sugar expanding to a ::: tabs block 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 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):

$env->addExtension(new AlertExtension())
    ->addExtension(new TabsExtension())
    ->addExtension(new CodePreviewExtension(previewController: 'app_toolkit_component_preview'));
  • 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.
  • Generic inline preview = a fenced code block flagged {"preview": true}.
  • 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/.
{ "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";) 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)

  1. 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.
  2. 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.
  • Toolkit tests: cd src/Toolkit && php vendor/bin/phpunit. Snapshots: php vendor/bin/simple-phpunit -d --update-snapshots.
  • Specific exceptions only. Commit per task. PR A ships before PR B.

PR A: symfony/ux (Toolkit markdown + rendering assets + doc model + metadata)

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

Files:

  • Modify: src/Toolkit/composer.json (require-dev: "league/commonmark": "^2.4")
  • Modify: src/Toolkit/src/Assert.php (add Assert::commonMarkAvailable(): void)
  • Test: src/Toolkit/tests/Markdown/AssertCommonMarkTest.php

Interfaces:

  • Produces: Assert::commonMarkAvailable() throws RuntimeException when league/commonmark is 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:

  • Create: src/Toolkit/src/Markdown/Extension/{Alert,Tabs,Popover}/{Node,Parser,Renderer,Extension}.php
  • Create: src/Toolkit/templates/markdown/{alert,tabs,popover}.html.twig (semantic HTML + Toolkit classes, not Shadcn)
  • Test: src/Toolkit/tests/Markdown/{Alert,Tabs,Popover}Test.php

Interfaces:

  • 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

Files:

  • Create: src/Toolkit/src/Markdown/Extension/FencedCode/FencedCodeRenderer.php
  • Test: src/Toolkit/tests/Markdown/FencedCodeTest.php

Interfaces:

  • Produces: FencedCodeRenderer reading the fence info-string JSON tail; on {"preview": true} it builds Tabs(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: CodePreview node + PreviewUrlGenerator contract

Files:

  • Create: src/Toolkit/src/Markdown/Extension/CodePreview/{CodePreview,CodePreviewParser,CodePreviewRenderer,CodePreviewExtension}.php
  • Create: src/Toolkit/src/Markdown/PreviewUrlGenerator.php (interface)
  • Create: src/Toolkit/templates/markdown/code_preview.html.twig
  • Test: src/Toolkit/tests/Markdown/CodePreviewTest.php

Interfaces:

  • Produces:
    • interface PreviewUrlGenerator { public function generate(string $code, array $options): ?string; } (host-implemented).
    • final class CodePreview extends AbstractBlock carrying string $code, array $options.
    • CodePreviewExtension(?PreviewUrlGenerator $urlGenerator = null). Renderer: URL present -> iframe via template; null -> static highlighted block.

What: port .../ToolkitPreview/*, decouple from ToolkitKitId/UriSigner/routes (all behind PreviewUrlGenerator). Node has no kit knowledge.

  • Test: stub generator -> iframe HTML; null generator -> static block. Red, implement, green, commit.

Task A5: ::: example <Name> leaf directive (recipe-aware)

Files:

  • Create: src/Toolkit/src/Markdown/Extension/Example/{ExampleParser,ExampleExtension}.php
  • Test: src/Toolkit/tests/Markdown/ExampleDirectiveTest.php

Interfaces:

  • 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.

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.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.

  • Wire templates + controllers; run PHP --filter 'Markdown|Doc', oxfmt/oxlint, twig-cs-fixer green; commit.

Task A7: Manifest color / icon

Files:

  • Modify: src/Toolkit/schema-kit-v1.json, src/Toolkit/src/Kit/KitManifest.php, src/Toolkit/src/Kit/Kit.php (if it re-exposes)
  • Test: src/Toolkit/tests/Unit/Kit/KitManifestTest.php

Interfaces:

  • 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.

Task A8: RecipeDocRenderer (renderAsHtml / renderAsMarkdown) + relocated scaffolding template

Files:

  • Create: src/Toolkit/src/Doc/RecipeDocRenderer.php
  • Create: src/Toolkit/templates/doc/recipe.md.twig (the relocated _base_component.md.twig, de-coupled from the site)
  • Test: src/Toolkit/tests/Functional/RecipeDocRendererTest.php (snapshot)

Interfaces:

  • 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.
  • Consumes: Kit, Recipe, Installer/InstallationReport (files + deps), ComponentDocParser/ComponentDoc, the recipe doc.md (A9), Twig.

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.

Task A9: doc.md loading + linter check

Files:

  • Modify: src/Toolkit/src/Recipe/Recipe.php (?string $doc), src/Toolkit/src/Recipe/RecipeSynchronizer.php (read doc.md)
  • Create: src/Toolkit/src/Kit/Lint/Checker/DocChecker.php; register in KitLinter.php
  • Test: src/Toolkit/tests/Unit/Recipe/RecipeSynchronizerTest.php, .../Lint/DocCheckerTest.php

Interfaces:

  • Produces: Recipe::$doc; DocChecker (flags missing doc.md). Follow src/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.md conversion script

Files:

  • Create: src/Toolkit/bin/convert-mdtwig-to-doc.php
  • Test: src/Toolkit/tests/Unit/ConvertMdTwigTest.php

Interfaces:

  • 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).

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:

  • 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.

Task B2: Site SignedPreviewUrlGenerator

Files:

  • Create: src/Service/Toolkit/SignedPreviewUrlGenerator.php implements Symfony\UX\Toolkit\Markdown\PreviewUrlGenerator
  • Modify: config/services.yaml

Interfaces:

  • Consumes: UriSigner, UrlGeneratorInterface (app_toolkit_component_preview), current kit id (via render context).

  • Produces: generate($code,$options): string signed URL (port ToolkitPreviewRenderer logic).

  • Test: URL is signed + points at the preview route. Commit.

Task B3: KitDiscovery; drop ToolkitKitId

Files:

  • Create: src/Service/Toolkit/KitDiscovery.php (scan vendor/symfony/ux-toolkit/kits/*/manifest.json)

  • Delete: src/Enum/ToolkitKitId.php; update all usages (ToolkitService, ToolkitRuntime::kitColor, ComponentsController, GenerateLlmsFilesCommand, listing templates) to string ids + manifest color

  • Test: tests/.../KitDiscoveryTest.php

  • Test discovery returns the three kits + colors; replace call sites; suite green; commit.

Task B4: Render docs via RecipeDocRenderer; delete .md.twig

Files:

  • Modify: src/Service/Toolkit/ToolkitService.php (renderComponentDoc -> RecipeDocRenderer::render() then convert with the B1 converter; .md LLM -> render(llm: true))

  • Delete: templates/toolkit/docs/**, src/Twig/Extension/{ToolkitExtension,ToolkitRuntime}.php toolkit_code_*

  • Test: functional test on a component page + a .md page

  • Functional: /toolkit/shadcn/button renders (docs + preview); .md renders markdown. Delete templates/functions, commit.

Task B5: KitAssetsPass + ImportMapConfigReader decorator (dynamic wiring)

Files:

  • Create: src/DependencyInjection/Compiler/KitAssetsPass.php, src/AssetMapper/KitImportMapConfigReader.php
  • 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/tailwind input_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.

Task B6: Delete hand-written per-kit files

Files:

  • Delete: assets/toolkit-<kit>.js, assets/styles/toolkit-<kit>.css, assets/icons/toolkit/<kit>.svg

  • Modify: drop per-kit lines from importmap.php, config/packages/symfonycasts_tailwind.yaml, composer.json tailwind: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.
  • Open PR against symfony/ux.symfony.com.

Self-review notes

  • Spec coverage: Layer 1 -> A1-A6; Layer 2 -> A7-A11; Layer 3 -> B1-B6. Migration -> A10-A11.
  • Remarks folded in: (1) league/commonmark dev-only + Assert::commonMarkAvailable() guard; (2) no MarkdownConverterFactory; RecipeDocRenderer exposes renderAsHtml (Toolkit assembles the converter from its extensions + the host PreviewUrlGenerator) and renderAsMarkdown (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.
  • Sequencing: A4 before/with A3 and A5; A6 pairs with A2/A4 templates. B depends on A shipped.
  • Open questions: preview transport (signed GET vs POST) decided in A4/B2; Tailwind vendored-input @source validated early in A11/B5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions