Skip to content

Commit 8e04513

Browse files
committed
Improve site navigation and services component docs.
1 parent 81d783e commit 8e04513

17 files changed

Lines changed: 986 additions & 146 deletions

File tree

‎frontend/docs/docsComponents.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import { defineChartAppearanceGallery } from './dataGuide'
1010
import { defineFormAppearanceGallery } from './formAppearance'
1111
import { defineLandingAppearanceGallery } from './landingGuide'
1212
import { defineModalExampleComponents } from './modal'
13+
import { defineSiteAppearanceGallery } from './siteGuide'
1314

1415
export function defineDocumentationComponents(site: SiteConfig) {
1516
return {
1617
...defineComponentGuideComponents(),
1718
FormAppearanceGallery: defineFormAppearanceGallery(),
1819
ChartAppearanceGallery: defineChartAppearanceGallery(),
1920
LandingAppearanceGallery: defineLandingAppearanceGallery(),
21+
SiteAppearanceGallery: defineSiteAppearanceGallery(site),
2022
ApiProperty: defineApiPropertyComponent(),
2123
ConsentPreview: defineConsentPreviewComponent(site),
2224
NavMenuPreview: defineNavMenuPreviewComponent(site),

‎frontend/docs/siteGuide.ts‎

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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('<', '&lt;')
57+
.replaceAll('>', '&gt;')
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

Comments
 (0)