diff --git a/docs/app/assets/play.css b/docs/app/assets/play.css index 7c344258..cda98b03 100644 --- a/docs/app/assets/play.css +++ b/docs/app/assets/play.css @@ -1,222 +1,65 @@ -/* The playground's styles, from the old site's style.css, with its sidebar's presets column in .tool-index. */ -/* The playground: the editor and its issues on the left, the rendered page on the right. */ +/* The playground's styles. PlaygroundRender from @vite-hub/ui draws the editor, the presets' column, and the preview + with its own; these fit it to the page and draw what comark-ui adds: the presets' stills, the package link, and the + Theme pane. */ .play { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - height: 100%; + height: calc(100dvh - var(--ui-header-height)); } -.play-editor { - position: relative; - display: flex; - flex-direction: column; - min-height: 0; - border-right: 1px solid var(--line); +.play .vh-pg { + border: 0; + border-radius: 0; } -.play-bar { - display: flex; - flex: none; - align-items: center; - justify-content: space-between; - height: 2.75rem; - padding: 0 0.5rem; - border-bottom: 1px solid var(--line); - color: var(--faint); - font-size: 0.8125rem; +.play .vh-pg:has(> .vh-pg-presets) { + grid-template-columns: 15rem minmax(0, 1fr) minmax(0, 1fr); } -.play-share, -.play-theme-actions button { - padding: 0.25rem 0.625rem; - border: 0; - border-radius: var(--r-sm); - background: var(--bg); - box-shadow: inset 0 0 0 1px var(--line); - color: var(--muted); - font: 500 0.75rem/1.5 var(--sans); - cursor: pointer; - transition: - color 150ms ease, - scale 160ms var(--ease-out); +/* Let the sandbox receive the system preference instead of the renderer's light-only default. */ +.play .vh-pg-preview { + color-scheme: light dark; } -.play-share:hover, -.play-theme-actions button:hover { - color: var(--fg); +/* The package the preset documents, which the old site's header showed, and the way to the gallery. */ +.play-npmx, +.play-gallery { + margin-right: 0.25rem; + color: var(--faint); + font: 0.75rem/1.5 var(--mono); + text-decoration: none; + white-space: nowrap; } -/* Reset and Copy float in the code's corner, as comark-code's buttons do, and leave the bar to the panes and the - link. Their backdrop is the pane's, so code scrolling under them doesn't show through. */ -.play-tools { - position: absolute; - top: 3.25rem; - right: 1rem; - z-index: 1; - display: flex; - align-items: center; - gap: 0.25rem; - padding: 2px; - border-radius: var(--r-sm); - background: var(--soft); +.play-gallery { + font-family: var(--sans); } -/* Square twins of Copy link: the same outline, height, and press. */ -.play-reset, -.play-copy { - display: grid; - place-items: center; - width: 1.625rem; - height: 1.625rem; - padding: 0; +.play-npmx:hover, +.play-gallery:hover { + color: var(--fg); +} + +.play-theme-actions button { + padding: 0.25rem 0.625rem; border: 0; border-radius: var(--r-sm); background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); color: var(--muted); + font: 500 0.75rem/1.5 var(--sans); cursor: pointer; transition: color 150ms ease, scale 160ms var(--ease-out); } -.play-reset svg, -.play-copy svg { - width: 0.875rem; - height: 0.875rem; -} - -.play-reset:hover, -.play-copy:hover { - color: var(--fg); -} - -.play-reset[hidden], -.play-tools[hidden], -.play-theme-actions [hidden] { - display: none; -} - -.play-confirm { - width: min(22rem, 100% - 2rem); - padding: 1.25rem; - border: 0; - border-radius: var(--r-md); - background: var(--bg); - box-shadow: 0 0 0 1px var(--line); - color: var(--muted); - transition: - opacity 150ms ease-out, - scale 150ms ease-out, - display 150ms allow-discrete, - overlay 150ms allow-discrete; -} - -@starting-style { - .play-confirm[open] { - opacity: 0; - scale: 0.97; - } -} - -.play-confirm::backdrop { - background: rgb(0 0 0 / 0.3); -} - -.play-confirm h2 { - margin: 0 0 0.25rem; - padding: 0; - border: 0; - letter-spacing: normal; - color: var(--fg); - font: 600 0.9375rem/1.4 var(--sans); -} - -.play-confirm p { - margin: 0 0 1rem; - font-size: 0.8125rem; - text-wrap: pretty; -} - -.play-confirm div { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} - -.play-confirm button { - padding: 0.375rem 0.75rem; - border: 0; - border-radius: var(--r-sm); - background: var(--bg); - box-shadow: inset 0 0 0 1px var(--line); +.play-theme-actions button:hover { color: var(--fg); - font: 500 0.8125rem/1.5 var(--sans); - cursor: pointer; - transition: scale 160ms var(--ease-out); } -.play-confirm button[value="reset"] { - background: var(--fg); - box-shadow: none; - color: var(--bg); -} - -.play-confirm button:active { +.play-theme-actions button:active { scale: 0.97; } -/* The textarea sits over a colored copy of its text in one grid cell; the copy sets the height and the box scrolls. */ -.play-code { - display: grid; - flex: 1; - min-height: 0; - overflow: auto; - background: var(--soft); -} - -.play-code > *, -.play-paint pre { - grid-area: 1 / 1; -} - -.play-paint { - display: grid; - pointer-events: none; -} - -.play-paint pre, -.play-code textarea { - margin: 0; - padding: 1rem; - overflow: visible; - border: 0; - border-radius: 0; - box-shadow: none; - background: none !important; - color: var(--fg); - font: 0.8125rem/1.65 var(--mono); - tab-size: 2; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.play-paint code { - font: inherit; -} - -.play-code textarea { - overflow: hidden; - outline: none; - color: transparent; - caret-color: var(--fg); - resize: none; -} - -.play-code textarea::selection { - background: color-mix(in oklch, var(--fg) 18%, transparent); -} - /* The Theme pane: the preview's scheme and preset over a row per token, each a swatch or slider and the value it writes, then free CSS. */ .play-theme { @@ -232,10 +75,6 @@ font-size: 0.8125rem; } -.play-theme[hidden] { - display: none; -} - .play-theme-bar, .play-theme-field { display: flex; @@ -383,66 +222,25 @@ resize: vertical; } -.play-issues { - flex: none; - max-height: 30%; - margin: 0; - padding: 0.75rem 1rem; - overflow-y: auto; - border-top: 1px solid var(--line); - color: oklch(0.55 0.15 50); - font-size: 0.8125rem; - list-style: none; -} - -.play-issues li + li { - margin-top: 0.375rem; -} - -.play-preview { - width: 100%; - height: 100%; - border: 0; - background: var(--bg); -} - -@media (max-width: 60rem) { - .play { - grid-template-rows: 50% 50%; - grid-template-columns: minmax(0, 1fr); - } - - .play-editor { - border-right: 0; - border-bottom: 1px solid var(--line); - } -} - -/* The playground's presets: a still of each example over its name, the whole card a link. */ -.tool-index .nav-group { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.tool-index .nav-group .ci { - color: var(--faint); -} - -.presets { - display: grid; +/* The presets: a still of each example over its name, two to a row, the whole card a button. */ +.play .vh-pg-group ul { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; } -.preset { +.play .vh-pg-preset { position: relative; + display: block; + padding: 0; border-radius: var(--r-md); + background: none; + color: var(--muted); + font-weight: 400; transition: scale 150ms ease; } /* The frame sits over the still and the name's divider, so neither covers its edge. */ -.preset::before { +.play .vh-pg-preset::before { content: ""; position: absolute; inset: 0; @@ -453,20 +251,30 @@ transition: box-shadow 150ms ease; } -.preset:hover::before { +.play .vh-pg-preset:hover::before { box-shadow: inset 0 0 0 1px var(--faint); } -.preset:active { +.play .vh-pg-preset:active { scale: 0.98; } -.preset:has(a[aria-current])::before { +.play .vh-pg-preset[aria-current="true"] { + color: var(--fg); +} + +.play .vh-pg-preset[aria-current="true"]::before { box-shadow: inset 0 0 0 1.5px var(--fg); } +.play .vh-pg-preset:focus-visible { + outline: 2px solid var(--fg); + outline-offset: 2px; +} + /* Its size comes from the grid and its height, so the browser can skip laying out a still that's off screen. */ .preset-thumb { + display: block; height: 6rem; overflow: hidden; content-visibility: auto; @@ -478,8 +286,12 @@ /* Laid out at a page's width, then scaled to its card: the playground fits each one, and until it runs, they fill the card's width. */ .preset-page { + display: block; width: 40rem; padding: 1.5rem; + color: var(--fg); + font-size: 1rem; + text-align: start; transform: scale(0.23); transform-origin: 0 0; } @@ -496,83 +308,55 @@ color: var(--faint); } -.tool-index .preset > a { +.preset-label { display: block; overflow: hidden; - margin: 0; padding: 0.3rem 0.625rem 0.375rem; + border-top: 1px solid var(--line); font-size: 0.75rem; text-overflow: ellipsis; white-space: nowrap; - border-top: 1px solid var(--line); - border-radius: 0 0 var(--r-md) var(--r-md); - background: none; } -/* The link covers the card, so the still opens the preset too. */ -.tool-index .preset > a::after { - content: ""; +/* The hidden copy of the preview's page that the Theme pane reads its colors from. */ +.play-theme-probe { position: absolute; - inset: 0; - border-radius: var(--r-md); -} - -.tool-index .preset > a:focus-visible { - outline: none; + width: 0; + height: 0; + border: 0; + visibility: hidden; } -.preset:has(a:focus-visible) { - outline: 2px solid var(--fg); - outline-offset: 2px; -} +/* Narrow: the presets are a row of names, without their stills. */ +@media (max-width: 60rem) { + .play .vh-pg:has(> .vh-pg-presets) { + grid-template-columns: minmax(0, 1fr); + } -/* The copy icon gives way to a check while the Markdown is on the clipboard. */ -.play-share:active, -.play-theme-actions button:active, -.play-reset:active, -.play-copy:active { - scale: 0.97; -} + .play .vh-pg-group ul { + display: flex; + } -.play-copy svg { - grid-area: 1 / 1; - transition: - opacity 200ms var(--ease-out), - scale 200ms var(--ease-out), - filter 200ms var(--ease-out); -} + .play .vh-pg-preset { + width: auto; + } -.play-copy .check, -.play-copy.copied svg:not(.check) { - opacity: 0; - scale: 0.25; - filter: blur(2px); -} + .preset-thumb { + display: none; + } -.play-copy.copied .check { - opacity: 1; - scale: 1; - filter: none; - color: #16a34a; + .preset-label { + border: 0; + } } -/* Touch: every control at least 40px tall, and the editor at 16px so iOS doesn't zoom into it. */ +/* Touch: every control at least 40px tall, and the fields at 16px so iOS doesn't zoom into them. */ @media (pointer: coarse) { - .play-share, .play-theme-actions button, - .play-confirm button, .play-theme input:is([type="text"], [type="color"]) { min-height: 2.5rem; } - .play-reset, - .play-copy { - width: 2.5rem; - height: 2.5rem; - } - - .play-paint pre, - .play-code textarea, .play-theme :is(input, textarea) { font-size: 1rem; } diff --git a/docs/app/components/PlayTheme.vue b/docs/app/components/PlayTheme.vue new file mode 100644 index 00000000..e70c1621 --- /dev/null +++ b/docs/app/components/PlayTheme.vue @@ -0,0 +1,250 @@ + + + + + + + + {{ capitalize(option) }} + + + + Reset + {{ copyLabel }} + + + + Preset + + + {{ option.label }} + + + + + + {{ capitalize(token) }}--comark-{{ token }} + + + + Radius--comark-radius + + + Font--comark-font + + + + + Extra CSS + + Each plugin's own variables, like --cc-* or --ck-*, or any rule, + applied to the preview. + + + + + + diff --git a/docs/app/pages/play/gallery.vue b/docs/app/pages/play/gallery.vue index fedc74ef..6371136f 100644 --- a/docs/app/pages/play/gallery.vue +++ b/docs/app/pages/play/gallery.vue @@ -191,7 +191,7 @@ onBeforeUnmount(() => { -// The playground, as the old site drew it: the presets' column, the editor with its Markdown, Config, and Theme -// panes, and the preview. docs/tools.ts renders the first preset's colored Markdown, the presets, and their stills; -// docs/play.ts runs it all in the browser. +// PlaygroundRender runs the presets, the Markdown, Config, and Theme panes, and the preview. docs/tools.ts writes the +// presets with their stills; docs/play.ts renders them. import "~/assets/tools.css"; import "~/assets/play.css"; -import first from "~~/.generated/tools-data/play.json"; -import { colorTokens, themePresets } from "~~/play-theme.ts"; +import { PlaygroundRender } from "@vite-hub/ui-playground/playground-render"; +import { configFile, contentFile, themeFile } from "~~/play-link.ts"; +import type { Preset } from "~~/play.ts"; useDocsSeo({ title: "Playground", @@ -13,31 +13,35 @@ useDocsSeo({ "Write Comark with every plugin and see it rendered as you type. Start from any example in the docs.", }); -const capitalize = (word: string) => `${word[0]?.toUpperCase()}${word.slice(1)}`; +type Play = typeof import("~~/play.ts"); +const panes = [ + { file: contentFile, label: "Markdown", language: "mdc" }, + { file: configFile, label: "Config", language: "json" }, + { file: themeFile, label: "Theme", slot: "theme" }, +]; +const languages = [ + () => import("comark/plugins/shiki/language-comark").then((module) => module.comarkLanguages), + () => import("shiki/langs/json.mjs"), +]; + +const play = shallowRef<{ module: Play; styles: string; presets: Preset[] }>(); +const presetOf = (id?: string) => play.value?.presets.find((preset) => preset.id === id); +// A shared link's Markdown could use any package, so it gets comark-ui, which installs all of them. +const npmOf = (id?: string) => presetOf(id)?.npm || "comark-ui"; const root = ref(); -const column = ref(); -const npm = ref(first.npm); let stop: (() => void) | undefined; let gone = false; onMounted(async () => { - const [{ startPlayground }, { indicate }, presets, stills] = await Promise.all([ + const [module, { styles, presets }] = await Promise.all([ import("~~/play.ts"), - import("~~/seg.ts"), - $fetch<{ id: string; source: string; npm: string }[]>("/play/presets.json"), - $fetch("/play/presets.html", { responseType: "text" }), + $fetch<{ styles: string; presets: Preset[] }>("/play/presets.json"), ]); - if (gone || !root.value || !column.value) return; - column.value.innerHTML = stills; - const switches = [...root.value.querySelectorAll(".seg")].map((group) => - indicate(group), - ); - const stopPlayground = await startPlayground(root.value, presets, (name) => (npm.value = name)); - stop = () => { - stopPlayground(); - for (const unswitch of switches) unswitch(); - }; - if (gone) stop(); + if (gone) return; + play.value = { module, styles, presets }; + await nextTick(); + const column = root.value?.querySelector(".vh-pg-presets"); + if (column) stop = module.watchPresets(column); }); onBeforeUnmount(() => { gone = true; @@ -46,223 +50,37 @@ onBeforeUnmount(() => { - - - Playground - Component gallery - - - - - - - Markdown - Config - Theme - - - ./ {{ npm }} - Copy link - - - - - - - - - - - - - - - - - - - - - - Reset to the example? - Your changes to the Markdown will be lost. - - CancelReset - - - - - - - - - - - - - - - - Light - Dark - - - Reset - Copy CSS - - - - Preset - - - {{ preset.label }} - - - - - - {{ capitalize(token) }}--comark-{{ token }} - - - - Radius--comark-radius - - - Font--comark-font - - - - - Extra CSS - - Each plugin's own variables, like --cc-* or --ck-*, or any - rule, applied to the preview. - - - - - - - - + + + + + + + + + {{ preset.label }} + + + ./ {{ npmOf(preset?.id) }} + Gallery + + + + + - - diff --git a/docs/app/types/tools-data.d.ts b/docs/app/types/tools-data.d.ts index 6d923b5e..92985acb 100644 --- a/docs/app/types/tools-data.d.ts +++ b/docs/app/types/tools-data.d.ts @@ -1,23 +1,12 @@ // docs/tools.ts writes these before `nuxi dev` and `nuxi build`. Typechecks run without it, so the pages' imports // fall back to these shapes when the files aren't there. -declare module "~~/.generated/tools-data/play.json" { - const first: { - npm: string; - source: string; - config: string; - sourcePaint: string; - configPaint: string; - }; - export default first; -} - declare module "~~/.generated/tools-data/gallery.json" { const groups: { slug: string; label: string; icon: string; npm: string; - examples: { id: string; label: string; page: string; playable: boolean }[]; + examples: { id: string; label: string; page: string; playable: boolean; play?: string }[]; }[]; export default groups; } diff --git a/docs/gallery.ts b/docs/gallery.ts index b700425d..28848172 100644 --- a/docs/gallery.ts +++ b/docs/gallery.ts @@ -1,10 +1,16 @@ // The component gallery's script. It pans and zooms the canvas, shows each frame in its color scheme and as tall as its // content, filters the examples, and edits them: an example's props, a copy of it, or one copy per value of a prop. A changed cell renders // in the browser with the plugins its page used, which load with the first change, and the changes ride in the URL's -// `edit` parameter, so a link or a screenshot shows them. docs/app/pages/play/gallery.vue starts it on mount and stops +// `code` parameter as files, through @vite-hub/ui/playground, so a link or a screenshot shows them. docs/app/pages/play/gallery.vue starts it on mount and stops // it on unmount, since the docs app navigates without loading pages. import { cellDocument } from "./preview.ts"; -import { pack, unpack } from "./play-link.ts"; +import { + decodePlaygroundLink, + encodePlaygroundLink, + playgroundHashKey, + type PlaygroundFiles, +} from "@vite-hub/ui-playground/playground"; +import { playLink } from "./play-link.ts"; import { errorText } from "../shared/text.ts"; export interface Cell { @@ -438,33 +444,36 @@ export function startGallery(root: HTMLElement, data: GalleryData): () => void { let saving: ReturnType | undefined; let saveVersion = 0; - function savedState() { - const edits = Object.fromEntries( - [...sources].filter(([id, source]) => data.cells[id] && data.cells[id].source !== source), - ); - return Object.keys(edits).length || added.length - ? { - edits, - added: added.map((cell) => ({ ...cell, source: sources.get(cell.id) ?? cell.source })), - } - : undefined; - } + // As files, each cell's Markdown is `.md`, and `added.json` places the copies. The examples' own Markdown is the + // base, so a link holds only the cells that changed. + const fileOf = (id: string) => `${id}.md`; + const examplesFiles: PlaygroundFiles = Object.fromEntries( + Object.entries(data.cells).map(([id, cell]) => [fileOf(id), cell.source]), + ); + const edited = () => + added.length > 0 || [...sources].some(([id, source]) => data.cells[id]?.source !== source); async function saveNow(): Promise { clearTimeout(saving); const version = saveVersion; - const state = savedState(); - const code = state ? await pack(JSON.stringify(state)) : undefined; + const files: PlaygroundFiles = Object.fromEntries( + [...sources].map(([id, source]) => [fileOf(id), source]), + ); + if (added.length) + files["added.json"] = JSON.stringify( + added.map(({ id, after, label }) => ({ id, after, label })), + ); + const code = edited() ? await encodePlaygroundLink({ files }, examplesFiles) : undefined; // Compression can finish after another edit or reset. Only the current snapshot may reach the URL. if (version !== saveVersion) return saveNow(); const url = new URL(location.href); - if (code) url.searchParams.set("edit", code); - else url.searchParams.delete("edit"); + if (code) url.searchParams.set(playgroundHashKey, code); + else url.searchParams.delete(playgroundHashKey); if (!signal.aborted) history.replaceState(history.state, "", url); return url.href; } function save() { saveVersion++; - clear.hidden = share.hidden = !savedState(); + clear.hidden = share.hidden = !edited(); clearTimeout(saving); saving = setTimeout(() => void saveNow().catch((error) => reportSharing(error)), 250); } @@ -472,7 +481,7 @@ export function startGallery(root: HTMLElement, data: GalleryData): () => void { // A cell's Playground link, and the inspector's while it's open, take the cell's Markdown as it is now. async function link(id: string): Promise { const source = sources.get(id) ?? ""; - const href = `/play#md=${await pack(source)}`; + const href = await playLink(source); if (source !== (sources.get(id) ?? "")) return link(id); const own = article(id)?.querySelector('[data-action="play"]'); if (own) own.href = href; @@ -1092,19 +1101,22 @@ export function startGallery(root: HTMLElement, data: GalleryData): () => void { }); // A link's changes render as the page opens. - const edit = new URLSearchParams(location.search).get("edit"); - if (edit) { + const code = new URLSearchParams(location.search).get(playgroundHashKey); + if (code) { pending.add(""); gallery.dataset.busy = ""; - void unpack(edit) - .then((text) => { - const saved: { edits?: Record; added?: Added[] } = JSON.parse(text); - for (const [id, source] of Object.entries(saved.edits ?? {})) - if (data.cells[id]) change(id, source); - for (const cell of saved.added ?? []) { + void decodePlaygroundLink(code) + .then((state) => { + if (!state) throw new Error("The link is cut off or changed."); + const copiesOf: Omit[] = JSON.parse(state.files["added.json"] ?? "[]"); + for (const id of Object.keys(data.cells)) { + const source = state.files[fileOf(id)]; + if (source !== undefined) change(id, source); + } + for (const cell of copiesOf) { const number = Number(cell.id.split("--")[1]); if (Number.isFinite(number)) copies = Math.max(copies, number); - addCell(cell.after, cell.label, cell.source, cell.id); + addCell(cell.after, cell.label, state.files[fileOf(cell.id)] ?? "", cell.id); } hold(); keep(); diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts index 0c47c064..30fb4cd2 100644 --- a/docs/nuxt.config.ts +++ b/docs/nuxt.config.ts @@ -8,8 +8,9 @@ export default defineNuxtConfig({ compatibilityDate: "2026-10-01", devtools: { enabled: false }, telemetry: false, + // Load the renderer's styles before the current UI styles, which the modules append below. + css: ["@vite-hub/ui-playground/styles.css", "~/assets/landing.css"], // vite-hub/nuxt first: @vite-hub/docs turns its Comments on. - css: ["~/assets/landing.css"], modules: [ "vite-hub/nuxt", "@vite-hub/docs/nuxt", @@ -109,4 +110,7 @@ export default defineNuxtConfig({ ], prerender: { ignore: excludedLinks, routes: ["/play", "/play/gallery", "/badges"] }, }, + // The playground's preview is a sandboxed frame with no origin, so a font it loads needs CORS. On the Worker, + // docs/server/middleware/assets.ts sets it. + routeRules: { "/play/fonts/**": { headers: { "access-control-allow-origin": "*" } } }, }); diff --git a/docs/package.json b/docs/package.json index 69ad45d7..e6e71d65 100644 --- a/docs/package.json +++ b/docs/package.json @@ -36,6 +36,7 @@ "@vite-hub/schedule": "catalog:", "@vite-hub/shell": "catalog:", "@vite-hub/ui": "catalog:", + "@vite-hub/ui-playground": "catalog:", "@vite-hub/workflow": "catalog:", "@vite-hub/workspace": "catalog:", "agents": "^0.33.0", diff --git a/docs/pages/mermaid/index.md b/docs/pages/mermaid/index.md index 9bf3c5bf..9f145bf4 100644 --- a/docs/pages/mermaid/index.md +++ b/docs/pages/mermaid/index.md @@ -124,7 +124,7 @@ pele({ languages: ["mermaid"] }); // only `mermaid` fences, not `pele` ones ### Check a diagram before you publish -A diagram Pele can't draw shows its source as code, with the error in the wrapper's `data-error`. Nothing goes in `meta.issues`, so try it in the [playground](/play#preset=mermaid-flowchart) first. +A diagram Pele can't draw shows its source as code, with the error in the wrapper's `data-error`. Nothing goes in `meta.issues`, so try it in the [playground](/play#code=q1YqUbJSyk0tyk3MTNFNy8kvT85ILCpRqgUA) first. ### Comark's own mermaid plugin diff --git a/docs/play-link.ts b/docs/play-link.ts index bbf3102a..70f59791 100644 --- a/docs/play-link.ts +++ b/docs/play-link.ts @@ -1,35 +1,40 @@ -// A playground link carries its Markdown in the hash, deflated, so nothing is stored anywhere. The pages' examples, -// the playground, and the gallery all write it, in the browser or on the server, so it uses the web's streams. -export const sharingUnsupported = - "Sharing is unavailable in this browser (raw deflate is not supported)."; +// The playground's state is files: the Markdown in `content.md`, the options in `config.json`, and the Theme pane's +// `theme.json`. A link carries them in the hash through @vite-hub/ui-playground/playground, so nothing is stored +// anywhere. A preset is a template with the default config and theme, so a link to it holds only its id and what +// changed. The pages' examples, the playground, and the gallery all write links, in the browser or on the server. +import { + encodePlaygroundLink, + playgroundUrl, + type PlaygroundTemplate, +} from "@vite-hub/ui-playground/playground"; +import { defaultConfig } from "./config.ts"; +import { defaultThemeText } from "./play-theme.ts"; -function compressionStream(kind: "compress" | "decompress") { - const Stream = - kind === "compress" ? globalThis.CompressionStream : globalThis.DecompressionStream; - if (typeof Stream !== "function") throw new Error(sharingUnsupported); - try { - return new Stream("deflate-raw"); - } catch { - throw new Error(sharingUnsupported); - } -} - -export async function pack(text: string) { - const stream = new Blob([text]).stream().pipeThrough(compressionStream("compress")); - const bytes = new Uint8Array(await new Response(stream).arrayBuffer()); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); -} - -export async function unpack(code: string) { - const binary = atob(code.replaceAll("-", "+").replaceAll("_", "/")); - const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); - const stream = new Blob([bytes]).stream().pipeThrough(compressionStream("decompress")); - return new Response(stream).text(); -} +export const contentFile = "content.md"; +export const configFile = "config.json"; +export const themeFile = "theme.json"; /** An include reads files, so the playground can't run it. */ export const playable = (source: string) => !/^:+include\b/m.test(source); -export const playLink = async (source: string) => `/play#md=${await pack(source)}`; +export const templateOf = (preset: { id: string; source: string }): PlaygroundTemplate => ({ + id: preset.id, + files: { + [contentFile]: preset.source, + [configFile]: defaultConfig, + [themeFile]: defaultThemeText, + }, +}); + +/** The code of a link that opens the preset as it is. */ +export const presetCode = (preset: { id: string; source: string }) => { + const { id, files } = templateOf(preset); + return encodePlaygroundLink({ template: id, files }, files); +}; + +/** A link that opens `source` in the playground, with the default config, so the Config pane shows it, and theme. */ +export const playLink = async (source: string) => + playgroundUrl( + "/play", + await encodePlaygroundLink({ files: { [contentFile]: source, [configFile]: defaultConfig } }), + ); diff --git a/docs/play-links.ts b/docs/play-links.ts index e5b8e479..1e26f988 100644 --- a/docs/play-links.ts +++ b/docs/play-links.ts @@ -1,11 +1,12 @@ -// Opens every playground link in the built docs the way the playground does. A link carries its state in the fragment: -// a preset by id, or Markdown packed by play-link.ts. The preset must exist, or the Markdown must unpack and parse with -// the playground's plugins and default config without issues. docs/links.ts checks the rest of each link. +// Opens every playground link in the built docs the way the playground does. A link carries its files in the fragment, +// `#code=…`, with a preset as its template. The code must decode against the presets, and the Markdown must parse with +// the playground's plugins and the link's config without issues. docs/links.ts checks the rest of each link. import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { parseMarkdown } from "comark"; import { defaultConfig, readConfig } from "./config.ts"; -import { playable, unpack } from "./play-link.ts"; +import { decodePlaygroundLink, readPlaygroundHash } from "@vite-hub/ui-playground/playground"; +import { configFile, contentFile, playable } from "./play-link.ts"; import { browserPlugins } from "./plugins.ts"; const root = new URL("./dist/public/", import.meta.url).pathname; @@ -20,33 +21,32 @@ const pages = (dir: string): string[] => : [], ); -const presets = new Set( - (JSON.parse(readFileSync(join(root, "play/presets.json"), "utf8")) as { id: string }[]).map( - (preset) => preset.id, - ), -); -const { config } = readConfig(defaultConfig); +const { presets: templates } = JSON.parse( + readFileSync(join(root, "play/presets.json"), "utf8"), +) as { + presets: { id: string; files: Record }[]; +}; async function problemOf(hash: string) { - const state = new URLSearchParams(hash.slice(1)); - const preset = state.get("preset"); - if (preset) return presets.has(preset) ? undefined : `the preset "${preset}" doesn't exist`; - const packed = state.get("md"); - if (!packed) return; - const source = await unpack(packed).catch(() => undefined); - if (!source) return "its Markdown doesn't unpack"; + const code = readPlaygroundHash(hash); + if (!code) return "it has no `#code=`"; + const state = await decodePlaygroundLink(code, templates); + if (!state) return "its code doesn't decode, or names a preset that doesn't exist"; + const source = state.files[contentFile]; + if (source === undefined) return `it has no ${contentFile}`; if (!playable(source)) return "its Markdown includes files, which the playground can't read"; + const { config, issues } = readConfig(state.files[configFile] ?? defaultConfig); const { meta } = await parseMarkdown(source, { plugins: browserPlugins(config, { download: false }), }); - return meta.issues?.length ? `the playground reports: ${meta.issues.join(" ")}` : undefined; + const all = [...issues, ...(meta.issues ?? [])]; + return all.length ? `the playground reports: ${all.join(" ")}` : undefined; } const checked = new Map>(); const problems = new Set(); let links = 0; -// The preset column is a piece of the playground, not a page. -for (const file of pages(root).filter((file) => relative(root, file) !== "play/presets.html")) { +for (const file of pages(root)) { const page = `/${relative(root, file)}`.replace(/(\/index)?\.html$/, "") || "/"; for (const [, hash] of readFileSync(file, "utf8").matchAll(/href="\/play\/?(#[^"]+)"/g)) { const state = hash!.replaceAll("&", "&"); diff --git a/docs/play-theme.ts b/docs/play-theme.ts index debe1eab..8db72193 100644 --- a/docs/play-theme.ts +++ b/docs/play-theme.ts @@ -1,5 +1,5 @@ -// The playground's Theme pane: the shared `--comark-*` tokens, its presets, and the CSS it copies. No DOM here, so the -// build can write the pane's markup from the same lists. +// The playground's Theme pane: the shared `--comark-*` tokens, its presets, and the CSS it copies. Only schemeOf() +// reads the DOM, so the build and the tests can load the rest. export const colorTokens = [ "background", "foreground", @@ -64,6 +64,39 @@ export const themePresets: { id: string; label: string; tokens: Theme["tokens"]; export const defaultTheme = (): Theme => ({ preset: "shadcn", tokens: {}, css: "" }); +/** The text of `theme.json`. A preset's own file is the default theme's, so a link leaves it out until it changes. */ +export const themeText = (theme: Theme) => `${JSON.stringify(theme, null, 2)}\n`; +export const defaultThemeText = themeText(defaultTheme()); + +/** The theme's scheme, or the system's when it sets none. */ +export const schemeOf = (theme: Theme) => + theme.scheme ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + +/** The shadcn tokens the plugins fall back to, for a preview that sets none: a page's own. */ +export function schemeCss(scheme: "light" | "dark") { + const fallback = + scheme === "dark" + ? { + background: "#0a0a0a", + foreground: "#fafafa", + "muted-foreground": "#a1a1a1", + border: "rgb(255 255 255 / 0.1)", + accent: "#262626", + ring: "#737373", + } + : { + background: "#fff", + foreground: "#0a0a0a", + "muted-foreground": "#737373", + border: "#e5e5e5", + accent: "#f5f5f5", + ring: "#a1a1a1", + }; + return `:root {\n${Object.entries(fallback) + .map(([name, value]) => ` --${name}: ${value};`) + .join("\n")}\n}`; +} + /** The CSS a reader copies: a `:root` block of the tokens they set, then their own CSS. */ export function themeCss(theme: Theme) { const lines = tokens.flatMap((token) => @@ -77,8 +110,20 @@ export function themeCss(theme: Theme) { .join("\n\n"); } -/** Reads a theme from a shared link, keeping only what the pane can show. */ -export function readTheme(value: unknown): Theme { +/** Reads `theme.json`, keeping only what the pane can show. A file that isn't a theme gives the default and an issue. */ +export function readTheme(text: string | undefined): { theme: Theme; issue?: string } { + if (!text?.trim()) return { theme: defaultTheme() }; + try { + return { theme: themeOf(JSON.parse(text)) }; + } catch { + return { + theme: defaultTheme(), + issue: "The theme in theme.json is not valid; the preview uses the default theme.", + }; + } +} + +function themeOf(value: unknown): Theme { const theme = defaultTheme(); if (typeof value !== "object" || value === null) throw new Error("Not a theme."); const record = value as Record; diff --git a/docs/play.ts b/docs/play.ts index e8c140d7..bb766331 100644 --- a/docs/play.ts +++ b/docs/play.ts @@ -1,145 +1,90 @@ -// The playground: renders the editor's Markdown in the browser with every plugin that runs there, but not Shiki, -// which would triple the bundle. docs/app/pages/play/index.vue starts it on mount and stops it on unmount, since the -// docs app navigates without loading pages. +// The playground's Comark side. PlaygroundRender runs the editor, presets, links, and preview; this renders its files +// with every plugin that runs in a browser, but not Shiki, which would triple the bundle, and fits the presets' +// stills. docs/app/pages/play/index.vue loads it on mount. import { renderHtmlFromDocument } from "@comark/html"; +import type { + PlaygroundFiles, + PlaygroundPreset, + PlaygroundResult, +} from "@vite-hub/ui-playground/playground-render"; import { parseMarkdown } from "comark"; import { defaultConfig, readConfig } from "./config.ts"; -import { defaultTheme, readTheme, themeCss, themePresets, type Theme } from "./play-theme.ts"; +import { readTheme } from "./play-theme.ts"; +import { configFile, contentFile, themeFile } from "./play-link.ts"; import { browserPlugins, components } from "./plugins.ts"; -import { previewDocument } from "./preview.ts"; -import { pack, sharingUnsupported, unpack } from "./play-link.ts"; import { errorText } from "../shared/text.ts"; +export { playDocument } from "./preview.ts"; -export interface Preset { - id: string; - source: string; +/** A preset as docs/tools.ts writes it to /play/presets.json: its files, its package, and its still. */ +export interface Preset extends PlaygroundPreset { npm: string; + still: string; } -/** Starts the playground in `root`, which holds the page's markup, and returns what stops it. */ -export async function startPlayground( - root: HTMLElement, - presets: Preset[], - onPackage: (npm: string) => void, -): Promise<() => void> { - const stopped = new AbortController(); - const { signal } = stopped; - const pending = new Set>(); - // Vue Router keeps its own state in history.state, so a replace keeps it, and none lands after the page has gone. - const replace = (url: string) => { - if (!signal.aborted) history.replaceState(history.state, "", url); - }; - const later = (run: () => void, ms: number) => { - const id = setTimeout(() => { - pending.delete(id); - run(); - }, ms); - pending.add(id); - }; - - function find(selector: string, type: new () => T) { - const element = root.querySelector(selector); - if (!(element instanceof type)) throw new Error(`The playground needs ${selector}.`); - return element; - } - const editor = find('.play-code[data-pane="markdown"] textarea', HTMLTextAreaElement); - const configEditor = find('.play-code[data-pane="config"] textarea', HTMLTextAreaElement); - const panes = find(".play-panes", HTMLDivElement); - const preview = find(".play-preview", HTMLIFrameElement); - const issues = find(".play-issues", HTMLUListElement); - const share = find(".play-share", HTMLButtonElement); - const copy = find(".play-copy", HTMLButtonElement); - const tools = find(".play-tools", HTMLDivElement); - const themePane = find(".play-theme", HTMLDivElement); - const schemes = find(".play-scheme", HTMLDivElement); - const themeButtons = find(".play-theme-presets", HTMLDivElement); - const themeReset = find(".play-theme-reset", HTMLButtonElement); - const themeCopy = find(".play-theme-copy", HTMLButtonElement); - const extraCss = find("#play-theme-css", HTMLTextAreaElement); - const reset = find(".play-reset", HTMLButtonElement); - const confirmReset = find(".play-confirm", HTMLDialogElement); - - // A shared link carries its Markdown in the hash, deflated, so nothing is stored anywhere. A changed config rides - // along the same way. - - // The editor is a transparent textarea over its own text, colored. Until the grammar loads, the text under it is - // plain. The extra line keeps a trailing newline as tall as the textarea's. - let highlight: ((code: string, lang: "mdc" | "json") => string) | undefined; - function repaint(textarea = editor) { - const paint = textarea.previousElementSibling; - if (!paint) return; - const text = `${textarea.value}\n `; - if (highlight) paint.innerHTML = highlight(text, textarea === editor ? "mdc" : "json"); - else paint.replaceChildren(Object.assign(document.createElement("pre"), { textContent: text })); - } - // The grammar is as big as the rest of the playground, so it loads after the first render instead of delaying it. - const loadHighlight = () => - import("./highlight.ts").then((module) => { - highlight = module.highlight; - repaint(editor); - repaint(configEditor); +export async function render(files: PlaygroundFiles): Promise { + const { config, issues: configIssues } = readConfig(files[configFile] ?? defaultConfig); + const { issue: themeIssue } = readTheme(files[themeFile]); + const issues = [...configIssues, ...(themeIssue ? [themeIssue] : [])]; + try { + const parsed = await parseMarkdown(files[contentFile] ?? "", { + plugins: browserPlugins(config, { download: false }), }); + const html = await renderHtmlFromDocument(parsed, { components }); + return { html, issues: [...issues, ...parsed.meta.issues].map((message) => ({ message })) }; + } catch (error) { + return { html: "", issues: [...issues, errorText(error)].map((message) => ({ message })) }; + } +} - // The Markdown, the config, and the theme share the editor's column; the bar's switch shows one of them. The theme - // has its own Reset and Copy, so the corner's are for the code panes. - panes.addEventListener("click", (event) => { - const button = event.target instanceof Element ? event.target.closest("button") : null; - if (!button?.dataset.pane) return; - for (const other of panes.querySelectorAll("button")) - other.setAttribute("aria-pressed", String(other === button)); - for (const pane of root.querySelectorAll(".play-code, .play-theme")) - pane.hidden = pane.dataset.pane !== button.dataset.pane; - tools.hidden = button.dataset.pane === "theme"; - copy.setAttribute( - "aria-label", - button.dataset.pane === "config" ? "Copy the config" : "Copy the Markdown", - ); +// Each still in the presets' column is a page scaled to show all of itself, unless that would fill less than most of +// its card's width; then it fills that much and shows its top. A page that fits whole lays out at whichever width +// shows it largest, so a sentence wraps narrow and stays legible. A chart or a screenshot, whose height follows its +// width, gains nothing narrower and keeps the page's width. +const NARROWER = [448, 320, 224]; +// Reading a page's height lays out the whole playground, a few hundred stills, so every still takes a width before any +// is read: one layout per width, not one per width per still, which took seconds. +function fit(thumbs: Set) { + const stills = [...thumbs].flatMap((thumb) => { + const page = thumb.firstElementChild; + return page instanceof HTMLElement && thumb.clientWidth && page.offsetWidth + ? [{ page, width: thumb.clientWidth, height: thumb.clientHeight, best: 640, largest: 0 }] + : []; }); - - // Each still in the presets' column is a page scaled to show all of itself, unless that would fill less than most of - // its card's width; then it fills that much and shows its top. A page that fits whole lays out at whichever width - // shows it largest, so a sentence wraps narrow and stays legible. A chart or a screenshot, whose height follows its - // width, gains nothing narrower and keeps the page's width. - const NARROWER = [448, 320, 224]; - // Reading a page's height lays out the whole playground, a few hundred stills, so every still takes a width before any - // is read: one layout per width, not one per width per still, which took seconds. - function fit(thumbs: Set) { - const stills = [...thumbs].flatMap((thumb) => { - const page = thumb.firstElementChild; - return page instanceof HTMLElement && thumb.clientWidth && page.offsetWidth - ? [{ page, width: thumb.clientWidth, height: thumb.clientHeight, best: 640, largest: 0 }] - : []; - }); - const whole = (pageWidth: (still: (typeof stills)[number]) => number) => { - for (const still of stills) still.page.style.width = `${pageWidth(still)}px`; - return stills.map((still) => - Math.min(still.width / pageWidth(still), still.height / still.page.offsetHeight), - ); - }; - for (const [index, scale] of whole(({ best }) => best).entries()) - stills[index]!.largest = scale; - // Only a clearly larger still is worth a narrower layout. - for (const pageWidth of NARROWER) - for (const [index, scale] of whole(() => pageWidth).entries()) { - const still = stills[index]!; - if (scale > still.largest * 1.15) [still.best, still.largest] = [pageWidth, scale]; - } - for (const still of stills) still.page.style.width = `${still.best}px`; - const sizes = stills.map(({ page }) => ({ - pageWidth: page.offsetWidth, - pageHeight: page.offsetHeight, - })); - for (const [index, { page, width, height }] of stills.entries()) { - const { pageWidth, pageHeight } = sizes[index]!; - const scale = Math.min( - width / pageWidth, - Math.max(height / pageHeight, (width * 0.7) / pageWidth), - ); - const x = (width - pageWidth * scale) / 2; - const y = Math.max(0, (height - pageHeight * scale) / 2); - page.style.transform = `translate(${x}px, ${y}px) scale(${scale})`; + const whole = (pageWidth: (still: (typeof stills)[number]) => number) => { + for (const still of stills) still.page.style.width = `${pageWidth(still)}px`; + return stills.map((still) => + Math.min(still.width / pageWidth(still), still.height / still.page.offsetHeight), + ); + }; + for (const [index, scale] of whole(({ best }) => best).entries()) stills[index]!.largest = scale; + // Only a clearly larger still is worth a narrower layout. + for (const pageWidth of NARROWER) + for (const [index, scale] of whole(() => pageWidth).entries()) { + const still = stills[index]!; + if (scale > still.largest * 1.15) [still.best, still.largest] = [pageWidth, scale]; } + for (const still of stills) still.page.style.width = `${still.best}px`; + const sizes = stills.map(({ page }) => ({ + pageWidth: page.offsetWidth, + pageHeight: page.offsetHeight, + })); + for (const [index, { page, width, height }] of stills.entries()) { + const { pageWidth, pageHeight } = sizes[index]!; + const scale = Math.min( + width / pageWidth, + Math.max(height / pageHeight, (width * 0.7) / pageWidth), + ); + const x = (width - pageWidth * scale) / 2; + const y = Math.max(0, (height - pageHeight * scale) / 2); + page.style.transform = `translate(${x}px, ${y}px) scale(${scale})`; } +} + +/** + * Fits the stills in `column` as they near the screen, and scrolls the column to the open preset when it is out of + * view, as a package's "Open in playground" lands far down it. Returns what stops both. + */ +export function watchPresets(column: HTMLElement): () => void { const fitting = new ResizeObserver((entries) => fit( new Set( @@ -168,384 +113,22 @@ export async function startPlayground( }, { rootMargin: "50% 0px" }, ); - for (const thumb of root.querySelectorAll(".preset-thumb")) nearing.observe(thumb); - function showIssues(list: string[]) { - issues.replaceChildren( - ...list.map((issue) => Object.assign(document.createElement("li"), { textContent: issue })), - ); - issues.hidden = !list.length; - } + for (const thumb of column.querySelectorAll(".preset-thumb")) nearing.observe(thumb); - function markPreset(id?: string) { - for (const link of root.querySelectorAll(".play-presets a[data-preset]")) - link.toggleAttribute("aria-current", link.dataset.preset === id); - } - - // A package's "Open in playground" lands on its example, which can sit far down the presets' column, so the column - // scrolls to center it. One already in view stays put, so a click in the column doesn't move it. - function revealPreset(id?: string) { - const card = root.querySelector(`.play-presets a[data-preset="${id}"]`)?.closest(".preset"); - const column = card?.closest(".play-presets"); - if (!(card instanceof HTMLElement) || !(column instanceof HTMLElement) || !column.clientHeight) - return; + // One already in view stays put, so a click in the column doesn't move it. + function reveal() { + const card = column.querySelector("[aria-current='true']"); + if (!card || !column.clientHeight) return; const top = card.getBoundingClientRect().top - column.getBoundingClientRect().top; if (top >= 0 && top + card.offsetHeight <= column.clientHeight) return; column.scrollTop += top - (column.clientHeight - card.offsetHeight) / 2; } - - // The example the Markdown started from. Once the Markdown differs from it, Reset appears and brings it back. - let base: (typeof presets)[number] | undefined; - function markEdited() { - reset.hidden = !base || editor.value === base.source; - if (base) markPreset(reset.hidden ? base.id : undefined); - } - - // The package the preset documents. A shared link's Markdown could use any of them, so it gets comark-ui, which - // installs all of them. The page shows it beside the editor. - function pointNpmx(npm?: string) { - onPackage(npm || "comark-ui"); - } - - // The Theme pane. Its tokens and CSS go in the preview's #play-theme and its scheme on the preview's root, so a change - // shows without rendering the Markdown again. - let theme = defaultTheme(); - const systemDark = matchMedia("(prefers-color-scheme: dark)"); - const scheme = () => theme.scheme ?? (systemDark.matches ? "dark" : "light"); - const tokenFields = [...themePane.querySelectorAll("input[data-token]")]; - const field = (token: string) => tokenFields.find((input) => input.dataset.token === token); - const swatches = [...themePane.querySelectorAll("input[data-swatch]")]; - const radius = find('.play-theme input[data-slider="radius"]', HTMLInputElement); - const shadcnNames: Record = { muted: "muted-foreground" }; - - // Each swatch shows the color the preview draws now, flattened onto its background: a canvas pixel turns any CSS - // color, oklch or translucent, into the hex a color input takes. - const pixel = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); - function hex(...layers: string[]) { - if (!pixel) return "#000000"; - pixel.clearRect(0, 0, 1, 1); - for (const layer of layers) { - pixel.fillStyle = layer; - pixel.fillRect(0, 0, 1, 1); - } - const [red = 0, green = 0, blue = 0] = pixel.getImageData(0, 0, 1, 1).data; - return `#${[red, green, blue].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; - } - - function showTheme() { - const doc = preview.contentDocument; - const view = doc?.defaultView; - if (!doc?.body || !view) return; - const currentScheme = scheme(); - doc.documentElement.className = currentScheme; - doc.documentElement.style.colorScheme = currentScheme; - const fallback = - currentScheme === "dark" - ? { - background: "#0a0a0a", - foreground: "#fafafa", - "muted-foreground": "#a1a1a1", - border: "rgb(255 255 255 / 0.1)", - accent: "#262626", - ring: "#737373", - } - : { - background: "#fff", - foreground: "#0a0a0a", - "muted-foreground": "#737373", - border: "#e5e5e5", - accent: "#f5f5f5", - ring: "#a1a1a1", - }; - const style = - doc.getElementById("play-theme") ?? - doc.head.appendChild(Object.assign(doc.createElement("style"), { id: "play-theme" })); - const fallbackCss = `:root {\n${Object.entries(fallback) - .map(([name, value]) => ` --${name}: ${value};`) - .join("\n")}\n}`; - style.textContent = [fallbackCss, themeCss(theme)].filter(Boolean).join("\n\n"); - const probe = doc.body.appendChild(doc.createElement("i")); - probe.hidden = true; - const color = (value: string) => { - probe.style.color = value; - return view.getComputedStyle(probe).color; - }; - const background = color("var(--comark-background, var(--background))"); - for (const swatch of swatches) { - const token = swatch.dataset.swatch ?? ""; - const fallback = `var(--${shadcnNames[token] ?? token})`; - swatch.value = hex(background, color(`var(--comark-${token}, ${fallback})`)); - const input = field(token); - if (input) input.placeholder = hex(background, color(fallback)); - } - probe.style.borderRadius = "var(--comark-radius, var(--radius, 0.625rem))"; - radius.value = String(parseFloat(view.getComputedStyle(probe).borderTopLeftRadius) / 16); - probe.style.fontFamily = - "var(--comark-font, var(--font-sans, ui-sans-serif, system-ui, sans-serif))"; - const font = field("font"); - if (font && !theme.tokens.font) font.placeholder = view.getComputedStyle(probe).fontFamily; - const radiusField = field("radius"); - if (radiusField) radiusField.placeholder = "0.625rem"; - probe.remove(); - } - - // The switches show the theme's preset and scheme, and Reset appears once the theme differs from its preset. A click - // on a switch moves its sliding indicator, so one is sent when the script changes it. - function markTheme() { - for (const [group, pressed] of [ - [themeButtons, theme.preset], - [schemes, scheme()], - ] as const) { - for (const button of group.querySelectorAll("button")) - button.setAttribute( - "aria-pressed", - String((button.dataset.theme ?? button.dataset.scheme) === pressed), - ); - group.dispatchEvent(new Event("click")); - } - const preset = themePresets.find((preset) => preset.id === theme.preset); - themeReset.hidden = - !preset || - (preset.css === theme.css && - tokenFields.every(({ dataset: { token = "" } }) => { - const key = token as keyof Theme["tokens"]; - return (preset.tokens[key] ?? "") === (theme.tokens[key] ?? ""); - })); - } - - function setTheme(next: Theme) { - theme = next; - for (const input of tokenFields) - input.value = theme.tokens[(input.dataset.token ?? "") as keyof Theme["tokens"]] ?? ""; - extraCss.value = theme.css; - // Extra CSS stays folded until there is some to show. - extraCss.closest("details")?.toggleAttribute("open", Boolean(theme.css)); - showTheme(); - markTheme(); - } - - let themeTimer: ReturnType | undefined; - function themeChanged() { - showTheme(); - markTheme(); - clearTimeout(themeTimer); - themeTimer = setTimeout(async () => { - try { - replace(await link()); - } catch (error) { - showIssues([errorText(error)]); - } - }, 200); - } - - function setToken(token: string, value: string) { - const key = token as keyof Theme["tokens"]; - if (value.trim()) theme.tokens[key] = value.trim(); - else delete theme.tokens[key]; - themeChanged(); - } - - function pickPreset(id: string) { - const preset = themePresets.find((preset) => preset.id === id); - if (!preset) return; - setTheme({ preset: id, tokens: { ...preset.tokens }, css: preset.css, scheme: theme.scheme }); - themeChanged(); - } - - themePane.addEventListener("input", (event) => { - const input = event.target; - if (input === extraCss) { - theme.css = extraCss.value; - themeChanged(); - } - if (!(input instanceof HTMLInputElement)) return; - const { token, swatch, slider } = input.dataset; - const target = field(swatch ?? slider ?? ""); - if (target) target.value = slider ? `${input.value}rem` : input.value; - if (token ?? target?.dataset.token) - setToken(token ?? target?.dataset.token ?? "", target?.value ?? input.value); - }); - themeButtons.addEventListener("click", (event) => { - const id = event.target instanceof Element && event.target.closest("button")?.dataset.theme; - if (id) pickPreset(id); - }); - schemes.addEventListener("click", (event) => { - const value = event.target instanceof Element && event.target.closest("button")?.dataset.scheme; - if (value !== "light" && value !== "dark") return; - theme.scheme = value; - themeChanged(); - }); - themeReset.addEventListener("click", () => pickPreset(theme.preset)); - themeCopy.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText( - themeCss(theme) || "/* The default theme: nothing to set. */", - ); - themeCopy.textContent = "Copied"; - later(() => (themeCopy.textContent = "Copy CSS"), 1500); - } catch (error) { - showIssues([errorText(error)]); - } - }); - systemDark.addEventListener( - "change", - () => { - if (theme.scheme) return; - showTheme(); - markTheme(); - }, - { signal }, - ); - preview.addEventListener("load", showTheme); - showTheme(); - markTheme(); - - let run = 0; - let loadIssues: string[] = []; - async function render() { - const current = ++run; - let html = ""; - const { config, issues: configIssues } = readConfig(configEditor.value); - let problems: string[] = []; - try { - const parsed = await parseMarkdown(editor.value, { - plugins: browserPlugins(config, { download: false }), - }); - html = await renderHtmlFromDocument(parsed, { components }); - problems = [...loadIssues, ...configIssues, ...parsed.meta.issues]; - } catch (error) { - problems = [...loadIssues, ...configIssues, errorText(error)]; - } - // A slower render of older text must not replace a newer one. - if (current !== run) return; - const top = preview.contentWindow?.scrollY ?? 0; - preview.addEventListener("load", () => preview.contentWindow?.scrollTo(0, top), { once: true }); - preview.srcdoc = previewDocument(html, { scheme: scheme(), css: themeCss(theme) }); - showIssues(problems); - } - - async function link() { - const config = - configEditor.value === defaultConfig ? "" : `&config=${await pack(configEditor.value)}`; - const themed = - themeCss(theme) || theme.preset !== "shadcn" || theme.scheme - ? `&theme=${await pack(JSON.stringify(theme))}` - : ""; - return `#md=${await pack(editor.value)}${config}${themed}`; - } - - let loadRun = 0; - async function load() { - if (signal.aborted) return; - const run = ++loadRun; - const navigationIssues: string[] = []; - const hash = new URLSearchParams(location.hash.slice(1)); - const code = hash.get("md"); - const config = hash.get("config"); - const sharedTheme = hash.get("theme"); - // A preset keeps whatever config is in the tab, so one config can be tried across them. - if (config !== null) { - const decodedConfig = await unpack(config).catch(() => { - navigationIssues.push("The link's config is cut off or changed; using the default config."); - return defaultConfig; - }); - if (run !== loadRun) return; - configEditor.value = decodedConfig; - repaint(configEditor); - } else if (code !== null) configEditor.value = defaultConfig; - // The theme rides along like the config. - let nextTheme: Theme | undefined; - if (sharedTheme !== null) - nextTheme = await unpack(sharedTheme) - .then((text) => readTheme(JSON.parse(text))) - .catch(() => { - navigationIssues.push("The link's theme is cut off or changed; using the default theme."); - return defaultTheme(); - }); - else if (code !== null) nextTheme = defaultTheme(); - if (run !== loadRun) return; - if (nextTheme) setTheme(nextTheme); - const preset = - presets.find((preset) => preset.id === hash.get("preset")) ?? - (code === null ? presets[0] : undefined); - let markdown = preset?.source; - if (!preset && code !== null) - markdown = await unpack(code).catch((error) => - error instanceof Error && error.message === sharingUnsupported - ? sharingUnsupported - : "The link's Markdown is cut off or changed.", - ); - if (run !== loadRun) return; - loadIssues = navigationIssues; - if (markdown !== undefined) editor.value = markdown; - base = preset; - markPreset(preset?.id); - revealPreset(preset?.id); - reset.hidden = true; - pointNpmx(preset?.npm); - repaint(editor); - repaint(configEditor); - if (run === loadRun) await render(); - } - - let timer: ReturnType | undefined; - for (const textarea of [editor, configEditor]) - textarea.addEventListener("input", () => { - if (textarea === configEditor) { - loadIssues = []; - } - repaint(textarea); - clearTimeout(timer); - timer = setTimeout(async () => { - if (textarea === editor) markEdited(); - // Replaced, not pushed: Back leaves the playground instead of stepping through keystrokes. - try { - replace(await link()); - } catch (error) { - showIssues([errorText(error)]); - } - await render(); - }, 200); - }); - share.addEventListener("click", async () => { - try { - replace(await link()); - await navigator.clipboard.writeText(location.href); - share.textContent = "Copied"; - later(() => (share.textContent = "Copy link"), 1500); - } catch (error) { - showIssues([errorText(error)]); - } - }); - // Resetting drops edits that live nowhere else, so it asks first. - reset.addEventListener("click", () => { - confirmReset.returnValue = ""; - confirmReset.showModal(); - }); - confirmReset.addEventListener("close", () => { - if (confirmReset.returnValue === "reset" && base) location.hash = `preset=${base.id}`; - }); - copy.addEventListener("click", async () => { - const shown = configEditor.closest(".play-code")?.hidden ? editor : configEditor; - try { - await navigator.clipboard.writeText(shown.value); - copy.classList.add("copied"); - later(() => copy.classList.remove("copied"), 1200); - } catch (error) { - showIssues([errorText(error)]); - } - }); - addEventListener("hashchange", load, { signal }); - if (location.hash) await load(); - else { - base = presets[0]; - markPreset(base?.id); - await render(); - } - void loadHighlight(); + const current = new MutationObserver(reveal); + current.observe(column, { subtree: true, attributeFilter: ["aria-current"] }); + reveal(); return () => { - stopped.abort(); fitting.disconnect(); nearing.disconnect(); - for (const id of [timer, themeTimer, ...pending]) clearTimeout(id); + current.disconnect(); }; } diff --git a/docs/preview.ts b/docs/preview.ts index 8e1aaccd..34f87cb4 100644 --- a/docs/preview.ts +++ b/docs/preview.ts @@ -1,3 +1,7 @@ +import type { PlaygroundFiles } from "@vite-hub/ui-playground/playground-render"; +import { themeFile } from "./play-link.ts"; +import { readTheme, schemeCss, schemeOf, themeCss } from "./play-theme.ts"; + // The playground's preview is a document of its own, so the plugins' inlined scripts run as they do on a real page. // The build writes the first preset with it and the playground every edit after, with the Theme pane's scheme as a // class on the root and its CSS in #play-theme, so a render doesn't flash the default theme. @@ -7,6 +11,23 @@ export const previewDocument = (html: string, theme?: { scheme: string; css: str `` + `${html}
--comark-{{ token }}
--comark-radius
--comark-font
+ Each plugin's own variables, like --cc-* or --ck-*, or any rule, + applied to the preview. +
--cc-*
--ck-*
Playground
Your changes to the Markdown will be lost.
- Each plugin's own variables, like --cc-* or --ck-*, or any - rule, applied to the preview. -