|
| 1 | +import fs from 'node:fs/promises' |
| 2 | +import path from 'node:path' |
| 3 | +import type { |
| 4 | + PageOutlineItem, |
| 5 | + SiteConfig, |
| 6 | + TsSsgContext, |
| 7 | +} from '@purestack/ts-common' |
| 8 | +import { |
| 9 | + type ComponentVariant, |
| 10 | + defineComponents, |
| 11 | +} from '@purestack/ts-components' |
| 12 | +import { renderApp } from '@purestack/ts-render' |
| 13 | +import { SEMANTIC_TONES } from '@purestack/ts-style' |
| 14 | +import { getSvgIcon } from '@purestack/ts-svg-icons' |
| 15 | +import { withBasePath } from '@purestack/ts-util' |
| 16 | +import { defineComponent, html, type RefOrValue, unref } from 'regor' |
| 17 | + |
| 18 | +const variants: ComponentVariant[] = [ |
| 19 | + 'solid', |
| 20 | + 'surface', |
| 21 | + 'surfaceAlt', |
| 22 | + 'spotlight', |
| 23 | + 'glass', |
| 24 | + 'flat', |
| 25 | + 'flatAlt', |
| 26 | + 'flatSolid', |
| 27 | + 'outlineFill', |
| 28 | + 'outline', |
| 29 | + 'subtle', |
| 30 | + 'subtleBtn', |
| 31 | + 'link', |
| 32 | + 'sheen', |
| 33 | + 'underline', |
| 34 | + 'rail', |
| 35 | + 'bracket', |
| 36 | + 'none', |
| 37 | +] |
| 38 | +const components = [ |
| 39 | + 'SiteFooter', |
| 40 | + 'TopBar', |
| 41 | + 'NavMenu', |
| 42 | + 'NavList', |
| 43 | + 'PageToc', |
| 44 | + 'SearchBox', |
| 45 | + 'SignIn', |
| 46 | + 'Consent', |
| 47 | + 'SiteLogo', |
| 48 | +] |
| 49 | +const modes = new Set(['NavMenu', 'PageToc', 'SignIn', 'Consent']) |
| 50 | +const slugOf = (name: string) => |
| 51 | + name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() |
| 52 | +const escapeAttribute = (value: string) => |
| 53 | + value |
| 54 | + .replaceAll('&', '&') |
| 55 | + .replaceAll('"', '"') |
| 56 | + .replaceAll('<', '<') |
| 57 | + .replaceAll('>', '>') |
| 58 | +const mark = |
| 59 | + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%232563eb'/%3E%3Cpath d='m8 16 5 5 11-11' fill='none' stroke='white' stroke-width='3'/%3E%3C/svg%3E" |
| 60 | + |
| 61 | +interface Sample { |
| 62 | + label: string |
| 63 | + template: string |
| 64 | + signedIn?: boolean |
| 65 | + search?: boolean |
| 66 | + auth?: boolean |
| 67 | + signUp?: boolean |
| 68 | + outline?: PageOutlineItem[] |
| 69 | +} |
| 70 | + |
| 71 | +function contextFor(site: SiteConfig, sample: Sample): TsSsgContext { |
| 72 | + return { |
| 73 | + site: { |
| 74 | + ...site, |
| 75 | + logo: { ...site.logo, brand: 'Studio', subtitle: undefined }, |
| 76 | + pagefind: { ...site.pagefind, enabled: sample.search === true }, |
| 77 | + auth: { |
| 78 | + ...site.auth, |
| 79 | + enabled: sample.auth !== false, |
| 80 | + signUp: sample.signUp === true, |
| 81 | + }, |
| 82 | + consent: { |
| 83 | + ...site.consent, |
| 84 | + enabled: true, |
| 85 | + categories: [], |
| 86 | + services: [], |
| 87 | + bannerTitle: 'Privacy choices', |
| 88 | + bannerDescription: 'Choose how this example remembers preferences.', |
| 89 | + }, |
| 90 | + }, |
| 91 | + pageInfo: { |
| 92 | + relPath: 'components/site/index.mdx', |
| 93 | + urlPath: '/components/site/', |
| 94 | + frontmatter: { |
| 95 | + template: 'doc', |
| 96 | + hidden: false, |
| 97 | + draft: false, |
| 98 | + nav: { hidden: false }, |
| 99 | + layout: { |
| 100 | + showNav: false, |
| 101 | + showToc: false, |
| 102 | + showFooter: false, |
| 103 | + fullWidth: false, |
| 104 | + navMode: 'sidebar', |
| 105 | + tocCollapsed: false, |
| 106 | + }, |
| 107 | + }, |
| 108 | + }, |
| 109 | + theme: site.style.theme, |
| 110 | + basePath: site.basePath, |
| 111 | + locales: site.i18n.locales, |
| 112 | + defaultLocale: site.i18n.defaultLocale, |
| 113 | + resolveLocaleHref: () => undefined, |
| 114 | + resolvePublicHref: (href) => withBasePath(site.basePath, href), |
| 115 | + recordScriptEntrypoint: () => {}, |
| 116 | + recordRuntimeEmbed: () => {}, |
| 117 | + navigation: { root: '/components/', items: [] }, |
| 118 | + outline: sample.outline ?? [ |
| 119 | + { |
| 120 | + id: 'guide-intro', |
| 121 | + title: 'Introduction', |
| 122 | + depth: 2, |
| 123 | + children: [{ id: 'guide-detail', title: 'Details', depth: 3 }], |
| 124 | + }, |
| 125 | + ], |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +const previewCss = `html,body{margin:0;min-height:0;height:auto}body{padding:8px;box-sizing:border-box}.site-guide-samples{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr));gap:1rem}.site-guide-sample{min-width:0}.site-guide-sample>code{display:block;margin:0 0 1rem}.site-guide-sample iframe{width:100%;border:0;display:block}.site-guide-sample .nav__menu,.site-guide-sample .page-toc{position:static!important;width:100%!important;max-height:none!important}.site-guide-sample .page-toc__panel-toggle,.site-guide-sample .page-toc__restore-toggle,.site-guide-sample .nav__panel-toggle,.site-guide-sample .nav__restore-toggle{display:none!important}.site-guide-sample .consent{position:static!important;pointer-events:auto!important}.site-guide-sample .consent__banner{position:static!important;display:block!important;max-width:none!important;width:100%!important}.site-guide-sample .consent__panel,.site-guide-sample [data-consent-settings]{display:none!important}.site-guide-sample .site-footer{margin:0;padding:1rem}.site-guide-sample .site-footer__inner{padding:0}.site-guide-sample .site-footer__bottom{flex-direction:column!important;align-items:start!important}.site-guide-sample .sign-in__panel{position:static!important;transform:none!important;min-width:0!important;width:100%!important}.site-guide-sample .sign-in{width:100%}.site-guide-sample .topbar{position:static!important;padding:.5rem!important}.site-guide-sample .topbar__search{min-width:0}.site-guide-sample .text-title{font-size:1.15rem;line-height:1.35}.site-guide-sample .site-logo{max-width:100%}` |
| 130 | + |
| 131 | +// Each frame follows its containing page's theme and grows to show every sample. |
| 132 | +// No rendered page rewriting, global service registration, or preference mutation. |
| 133 | +const frameScript = `document.querySelectorAll('.site-guide-sample details.sign-in').forEach(menu=>menu.open=true);document.querySelectorAll('.site-guide-sample .consent__banner').forEach(banner=>{banner.hidden=false;banner.removeAttribute('aria-hidden')});const sync=()=>{document.documentElement.dataset.theme=parent.document.documentElement.dataset.theme||'light';document.documentElement.setAttribute('data-theme-ready','true')};sync();new MutationObserver(sync).observe(parent.document.documentElement,{attributes:true,attributeFilter:['data-theme']});const resize=()=>{if(frameElement)frameElement.style.height=Math.ceil(document.body.getBoundingClientRect().height)+'px'};new ResizeObserver(resize).observe(document.body);addEventListener('load',resize);` |
| 134 | + |
| 135 | +function documentFor(site: SiteConfig, body: string, signedIn = false): string { |
| 136 | + return `<!doctype html><html lang="en"${signedIn ? ' class="signed-in"' : ''}><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="${withBasePath(site.basePath, '/assets/site.css')}" data-theme="light"><link rel="stylesheet" href="${withBasePath(site.basePath, '/assets/site.dark.css')}" data-theme="dark"><style>${previewCss}</style></head><body class="template-doc doc-content tone--neutral" data-pagefind-ignore="all">${body}<script>${frameScript}</script></body></html>` |
| 137 | +} |
| 138 | + |
| 139 | +function templateFor( |
| 140 | + name: string, |
| 141 | + tone: string, |
| 142 | + variant: string, |
| 143 | + mode: string, |
| 144 | + index: number, |
| 145 | +): string { |
| 146 | + const appearance = `tone="${tone}" variant="${variant}"${modes.has(name) ? ` variantMode="${mode}"` : ''}` |
| 147 | + switch (name) { |
| 148 | + case 'SiteLogo': |
| 149 | + return `<SiteLogo brand="Studio" subtitle="Build together" :href="null" tone="${tone}" markStyle="soft" appearance="badge"/>` |
| 150 | + case 'TopBar': |
| 151 | + return `<TopBar ${appearance}/>` |
| 152 | + case 'SignIn': |
| 153 | + return `<SignIn ${appearance} open label="Workspace account"><nav aria-label="Example account destinations"><BtnLink href="/components/" tone="${tone}" variant="subtleBtn">Component library</BtnLink><BtnLink href="/components/site/site-logo/" tone="${tone}" variant="subtleBtn">Branding guide</BtnLink></nav></SignIn>` |
| 154 | + case 'Consent': |
| 155 | + return `<Consent ${appearance}/>` |
| 156 | + case 'SearchBox': |
| 157 | + return `<SearchBox ${appearance}/>` |
| 158 | + case 'NavMenu': |
| 159 | + return `<NavMenu ${appearance} currentUrl="/components/site/" :items="[{title:'Components',tone:'${tone}',children:[{title:'Site guides',url:'/components/site/',tone:'${tone}'},{title:'Layout guides',url:'/components/layout/',tone:'${tone}'}]}]"/>` |
| 160 | + case 'NavList': |
| 161 | + return `<nav aria-label="Example guide links"><NavList tone="${tone}" :items="[{title:'Site guides',url:'/components/site/',tone:'${tone}',isActive:true,isOpen:false,stateKey:'site-${index}',ariaCurrent:'page'},{title:'Layout guides',url:'/components/layout/',tone:'${tone}',isActive:false,isOpen:false,stateKey:'layout-${index}'}]"/></nav>` |
| 162 | + case 'PageToc': |
| 163 | + return `<PageToc ${appearance} title="In this guide"/>` |
| 164 | + default: |
| 165 | + return `<div id="footer-${index}"></div><SiteFooter ${appearance} teleport="#footer-${index}" copyright="© Studio" legalLabel="Example guide destinations"><strong>A shared starting point</strong><template #legal><a href="/components/site/consent/">Privacy guide</a></template><template #social><a href="/components/">Components</a></template></SiteFooter>` |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +function featureSamples(name: string): Sample[] { |
| 170 | + switch (name) { |
| 171 | + case 'TopBar': |
| 172 | + return [ |
| 173 | + { |
| 174 | + label: 'SiteLogo identity', |
| 175 | + template: '<TopBar logoComponent="SiteLogo"/>', |
| 176 | + }, |
| 177 | + { |
| 178 | + label: 'ClassicLogo identity', |
| 179 | + template: '<TopBar logoComponent="ClassicLogo"/>', |
| 180 | + }, |
| 181 | + { |
| 182 | + label: 'Search enabled by site config', |
| 183 | + template: '<TopBar/>', |
| 184 | + search: true, |
| 185 | + }, |
| 186 | + { |
| 187 | + label: 'Account access and avatar', |
| 188 | + template: `<TopBar :signInEnabled="true" signInAvatarSrc="${mark}" signInAvatarAlt="Studio account"/>`, |
| 189 | + signedIn: true, |
| 190 | + }, |
| 191 | + ] |
| 192 | + case 'SignIn': |
| 193 | + return [ |
| 194 | + { label: 'Signed out, sign-up disabled', template: '<SignIn open/>' }, |
| 195 | + { |
| 196 | + label: 'Signed out, sign-up enabled', |
| 197 | + template: '<SignIn open/>', |
| 198 | + signUp: true, |
| 199 | + }, |
| 200 | + { |
| 201 | + label: 'Signed in, default account icon', |
| 202 | + template: '<SignIn open/>', |
| 203 | + signedIn: true, |
| 204 | + }, |
| 205 | + { |
| 206 | + label: 'Signed in, custom avatar', |
| 207 | + template: `<SignIn open avatarSrc="${mark}" avatarAlt="Studio account" label="Studio"/>`, |
| 208 | + signedIn: true, |
| 209 | + }, |
| 210 | + { |
| 211 | + label: 'Custom icons and avatar slot', |
| 212 | + template: |
| 213 | + '<SignIn open icon="lucide:check" accountIcon="iconoir:check"><template #avatar><strong>PS</strong></template><a href="/components/">Custom account destination</a></SignIn>', |
| 214 | + signedIn: true, |
| 215 | + }, |
| 216 | + { |
| 217 | + label: 'Authentication disabled', |
| 218 | + template: |
| 219 | + '<SignIn/><p>With site.auth.enabled false, no account control is rendered.</p>', |
| 220 | + auth: false, |
| 221 | + }, |
| 222 | + ] |
| 223 | + case 'PageToc': |
| 224 | + return [ |
| 225 | + { |
| 226 | + label: 'Nested outline', |
| 227 | + template: templateFor(name, 'accent', 'surface', 'stateless', 0), |
| 228 | + }, |
| 229 | + { |
| 230 | + label: 'Flat outline and custom title', |
| 231 | + template: '<PageToc title="Read this chapter"/>', |
| 232 | + outline: [ |
| 233 | + { id: 'guide-intro', title: 'Introduction', depth: 2 }, |
| 234 | + { id: 'guide-detail', title: 'Details', depth: 2 }, |
| 235 | + ], |
| 236 | + }, |
| 237 | + { |
| 238 | + label: 'Empty outline', |
| 239 | + template: '<PageToc title="Waiting for sections"/>', |
| 240 | + outline: [], |
| 241 | + }, |
| 242 | + ] |
| 243 | + case 'NavMenu': |
| 244 | + return [ |
| 245 | + { |
| 246 | + label: 'Active child and open ancestor', |
| 247 | + template: templateFor(name, 'accent', 'surface', 'stateless', 0), |
| 248 | + }, |
| 249 | + { |
| 250 | + label: 'Inactive, closed group', |
| 251 | + template: |
| 252 | + "<NavMenu currentUrl=\"/other/\" :items=\"[{title:'Components',children:[{title:'Layout',url:'/components/layout/'}]}]\"/>", |
| 253 | + }, |
| 254 | + { |
| 255 | + label: 'Leaf links and icons', |
| 256 | + template: |
| 257 | + "<NavMenu :items=\"[{title:'Site guides',url:'/components/site/',icon:'lucide:check'},{title:'Forms',url:'/components/forms/',tone:'feature'}]\"/>", |
| 258 | + }, |
| 259 | + { |
| 260 | + label: 'Search and account configuration', |
| 261 | + template: `<NavMenu signInAvatarSrc="${mark}" signInAvatarAlt="Studio account" :items="[{title:'Site guides',url:'/components/site/'}]"/>`, |
| 262 | + search: true, |
| 263 | + signedIn: true, |
| 264 | + }, |
| 265 | + ] |
| 266 | + default: |
| 267 | + return [] |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +export function defineSiteAppearanceGallery(site: SiteConfig) { |
| 272 | + return defineComponent<{ |
| 273 | + component: RefOrValue<string> |
| 274 | + axis: RefOrValue<string> |
| 275 | + }>( |
| 276 | + html`<iframe class="site-guide-frame w-full" :title="title" :src="previewHref" style="height:400px;border:0;display:block" loading="lazy"></iframe>`, |
| 277 | + { |
| 278 | + props: ['component', 'axis'], |
| 279 | + context: (head) => ({ |
| 280 | + ...head.props, |
| 281 | + title: `${unref(head.props.component)} ${unref(head.props.axis)} examples`, |
| 282 | + previewHref: withBasePath( |
| 283 | + site.basePath, |
| 284 | + `/components/site/${slugOf(unref(head.props.component))}/guide-${unref(head.props.axis)}.html`, |
| 285 | + ), |
| 286 | + }), |
| 287 | + }, |
| 288 | + ) |
| 289 | +} |
| 290 | + |
| 291 | +export async function writeSiteGuidePreviews(site: SiteConfig) { |
| 292 | + for (const name of components) { |
| 293 | + const axes = |
| 294 | + name === 'SiteLogo' |
| 295 | + ? ['tone'] |
| 296 | + : name === 'NavList' |
| 297 | + ? ['tone'] |
| 298 | + : ['tone', 'variant', ...(modes.has(name) ? ['mode'] : [])] |
| 299 | + if (featureSamples(name).length) axes.push('features') |
| 300 | + for (const axis of axes) { |
| 301 | + const values = |
| 302 | + axis === 'tone' |
| 303 | + ? SEMANTIC_TONES |
| 304 | + : axis === 'variant' |
| 305 | + ? variants |
| 306 | + : ['stateless', 'stateful'] |
| 307 | + const samples: Sample[] = |
| 308 | + axis === 'features' |
| 309 | + ? featureSamples(name) |
| 310 | + : values.map((value, index) => ({ |
| 311 | + label: value, |
| 312 | + template: templateFor( |
| 313 | + name, |
| 314 | + axis === 'tone' ? value : 'accent', |
| 315 | + axis === 'variant' |
| 316 | + ? value |
| 317 | + : axis === 'mode' |
| 318 | + ? 'outlineFill' |
| 319 | + : 'surface', |
| 320 | + axis === 'mode' ? value : 'stateless', |
| 321 | + index, |
| 322 | + ), |
| 323 | + })) |
| 324 | + const cards = samples |
| 325 | + .map((sample) => { |
| 326 | + const body = renderApp(sample.template, { |
| 327 | + components: defineComponents(getSvgIcon), |
| 328 | + context: contextFor(site, sample), |
| 329 | + }) |
| 330 | + const inner = `<div class="site-guide-sample">${body}</div>` |
| 331 | + // TopBar has a fixed toggle ID; account state is a document-level class. |
| 332 | + const isolated = |
| 333 | + name === 'TopBar' || name === 'SignIn' || sample.signedIn |
| 334 | + return `<section class="component-appearance-cell site-guide-sample"><code>${escapeAttribute(sample.label)}</code>${isolated ? `<iframe title="${escapeAttribute(sample.label)}" style="height:160px" srcdoc="${escapeAttribute(documentFor(site, inner, sample.signedIn))}"></iframe>` : body}</section>` |
| 335 | + }) |
| 336 | + .join('') |
| 337 | + const targets = |
| 338 | + name === 'PageToc' |
| 339 | + ? '<section id="guide-intro"><h3>Introduction</h3><p>Outline links have real targets in this document.</p></section><section id="guide-detail"><h3>Details</h3><p>Nested entries link to supporting content.</p></section>' |
| 340 | + : '' |
| 341 | + const output = path.join( |
| 342 | + site.outDir, |
| 343 | + 'components', |
| 344 | + 'site', |
| 345 | + slugOf(name), |
| 346 | + `guide-${axis}.html`, |
| 347 | + ) |
| 348 | + await fs.mkdir(path.dirname(output), { recursive: true }) |
| 349 | + await fs.writeFile( |
| 350 | + output, |
| 351 | + documentFor( |
| 352 | + site, |
| 353 | + `<main class="site-guide-samples">${cards}</main>${targets}`, |
| 354 | + ), |
| 355 | + 'utf8', |
| 356 | + ) |
| 357 | + } |
| 358 | + } |
| 359 | +} |
0 commit comments