From 46cd24cb5e64517c50320ab33804e5740efce0ef Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 5 Oct 2026 10:42:40 +0200 Subject: [PATCH] Import code blocks and shared content into pages. --- .../components/actions/badge/badge.mdx | 217 +----- .../btn-group-drop-down.mdx | 333 +--------- .../actions/btn-group/btn-group.mdx | 354 +--------- .../components/actions/btn-link/btn-link.mdx | 204 +----- .../components/actions/buttons/buttons.mdx | 373 +---------- .../actions/toast-host/toast-host.mdx | 80 +-- .../components/data/bar-chart/bar-chart.mdx | 262 +------- .../data/doughnut-chart/doughnut-chart.mdx | 356 +--------- .../components/data/line-chart/line-chart.mdx | 347 +--------- .../variable-virtual-list.mdx | 439 +----------- .../variable-virtual-table.mdx | 539 +-------------- .../data/virtual-list/virtual-list.mdx | 495 +------------- .../data/virtual-table/virtual-table.mdx | 516 +------------- .../components/forms/app-form/app-form.mdx | 78 +-- .../auto-complete-input.mdx | 235 +------ .../auto-complete-option-row.mdx | 50 +- .../components/forms/composer/composer.mdx | 629 +----------------- .../forms/contact-form/contact-form.mdx | 64 +- .../forms/drop-files/drop-files.mdx | 124 +--- .../forms/form-check/form-check.mdx | 68 +- .../form-input-field/form-input-field.mdx | 101 +-- .../form-select-field/form-select-field.mdx | 75 +-- .../forms/form-status/form-status.mdx | 72 +- .../forms/form-submit/form-submit.mdx | 71 +- .../multi-auto-complete-input.mdx | 252 +------ .../multi-auto-complete-option-row.mdx | 49 +- .../components/layout/tabs/tabs.mdx | 551 +-------------- .../runtime/page-script/page-script.mdx | 24 +- .../runtime/regor-app/regor-app.mdx | 80 +-- .../site/classic-logo/classic-logo.mdx | 146 +--- .../components/site/consent/consent.mdx | 143 +--- .../components/site/nav-menu/nav-menu.mdx | 451 +------------ .../components/site/page-toc/page-toc.mdx | 109 +-- .../components/site/sign-in/sign-in.mdx | 110 +-- .../components/site/site-logo/site-logo.mdx | 319 +-------- .../components/site/top-bar/top-bar.mdx | 96 +-- frontend/purestack.studio/guides/_nav.json | 2 + .../purestack.studio/guides/code-blocks.mdx | 72 ++ frontend/purestack.studio/guides/index.mdx | 12 +- .../purestack.studio/guides/purestack-cli.mdx | 2 +- .../guides/shared-content.mdx | 66 ++ packages/ts-ssg/README.md | 2 + packages/ts-ssg/package.json | 1 + .../src/build/incremental/content-state.ts | 23 + .../src/build/incremental/incremental.md | 5 + .../src/build/incremental/incremental.test.ts | 98 +++ .../ts-ssg/src/build/incremental/index.ts | 5 +- packages/ts-ssg/src/build/page.ts | 58 +- packages/ts-ssg/src/discover/content.ts | 16 + packages/ts-ssg/src/mdx/imports.test.ts | 212 ++++++ packages/ts-ssg/src/mdx/imports.ts | 250 +++++++ packages/ts-ssg/src/mdx/regorMarkup.ts | 17 + .../src/plugins/generated-pages.test.ts | 4 + .../ts-ssg/src/plugins/generated-pages.ts | 6 + yarn.lock | 1 + 55 files changed, 896 insertions(+), 8368 deletions(-) create mode 100644 frontend/purestack.studio/guides/code-blocks.mdx create mode 100644 frontend/purestack.studio/guides/shared-content.mdx create mode 100644 packages/ts-ssg/src/mdx/imports.test.ts create mode 100644 packages/ts-ssg/src/mdx/imports.ts diff --git a/frontend/purestack.studio/components/actions/badge/badge.mdx b/frontend/purestack.studio/components/actions/badge/badge.mdx index c99df9d3..4c96c1d7 100644 --- a/frontend/purestack.studio/components/actions/badge/badge.mdx +++ b/frontend/purestack.studio/components/actions/badge/badge.mdx @@ -60,105 +60,7 @@ Edit the label and explore every tone and variant. The default surface treatment ``` -```typescript -import { - type ComponentVariant, - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { batch, createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface BadgePlayground { - badgeLabel: Ref - tone: Ref - variant: Ref - tones: FormSelectOption[] - variants: FormSelectOption[] - reset: () => void -} - -const badgePlaygroundTemplate = html` - - {{ badgeLabel }} - - - - - - - Reset playground -` - -function createBadgePlayground(): BadgePlayground { - const badgeLabel = ref('Stable') - const tone = ref('success') - const variant = ref('surface') - return { - badgeLabel, - tone, - variant, - tones: SEMANTIC_TONES.map((value) => ({ label: value, value })), - variants: [ - 'solid', - 'surface', - 'surfaceAlt', - 'spotlight', - 'glass', - 'flat', - 'flatAlt', - 'flatSolid', - 'outlineFill', - 'outline', - 'subtle', - 'subtleBtn', - 'link', - 'sheen', - 'underline', - 'rail', - 'bracket', - 'none', - ].map((value) => ({ label: value, value })), - reset: () => - batch(() => { - badgeLabel('Stable') - tone('success') - variant('surface') - }), - } -} - -const component = defineComponent(badgePlaygroundTemplate, { - context: createBadgePlayground, -}) - -createApp( - { - components: { - BadgePlayground: component, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineIconComponents((name) => { - if (name !== 'lucide:chevron-down') - throw new Error(`Icon is not registered: ${name}`) - return lucide_chevron_down - }), - }, - }, - { selector: 'app#badge-playground', template: html`` }, -) -``` + @@ -341,122 +243,7 @@ This example runs entirely in your browser. It models a release workflow without ``` -```typescript -import { - defineBadgeComponents, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface ReleaseChecklist { - documentation: Ref - tests: Ref - review: Ref - published: Ref - completed: ComputedRef - status: ComputedRef - tone: ComputedRef - message: ComputedRef - publishDisabled: ComputedRef - publish: () => void - reset: () => void -} - -const releaseChecklistTemplate = html` -
- Release checklist - - - - - -
- - {{ status }} - {{ message }} - - - Publish preview - Reset checklist - -
` - -function createReleaseChecklist(): ReleaseChecklist { - const documentation = ref(false) - const tests = ref(false) - const review = ref(false) - const published = ref(false) - const completed = computed( - () => Number(documentation()) + Number(tests()) + Number(review()), - ) - const status = computed(() => - published() ? 'Published' : `${completed()}/3 ready`, - ) - const tone = computed(() => - published() || completed() === 3 ? 'success' : 'warning', - ) - const message = computed(() => - published() - ? 'Preview release published.' - : completed() === 3 - ? 'All checks passed. Ready to publish.' - : 'Complete every check to publish the preview.', - ) - const publishDisabled = computed(() => completed() !== 3 || published()) - return { - documentation, - tests, - review, - published, - completed, - status, - tone, - message, - publishDisabled, - publish: () => { - if (!publishDisabled()) published(true) - }, - reset: () => - batch(() => { - documentation(false) - tests(false) - review(false) - published(false) - }), - } -} - -const component = defineComponent(releaseChecklistTemplate, { - context: createReleaseChecklist, -}) - -createApp( - { - components: { - ReleaseChecklist: component, - ...defineBadgeComponents(), - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - }, - }, - { selector: 'app#release-checklist', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/actions/btn-group-drop-down/btn-group-drop-down.mdx b/frontend/purestack.studio/components/actions/btn-group-drop-down/btn-group-drop-down.mdx index c824ae8f..9984476e 100644 --- a/frontend/purestack.studio/components/actions/btn-group-drop-down/btn-group-drop-down.mdx +++ b/frontend/purestack.studio/components/actions/btn-group-drop-down/btn-group-drop-down.mdx @@ -64,212 +64,7 @@ An empty accessible-name field lets the visible label name the trigger. In icon- ``` -```typescript -import { - type BtnGroupDropDownAlign, - type BtnIconPosition, - type BtnSize, - type ComponentVariant, - type ComponentVariantMode, - defineBadgeComponents, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { - iconoir_calendar, - iconoir_more_horiz, - lucide_chevron_down, -} from '@purestack/ts-svg-icons' -import { batch, createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface DropdownPlayground { - triggerLabel: Ref - triggerAriaLabel: Ref - triggerIcon: Ref - triggerPosition: Ref - triggerIconOnly: Ref - triggerTone: Ref - triggerSize: Ref - triggerVariant: Ref - triggerMode: Ref - panelTone: Ref - panelVariant: Ref - panelMode: Ref - panelAlign: Ref - tones: FormSelectOption[] - variants: FormSelectOption[] - sizes: FormSelectOption[] - modes: FormSelectOption[] - positions: FormSelectOption[] - alignments: FormSelectOption[] - icons: FormSelectOption[] - reset: () => void -} - -const dropdownPlaygroundTemplate = html` - - -
Component guides 3
- Buttons - BtnLink - Badge -
-
- - - - - - - - - - - - - - - - - Reset playground - -
` - -function createDropdownPlayground(): DropdownPlayground { - const triggerLabel = ref('Explore') - const triggerAriaLabel = ref('') - const triggerIcon = ref('lucide:chevron-down') - const triggerPosition = ref('end') - const triggerIconOnly = ref(false) - const triggerTone = ref('accent') - const triggerSize = ref('md') - const triggerVariant = ref('solid') - const triggerMode = ref('stateful') - const panelTone = ref('neutral') - const panelVariant = ref('surfaceAlt') - const panelMode = ref('stateless') - const panelAlign = ref('end') - const options = (values: string[]): FormSelectOption[] => - values.map((value) => ({ label: value, value })) - return { - triggerLabel, - triggerAriaLabel, - triggerIcon, - triggerPosition, - triggerIconOnly, - triggerTone, - triggerSize, - triggerVariant, - triggerMode, - panelTone, - panelVariant, - panelMode, - panelAlign, - tones: options(SEMANTIC_TONES), - variants: options([ - 'solid', - 'surface', - 'surfaceAlt', - 'spotlight', - 'glass', - 'flat', - 'flatAlt', - 'flatSolid', - 'outlineFill', - 'outline', - 'subtle', - 'subtleBtn', - 'link', - 'sheen', - 'underline', - 'rail', - 'bracket', - 'none', - ]), - sizes: options(['sm', 'md', 'lg']), - modes: options(['stateful', 'stateless']), - positions: options(['start', 'end']), - alignments: options(['start', 'end']), - icons: options([ - 'lucide:chevron-down', - 'iconoir:more-horiz', - 'iconoir:calendar', - ]), - reset: () => - batch(() => { - triggerLabel('Explore') - triggerAriaLabel('') - triggerIcon('lucide:chevron-down') - triggerPosition('end') - triggerIconOnly(false) - triggerTone('accent') - triggerSize('md') - triggerVariant('solid') - triggerMode('stateful') - panelTone('neutral') - panelVariant('surfaceAlt') - panelMode('stateless') - panelAlign('end') - }), - } -} - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'iconoir:more-horiz': iconoir_more_horiz, - 'iconoir:calendar': iconoir_calendar, -} -const component = defineComponent( - dropdownPlaygroundTemplate, - { context: createDropdownPlayground }, -) - -createApp( - { - components: { - DropdownPlayground: component, - ...defineBadgeComponents(), - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { - selector: 'app#dropdown-playground', - template: html``, - }, -) -``` +
@@ -298,131 +93,7 @@ This example models the workflow locally; it does not publish content or schedul ``` -```typescript -import { - defineBadgeComponents, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, - defineIconComponents, - definePanelComponents, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { - iconoir_calendar, - iconoir_check, - iconoir_send, - lucide_chevron_down, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export type ReleaseState = 'Draft' | 'Ready' | 'Scheduled' | 'Published' - -export interface PublishActions { - releaseState: Ref - releaseTone: ComputedRef - isPublished: ComputedRef - feedback: ComputedRef - publish: () => void - choose: (state: 'Draft' | 'Ready' | 'Scheduled', event: Event) => void - reset: () => void -} - -const publishActionsTemplate = html` - -
-
Release notes
-

Keep the main action close to its publishing choices.

-
- - {{ releaseState }} - {{ feedback }} - - - Publish now - - Schedule for tomorrow - Mark ready - Return to draft - - - Reset release -
-
` - -function createPublishActions(): PublishActions { - const releaseState = ref('Draft') - const isPublished = computed(() => releaseState() === 'Published') - const releaseTone = computed(() => - releaseState() === 'Draft' - ? 'neutral' - : releaseState() === 'Scheduled' - ? 'info' - : 'success', - ) - const feedback = computed( - () => - ({ - Draft: 'Changes stay in this example.', - Ready: 'Reviewed and ready to publish.', - Scheduled: 'Preview scheduled for tomorrow at 09:00.', - Published: 'Preview release published.', - })[releaseState()], - ) - return { - releaseState, - releaseTone, - isPublished, - feedback, - publish: () => releaseState('Published'), - choose: (state, event) => { - releaseState(state) - const menu = (event.currentTarget as HTMLElement).closest('details') - if (menu) { - menu.open = false - menu.querySelector('summary')?.focus() - } - }, - reset: () => releaseState('Draft'), - } -} - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'iconoir:send': iconoir_send, - 'iconoir:calendar': iconoir_calendar, - 'iconoir:check': iconoir_check, -} -const component = defineComponent(publishActionsTemplate, { - context: createPublishActions, -}) - -createApp( - { - components: { - PublishActions: component, - ...defineBadgeComponents(), - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { selector: 'app#publish-actions', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/actions/btn-group/btn-group.mdx b/frontend/purestack.studio/components/actions/btn-group/btn-group.mdx index 83b3484a..68195351 100644 --- a/frontend/purestack.studio/components/actions/btn-group/btn-group.mdx +++ b/frontend/purestack.studio/components/actions/btn-group/btn-group.mdx @@ -59,115 +59,7 @@ The preview gives the group the full available width; this makes alignment visib ``` -```typescript -import { - type BtnGroupAlign, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface GroupPlayground { - groupAlign: Ref - allowWrap: Ref - width: Ref - previewStyle: ComputedRef<{ width: string; maxWidth: string }> - message: Ref - alignments: FormSelectOption[] - widths: FormSelectOption[] - choose: (action: string) => void - reset: () => void -} - -const groupPlaygroundTemplate = html` - - - - - -
- - Save draft - Preview - Duplicate - Discard - -
-

{{ message }}

- Reset playground -
` - -function createGroupPlayground(): GroupPlayground { - const groupAlign = ref('start') - const allowWrap = ref(true) - const width = ref('100%') - const message = ref('Choose an action to test the group.') - const previewStyle = computed(() => ({ width: '100%', maxWidth: width() })) - return { - groupAlign, - allowWrap, - width, - message, - previewStyle, - alignments: ['start', 'center', 'end'].map((value) => ({ - label: value, - value, - })), - widths: [ - { label: 'Full width', value: '100%' }, - { label: 'Compact (18rem)', value: '18rem' }, - ], - choose: (action) => message(`${action} selected.`), - reset: () => - batch(() => { - groupAlign('start') - allowWrap(true) - width('100%') - message('Choose an action to test the group.') - }), - } -} - -const component = defineComponent(groupPlaygroundTemplate, { - context: createGroupPlayground, -}) - -createApp( - { - components: { - GroupPlayground: component, - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineFormComponents(), - ...defineFormSelectField(), - ...defineIconComponents((name) => { - if (name !== 'lucide:chevron-down') - throw new Error(`Icon is not registered: ${name}`) - return lucide_chevron_down - }), - }, - }, - { selector: 'app#group-playground', template: html`` }, -) -``` +
@@ -354,104 +246,7 @@ A complete stateful example: Bold and Italic change the text below, update their ``` -```typescript -import { - type ComponentVariant, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, -} from '@purestack/ts-components' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface FormattingControls { - bold: Ref - italic: Ref - boldVariant: ComputedRef - italicVariant: ComputedRef - textStyle: ComputedRef<{ fontWeight: string; fontStyle: string }> - summary: ComputedRef - toggleBold: () => void - toggleItalic: () => void - reset: () => void -} - -const formattingControlsTemplate = html` - - Bold - Italic - Reset - -

Build something worth sharing.

-

{{ summary }}

-
` - -function createFormattingControls(): FormattingControls { - const bold = ref(false) - const italic = ref(false) - const boldVariant = computed(() => - bold() ? 'solid' : 'surface', - ) - const italicVariant = computed(() => - italic() ? 'solid' : 'surface', - ) - const textStyle = computed(() => ({ - fontWeight: bold() ? '700' : '400', - fontStyle: italic() ? 'italic' : 'normal', - })) - const summary = computed(() => { - const active = [bold() ? 'bold' : '', italic() ? 'italic' : ''].filter( - Boolean, - ) - return active.length - ? `Formatting: ${active.join(' and ')}.` - : 'Formatting: normal.' - }) - return { - bold, - italic, - boldVariant, - italicVariant, - textStyle, - summary, - toggleBold: () => bold(!bold()), - toggleItalic: () => italic(!italic()), - reset: () => - batch(() => { - bold(false) - italic(false) - }), - } -} - -const component = defineComponent( - formattingControlsTemplate, - { context: createFormattingControls }, -) - -createApp( - { - components: { - FormattingControls: component, - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - }, - }, - { - selector: 'app#formatting-controls-demo', - template: html``, - }, -) -``` +
@@ -480,150 +275,7 @@ The icon-only overflow trigger is [BtnGroupDropDown](/components/actions/btn-gro ``` -```typescript -import { - defineBadgeComponents, - defineBtnGroupComponents, - defineButtonComponents, - defineFlexComponents, - defineIconComponents, - definePanelComponents, -} from '@purestack/ts-components' -import { - iconoir_archive, - iconoir_mail, - iconoir_mail_open, - iconoir_more_horiz, - iconoir_trash, -} from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface SelectionToolbar { - selectedCount: Ref - unread: Ref - selectionEmpty: ComputedRef - readState: ComputedRef - selectionLabel: ComputedRef - message: Ref - archive: () => void - markRead: () => void - markUnread: (event: Event) => void - trash: (event: Event) => void - reset: () => void -} - -const selectionToolbarTemplate = html` - - -
-
{{ selectionLabel }}
-

Inbox preview {{ readState }}

-
- - Archive - Mark read - - Mark unread - Move to trash - - -
-

{{ message }}

- Reset selection -
-
` - -function createSelectionToolbar(): SelectionToolbar { - const selectedCount = ref(8) - const unread = ref(true) - const selectionEmpty = computed(() => selectedCount() === 0) - const readState = computed(() => - selectionEmpty() ? 'No selection' : unread() ? 'Unread' : 'Read', - ) - const selectionLabel = computed(() => `${selectedCount()} messages selected`) - const message = ref('Actions affect only these eight example messages.') - const close = (event: Event) => { - const menu = (event.currentTarget as HTMLElement).closest('details') - if (menu) { - menu.open = false - menu.querySelector('summary')?.focus() - } - } - return { - selectedCount, - unread, - selectionEmpty, - readState, - selectionLabel, - message, - archive: () => { - message(`Archived ${selectedCount()} messages.`) - selectedCount(0) - }, - markRead: () => { - unread(false) - message(`Marked ${selectedCount()} messages as read.`) - }, - markUnread: (event) => { - unread(true) - message(`Marked ${selectedCount()} messages as unread.`) - close(event) - }, - trash: (event) => { - message(`Moved ${selectedCount()} messages to trash.`) - selectedCount(0) - close(event) - }, - reset: () => - batch(() => { - selectedCount(8) - unread(true) - message('Actions affect only these eight example messages.') - }), - } -} - -const icons: Record = { - 'iconoir:archive': iconoir_archive, - 'iconoir:mail-open': iconoir_mail_open, - 'iconoir:mail': iconoir_mail, - 'iconoir:more-horiz': iconoir_more_horiz, - 'iconoir:trash': iconoir_trash, -} -const component = defineComponent(selectionToolbarTemplate, { - context: createSelectionToolbar, -}) - -createApp( - { - components: { - SelectionToolbar: component, - ...defineBadgeComponents(), - ...defineBtnGroupComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { - selector: 'app#selection-toolbar-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/actions/btn-link/btn-link.mdx b/frontend/purestack.studio/components/actions/btn-link/btn-link.mdx index 987998a0..690345f0 100644 --- a/frontend/purestack.studio/components/actions/btn-link/btn-link.mdx +++ b/frontend/purestack.studio/components/actions/btn-link/btn-link.mdx @@ -56,209 +56,7 @@ Change the destination, target, relationship, and appearance. The preview is a w ``` -```typescript -import { - type BtnIconPosition, - type BtnSize, - type ComponentVariant, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { - lucide_chevron_down, - tabler_arrow_right, - tabler_arrow_up_right, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface LinkPlayground { - tone: Ref - variant: Ref - size: Ref - href: Ref - target: Ref - rel: Ref - icon: Ref - position: Ref - iconOnly: Ref - linkLabel: Ref - resolvedIcon: ComputedRef - resolvedIconOnly: ComputedRef - effectiveRel: ComputedRef - tones: FormSelectOption[] - variants: FormSelectOption[] - sizes: FormSelectOption[] - destinations: FormSelectOption[] - targets: FormSelectOption[] - relationships: FormSelectOption[] - icons: FormSelectOption[] - positions: FormSelectOption[] - reset: () => void -} - -const linkPlaygroundTemplate = html` - - {{ linkLabel }} - -

Destination: {{ href }} · Target: {{ target }} · Rel: {{ effectiveRel }}

- - - - - - - - - - - - - - Reset playground - -
` - -function createLinkPlayground(): LinkPlayground { - const tone = ref('accent') - const variant = ref('solid') - const size = ref('md') - const href = ref('#link-destination') - const target = ref('_self') - const rel = ref('') - const icon = ref('tabler:arrow-right') - const position = ref('end') - const iconOnly = ref(false) - const linkLabel = ref('Explore the destination') - const resolvedIcon = computed(() => (icon() === 'none' ? '' : icon())) - const resolvedIconOnly = computed(() => iconOnly() && resolvedIcon() !== '') - const effectiveRel = computed( - () => rel() || (target() === '_blank' ? 'noopener noreferrer' : 'not set'), - ) - const options = (values: string[]): FormSelectOption[] => - values.map((value) => ({ label: value, value })) - return { - tone, - variant, - size, - href, - target, - rel, - icon, - position, - iconOnly, - linkLabel, - resolvedIcon, - resolvedIconOnly, - effectiveRel, - tones: options(SEMANTIC_TONES), - variants: options([ - 'solid', - 'surface', - 'surfaceAlt', - 'spotlight', - 'glass', - 'flat', - 'flatAlt', - 'flatSolid', - 'outlineFill', - 'outline', - 'subtle', - 'subtleBtn', - 'link', - 'sheen', - 'underline', - 'rail', - 'bracket', - 'none', - ]), - sizes: options(['sm', 'md', 'lg']), - destinations: [ - { label: 'This page’s destination', value: '#link-destination' }, - { label: 'Buttons documentation', value: '/components/actions/buttons/' }, - { label: 'Badge documentation', value: '/components/actions/badge/' }, - ], - targets: [ - { label: 'Current tab (_self)', value: '_self' }, - { label: 'New tab (_blank)', value: '_blank' }, - ], - relationships: [ - { label: 'Automatic', value: '' }, - { - label: 'nofollow + noopener + noreferrer', - value: 'nofollow noopener noreferrer', - }, - ], - icons: options(['none', 'tabler:arrow-right', 'tabler:arrow-up-right']), - positions: options(['start', 'end']), - reset: () => { - icon('tabler:arrow-right') - iconOnly(false) - position('end') - tone('accent') - variant('solid') - size('md') - href('#link-destination') - target('_self') - rel('') - linkLabel('Explore the destination') - }, - } -} - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'tabler:arrow-right': tabler_arrow_right, - 'tabler:arrow-up-right': tabler_arrow_up_right, -} -const component = defineComponent(linkPlaygroundTemplate, { - context: createLinkPlayground, -}) - -createApp( - { - components: { - LinkPlayground: component, - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { selector: 'app#link-playground', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/actions/buttons/buttons.mdx b/frontend/purestack.studio/components/actions/buttons/buttons.mdx index 13582a95..3ebf0fdd 100644 --- a/frontend/purestack.studio/components/actions/buttons/buttons.mdx +++ b/frontend/purestack.studio/components/actions/buttons/buttons.mdx @@ -56,221 +56,7 @@ Explore every variant and all ten semantic tones. Change the size, move the icon ``` -```typescript -import { - type BtnIconPosition, - type BtnSize, - type ComponentVariant, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { - lucide_chevron_down, - tabler_arrow_right, - tabler_check, - tabler_plus, - tabler_refresh, -} from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface ButtonPlayground { - tone: Ref - variant: Ref - size: Ref - icon: Ref - position: Ref - iconOnly: Ref - disabled: Ref - resolvedIcon: ComputedRef - resolvedIconOnly: ComputedRef - feedback: Ref - tones: FormSelectOption[] - variants: FormSelectOption[] - sizes: FormSelectOption[] - icons: FormSelectOption[] - positions: FormSelectOption[] - activate: () => void - reset: () => void -} - -const variants: ComponentVariant[] = [ - 'solid', - 'surface', - 'surfaceAlt', - 'spotlight', - 'glass', - 'flat', - 'flatAlt', - 'flatSolid', - 'outlineFill', - 'outline', - 'subtle', - 'subtleBtn', - 'link', - 'sheen', - 'underline', - 'rail', - 'bracket', - 'none', -] -const options = (values: string[]) => - values.map((value) => ({ label: value, value })) - -const buttonPlaygroundTemplate = html` - - - Create project - -

{{ feedback }}

-
- - - - - - - - - - - - Reset - - -
` - -function createButtonPlayground(): ButtonPlayground { - const tone = ref('accent') - const variant = ref('solid') - const size = ref('md') - const icon = ref('tabler:plus') - const position = ref('start') - const iconOnly = ref(false) - const disabled = ref(false) - const feedback = ref('Click the button to try it.') - const count = ref(0) - const resolvedIcon = computed(() => (icon() === 'none' ? '' : icon())) - const resolvedIconOnly = computed(() => iconOnly() && resolvedIcon() !== '') - const reset = () => - batch(() => { - tone('accent') - variant('solid') - size('md') - iconOnly(false) - position('start') - icon('tabler:plus') - disabled(false) - count(0) - feedback('Click the button to try it.') - }) - const activate = () => { - count(count() + 1) - feedback(`Action triggered ${count()} ${count() === 1 ? 'time' : 'times'}.`) - } - return { - tone, - variant, - size, - icon, - position, - iconOnly, - resolvedIcon, - resolvedIconOnly, - disabled, - feedback, - activate, - reset, - tones: options(SEMANTIC_TONES), - variants: options(variants), - sizes: options(['sm', 'md', 'lg']), - icons: options([ - 'none', - 'tabler:plus', - 'tabler:check', - 'tabler:arrow-right', - ]), - positions: options(['start', 'end']), - } -} - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'tabler:arrow-right': tabler_arrow_right, - 'tabler:check': tabler_check, - 'tabler:plus': tabler_plus, - 'tabler:refresh': tabler_refresh, -} - -const component = defineComponent(buttonPlaygroundTemplate, { - context: createButtonPlayground, -}) - -createApp( - { - components: { - ButtonPlayground: component, - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { - selector: 'app#button-playground', - template: html``, - }, -) -``` +
@@ -466,94 +252,7 @@ Build a collection of up to five items. Add creates a visible list item; Remove ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, -} from '@purestack/ts-components' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type SRef, - sref, -} from 'regor' - -export interface Collection { - items: SRef - limit: number - summary: ComputedRef - isEmpty: ComputedRef - isFull: ComputedRef - addItem: () => void - removeItem: () => void - reset: () => void -} - -const collectionTemplate = html` - Build a collection -

Add up to {{ limit }} items. Remove one or start over.

-

{{ summary }}

-
    -
  • {{ item }}
  • -
- - Add item - Remove item - Reset - -
` - -function createCollection(): Collection { - const items = sref([]) - const limit = 5 - let nextItem = 1 - const isEmpty = computed(() => items().length === 0) - const isFull = computed(() => items().length === limit) - const summary = computed(() => - isFull() - ? `Collection full: ${limit} items.` - : `${items().length} ${items().length === 1 ? 'item' : 'items'} in your collection.`, - ) - return { - items, - limit, - summary, - isEmpty, - isFull, - addItem: () => { - if (!isFull()) items([...items(), `Item ${nextItem++}`]) - }, - removeItem: () => { - if (!isEmpty()) items(items().slice(0, -1)) - }, - reset: () => { - items([]) - nextItem = 1 - }, - } -} - -const component = defineComponent(collectionTemplate, { - context: createCollection, -}) - -createApp( - { - components: { - Collection: component, - ...defineButtonComponents(), - ...defineFlexComponents(), - }, - }, - { - selector: 'app#collection', - template: html``, - }, -) -``` +
@@ -582,73 +281,7 @@ The form handles `@reset.prevent` so its reactive value stays synchronized with ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormInputField, -} from '@purestack/ts-components' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface ProjectForm { - project: Ref - message: Ref - submit: () => void - resetForm: () => void -} - -const projectFormTemplate = html` - - - Save project - Reset - -

{{ message }}

-
` - -function createProjectForm(): ProjectForm { - const project = ref('My next idea') - const message = ref('Changes stay in this demo.') - return { - project, - message, - submit: () => message(`Saved “${project()}”.`), - resetForm: () => { - project('My next idea') - message('Form reset.') - }, - } -} - -const component = defineComponent(projectFormTemplate, { - context: createProjectForm, -}) - -createApp( - { - components: { - ProjectForm: component, - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormInputField(), - }, - }, - { - selector: 'app#project-form', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/actions/toast-host/toast-host.mdx b/frontend/purestack.studio/components/actions/toast-host/toast-host.mdx index 2d8640ee..e6be0cf5 100644 --- a/frontend/purestack.studio/components/actions/toast-host/toast-host.mdx +++ b/frontend/purestack.studio/components/actions/toast-host/toast-host.mdx @@ -48,85 +48,7 @@ Success disappears after five seconds; warning after ten. The error stays until ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineIconComponents, - definePanelComponents, - defineToastComponents, - ToastStore, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { iconoir_xmark } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html } from 'regor' - -export interface ToastHostExample { - notifications: ToastStore - save: () => void - warn: () => void - fail: () => void - clear: () => void - tones: SemanticTone[] - notifyTone: (tone: SemanticTone) => void -} - -const toastHostExampleTemplate = html` - - - Save local draft - Show review reminder - Show persistent error - Clear notifications - -

Try every supported tone. Info and success last 5 seconds, warning lasts 10; all others stay until dismissed.

- - {{ tone }} - - {{ notifications.items.length }} active notifications -
` - -function createToastHostExample(): ToastHostExample { - const notifications = new ToastStore() - return { - notifications, - tones: SEMANTIC_TONES, - notifyTone: (tone) => - notifications.notify(`${tone} example notification.`, tone), - save: () => notifications.notify('Local draft saved.', 'success'), - warn: () => - notifications.notify('Review the content before publishing.', 'warning'), - fail: () => - notifications.notify( - 'Example build failed. Dismiss when acknowledged.', - 'danger', - ), - clear: notifications.clear, - } -} - -const component = defineComponent(toastHostExampleTemplate, { - context: createToastHostExample, -}) -const icons: Record = { 'iconoir:xmark': iconoir_xmark } - -createApp( - { - components: { - ToastHostExample: component, - - ...defineToastComponents(), - ...definePanelComponents(), - ...defineFlexComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#toast-host-demo', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/bar-chart/bar-chart.mdx b/frontend/purestack.studio/components/data/bar-chart/bar-chart.mdx index 4d88f48a..c68cbfc6 100644 --- a/frontend/purestack.studio/components/data/bar-chart/bar-chart.mdx +++ b/frontend/purestack.studio/components/data/bar-chart/bar-chart.mdx @@ -32,267 +32,7 @@ Change the dataset, edit each daily value, and see the chart update immediately. ``` -```typescript -import { - type BarChartItem, - defineBarChartComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { getThemePaletteVar } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, - type SRef, - sref, -} from 'regor' -import { - type ChartAppearanceState, - createChartAppearanceState, - defineChartAppearanceControls, -} from '../../../../docs/dataGuide' - -export type BarChartPreset = 'mixed' | 'positive' | 'negative' | 'zero' -export type BarChartPalette = 'balance' | 'categorical' | 'accent' - -export interface BarChartEditorRow { - id: string - label: string - value: Ref -} - -export interface BarChartPlayground extends ChartAppearanceState { - rows: SRef - chartItems: ComputedRef - summary: ComputedRef - preset: Ref - palette: Ref - suffix: Ref - minimum: Ref - maximum: Ref - height: Ref - valuesVisible: Ref - axisVisible: Ref - labelsVisible: Ref - motionEnabled: Ref - presets: FormSelectOption[] - palettes: FormSelectOption[] - units: FormSelectOption[] - applyPreset: (event: Event) => void - clear: () => void - reset: () => void -} - -const barChartPlaygroundTemplate = html` - - - - - - - -

LIVE PREVIEW · WEEKLY BALANCE

-
- -
- {{ summary }} -
- - - - - - - -

Edit the data

- - Empty data - Reset playground - -
- - - - - - - - -

Blank bounds use the data range. The scale always includes zero and every value. On narrow screens, scroll the preview horizontally to keep its labels readable.

-
` - -const presetValues: Record = { - mixed: [24, 38, -12, 46, 32], - positive: [18, 32, 25, 48, 60], - negative: [-18, -32, -8, -24, -12], - zero: [0, 0, 0, 0, 0], -} - -function createBarChartRows(preset: BarChartPreset): BarChartEditorRow[] { - return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].map((label, index) => ({ - id: `bar-value-${index}`, - label, - value: ref(presetValues[preset][index]), - })) -} - -function createBarChartPlayground(): BarChartPlayground { - const appearance = createChartAppearanceState() - const preset = ref('mixed') - const palette = ref('balance') - const suffix = ref('') - const minimum = ref('') - const maximum = ref('') - const height = ref(280) - const valuesVisible = ref(true) - const axisVisible = ref(true) - const labelsVisible = ref(true) - const motionEnabled = ref(true) - const rows = sref(createBarChartRows('mixed')) - const accent = getThemePaletteVar('semanticTone.accent.button.rest.bgcolor') - const danger = getThemePaletteVar('semanticTone.danger.button.rest.bgcolor') - const chartItems = computed(() => - rows().map((row) => { - const value = Number(row.value()) - return { - label: row.label, - value, - color: - palette() === 'categorical' - ? undefined - : palette() === 'balance' && value < 0 - ? danger - : accent, - } - }), - ) - return { - ...appearance, - rows, - chartItems, - summary: computed(() => { - if (!rows().length) - return 'No data. Choose a dataset or reset the playground.' - const values = rows().map( - (row) => `${row.label}: ${Number(row.value())}${suffix()}`, - ) - return values.join(' · ') - }), - preset, - palette, - suffix, - minimum, - maximum, - height, - valuesVisible, - axisVisible, - labelsVisible, - motionEnabled, - presets: [ - { label: 'Mixed gains and losses', value: 'mixed' }, - { label: 'All positive', value: 'positive' }, - { label: 'All negative', value: 'negative' }, - { label: 'All zero', value: 'zero' }, - ], - palettes: [ - { label: 'Gains / losses', value: 'balance' }, - { label: 'One color per category', value: 'categorical' }, - { label: 'Single accent', value: 'accent' }, - ], - units: [ - { label: 'No suffix', value: '' }, - { label: 'Percent (%)', value: '%' }, - { label: 'Milliseconds (ms)', value: 'ms' }, - { label: 'Credits (cr)', value: 'cr' }, - ], - applyPreset: (event) => { - const next = (event.target as HTMLSelectElement).value as BarChartPreset - rows(createBarChartRows(next)) - }, - clear: () => rows([]), - reset: () => - batch(() => { - appearance.resetAppearance() - preset('mixed') - palette('balance') - suffix('') - minimum('') - maximum('') - height(280) - valuesVisible(true) - axisVisible(true) - labelsVisible(true) - motionEnabled(true) - rows(createBarChartRows('mixed')) - }), - } -} - -const barChartPlayground = defineComponent( - barChartPlaygroundTemplate, - { context: createBarChartPlayground }, -) - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - ChartAppearanceControls: defineChartAppearanceControls(), - BarChartPlayground: barChartPlayground, - ...defineBarChartComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - }, - }, - { selector: 'app#bar-chart-demo', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/doughnut-chart/doughnut-chart.mdx b/frontend/purestack.studio/components/data/doughnut-chart/doughnut-chart.mdx index 3cb6a5f9..1982afba 100644 --- a/frontend/purestack.studio/components/data/doughnut-chart/doughnut-chart.mdx +++ b/frontend/purestack.studio/components/data/doughnut-chart/doughnut-chart.mdx @@ -32,361 +32,7 @@ Edit segment names and values, hide a category, and watch the total and percenta ``` -```typescript -import { - type DoughnutChartSegment, - defineButtonComponents, - defineDoughnutChartComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { getThemePaletteVar } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, - type SRef, - sref, -} from 'regor' -import { - type ChartAppearanceState, - createChartAppearanceState, - defineChartAppearanceControls, -} from '../../../../docs/dataGuide' - -export type DoughnutPreset = - | 'tasks' - | 'equal' - | 'dominant' - | 'tiny' - | 'single' - | 'zero' -export type DoughnutCenterMode = 'total' | 'largest' | 'custom' - -export interface DoughnutEditorSegment { - id: string - label: Ref - value: Ref - enabled: Ref - color: string -} - -export interface DoughnutLegendEntry { - label: string - value: number - color: string - share: string -} - -export interface DoughnutChartPlayground extends ChartAppearanceState { - editorSegments: SRef - chartSegments: ComputedRef - legend: ComputedRef - total: ComputedRef - summary: ComputedRef - centerValue: ComputedRef - centerLabel: ComputedRef - replayDisabled: ComputedRef - preset: Ref - centerMode: Ref - customValue: Ref - caption: Ref - suffix: Ref - thickness: Ref - gap: Ref - angle: Ref - size: Ref - valueSize: Ref - labelSize: Ref - animated: Ref - presets: FormSelectOption[] - centerModes: FormSelectOption[] - units: FormSelectOption[] - applyPreset: (event: Event) => void - replay: () => void - clear: () => void - reset: () => void -} - -const doughnutChartPlaygroundTemplate = html` - - - - - - - -

LIVE PREVIEW · PARTS OF A WHOLE

- Replay animation -
- - - - - - - {{ entry.label }} - - {{ entry.value }}{{ suffix }} · {{ entry.share }}% - -

Enable a positive segment or choose another distribution.

- {{ summary }} -
-
-
- -

Edit the distribution

- - Empty data - Reset playground - -
- - - - - - - -

Only finite, positive values contribute. Hide a segment to recalculate the total and shares; its color stays attached when restored.

-

Ring geometry

- - - - - - -

Thickness and gap use the 100-unit SVG canvas and are clamped to the ranges above. Gaps shrink around tiny slices. Size fits the available preview width.

-

Center content

- - - - - - - - - -

Largest share is calculated from the visible values. Custom copy changes only the text. Font sizes scale with the SVG; thick rings and long labels need smaller type. This demo clears center copy when the total is zero.

- -
` - -const presetValues: Record = { - tasks: [18, 7, 5], - equal: [10, 10, 10], - dominant: [27, 2, 1], - tiny: [99.5, 0.4, 0.1], - single: [30, 0, 0], - zero: [0, 0, 0], -} - -function createSegments(preset: DoughnutPreset): DoughnutEditorSegment[] { - const colors = [ - getThemePaletteVar('semanticTone.accent.button.hover.bgcolor'), - getThemePaletteVar('semanticTone.feature.button.hover.bgcolor'), - getThemePaletteVar('semanticTone.info.button.hover.bgcolor'), - ] - return presetValues[preset].map((value, index) => ({ - id: String(index), - label: ref(['Complete', 'In review', 'Planned'][index]), - value: ref(value), - enabled: ref(true), - color: colors[index], - })) -} - -function createDoughnutChartPlayground(): DoughnutChartPlayground { - const appearance = createChartAppearanceState() - const editorSegments = sref(createSegments('tasks')) - const preset = ref('tasks') - const centerMode = ref('total') - const customValue = ref('Ready') - const caption = ref('Tasks') - const suffix = ref('') - const thickness = ref(14) - const gap = ref(2) - const angle = ref(-90) - const size = ref(280) - const valueSize = ref(16) - const labelSize = ref(5) - const animated = ref(true) - const visible = computed(() => - editorSegments() - .filter((entry) => entry.enabled()) - .map((entry) => ({ - label: entry.label().trim() || `Segment ${Number(entry.id) + 1}`, - value: Number(entry.value()), - color: entry.color, - })) - .filter((entry) => Number.isFinite(entry.value) && entry.value > 0), - ) - const total = computed(() => - visible().reduce((sum, entry) => sum + entry.value, 0), - ) - const legend = computed(() => - visible().map((entry) => ({ - ...entry, - share: String(Number(((entry.value / total()) * 100).toFixed(1))), - })), - ) - const largest = computed(() => - legend().reduce( - (best, entry) => (!best || entry.value > best.value ? entry : best), - undefined, - ), - ) - return { - ...appearance, - editorSegments, - chartSegments: computed(() => visible()), - legend, - total, - summary: computed(() => - total() - ? `${visible().length} segments · Total ${Number(total().toFixed(3))}${suffix()}` - : 'No positive values', - ), - centerValue: computed(() => - !total() - ? '' - : centerMode() === 'largest' - ? `${largest()?.share}%` - : centerMode() === 'custom' - ? customValue() - : '', - ), - centerLabel: computed(() => - !total() - ? '' - : centerMode() === 'largest' - ? (largest()?.label ?? '') - : caption(), - ), - replayDisabled: computed(() => !animated() || !total()), - preset, - centerMode, - customValue, - caption, - suffix, - thickness, - gap, - angle, - size, - valueSize, - labelSize, - animated, - presets: [ - { label: 'Task distribution', value: 'tasks' }, - { label: 'Equal shares', value: 'equal' }, - { label: 'Dominant segment', value: 'dominant' }, - { label: 'Tiny slices', value: 'tiny' }, - { label: 'One positive segment', value: 'single' }, - { label: 'All zero', value: 'zero' }, - ], - centerModes: [ - { label: 'Automatic total', value: 'total' }, - { label: 'Largest share', value: 'largest' }, - { label: 'Custom text', value: 'custom' }, - ], - units: [ - { label: 'No suffix', value: '' }, - { label: 'Percent (%)', value: '%' }, - { label: 'Gigabytes (GB)', value: 'GB' }, - ], - applyPreset: (event) => - editorSegments( - createSegments( - (event.target as HTMLSelectElement).value as DoughnutPreset, - ), - ), - replay: () => { - for (const animation of document.querySelectorAll( - '#doughnut-chart-demo .doughnut-chart animate, #doughnut-chart-demo .doughnut-chart animateTransform', - )) - animation.beginElement() - }, - clear: () => editorSegments([]), - reset: () => - batch(() => { - appearance.resetAppearance() - preset('tasks') - centerMode('total') - customValue('Ready') - caption('Tasks') - suffix('') - thickness(14) - gap(2) - angle(-90) - size(280) - valueSize(16) - labelSize(5) - animated(true) - editorSegments(createSegments('tasks')) - }), - } -} - -const doughnutChartPlayground = defineComponent( - doughnutChartPlaygroundTemplate, - { context: createDoughnutChartPlayground }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - ChartAppearanceControls: defineChartAppearanceControls(), - DoughnutChartPlayground: doughnutChartPlayground, - ...defineButtonComponents(), - ...defineDoughnutChartComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - }, - }, - { - selector: 'app#doughnut-chart-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/line-chart/line-chart.mdx b/frontend/purestack.studio/components/data/line-chart/line-chart.mdx index 2f015710..812a928d 100644 --- a/frontend/purestack.studio/components/data/line-chart/line-chart.mdx +++ b/frontend/purestack.studio/components/data/line-chart/line-chart.mdx @@ -32,352 +32,7 @@ Edit either series, rename it, or hide it to inspect the other line. Try crossin ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - defineLineChartComponents, - definePanelComponents, - type FormSelectOption, - type LineChartCurve, - type LineChartSeries, -} from '@purestack/ts-components' -import { getThemePaletteVar, type SemanticTone } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, - type SRef, - sref, -} from 'regor' -import { - type ChartAppearanceState, - createChartAppearanceState, - defineChartAppearanceControls, -} from '../../../../docs/dataGuide' - -export type LineChartPreset = - | 'growth' - | 'crossing' - | 'signed' - | 'flat' - | 'single' - -export interface LineChartEditorPoint { - id: string - label: string - value: Ref -} - -export interface LineChartEditorSeries { - id: string - label: Ref - enabled: Ref - tone: SemanticTone - color: string - points: LineChartEditorPoint[] -} - -export interface LineChartPlayground extends ChartAppearanceState { - editorSeries: SRef - chartSeries: ComputedRef - summary: ComputedRef - replayDisabled: ComputedRef - preset: Ref - curve: Ref - suffix: Ref - minimum: Ref - maximum: Ref - height: Ref - areaVisible: Ref - pointsVisible: Ref - valuesVisible: Ref - labelsVisible: Ref - axisVisible: Ref - motionEnabled: Ref - presets: FormSelectOption[] - curves: FormSelectOption[] - units: FormSelectOption[] - applyPreset: (event: Event) => void - replay: () => void - clear: () => void - reset: () => void -} - -const lineChartPlaygroundTemplate = html` - - - - - - - - -

LIVE PREVIEW · SERIES COMPARISON

- Replay animation -
- - - -
- -
- {{ summary }} -
- - - - - - - - - - - - - -

Blank bounds use the data range. Bounds always expand to include zero and all visible points. Scroll the preview horizontally on narrow screens.

- -

Edit the series

- - Empty data - Reset playground - -
- - - - - - - - - - - -
` - -const presetValues: Record = { - growth: [ - [18, 26, 37, 48, 64], - [10, 18, 25, 34, 43], - ], - crossing: [ - [20, 38, 28, 58, 48], - [48, 26, 50, 32, 64], - ], - signed: [ - [-18, -8, 12, 28, 40], - [-32, -18, -4, 12, 22], - ], - flat: [ - [24, 24, 24, 24, 24], - [12, 12, 12, 12, 12], - ], - single: [[24], [12]], -} - -function createLineChartSeries( - preset: LineChartPreset, -): LineChartEditorSeries[] { - const colors = [ - getThemePaletteVar('semanticTone.accent.button.hover.bgcolor'), - getThemePaletteVar('semanticTone.feature.button.hover.bgcolor'), - ] - return presetValues[preset].map((values, index) => ({ - id: String(index), - label: ref(index === 0 ? 'Current' : 'Baseline'), - enabled: ref(true), - tone: index === 0 ? 'accent' : 'feature', - color: colors[index], - points: values.map((value, pointIndex) => ({ - id: `line-value-${index}-${pointIndex}`, - label: `R${pointIndex + 1}`, - value: ref(value), - })), - })) -} - -function createLineChartPlayground(): LineChartPlayground { - const appearance = createChartAppearanceState() - const editorSeries = sref(createLineChartSeries('growth')) - const preset = ref('growth') - const curve = ref('smooth') - const suffix = ref('%') - const minimum = ref('') - const maximum = ref('') - const height = ref(300) - const areaVisible = ref(false) - const pointsVisible = ref(true) - const valuesVisible = ref(false) - const labelsVisible = ref(true) - const axisVisible = ref(true) - const motionEnabled = ref(true) - const chartSeries = computed(() => - editorSeries() - .filter((entry) => entry.enabled()) - .map((entry, index) => ({ - label: entry.label().trim() || `Series ${index + 1}`, - color: entry.color, - points: entry.points.map((point) => ({ - label: point.label, - value: Number(point.value()), - })), - })), - ) - return { - ...appearance, - editorSeries, - chartSeries, - summary: computed(() => { - const visible = editorSeries().filter((entry) => entry.enabled()) - if (!visible.length) - return 'No visible series. Enable a series, choose a dataset, or reset.' - return visible - .map((entry, index) => { - const latest = entry.points.at(-1) - return `${entry.label().trim() || `Series ${index + 1}`}: ${latest?.label} = ${Number(latest?.value())}${suffix()}` - }) - .join(' · ') - }), - replayDisabled: computed(() => !motionEnabled() || !chartSeries().length), - preset, - curve, - suffix, - minimum, - maximum, - height, - areaVisible, - pointsVisible, - valuesVisible, - labelsVisible, - axisVisible, - motionEnabled, - presets: [ - { label: 'Steady growth', value: 'growth' }, - { label: 'Crossing trends', value: 'crossing' }, - { label: 'Gains and losses', value: 'signed' }, - { label: 'Flat series', value: 'flat' }, - { label: 'Single observation', value: 'single' }, - ], - curves: [ - { label: 'Smooth', value: 'smooth' }, - { label: 'Linear', value: 'linear' }, - ], - units: [ - { label: 'Percent (%)', value: '%' }, - { label: 'Milliseconds (ms)', value: 'ms' }, - { label: 'No suffix', value: '' }, - ], - applyPreset: (event) => { - const next = (event.target as HTMLSelectElement).value as LineChartPreset - editorSeries(createLineChartSeries(next)) - }, - replay: () => { - for (const animation of document.querySelectorAll( - '#line-chart-demo .line-chart animate', - )) { - animation.beginElement() - } - }, - clear: () => editorSeries([]), - reset: () => - batch(() => { - appearance.resetAppearance() - preset('growth') - curve('smooth') - suffix('%') - minimum('') - maximum('') - height(300) - areaVisible(false) - pointsVisible(true) - valuesVisible(false) - labelsVisible(true) - axisVisible(true) - motionEnabled(true) - editorSeries(createLineChartSeries('growth')) - }), - } -} - -const lineChartPlayground = defineComponent( - lineChartPlaygroundTemplate, - { - context: createLineChartPlayground, - }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - ChartAppearanceControls: defineChartAppearanceControls(), - LineChartPlayground: lineChartPlayground, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...defineLineChartComponents(), - ...definePanelComponents(), - }, - }, - { selector: 'app#line-chart-demo', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/variable-virtual-list/variable-virtual-list.mdx b/frontend/purestack.studio/components/data/variable-virtual-list/variable-virtual-list.mdx index 3194a508..4b3a3017 100644 --- a/frontend/purestack.studio/components/data/variable-virtual-list/variable-virtual-list.mdx +++ b/frontend/purestack.studio/components/data/variable-virtual-list/variable-virtual-list.mdx @@ -39,390 +39,7 @@ Browse a measured activity feed with short notes, wrapping paragraphs and expand ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineVirtualListComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - onMounted, - onUnmounted, - type Ref, - type RefOrValue, - ref, - type SRef, - sref, -} from 'regor' - -export interface ActivityRecord { - id: number - title: string - summary: string - detail: string - expanded: Ref -} - -export interface ActivityRow { - item: RefOrValue - index: RefOrValue -} - -const activityRowTemplate = html`
- - {{ item.title }} - {{ item.expanded ? 'Less detail' : 'More detail' }} - -

{{ item.summary }}

-
- Implementation notes -

{{ item.detail }}

-
-
` -const activityRow = defineComponent(activityRowTemplate, { - props: ['item', 'index'], -}) - -export interface VariableListPlayground { - records: SRef - filtered: ComputedRef - query: Ref - count: Ref - height: Ref - estimate: Ref - overscan: Ref - narrow: Ref - active: Ref - mountedRows: Ref - rowRange: Ref - measuredRange: Ref - scrollExtent: Ref - counts: FormSelectOption[] - heights: FormSelectOption[] - estimates: FormSelectOption[] - buffers: FormSelectOption[] - restart: () => void - load: () => void - clear: () => void - reset: () => void - first: () => void - next: () => void - previous: () => void -} - -const variableListPlaygroundTemplate = html` - - - - - - -

LIVE PREVIEW · ACTIVITY FEED

- {{ filtered.length }} matching records -
- -
-

Mounted rows

- {{ mountedRows }} -
-
-

Positions

- {{ rowRange }} -
-
-

Row heights

- {{ measuredRange }} -
-
-

Scroll height

- {{ scrollExtent }} px -
-
-
- -
- {{ records.length ? 'No matching activity. Clear the search to see the feed.' : 'The feed is empty. Reload the dataset to continue.' - }} - -

- Expand a row or narrow the feed. The row height is measured from its - content; it is not fixed to the initial estimate. -

-
- - - - - - - - Back to top - Previous screen - Next screen - -

- The counters inspect mounted rows. Scroll height includes estimates for - unseen rows and can change as you browse. Search, dataset, width and - estimate changes restart measurement from the top; expanded state stays on - each record. -

- - Restart measurement - Reload dataset - Empty feed - Reset playground - -
` - -function createActivities(count: number): ActivityRecord[] { - const summaries = [ - 'Updated the component documentation.', - 'Improved keyboard navigation and focus visibility across the release workflow. The review includes both desktop and narrow layouts.', - 'Added examples for empty results, long labels and loading transitions. Each example uses the shared theme, typed component props and real application state so it can be adapted to a production screen.', - ] - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - title: `Change ${index + 1}`, - summary: summaries[index % summaries.length], - detail: - 'The implementation keeps interaction state on the record. Expand this entry, scroll until it leaves the mounted window, and return to it: the notes remain open. Content can grow without assigning a new row height. ResizeObserver measures the rendered result and updates the virtual offsets.', - expanded: ref(false), - })) -} - -function createVariableListPlayground(): VariableListPlayground { - const records = sref(createActivities(1000)) - const query = ref(''), - count = ref('1000'), - height = ref('400'), - estimate = ref('140'), - overscan = ref('4') - const narrow = ref(false), - active = ref(true) - const mountedRows = ref(0), - rowRange = ref('-'), - measuredRange = ref('-'), - scrollExtent = ref(0) - const filtered = computed(() => - records().filter((item) => - item.title.toLowerCase().includes(query().trim().toLowerCase()), - ), - ) - let disposed = false - const restart = () => { - active(false) - queueMicrotask(() => { - if (!disposed) active(true) - }) - } - const stop = observe(filtered, restart) - const viewport = () => - document.querySelector('#activity-viewport') - const scroll = (direction: number) => { - const element = viewport() - if (!element) return - element.scrollTop = - direction === 0 ? 0 : element.scrollTop + direction * element.clientHeight - element.dispatchEvent(new Event('scroll')) - } - const measure = () => { - const element = viewport() - const rows = Array.from( - element?.querySelectorAll('[data-row]') ?? [], - ) - const sizes = rows.map((row) => - Math.round(row.getBoundingClientRect().height), - ) - mountedRows(rows.length) - rowRange( - rows.length - ? `${Number(rows[0].dataset.row) + 1}–${Number(rows.at(-1)?.dataset.row) + 1}` - : '-', - ) - measuredRange( - sizes.length ? `${Math.min(...sizes)}–${Math.max(...sizes)} px` : '-', - ) - scrollExtent(element?.scrollHeight ?? 0) - } - let observer: MutationObserver | undefined - let resize: ResizeObserver | undefined - onMounted(() => { - const host = document.querySelector('#activity-host') - if (!host) return - observer = new MutationObserver(measure) - observer.observe(host, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['style', 'data-row'], - }) - resize = new ResizeObserver(measure) - resize.observe(host) - measure() - }) - onUnmounted(() => { - disposed = true - stop() - observer?.disconnect() - resize?.disconnect() - }) - return { - records, - filtered, - query, - count, - height, - estimate, - overscan, - narrow, - active, - mountedRows, - rowRange, - measuredRange, - scrollExtent, - counts: [100, 1000, 10000, 50000].map((n) => ({ - label: `${n.toLocaleString('en-US')} records`, - value: String(n), - })), - heights: [280, 400, 520].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - estimates: [56, 140, 240].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - buffers: [1, 4, 12].map((n) => ({ label: `${n} rows`, value: String(n) })), - restart, - load: () => records(createActivities(Number(count()))), - clear: () => records([]), - first: () => scroll(0), - previous: () => scroll(-1), - next: () => scroll(1), - reset: () => - batch(() => { - query('') - count('1000') - height('400') - estimate('140') - overscan('4') - narrow(false) - records(createActivities(1000)) - }), - } -} -const variableListPlayground = defineComponent( - variableListPlaygroundTemplate, - { context: createVariableListPlayground }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} -createApp( - { - components: { - VariableListPlayground: variableListPlayground, - ActivityRow: activityRow, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - ...defineVirtualListComponents(), - }, - }, - { - selector: 'app#variable-virtual-list-demo', - template: html``, - }, -) -``` +
@@ -450,59 +67,7 @@ Let the row content determine its height. This complete example renders 200 note ``` -```typescript -import { defineVirtualListComponents } from '@purestack/ts-components' -import { createApp, defineComponent, html, type RefOrValue } from 'regor' - -export interface Note { - title: string - body: string -} -export interface NoteRow { - item: RefOrValue - index: RefOrValue -} -const noteRowTemplate = html`
- {{ item.title }} -

{{ item.body }}

-
` -const noteRow = defineComponent(noteRowTemplate, { - props: ['item', 'index'], -}) - -export interface NotesExample { - notes: Note[] -} -const notesTemplate = html`` -const notesExample = defineComponent(notesTemplate, { - context: () => ({ - notes: Array.from({ length: 200 }, (_, index) => ({ - title: `Note ${index + 1}`, - body: - index % 2 - ? 'A focused documentation update.' - : 'This update adds clearer examples for keyboard navigation, empty results and responsive layouts. The paragraph wraps naturally, and the virtual list measures the resulting row height.', - })), - }), -}) -createApp( - { - components: { - NotesExample: notesExample, - NoteRow: noteRow, - ...defineVirtualListComponents(), - }, - }, - { - selector: 'app#variable-list-basic-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/variable-virtual-table/variable-virtual-table.mdx b/frontend/purestack.studio/components/data/variable-virtual-table/variable-virtual-table.mdx index 5819c8ae..09b926f1 100644 --- a/frontend/purestack.studio/components/data/variable-virtual-table/variable-virtual-table.mdx +++ b/frontend/purestack.studio/components/data/variable-virtual-table/variable-virtual-table.mdx @@ -32,464 +32,7 @@ Review a large queue with wrapping summaries and expandable notes. Change the su ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineVariableVirtualTableComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - onMounted, - onUnmounted, - type Ref, - type RefOrValue, - ref, - type SRef, - sref, -} from 'regor' - -export interface ReviewRecord { - id: number - title: string - summary: string - detail: string - width: Ref - expanded: Ref -} - -export interface ReviewRow { - item: RefOrValue - index: RefOrValue -} - -export interface ReviewHeader {} -export interface ReviewFooter {} -export interface ReviewColumns {} - -const reviewRowTemplate = html` - {{ item.id }} - -
- - {{ item.title }} - {{ item.expanded ? 'Less detail' : 'More detail' }} - -

{{ item.summary }}

-
- Review notes -

{{ item.detail }}

-
-
- - - Open - -` -const reviewHeaderTemplate = html` - - ID - Review summary - Status - -` -const reviewFooterTemplate = html` - - - Review queue · illustrative data - - -` -const reviewColumnsTemplate = html` - - - -` - -const reviewRow = defineComponent(reviewRowTemplate, { - props: ['item', 'index'], -}) -const reviewHeader = defineComponent(reviewHeaderTemplate) -const reviewFooter = defineComponent(reviewFooterTemplate) -const reviewColumns = defineComponent(reviewColumnsTemplate) - -export interface VariableTablePlayground { - records: SRef - filtered: ComputedRef - query: Ref - count: Ref - height: Ref - estimate: Ref - overscan: Ref - width: Ref - header: Ref - footer: Ref - columns: Ref - sort: Ref - widths: FormSelectOption[] - sorts: FormSelectOption[] - active: Ref - mountedRows: Ref - rowRange: Ref - measuredRange: Ref - scrollExtent: Ref - counts: FormSelectOption[] - heights: FormSelectOption[] - estimates: FormSelectOption[] - buffers: FormSelectOption[] - restart: () => void - load: () => void - clear: () => void - reset: () => void - first: () => void - next: () => void - previous: () => void -} - -const variableTablePlaygroundTemplate = html` - - - - - - -

LIVE PREVIEW · REVIEW TABLE

- {{ filtered.length }} matching records -
- -
-

Mounted rows

- {{ mountedRows }} -
-
-

Positions

- {{ rowRange }} -
-
-

Row heights

- {{ measuredRange }} -
-
-

Scroll height

- {{ scrollExtent }} px -
-
-
- -
- {{ records.length ? 'No matching reviews. Clear the search to see the table.' : 'The table is empty. Reload the dataset to continue.' }} -

- Expand a review or change the summary width. Each row is measured after - its text wraps. The header and footer stay visible; wide columns scroll - inside this region. -

-
- - - - - - - - - - - - - - - - Back to top - Previous screen - Next screen - -

- The counters inspect mounted rows. Scroll height includes estimates for - unseen rows and can change as you browse. Search, sort, dataset, width and - estimate changes restart measurement from the top; expanded state stays on - each record. -

- - Restart measurement - Reload dataset - Empty table - Reset playground - -
` - -function createReviews(count: number, width: Ref): ReviewRecord[] { - const summaries = [ - 'Updated the component documentation.', - 'Improved keyboard navigation and focus visibility across the release workflow. The review includes both desktop and narrow layouts.', - 'Added examples for empty results, long labels and loading transitions. Each example uses the shared theme, typed component props and real application state so it can be adapted to a production screen.', - ] - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - title: `Review ${index + 1}`, - summary: summaries[index % summaries.length], - detail: - 'The implementation keeps interaction state on the record. Expand this entry, scroll until it leaves the mounted window, and return to it: the notes remain open. Content can grow without assigning a new row height. ResizeObserver measures the rendered result and updates the virtual offsets.', - width, - expanded: ref(false), - })) -} - -function createVariableTablePlayground(): VariableTablePlayground { - const width = ref('28') - const header = ref(true), - footer = ref(true), - columns = ref(true), - sort = ref('asc') - const records = sref(createReviews(1000, width)) - const query = ref(''), - count = ref('1000'), - height = ref('400'), - estimate = ref('140'), - overscan = ref('4') - const active = ref(true) - const mountedRows = ref(0), - rowRange = ref('-'), - measuredRange = ref('-'), - scrollExtent = ref(0) - const filtered = computed(() => { - const result = records().filter((item) => - item.title.toLowerCase().includes(query().trim().toLowerCase()), - ) - return sort() === 'desc' ? result.reverse() : result - }) - let disposed = false - const restart = () => { - active(false) - queueMicrotask(() => { - if (!disposed) active(true) - }) - } - const stop = observe(filtered, restart) - const viewport = () => - document.querySelector('#review-table-viewport') - const scroll = (direction: number) => { - const element = viewport() - if (!element) return - element.scrollTop = - direction === 0 ? 0 : element.scrollTop + direction * element.clientHeight - element.dispatchEvent(new Event('scroll')) - } - const measure = () => { - const element = viewport() - const rows = Array.from( - element?.querySelectorAll('[data-row]') ?? [], - ) - const sizes = rows.map((row) => - Math.round(row.getBoundingClientRect().height), - ) - mountedRows(rows.length) - rowRange( - rows.length - ? `${Number(rows[0].dataset.row) + 1}–${Number(rows.at(-1)?.dataset.row) + 1}` - : '-', - ) - measuredRange( - sizes.length ? `${Math.min(...sizes)}–${Math.max(...sizes)} px` : '-', - ) - scrollExtent(element?.scrollHeight ?? 0) - } - let observer: MutationObserver | undefined - let resize: ResizeObserver | undefined - onMounted(() => { - const host = document.querySelector('#review-table-host') - if (!host) return - observer = new MutationObserver(measure) - observer.observe(host, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['style', 'data-row'], - }) - resize = new ResizeObserver(measure) - resize.observe(host) - measure() - }) - onUnmounted(() => { - disposed = true - stop() - observer?.disconnect() - resize?.disconnect() - }) - return { - records, - filtered, - query, - count, - height, - estimate, - overscan, - width, - header, - footer, - columns, - sort, - active, - mountedRows, - rowRange, - measuredRange, - scrollExtent, - widths: [18, 28, 40].map((n) => ({ label: n + 'rem', value: String(n) })), - sorts: [ - { label: 'Oldest first', value: 'asc' }, - { label: 'Newest first', value: 'desc' }, - ], - counts: [100, 1000, 10000, 50000].map((n) => ({ - label: `${n.toLocaleString('en-US')} records`, - value: String(n), - })), - heights: [280, 400, 520].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - estimates: [56, 140, 240].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - buffers: [1, 4, 12].map((n) => ({ label: `${n} rows`, value: String(n) })), - restart, - load: () => records(createReviews(Number(count()), width)), - clear: () => records([]), - first: () => scroll(0), - previous: () => scroll(-1), - next: () => scroll(1), - reset: () => - batch(() => { - query('') - count('1000') - height('400') - estimate('140') - overscan('4') - width('28') - header(true) - footer(true) - columns(true) - sort('asc') - records(createReviews(1000, width)) - }), - } -} -const variableTablePlayground = defineComponent( - variableTablePlaygroundTemplate, - { context: createVariableTablePlayground }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} -createApp( - { - components: { - VariableTablePlayground: variableTablePlayground, - ReviewRow: reviewRow, - ReviewHeader: reviewHeader, - ReviewFooter: reviewFooter, - ReviewColumns: reviewColumns, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - ...defineVariableVirtualTableComponents(), - }, - }, - { - selector: 'app#variable-virtual-table-demo', - template: html``, - }, -) -``` +
@@ -510,85 +53,7 @@ Constrain the description width and let the browser determine row height. This e ``` -```typescript -import { defineVariableVirtualTableComponents } from '@purestack/ts-components' -import { createApp, defineComponent, html, type RefOrValue } from 'regor' - -export interface Finding { - id: number - title: string - detail: string -} -export interface FindingRow { - item: RefOrValue - index: RefOrValue -} -export interface FindingHeader {} -export interface FindingColumns {} -export interface FindingFooter {} - -const findingRowTemplate = html` - {{ item.id }} - -
- {{ item.title }} -

{{ item.detail }}

-
- -` -const findingHeaderTemplate = html` - ID - Finding -` -const findingColumnsTemplate = html`` -const findingFooterTemplate = html`200 illustrative review findings` - -const findingRow = defineComponent(findingRowTemplate, { - props: ['item', 'index'], -}) -const findingHeader = defineComponent(findingHeaderTemplate) -const findingColumns = defineComponent(findingColumnsTemplate) -const findingFooter = defineComponent(findingFooterTemplate) - -export interface FindingsExample { - findings: Finding[] -} -const findingsTemplate = html`` -const findingsExample = defineComponent(findingsTemplate, { - context: () => ({ - findings: Array.from({ length: 200 }, (_, index) => ({ - id: index + 1, - title: `Finding ${index + 1}`, - detail: - index % 2 - ? 'Ready for review.' - : 'Verify keyboard navigation, focus visibility and the empty state before release. The description wraps inside a bounded column, so the table measures a taller row for this finding.', - })), - }), -}) -createApp( - { - components: { - FindingsExample: findingsExample, - FindingRow: findingRow, - FindingHeader: findingHeader, - FindingColumns: findingColumns, - FindingFooter: findingFooter, - ...defineVariableVirtualTableComponents(), - }, - }, - { - selector: 'app#variable-table-basic-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/virtual-list/virtual-list.mdx b/frontend/purestack.studio/components/data/virtual-list/virtual-list.mdx index 3d9d97db..afd19696 100644 --- a/frontend/purestack.studio/components/data/virtual-list/virtual-list.mdx +++ b/frontend/purestack.studio/components/data/virtual-list/virtual-list.mdx @@ -32,307 +32,7 @@ Scroll through 10,000 release records while only a small window is mounted. Chan ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineVirtualListComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - onMounted, - onUnmounted, - type Ref, - type RefOrValue, - ref, - type SRef, - sref, -} from 'regor' - -export interface ReleaseRecord { - id: number - title: string - state: string - tone: SemanticTone -} - -export interface ReleaseRow { - item: RefOrValue - index: RefOrValue -} - -const releaseRowTemplate = html` - {{ item.title }} - {{ item.state }} -` - -const releaseRow = defineComponent(releaseRowTemplate, { - props: ['item', 'index'], -}) - -export interface VirtualListPlayground { - records: SRef - filtered: ComputedRef - count: Ref - query: Ref - state: Ref - height: Ref - rowHeight: Ref - overscan: Ref - target: Ref - mountedRows: Ref - mountedRange: Ref - scrollOffset: Ref - counts: FormSelectOption[] - states: FormSelectOption[] - heights: FormSelectOption[] - densities: FormSelectOption[] - buffers: FormSelectOption[] - load: () => void - clear: () => void - reset: () => void - jump: () => void - first: () => void - last: () => void -} - -const virtualListPlaygroundTemplate = html` - - - - - - - -

LIVE PREVIEW · RELEASE QUEUE

- {{ filtered.length }} matching records -
- -

Rows mounted

{{ mountedRows }}
-

Mounted positions

{{ mountedRange }}
-

Scroll offset

{{ scrollOffset }} px
-
- - - {{ records.length ? 'No releases match. Clear the search or choose another status.' : 'The queue is empty. Load a dataset or reset the playground.' }} - -

Focus the list and use Arrow keys or Page Up / Page Down to scroll. Counters show real mounted rows, including the overscan buffer.

-
- - - - - -

Increasing the viewport or overscan mounts more rows. Changing the row height also changes the scroll distance. Each row fills its fixed-height wrapper, so the geometry stays aligned.

- - - - Jump to row - First - Last - - - - Reload dataset - Empty list - Reset playground - -
` - -function createRecords(count: number): ReleaseRecord[] { - const states = ['Ready', 'Review', 'Queued'] - const tones: SemanticTone[] = ['success', 'warning', 'info'] - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - title: `Release ${index + 1}`, - state: states[index % 3], - tone: tones[index % 3], - })) -} - -function createVirtualListPlayground(): VirtualListPlayground { - const count = ref('10000') - const records = sref(createRecords(Number(count()))) - const query = ref('') - const state = ref('all') - const height = ref('336') - const rowHeight = ref('56') - const overscan = ref('4') - const target = ref(5000) - const mountedRows = ref(0) - const mountedRange = ref('-') - const scrollOffset = ref(0) - const filtered = computed(() => { - const search = query().trim().toLowerCase() - return records().filter( - (item) => - (state() === 'all' || item.state === state()) && - item.title.toLowerCase().includes(search), - ) - }) - const viewport = () => - document.querySelector('#release-viewport') - const scrollToPosition = (position: number) => { - const element = viewport() - if (!element) return - const index = Math.max(0, Math.min(filtered().length - 1, position - 1)) - element.scrollTop = index * Number(rowHeight()) - element.dispatchEvent(new Event('scroll')) - } - const stopFiltering = observe(filtered, () => scrollToPosition(1)) - let observer: MutationObserver | undefined - let element: HTMLElement | null = null - const measure = () => { - if (!element) return - const rows = element.querySelectorAll('[data-index]') - mountedRows(rows.length) - mountedRange( - rows.length - ? `${Number(rows[0].dataset.index) + 1}–${Number(rows[rows.length - 1].dataset.index) + 1}` - : '-', - ) - scrollOffset(Math.round(element.scrollTop)) - } - onMounted(() => { - element = viewport() - if (!element) return - observer = new MutationObserver(measure) - observer.observe(element, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['data-index', 'style'], - }) - element.addEventListener('scroll', measure) - measure() - }) - onUnmounted(() => { - stopFiltering() - observer?.disconnect() - element?.removeEventListener('scroll', measure) - }) - return { - records, - filtered, - count, - query, - state, - height, - rowHeight, - overscan, - target, - mountedRows, - mountedRange, - scrollOffset, - counts: [100, 1000, 10000, 50000].map((value) => ({ - label: `${value.toLocaleString('en-US')} records`, - value: String(value), - })), - states: [ - { label: 'All statuses', value: 'all' }, - ...['Ready', 'Review', 'Queued'].map((value) => ({ - label: value, - value, - })), - ], - heights: [224, 336, 448].map((value) => ({ - label: `${value} px`, - value: String(value), - })), - densities: [ - { label: 'Compact · 44 px', value: '44' }, - { label: 'Comfortable · 56 px', value: '56' }, - { label: 'Spacious · 72 px', value: '72' }, - ], - buffers: [1, 4, 12, 24].map((value) => ({ - label: `${value} rows`, - value: String(value), - })), - load: () => records(createRecords(Number(count()))), - clear: () => records([]), - first: () => scrollToPosition(1), - last: () => scrollToPosition(filtered().length), - jump: () => - scrollToPosition( - Number.isFinite(Number(target())) ? Math.trunc(Number(target())) : 1, - ), - reset: () => - batch(() => { - count('10000') - query('') - state('all') - height('336') - rowHeight('56') - overscan('4') - target(5000) - records(createRecords(10000)) - }), - } -} - -const virtualListPlayground = defineComponent( - virtualListPlaygroundTemplate, - { context: createVirtualListPlayground }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - VirtualListPlayground: virtualListPlayground, - ReleaseRow: releaseRow, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - ...defineVirtualListComponents(), - }, - }, - { - selector: 'app#virtual-list-demo', - template: html``, - }, -) -``` +
@@ -353,61 +53,7 @@ Start with a typed row component and register it alongside VirtualList. This com ``` -```typescript -import { defineVirtualListComponents } from '@purestack/ts-components' -import { createApp, defineComponent, html, type RefOrValue } from 'regor' - -export interface SimpleListRow { - item: RefOrValue - index: RefOrValue -} - -const simpleListRowTemplate = html`
{{ item }}
` - -const simpleListRow = defineComponent(simpleListRowTemplate, { - props: ['item', 'index'], -}) - -export interface BasicListExample { - items: string[] -} - -const basicListTemplate = html`` - -const basicList = defineComponent(basicListTemplate, { - context: () => ({ - items: Array.from({ length: 1000 }, (_, index) => `Record ${index + 1}`), - }), -}) - -createApp( - { - components: { - BasicListExample: basicList, - SimpleListRow: simpleListRow, - ...defineVirtualListComponents(), - }, - }, - { - selector: 'app#virtual-list-basic-demo', - template: html``, - }, -) -``` +
@@ -428,142 +74,7 @@ The checkbox ref belongs to each task in the parent dataset. Removing a row from ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - definePanelComponents, - defineVirtualListComponents, -} from '@purestack/ts-components' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - onUnmounted, - type Ref, - type RefOrValue, - ref, -} from 'regor' - -export interface ReviewTask { - id: number - title: string - reviewed: Ref -} - -export interface ReviewTaskRow { - item: RefOrValue - index: RefOrValue -} - -const reviewTaskRowTemplate = html` - -` - -const reviewTaskRow = defineComponent(reviewTaskRowTemplate, { - props: ['item', 'index'], -}) - -export interface PersistentStateExample { - tasks: ReviewTask[] - visible: ComputedRef - reviewedCount: ComputedRef - reviewedOnly: Ref - first: () => void - last: () => void - clear: () => void -} - -const persistentStateTemplate = html` - - -

Review checklist

- {{ reviewedCount }} / {{ tasks.length }} reviewed -
-

Check a task, jump to the last row, then return to the first. Your selection survives the row being removed and mounted again.

- - First task - Last task - Clear selections - - - - No reviewed tasks yet. Turn off the filter to select a task. -
-
` - -function createPersistentStateExample(): PersistentStateExample { - const tasks = Array.from({ length: 200 }, (_, index) => ({ - id: index + 1, - title: `Review task ${index + 1}`, - reviewed: ref(false), - })) - const reviewedOnly = ref(false) - const visible = computed(() => - reviewedOnly() ? tasks.filter((task) => task.reviewed()) : tasks, - ) - const scrollTo = (position: number) => { - const viewport = document.querySelector('#review-viewport') - if (!viewport) return - viewport.scrollTop = Math.max(0, position) * 48 - viewport.dispatchEvent(new Event('scroll')) - } - const stop = observe(visible, () => scrollTo(0)) - onUnmounted(stop) - return { - tasks, - visible, - reviewedOnly, - reviewedCount: computed( - () => tasks.filter((task) => task.reviewed()).length, - ), - first: () => scrollTo(0), - last: () => scrollTo(visible().length - 1), - clear: () => - batch(() => { - for (const task of tasks) task.reviewed(false) - }), - } -} - -const persistentStateExample = defineComponent( - persistentStateTemplate, - { context: createPersistentStateExample }, -) - -createApp( - { - components: { - PersistentStateExample: persistentStateExample, - ReviewTaskRow: reviewTaskRow, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...definePanelComponents(), - ...defineVirtualListComponents(), - }, - }, - { - selector: 'app#virtual-list-state-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/data/virtual-table/virtual-table.mdx b/frontend/purestack.studio/components/data/virtual-table/virtual-table.mdx index 65da3a45..4e3aade6 100644 --- a/frontend/purestack.studio/components/data/virtual-table/virtual-table.mdx +++ b/frontend/purestack.studio/components/data/virtual-table/virtual-table.mdx @@ -32,447 +32,7 @@ Explore up to 50,000 release records with search, status filtering, sorting and ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineVirtualTableComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - onMounted, - onUnmounted, - type Ref, - type RefOrValue, - ref, - type SRef, - sref, -} from 'regor' - -export interface ReleaseRecord { - id: number - title: string - state: string - tone: SemanticTone -} -export interface ReleaseTableRow { - item: RefOrValue - index: RefOrValue -} -export interface ReleaseTableHeader {} -export interface ReleaseTableFooter {} -export interface ReleaseTableColumns {} - -const releaseTableRowTemplate = html` - {{ item.id }} - - {{ item.title }} - - - {{ item.state }} - -` -const releaseTableHeaderTemplate = html` - - ID - Release - Status - -` -const releaseTableFooterTemplate = html` - - - Release queue · illustrative data - - -` -const releaseTableColumnsTemplate = html` - - - -` - -const releaseTableRow = defineComponent( - releaseTableRowTemplate, - { props: ['item', 'index'] }, -) -const releaseTableHeader = defineComponent( - releaseTableHeaderTemplate, -) -const releaseTableFooter = defineComponent( - releaseTableFooterTemplate, -) -const releaseTableColumns = defineComponent( - releaseTableColumnsTemplate, -) - -export interface VirtualTablePlayground { - records: SRef - filtered: ComputedRef - query: Ref - count: Ref - state: Ref - sort: Ref - height: Ref - rowHeight: Ref - overscan: Ref - layout: Ref<'auto' | 'fixed'> - header: Ref - footer: Ref - columns: Ref - target: Ref - mountedRows: Ref - rowRange: Ref - scrollOffset: Ref - counts: FormSelectOption[] - states: FormSelectOption[] - sorts: FormSelectOption[] - heights: FormSelectOption[] - densities: FormSelectOption[] - buffers: FormSelectOption[] - layouts: FormSelectOption[] - load: () => void - clear: () => void - reset: () => void - first: () => void - last: () => void - jump: () => void -} - -const virtualTablePlaygroundTemplate = html` - - - - - - - - -

LIVE PREVIEW · RELEASE TABLE

- {{ filtered.length }} matching records -
- -
-

Data rows mounted

- {{ mountedRows }} -
-
-

Positions

- {{ rowRange }} -
-
-

Scroll offset

- {{ scrollOffset }} px -
-
- - {{ records.length ? 'No releases match. Clear the search or change the status.' : 'The table is empty. Reload a dataset to continue.' }} -

- The header and footer stay visible while data rows scroll. Focus the - region for keyboard scrolling; wide content scrolls horizontally inside - it. -

-
- - - - - - - - - - - -

- Fixed layout uses the viewport width and the column group. Auto layout - follows content width. Cell content stays on one line so each data row fits - its configured height. -

- - - Jump to rowFirstLast - - Reload datasetEmpty tableReset playground -
` - -function createReleases(count: number): ReleaseRecord[] { - const states = ['Ready', 'Review', 'Queued'] - const tones: SemanticTone[] = ['success', 'warning', 'info'] - return Array.from({ length: count }, (_, index) => ({ - id: index + 1, - title: `Release ${index + 1} · ${index % 2 ? 'Component documentation' : 'Framework improvements'}`, - state: states[index % 3], - tone: tones[index % 3], - })) -} - -function createVirtualTablePlayground(): VirtualTablePlayground { - const records = sref(createReleases(10000)) - const query = ref(''), - count = ref('10000'), - state = ref('all'), - sort = ref('asc') - const height = ref('360'), - rowHeight = ref('48'), - overscan = ref('4'), - layout = ref<'auto' | 'fixed'>('fixed') - const header = ref(true), - footer = ref(true), - columns = ref(true), - target = ref(5000) - const mountedRows = ref(0), - rowRange = ref('-'), - scrollOffset = ref(0) - const filtered = computed(() => { - const result = records().filter( - (item) => - (state() === 'all' || state() === item.state) && - item.title.toLowerCase().includes(query().trim().toLowerCase()), - ) - return sort() === 'desc' ? result.reverse() : result - }) - const viewport = () => - document.querySelector('#release-table-viewport') - const scrollToPosition = (position: number) => { - const element = viewport() - if (!element) return - const index = Math.max(0, Math.min(filtered().length - 1, position - 1)) - element.scrollTop = index * Number(rowHeight()) - element.dispatchEvent(new Event('scroll')) - } - const stop = observe(filtered, () => scrollToPosition(1)) - let observer: MutationObserver | undefined - let element: HTMLElement | null = null - const measure = () => { - const rows = Array.from( - element?.querySelectorAll('[data-row]') ?? [], - ) - mountedRows(rows.length) - rowRange( - rows.length - ? `${Number(rows[0].dataset.row) + 1}–${Number(rows.at(-1)?.dataset.row) + 1}` - : '-', - ) - scrollOffset(Math.round(element?.scrollTop ?? 0)) - } - onMounted(() => { - element = viewport() - if (!element) return - observer = new MutationObserver(measure) - observer.observe(element, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['style', 'data-row'], - }) - element.addEventListener('scroll', measure) - measure() - }) - onUnmounted(() => { - stop() - observer?.disconnect() - element?.removeEventListener('scroll', measure) - }) - return { - records, - filtered, - query, - count, - state, - sort, - height, - rowHeight, - overscan, - layout, - header, - footer, - columns, - target, - mountedRows, - rowRange, - scrollOffset, - counts: [100, 1000, 10000, 50000].map((n) => ({ - label: `${n.toLocaleString('en-US')} records`, - value: String(n), - })), - states: [ - { label: 'All statuses', value: 'all' }, - ...['Ready', 'Review', 'Queued'].map((value) => ({ - label: value, - value, - })), - ], - sorts: [ - { label: 'Oldest first', value: 'asc' }, - { label: 'Newest first', value: 'desc' }, - ], - heights: [240, 360, 480].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - densities: [48, 60, 72].map((n) => ({ - label: `${n} px`, - value: String(n), - })), - buffers: [1, 4, 12].map((n) => ({ label: `${n} rows`, value: String(n) })), - layouts: [ - { label: 'Fixed · fit the viewport', value: 'fixed' }, - { label: 'Auto · fit the content', value: 'auto' }, - ], - load: () => records(createReleases(Number(count()))), - clear: () => records([]), - first: () => scrollToPosition(1), - last: () => scrollToPosition(filtered().length), - jump: () => - scrollToPosition( - Number.isFinite(Number(target())) ? Math.trunc(Number(target())) : 1, - ), - reset: () => - batch(() => { - query('') - count('10000') - state('all') - sort('asc') - height('360') - rowHeight('48') - overscan('4') - layout('fixed') - header(true) - footer(true) - columns(true) - target(5000) - records(createReleases(10000)) - }), - } -} -const virtualTablePlayground = defineComponent( - virtualTablePlaygroundTemplate, - { context: createVirtualTablePlayground }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} -createApp( - { - components: { - VirtualTablePlayground: virtualTablePlayground, - ReleaseTableRow: releaseTableRow, - ReleaseTableHeader: releaseTableHeader, - ReleaseTableFooter: releaseTableFooter, - ReleaseTableColumns: releaseTableColumns, - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - ...definePanelComponents(), - ...defineVirtualTableComponents(), - }, - }, - { - selector: 'app#virtual-table-demo', - template: html``, - }, -) -``` +
@@ -493,79 +53,7 @@ A small, copyable implementation with a typed row, semantic header, column group ``` -```typescript -import { defineVirtualTableComponents } from '@purestack/ts-components' -import { createApp, defineComponent, html, type RefOrValue } from 'regor' - -export interface Build { - id: number - name: string - duration: number -} -export interface BuildRow { - item: RefOrValue - index: RefOrValue -} -export interface BuildHeader {} -export interface BuildColumns {} -export interface BuildFooter {} - -const buildRowTemplate = html` - {{ item.id }} - {{ item.name }} - {{ item.duration }} ms -` -const buildHeaderTemplate = html` - ID - Build - Duration -` -const buildColumnsTemplate = html`` -const buildFooterTemplate = html`500 illustrative build results` - -const buildRow = defineComponent(buildRowTemplate, { - props: ['item', 'index'], -}) -const buildHeader = defineComponent(buildHeaderTemplate) -const buildColumns = defineComponent(buildColumnsTemplate) -const buildFooter = defineComponent(buildFooterTemplate) - -export interface BuildTableExample { - builds: Build[] -} -const buildTableTemplate = html`` -const buildTable = defineComponent(buildTableTemplate, { - context: () => ({ - builds: Array.from({ length: 500 }, (_, index) => ({ - id: index + 1, - name: `Build ${index + 1}`, - duration: 120 + (index % 80), - })), - }), -}) -createApp( - { - components: { - BuildTableExample: buildTable, - BuildRow: buildRow, - BuildHeader: buildHeader, - BuildColumns: buildColumns, - BuildFooter: buildFooter, - ...defineVirtualTableComponents(), - }, - }, - { - selector: 'app#virtual-table-basic-demo', - template: html``, - }, -) -``` + diff --git a/frontend/purestack.studio/components/forms/app-form/app-form.mdx b/frontend/purestack.studio/components/forms/app-form/app-form.mdx index d9549ee9..36158420 100644 --- a/frontend/purestack.studio/components/forms/app-form/app-form.mdx +++ b/frontend/purestack.studio/components/forms/app-form/app-form.mdx @@ -32,83 +32,7 @@ Edit the project name, save it locally, then reset. The handler explicitly check ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - definePanelComponents, -} from '@purestack/ts-components' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface AppFormExample { - projectName: Ref - savedProject: Ref - save: (event: Event) => void - reset: () => void -} - -const appFormExampleTemplate = html` - - - - - Reset - - - Saved locally: {{ savedProject }} - - -` - -function createAppFormExample(): AppFormExample { - const projectName = ref('Studio docs') - const savedProject = ref('') - return { - projectName, - savedProject, - save: (event) => { - const form = event.target as HTMLFormElement - if (!form.reportValidity()) return - savedProject(String(new FormData(form).get('project') ?? '')) - }, - reset: () => { - projectName('Studio docs') - savedProject('') - }, - } -} - -const component = defineComponent(appFormExampleTemplate, { - context: createAppFormExample, -}) - -createApp( - { - components: { - AppFormExample: component, - - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - }, - }, - { selector: 'app#app-form-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` + diff --git a/frontend/purestack.studio/components/forms/auto-complete-input/auto-complete-input.mdx b/frontend/purestack.studio/components/forms/auto-complete-input/auto-complete-input.mdx index 318ba7d3..ff7a5373 100644 --- a/frontend/purestack.studio/components/forms/auto-complete-input/auto-complete-input.mdx +++ b/frontend/purestack.studio/components/forms/auto-complete-input/auto-complete-input.mdx @@ -32,144 +32,7 @@ Type a label, value or keyword; choose a result with the mouse or arrow keys and ``` -```typescript -import { - type AutoCompleteOption, - type AutoCompleteValue, - defineAutoCompleteInputComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineIconComponents, - type ResolvedAutoCompleteOption, -} from '@purestack/ts-components' -import { - lucide_check, - lucide_chevron_down, - lucide_loader_circle, -} from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface AutoCompleteInputExample { - searchQuery: Ref - chosenRegion: Ref - regionOptions: AutoCompleteOption[] - pending: Ref - locked: Ref - minimum: Ref - limit: Ref - focusOpens: Ref - activity: Ref - searchActivity: Ref - searched: (query: string) => void - selected: (option: ResolvedAutoCompleteOption) => void - reset: () => void -} - -const autoCompleteInputExampleTemplate = html` - - - - - - - - - - Clear selection - - - Query: {{ searchQuery || 'Empty' }} · Selected value: {{ chosenRegion ?? 'None' - }} - -

onSearch: {{ searchActivity || '(empty query)' }}

-

{{ activity }}

-
` - -function createAutoCompleteInputExample(): AutoCompleteInputExample { - const searchQuery = ref('') - const chosenRegion = ref(null) - const activity = ref('Select a region to inspect onSelect.') - const searchActivity = ref('') - return { - searchQuery, - chosenRegion, - pending: ref(false), - locked: ref(false), - minimum: ref(0), - limit: ref(3), - focusOpens: ref(true), - activity, - searchActivity, - searched: (query) => searchActivity(query), - selected: (option) => - activity(`onSelect: ${option.label} → ${option.value}`), - regionOptions: [ - { label: 'Europe Central', value: 'eu-central', keywords: ['Frankfurt'] }, - { label: 'US East', value: 'us-east', keywords: ['Virginia'] }, - { label: 'Asia Pacific', value: 'apac', keywords: ['Singapore'] }, - { - label: 'Private region · unavailable', - value: 'private', - disabled: true, - }, - ], - reset: () => { - searchQuery('') - chosenRegion(null) - }, - } -} - -const component = defineComponent( - autoCompleteInputExampleTemplate, - { - context: createAutoCompleteInputExample, - }, -) -const icons: Record = { - 'lucide:loader-circle': lucide_loader_circle, - 'lucide:check': lucide_check, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - AutoCompleteInputExample: component, - - ...defineAutoCompleteInputComponents(), - ...defineFormInputField(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineButtonComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#auto-complete-input-demo', - template: html``, - }, -) - -mountFormAppearanceGalleries() -``` +
@@ -190,101 +53,7 @@ Rows can show richer context while the parent owns listbox semantics and selecti ``` -```typescript -import { - type AutoCompleteOption, - defineAutoCompleteInputComponents, - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineIconComponents, - type ResolvedAutoCompleteOption, -} from '@purestack/ts-components' -import { lucide_check, lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - createApp, - defineComponent, - html, - type Ref, - type RefOrValue, - ref, -} from 'regor' - -export interface ServiceSuggestion { - option: RefOrValue - selected: RefOrValue -} -const serviceSuggestionTemplate = html` - - {{ option?.label }} - Service ID: {{ option?.value }} - - Selected -` -const serviceSuggestion = defineComponent( - serviceSuggestionTemplate, - { props: ['option', 'selected'] }, -) -export interface CustomSuggestions { - serviceQuery: Ref - serviceOptions: AutoCompleteOption[] -} - -const customSuggestionsTemplate = html`` - -function createCustomSuggestions(): CustomSuggestions { - return { - serviceQuery: ref(''), - serviceOptions: [ - { label: 'Identity API', value: 'identity', keywords: ['auth'] }, - { label: 'Mail gateway', value: 'mail', keywords: ['email'] }, - { label: 'Billing API', value: 'billing', keywords: ['invoice'] }, - ], - } -} - -const component = defineComponent( - customSuggestionsTemplate, - { - context: createCustomSuggestions, - }, -) -const icons: Record = { - 'lucide:check': lucide_check, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - CustomSuggestions: component, - ServiceSuggestion: serviceSuggestion, - ...defineAutoCompleteInputComponents(), - ...defineFormInputField(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineButtonComponents(), - ...defineBadgeComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#auto-custom-row-demo', - template: html``, - }, -) -``` + diff --git a/frontend/purestack.studio/components/forms/auto-complete-option-row/auto-complete-option-row.mdx b/frontend/purestack.studio/components/forms/auto-complete-option-row/auto-complete-option-row.mdx index 68439ad4..70f87851 100644 --- a/frontend/purestack.studio/components/forms/auto-complete-option-row/auto-complete-option-row.mdx +++ b/frontend/purestack.studio/components/forms/auto-complete-option-row/auto-complete-option-row.mdx @@ -68,55 +68,7 @@ The default row is already registered by the input family. ``` -```typescript -import { - defineAutoCompleteInputComponents, - defineFormInputField, - defineIconComponents, -} from '@purestack/ts-components' -import { lucide_check, lucide_chevron_down } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html } from 'regor' - -export interface AutoCompleteOptionRowStates {} - -const autoCompleteOptionRowStatesTemplate = html`` - -function createAutoCompleteOptionRowStates(): AutoCompleteOptionRowStates { - return {} -} - -const component = defineComponent( - autoCompleteOptionRowStatesTemplate, - { - context: createAutoCompleteOptionRowStates, - }, -) -const icons: Record = { - 'lucide:check': lucide_check, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - AutoCompleteOptionRowStates: component, - - ...defineAutoCompleteInputComponents(), - ...defineFormInputField(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#auto-complete-option-row-states-demo', - template: html``, - }, -) -``` + diff --git a/frontend/purestack.studio/components/forms/composer/composer.mdx b/frontend/purestack.studio/components/forms/composer/composer.mdx index 6d073e18..92f2fc59 100644 --- a/frontend/purestack.studio/components/forms/composer/composer.mdx +++ b/frontend/purestack.studio/components/forms/composer/composer.mdx @@ -41,223 +41,7 @@ Change the tone, variant and height below the editor. Choose **Empty draft** to ``` -```typescript -import { - type ComponentVariant, - createComposerBodyHtml, - defineBadgeComponents, - defineButtonComponents, - defineComposerComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineTabsComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { - lucide_bold, - lucide_chevron_down, - lucide_code, - lucide_eraser, - lucide_italic, - lucide_link, - lucide_list, - lucide_list_ordered, - lucide_underline, - tabler_align_center, - tabler_align_left, - tabler_align_right, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface ComposerPlayground { - messageHtml: Ref - messageText: Ref - editorLabel: Ref - editorPlaceholder: Ref - disabled: Ref - tone: Ref - variant: Ref - height: Ref - words: ComputedRef - tones: FormSelectOption[] - variants: FormSelectOption[] - heights: FormSelectOption[] - release: () => void - reply: () => void - clear: () => void - reset: () => void -} - -const releaseHtml = createComposerBodyHtml( - '

Ready for your next release

Build with typed components, a shared theme and a little less ceremony.

  • Explore the interactive component guides.
  • Try both rich text and HTML source.
  • Share your feedback with the team.

Explore PureStack

', -) -const replyHtml = createComposerBodyHtml( - '

Hi Alex,

Thanks for trying the preview. Your feedback helped us improve keyboard navigation and the new component guides.

Can we use the same theme across the whole app?

Yes. Components inherit the active skin, so your interface stays consistent.

Best,
The PureStack team

', -) - -const composerPlaygroundTemplate = html` - -

A draft, two live outputs

- {{ disabled ? 'Editing disabled' : 'Live editor' }} -
- - Load release note - Load support reply - Empty draft - - - - {{ words }} words · {{ messageText.length }} text characters - Select text, then choose a toolbar action. - - - - - - - - - - - - - Reset playground - - -

Model inspector

- - -
{{ messageText || 'The draft is empty.' }}
-
- -
{{ messageHtml || 'The draft is empty.' }}
-
-
-

These values update as you type. The HTML source button in the editor lets you edit the markup itself.

-
-
` - -function createComposerPlayground(): ComposerPlayground { - const messageHtml = ref(releaseHtml) - const messageText = ref('') - const editorLabel = ref('Release note') - const editorPlaceholder = ref('What would you like to share?') - const disabled = ref(false) - const tone = ref('neutral') - const variant = ref('surfaceAlt') - const height = ref('18rem') - return { - messageHtml, - messageText, - editorLabel, - editorPlaceholder, - disabled, - tone, - variant, - height, - words: computed(() => - messageText().trim() ? messageText().trim().split(/\s+/).length : 0, - ), - tones: [ - 'neutral', - 'accent', - 'secondary', - 'info', - 'success', - 'warning', - 'danger', - 'feature', - ].map((value) => ({ value, label: value })), - variants: [ - 'surfaceAlt', - 'surface', - 'flat', - 'flatAlt', - 'outline', - 'outlineFill', - 'solid', - 'subtle', - 'none', - ].map((value) => ({ value, label: value })), - heights: ['12rem', '18rem', '24rem'].map((value) => ({ - value, - label: value, - })), - release: () => messageHtml(releaseHtml), - reply: () => messageHtml(replyHtml), - clear: () => messageHtml(''), - reset: () => { - messageHtml(releaseHtml) - editorLabel('Release note') - editorPlaceholder('What would you like to share?') - disabled(false) - tone('neutral') - variant('surfaceAlt') - height('18rem') - }, - } -} - -const composerPlayground = defineComponent( - composerPlaygroundTemplate, - { - context: createComposerPlayground, - }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'lucide:bold': lucide_bold, - 'lucide:italic': lucide_italic, - 'lucide:underline': lucide_underline, - 'tabler:align-left': tabler_align_left, - 'tabler:align-center': tabler_align_center, - 'tabler:align-right': tabler_align_right, - 'lucide:list': lucide_list, - 'lucide:list-ordered': lucide_list_ordered, - 'lucide:link': lucide_link, - 'lucide:eraser': lucide_eraser, - 'lucide:code': lucide_code, -} - -createApp( - { - components: { - ComposerPlayground: composerPlayground, - ...defineComposerComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineGridComponents(), - ...definePanelComponents(), - ...defineTabsComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#composer-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` +
@@ -300,156 +84,7 @@ The sample saves both models in memory and requires non-empty plain text. That v ``` -```typescript -import { - createComposerBodyHtml, - defineBadgeComponents, - defineButtonComponents, - defineComposerComponents, - defineFlexComponents, - defineFormComponents, - defineIconComponents, - definePanelComponents, -} from '@purestack/ts-components' -import { - lucide_bold, - lucide_code, - lucide_eraser, - lucide_italic, - lucide_link, - lucide_list, - lucide_list_ordered, - lucide_underline, - tabler_align_center, - tabler_align_left, - tabler_align_right, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface ComposerDraftWorkflow { - messageHtml: Ref - messageText: Ref - savedHtml: Ref - savedText: Ref - editing: Ref - dirty: ComputedRef - status: Ref - open: () => void - save: () => void - restore: () => void -} - -const composerDraftWorkflowTemplate = html` - -
-

Draft workspace

-

Open the editor, make a change, then save or restore the draft.

-
- {{ dirty ? 'Unsaved changes' : 'Saved snapshot' }} -
- Open draft editor - - - Save snapshot - Restore snapshot - Close editor - - {{ status }} - -

Saved plain-text copy

-

{{ savedText }}

-
-

The snapshot stays in this example's memory. Reloading the page resets it.

-
` - -function createComposerDraftWorkflow(): ComposerDraftWorkflow { - const initial = createComposerBodyHtml( - '

The preview is ready for review.

Next: test the keyboard flow and share your notes.

', - ) - const messageHtml = ref(initial) - const messageText = ref('') - const savedHtml = ref(initial) - const savedText = ref( - 'The preview is ready for review.\nNext: test the keyboard flow and share your notes.', - ) - const editing = ref(false) - const status = ref( - 'Open the editor to continue. Focus moves to the start of the draft.', - ) - return { - messageHtml, - messageText, - savedHtml, - savedText, - editing, - status, - dirty: computed(() => messageHtml() !== savedHtml()), - open: () => { - editing(true) - status('Editing. Save captures both HTML and plain text.') - }, - save: () => { - if (!messageText().trim()) return - savedHtml(messageHtml()) - savedText(messageText()) - status('Snapshot saved. You can keep editing or close the editor.') - }, - restore: () => { - messageHtml(savedHtml()) - status('Restored the saved snapshot.') - }, - } -} - -const composerDraftWorkflow = defineComponent( - composerDraftWorkflowTemplate, - { - context: createComposerDraftWorkflow, - }, -) -const icons: Record = { - 'lucide:bold': lucide_bold, - 'lucide:italic': lucide_italic, - 'lucide:underline': lucide_underline, - 'tabler:align-left': tabler_align_left, - 'tabler:align-center': tabler_align_center, - 'tabler:align-right': tabler_align_right, - 'lucide:list': lucide_list, - 'lucide:list-ordered': lucide_list_ordered, - 'lucide:link': lucide_link, - 'lucide:eraser': lucide_eraser, - 'lucide:code': lucide_code, -} - -createApp( - { - components: { - ComposerDraftWorkflow: composerDraftWorkflow, - ...defineComposerComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...definePanelComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#composer-draft-demo', - template: html``, - }, -) -``` +
@@ -494,265 +129,7 @@ Try **Insert demo image**, choose a local image, or paste/drop an image file int ``` -```typescript -import { - createComposerBodyHtml, - defineBadgeComponents, - defineButtonComponents, - defineComposerComponents, - defineFlexComponents, - defineFormComponents, - defineIconComponents, - definePanelComponents, - defineTabsComponents, -} from '@purestack/ts-components' -import { - lucide_bold, - lucide_code, - lucide_eraser, - lucide_italic, - lucide_link, - lucide_list, - lucide_list_ordered, - lucide_underline, - tabler_align_center, - tabler_align_left, - tabler_align_right, -} from '@purestack/ts-svg-icons' -import { - createApp, - defineComponent, - html, - onUnmounted, - type Ref, - ref, - type SRef, - sref, -} from 'regor' - -export interface ComposerAttachment { - contentId: string - file: File - previewUrl: string -} - -export interface ComposerInlineImages { - messageHtml: Ref - previewUrls: SRef> - attachments: SRef - fileInput: SRef - busy: Ref - status: Ref - choose: () => void - select: (event: Event) => void - receive: (event: CustomEvent<{ files: File[] }>) => void - insertDemo: () => Promise - remove: (contentId: string) => void -} - -const composerInlineImagesTemplate = html` - -

Inline image lab

- Local previews · no uploads -
- - - - Choose images - {{ busy ? 'Loading image…' : 'Insert demo image' }} - - {{ status }} - - - -

Add an image to see its file and content ID.

- - - {{ attachment.file.name }} - Remove - - {{ Math.ceil(attachment.file.size / 1024) }} KB · {{ attachment.file.type }} - cid:{{ attachment.contentId }} - -
- -
{{ messageHtml }}
-
-
-
-

You can also paste or drop image files into the editor. This example appends them to the message. PNG, JPEG, WebP and GIF files up to 5 MB each are accepted.

-
` - -function createComposerInlineImages(): ComposerInlineImages { - const messageHtml = ref( - createComposerBodyHtml( - '

A little more than words.

Add a product image, screenshot or the demo logo below.

', - ), - ) - const previewUrls = sref>({}) - const attachments = sref([]) - const fileInput = sref(null) - const status = ref( - 'Images stay in this browser. The HTML model keeps portable cid: references.', - ) - const busy = ref(false) - let disposed = false - - const add = (files: File[]) => { - const accepted = files.filter( - (file) => - ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes( - file.type, - ) && - file.size > 0 && - file.size <= 5 * 1024 * 1024, - ) - const entries = accepted.map((file) => ({ - file, - contentId: `${crypto.randomUUID()}@composer.demo`, - previewUrl: URL.createObjectURL(file), - })) - if (entries.length) { - const document = new DOMParser().parseFromString( - messageHtml(), - 'text/html', - ) - const first = document.body.firstElementChild - const body = - document.body.children.length === 1 && first?.tagName === 'DIV' - ? first - : document.body - for (const entry of entries) { - const paragraph = document.createElement('p') - const image = document.createElement('img') - image.src = `cid:${entry.contentId}` - image.alt = entry.file.name - image.style.width = '240px' - paragraph.append(image) - body.append(paragraph) - } - attachments([...attachments(), ...entries]) - previewUrls( - Object.fromEntries( - attachments().map((entry) => [entry.contentId, entry.previewUrl]), - ), - ) - messageHtml(document.body.innerHTML) - } - const skipped = files.length - entries.length - status( - `${entries.length} ${entries.length === 1 ? 'image' : 'images'} added.` + - (skipped - ? ` ${skipped} ${skipped === 1 ? 'file' : 'files'} skipped. Use a supported image under 5 MB.` - : ''), - ) - } - - onUnmounted(() => { - disposed = true - for (const entry of attachments()) URL.revokeObjectURL(entry.previewUrl) - }) - - return { - messageHtml, - previewUrls, - attachments, - fileInput, - busy, - status, - choose: () => fileInput()?.click(), - select: (event) => { - const input = event.currentTarget as HTMLInputElement - add(Array.from(input.files ?? [])) - input.value = '' - }, - receive: (event) => add(event.detail.files), - insertDemo: async () => { - busy(true) - try { - const response = await fetch('/assets/pure-stack-logo.png') - if (!response.ok) throw new Error('Image unavailable') - const blob = await response.blob() - if (!disposed) - add([new File([blob], 'pure-stack-logo.png', { type: 'image/png' })]) - } catch { - if (!disposed) - status( - 'The demo image could not be loaded. Choose a local image instead.', - ) - } finally { - if (!disposed) busy(false) - } - }, - remove: (contentId) => { - const entry = attachments().find((item) => item.contentId === contentId) - if (!entry) return - const document = new DOMParser().parseFromString( - messageHtml(), - 'text/html', - ) - for (const image of document.querySelectorAll('img')) { - if (image.getAttribute('src') !== `cid:${contentId}`) continue - const paragraph = image.parentElement - image.remove() - if (paragraph?.tagName === 'P' && !paragraph.hasChildNodes()) - paragraph.remove() - } - messageHtml(document.body.innerHTML) - attachments(attachments().filter((item) => item !== entry)) - previewUrls( - Object.fromEntries( - attachments().map((item) => [item.contentId, item.previewUrl]), - ), - ) - URL.revokeObjectURL(entry.previewUrl) - status(`Removed ${entry.file.name} and released its preview URL.`) - }, - } -} - -const composerInlineImages = defineComponent( - composerInlineImagesTemplate, - { - context: createComposerInlineImages, - }, -) -const icons: Record = { - 'lucide:bold': lucide_bold, - 'lucide:italic': lucide_italic, - 'lucide:underline': lucide_underline, - 'tabler:align-left': tabler_align_left, - 'tabler:align-center': tabler_align_center, - 'tabler:align-right': tabler_align_right, - 'lucide:list': lucide_list, - 'lucide:list-ordered': lucide_list_ordered, - 'lucide:link': lucide_link, - 'lucide:eraser': lucide_eraser, - 'lucide:code': lucide_code, -} - -createApp( - { - components: { - ComposerInlineImages: composerInlineImages, - ...defineComposerComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...definePanelComponents(), - ...defineTabsComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#composer-images-demo', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/forms/contact-form/contact-form.mdx b/frontend/purestack.studio/components/forms/contact-form/contact-form.mdx index bf6964d5..9f55d2ec 100644 --- a/frontend/purestack.studio/components/forms/contact-form/contact-form.mdx +++ b/frontend/purestack.studio/components/forms/contact-form/contact-form.mdx @@ -34,69 +34,7 @@ Fill in the required fields and submit. Native validation runs before the local ``` -```typescript -import { - defineButtonComponents, - defineContactFormComponents, - defineFlexComponents, - defineFormComponents, -} from '@purestack/ts-components' - -import { createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface ContactFormExample { - receipt: Ref - capture: (event: Event) => void -} - -const contactFormExampleTemplate = html` -
- -
- {{ receipt }} -
` - -function createContactFormExample(): ContactFormExample { - const receipt = ref('') - return { - receipt, - capture: (event) => { - const form = event.target as HTMLFormElement - if (!form.reportValidity()) return - const data = new FormData(form) - receipt( - `Message preview ready for ${String(data.get('name') ?? '')}: ${String(data.get('message') ?? '')}`, - ) - }, - } -} - -const component = defineComponent( - contactFormExampleTemplate, - { - context: createContactFormExample, - }, -) - -createApp( - { - components: { - ContactFormExample: component, - - ...defineContactFormComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - }, - }, - { selector: 'app#contact-form-demo', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx b/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx index e58c22b2..f5a65b14 100644 --- a/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx +++ b/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx @@ -32,129 +32,7 @@ Add local text, image or PDF files, remove individual entries and inspect the to ``` -```typescript -import { - type DropFileItem, - type DropFilesIconMap, - defineButtonComponents, - defineDropFilesComponents, - defineFlexComponents, - defineFormComponents, - defineIconComponents, -} from '@purestack/ts-components' -import { - lucide_cloud_upload, - lucide_file, - lucide_file_archive, - lucide_file_image, - lucide_file_text, - lucide_trash_2, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, - type SRef, - sref, -} from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface DropFilesExample { - selectedFiles: SRef - allowMany: Ref - locked: Ref - totalBytes: ComputedRef - clear: () => void - referenceFiles: SRef - fileIcons: DropFilesIconMap - activity: Ref - filesChanged: (files: DropFileItem[]) => void -} - -const dropFilesExampleTemplate = html` - - - - - Clear files - - - {{ selectedFiles.length }} files · {{ totalBytes }} bytes selected - -

Custom copy, icons, and change callback

- - {{ activity }} -
  • {{ file.name }} · {{ file.type || 'Unknown type' }} · {{ file.sizeLabel }} · {{ file.icon }}
-
` - -function createDropFilesExample(): DropFilesExample { - const selectedFiles = sref([]) - const activity = ref( - 'Choose a reference to inspect onChange and file metadata.', - ) - return { - selectedFiles, - allowMany: ref(true), - locked: ref(false), - totalBytes: computed(() => - selectedFiles().reduce((sum, item) => sum + item.size, 0), - ), - clear: () => selectedFiles([]), - referenceFiles: sref([]), - fileIcons: { 'image/*': 'lucide:file-image', '.pdf': 'lucide:file-text' }, - activity, - filesChanged: (files) => - activity(`onChange: ${files.length} reference files selected`), - } -} - -const component = defineComponent(dropFilesExampleTemplate, { - context: createDropFilesExample, -}) -const icons: Record = { - 'lucide:cloud-upload': lucide_cloud_upload, - 'lucide:file': lucide_file, - 'lucide:trash-2': lucide_trash_2, - 'lucide:file-image': lucide_file_image, - 'lucide:file-text': lucide_file_text, - 'lucide:file-archive': lucide_file_archive, -} - -createApp( - { - components: { - DropFilesExample: component, - - ...defineDropFilesComponents(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineButtonComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#drop-files-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` +
diff --git a/frontend/purestack.studio/components/forms/form-check/form-check.mdx b/frontend/purestack.studio/components/forms/form-check/form-check.mdx index feace696..3e51ec4b 100644 --- a/frontend/purestack.studio/components/forms/form-check/form-check.mdx +++ b/frontend/purestack.studio/components/forms/form-check/form-check.mdx @@ -32,73 +32,7 @@ Toggle each independent preference. The required security setting demonstrates a ``` -```typescript -import { - defineFlexComponents, - defineFormComponents, -} from '@purestack/ts-components' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface FormCheckExample { - emailUpdates: Ref - productUpdates: Ref -} - -const formCheckExampleTemplate = html` -
- Notification preferences - - - - - -
- - Email summaries: {{ emailUpdates ? 'On' : 'Off' }} · Product updates: {{ - productUpdates ? 'On' : 'Off' }} - -
` - -function createFormCheckExample(): FormCheckExample { - return { emailUpdates: ref(true), productUpdates: ref(false) } -} - -const component = defineComponent(formCheckExampleTemplate, { - context: createFormCheckExample, -}) - -createApp( - { - components: { - FormCheckExample: component, - - ...defineFlexComponents(), - ...defineFormComponents(), - }, - }, - { selector: 'app#form-check-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` +
diff --git a/frontend/purestack.studio/components/forms/form-input-field/form-input-field.mdx b/frontend/purestack.studio/components/forms/form-input-field/form-input-field.mdx index ae59b543..296e276f 100644 --- a/frontend/purestack.studio/components/forms/form-input-field/form-input-field.mdx +++ b/frontend/purestack.studio/components/forms/form-input-field/form-input-field.mdx @@ -39,106 +39,7 @@ Type an email, adjust the seat count and lock the fields. The summary reads the ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineGridComponents, - defineIconComponents, - definePanelComponents, -} from '@purestack/ts-components' -import { lucide_mail } from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface FormInputFieldExample { - contactEmail: Ref - seats: Ref - locked: Ref - summary: ComputedRef -} - -const formInputFieldExampleTemplate = html` - - - - - - {{ summary }} -` - -function createFormInputFieldExample(): FormInputFieldExample { - const contactEmail = ref('') - const seats = ref(3) - const locked = ref(false) - return { - contactEmail, - seats, - locked, - summary: computed( - () => `${seats()} seats · ${contactEmail() || 'No email entered'}`, - ), - } -} - -const component = defineComponent( - formInputFieldExampleTemplate, - { - context: createFormInputFieldExample, - }, -) -const icons: Record = { 'lucide:mail': lucide_mail } - -createApp( - { - components: { - FormInputFieldExample: component, - - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineGridComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#form-input-field-demo', - template: html``, - }, -) - -mountFormAppearanceGalleries() -``` + diff --git a/frontend/purestack.studio/components/forms/form-select-field/form-select-field.mdx b/frontend/purestack.studio/components/forms/form-select-field/form-select-field.mdx index 6f6204f7..c69e8bbd 100644 --- a/frontend/purestack.studio/components/forms/form-select-field/form-select-field.mdx +++ b/frontend/purestack.studio/components/forms/form-select-field/form-select-field.mdx @@ -32,80 +32,7 @@ Select a region and inspect its stored value. The unavailable option stays visib ``` -```typescript -import { - defineFlexComponents, - defineFormComponents, - defineFormSelectField, - defineIconComponents, - type FormSelectOption, - type FormSelectValue, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface FormSelectFieldExample { - regionValue: Ref - regionOptions: FormSelectOption[] - locked: Ref -} - -const formSelectFieldExampleTemplate = html` - - - Selected region: {{ regionValue || 'None' }} -` - -function createFormSelectFieldExample(): FormSelectFieldExample { - return { - regionValue: ref(''), - locked: ref(false), - regionOptions: [ - { label: 'Europe Central', value: 'eu-central' }, - { label: 'US East', value: 'us-east' }, - { label: 'Asia Pacific · unavailable', value: 'apac', disabled: true }, - ], - } -} - -const component = defineComponent( - formSelectFieldExampleTemplate, - { - context: createFormSelectFieldExample, - }, -) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - FormSelectFieldExample: component, - - ...defineFlexComponents(), - ...defineFormSelectField(), - ...defineFormComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#form-select-field-demo', - template: html``, - }, -) - -mountFormAppearanceGalleries() -``` + diff --git a/frontend/purestack.studio/components/forms/form-status/form-status.mdx b/frontend/purestack.studio/components/forms/form-status/form-status.mdx index 2d5e9183..6dddee64 100644 --- a/frontend/purestack.studio/components/forms/form-status/form-status.mdx +++ b/frontend/purestack.studio/components/forms/form-status/form-status.mdx @@ -32,77 +32,7 @@ The same mounted status region receives new text. Clear hides it until there is ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface FormStatusExample { - statusMessage: Ref - statusTone: Ref - showSuccess: () => void - showError: () => void - clear: () => void -} - -const formStatusExampleTemplate = html` - - Save local draft - - Show validation error - - Clear message - - - {{ statusMessage }} - -` - -function createFormStatusExample(): FormStatusExample { - const statusMessage = ref('Choose an action to see its feedback.') - const statusTone = ref('info') - return { - statusMessage, - statusTone, - showSuccess: () => { - statusTone('success') - statusMessage('Draft saved in this example.') - }, - showError: () => { - statusTone('danger') - statusMessage('Enter a project name before continuing.') - }, - clear: () => statusMessage(''), - } -} - -const component = defineComponent( - formStatusExampleTemplate, - { - context: createFormStatusExample, - }, -) - -createApp( - { - components: { - FormStatusExample: component, - - ...defineFlexComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - }, - }, - { selector: 'app#form-status-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` + diff --git a/frontend/purestack.studio/components/forms/form-submit/form-submit.mdx b/frontend/purestack.studio/components/forms/form-submit/form-submit.mdx index adfd2ab9..2a63585d 100644 --- a/frontend/purestack.studio/components/forms/form-submit/form-submit.mdx +++ b/frontend/purestack.studio/components/forms/form-submit/form-submit.mdx @@ -32,76 +32,7 @@ The button triggers the form’s submit event, including when the user submits f ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - definePanelComponents, -} from '@purestack/ts-components' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface FormSubmitExample { - releaseTitle: Ref - savedTitle: Ref - submit: (event: Event) => void -} - -const formSubmitExampleTemplate = html` - - - - - Saved locally: {{ savedTitle }} - - -` - -function createFormSubmitExample(): FormSubmitExample { - const releaseTitle = ref('Preview release') - const savedTitle = ref('') - return { - releaseTitle, - savedTitle, - submit: (event) => { - const form = event.target as HTMLFormElement - if (form.reportValidity()) savedTitle(releaseTitle()) - }, - } -} - -const component = defineComponent( - formSubmitExampleTemplate, - { - context: createFormSubmitExample, - }, -) - -createApp( - { - components: { - FormSubmitExample: component, - - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - }, - }, - { selector: 'app#form-submit-demo', template: html`` }, -) - -mountFormAppearanceGalleries() -``` + diff --git a/frontend/purestack.studio/components/forms/multi-auto-complete-input/multi-auto-complete-input.mdx b/frontend/purestack.studio/components/forms/multi-auto-complete-input/multi-auto-complete-input.mdx index ed16d6f1..c4ec2a15 100644 --- a/frontend/purestack.studio/components/forms/multi-auto-complete-input/multi-auto-complete-input.mdx +++ b/frontend/purestack.studio/components/forms/multi-auto-complete-input/multi-auto-complete-input.mdx @@ -32,196 +32,7 @@ Choose suggestions, type a new tag and press Enter, or paste comma-separated val ``` -```typescript -import { - type AutoCompleteOption, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineIconComponents, - defineMultiAutoCompleteInputComponents, - type MultiAutoCompleteItem, - type ResolvedAutoCompleteOption, -} from '@purestack/ts-components' -import { - lucide_chevron_down, - lucide_loader_circle, - lucide_x, -} from '@purestack/ts-svg-icons' -import { - createApp, - defineComponent, - html, - type Ref, - type RefOrValue, - ref, - type SRef, - sref, -} from 'regor' -import { mountFormAppearanceGalleries } from '../appearance' - -export interface MultiAutoCompleteInputExample { - selectedTags: SRef - tagQuery: Ref - tagOptions: AutoCompleteOption[] - customAllowed: Ref - duplicatesAllowed: Ref - pending: Ref - locked: Ref - minimum: Ref - limit: Ref - focusOpens: Ref - customItems: SRef - activity: Ref - createTag: (query: string) => MultiAutoCompleteItem - mapTag: (option: ResolvedAutoCompleteOption) => MultiAutoCompleteItem - splitTags: (value: string) => string[] - itemsChanged: (items: MultiAutoCompleteItem[]) => void - clear: () => void -} - -const multiAutoCompleteInputExampleTemplate = html` - - - - - - - - - - - - - Clear editable tags - - {{ selectedTags.length }} tags selected. Query: {{ tagQuery || 'Empty' }} - -
    -
  • {{ tag.label }} · {{ tag.value }}
  • -
-

Callback transforms and a custom row

-

Choose a suggestion to give it a success tone. New tags use lowercase values and a feature tone. Paste tags separated by commas, semicolons, pipes, or newlines.

- - {{ activity }} -
` - -function createMultiAutoCompleteInputExample(): MultiAutoCompleteInputExample { - const requiredTag: MultiAutoCompleteItem = { - label: 'Documentation', - value: 'docs', - disabled: true, - } - const selectedTags = sref([ - requiredTag, - { label: 'Needs review', value: 'review', tone: 'warning' }, - ]) - const activity = ref('Add a tag to inspect onItemsChange.') - return { - selectedTags, - tagQuery: ref(''), - tagOptions: [ - { label: 'TypeScript', value: 'ts', keywords: ['typed'] }, - { label: 'Accessibility', value: 'a11y' }, - { label: 'Performance', value: 'perf' }, - { label: 'Internal · unavailable', value: 'internal', disabled: true }, - ], - customAllowed: ref(true), - duplicatesAllowed: ref(false), - pending: ref(false), - locked: ref(false), - minimum: ref(0), - limit: ref(3), - focusOpens: ref(true), - customItems: sref([]), - activity, - createTag: (query) => ({ - label: query.trim(), - value: query.trim().toLowerCase(), - tone: 'feature', - }), - mapTag: (option) => ({ - label: option.label, - value: option.value, - tone: 'success', - }), - splitTags: (value) => value.split(/[;,|\n]/), - itemsChanged: (items) => - activity( - `onItemsChange: ${items.map((item) => item.value).join(', ') || '(empty)'}`, - ), - clear: () => selectedTags([requiredTag]), - } -} - -const component = defineComponent( - multiAutoCompleteInputExampleTemplate, - { - context: createMultiAutoCompleteInputExample, - }, -) -const tagSuggestion = defineComponent<{ - option: RefOrValue - active?: RefOrValue - query?: RefOrValue -}>( - html`{{ option.label }}{{ option.value }} · {{ active ? 'Active suggestion' : 'Available suggestion' }}{{ query ? ' · query: ' + query : '' }}`, - { - props: ['option', 'active', 'query'], - }, -) -const icons: Record = { - 'lucide:loader-circle': lucide_loader_circle, - 'lucide:x': lucide_x, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - MultiAutoCompleteInputExample: component, - TagSuggestion: tagSuggestion, - - ...defineMultiAutoCompleteInputComponents(), - ...defineFormInputField(), - ...defineFlexComponents(), - ...defineFormComponents(), - ...defineButtonComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#multi-auto-complete-input-demo', - template: html``, - }, -) - -mountFormAppearanceGalleries() -``` +
@@ -242,66 +53,7 @@ Token state can express validation results supplied by your application. ``` -```typescript -import { - defineIconComponents, - defineMultiAutoCompleteInputComponents, - type MultiAutoCompleteItem, -} from '@purestack/ts-components' -import { lucide_chevron_down, lucide_x } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html, type SRef, sref } from 'regor' - -export interface MultiAutoCompleteInputStates { - recipients: SRef -} - -const multiAutoCompleteInputStatesTemplate = html`` - -function createMultiAutoCompleteInputStates(): MultiAutoCompleteInputStates { - return { - recipients: sref([ - { label: 'Required reviewer', value: 'reviewer', disabled: true }, - { - label: 'Invalid address', - value: 'invalid', - invalid: true, - tone: 'danger', - }, - ]), - } -} - -const component = defineComponent( - multiAutoCompleteInputStatesTemplate, - { - context: createMultiAutoCompleteInputStates, - }, -) -const icons: Record = { - 'lucide:x': lucide_x, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - MultiAutoCompleteInputStates: component, - - ...defineMultiAutoCompleteInputComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#multi-auto-complete-input-states-demo', - template: html``, - }, -) -``` + diff --git a/frontend/purestack.studio/components/forms/multi-auto-complete-option-row/multi-auto-complete-option-row.mdx b/frontend/purestack.studio/components/forms/multi-auto-complete-option-row/multi-auto-complete-option-row.mdx index 736895f2..796f4ff5 100644 --- a/frontend/purestack.studio/components/forms/multi-auto-complete-option-row/multi-auto-complete-option-row.mdx +++ b/frontend/purestack.studio/components/forms/multi-auto-complete-option-row/multi-auto-complete-option-row.mdx @@ -64,54 +64,7 @@ Select more than one option to see the owning component manage state. ``` -```typescript -import { - defineIconComponents, - defineMultiAutoCompleteInputComponents, -} from '@purestack/ts-components' -import { lucide_chevron_down, lucide_x } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html } from 'regor' - -export interface MultiAutoCompleteOptionRowStates {} - -const multiAutoCompleteOptionRowStatesTemplate = html`` - -function createMultiAutoCompleteOptionRowStates(): MultiAutoCompleteOptionRowStates { - return {} -} - -const component = defineComponent( - multiAutoCompleteOptionRowStatesTemplate, - { - context: createMultiAutoCompleteOptionRowStates, - }, -) -const icons: Record = { - 'lucide:x': lucide_x, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - MultiAutoCompleteOptionRowStates: component, - - ...defineMultiAutoCompleteInputComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#multi-auto-complete-option-row-states-demo', - template: html``, - }, -) -``` + diff --git a/frontend/purestack.studio/components/layout/tabs/tabs.mdx b/frontend/purestack.studio/components/layout/tabs/tabs.mdx index 3760a575..354a882b 100644 --- a/frontend/purestack.studio/components/layout/tabs/tabs.mdx +++ b/frontend/purestack.studio/components/layout/tabs/tabs.mdx @@ -67,243 +67,7 @@ Style the outer container, the tab controls and the panel independently. Enable ``` -```typescript -import { - type ComponentVariant, - type ComponentVariantMode, - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - definePanelComponents, - defineTabsComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import type { SemanticTone } from '@purestack/ts-style' -import { - lucide_chevron_down, - tabler_code, - tabler_layout_grid, - tabler_users, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface TabsPlayground { - selected: Ref - tone: Ref - shell: Ref - controls: Ref - panel: Ref - mode: Ref - icons: Ref - header: Ref - teamDisabled: Ref - tones: FormSelectOption[] - variants: FormSelectOption[] - modes: FormSelectOption[] - selectionOptions: ComputedRef - configuration: ComputedRef - refresh: () => void - reset: () => void -} - -const tabsPlaygroundTemplate = html` - -

One component, three styling surfaces

- Live playground -
- - - -

Release overview

Everything your next release needs.

-

Content, components and a shared theme, ready to publish together.

- - 12 pages

Ready to publish

- 2 themes

Light and dark

- 0 warnings

Content checks

-
-
- -

Build pipeline

From typed source to a static site.

-
  1. Resolve content and shared configuration.
  2. Render pages, components and theme styles.
  3. Bundle browser apps and generate the search index.
-
- -

Project access

A shared place to build.

-

Designers review the preview. Developers own the components. Editors keep the content current.

-
-
-
- - - - - - - - - - - - - -
- -

Selected: {{ selected }}

- Reset playground -
- -

Current settings

-
{{ configuration }}
-
-
` - -const tabsPlayground = defineComponent(tabsPlaygroundTemplate, { - context: () => { - const selected = ref('tabs-lab-overview') - const tone = ref('neutral') - const shell = ref('surface') - const controls = ref('underline') - const panel = ref('none') - const mode = ref('stateless') - const icons = ref(true) - const header = ref(true) - const teamDisabled = ref(true) - const refresh = () => { - if (teamDisabled() && selected() === 'tabs-lab-team') - selected('tabs-lab-overview') - requestAnimationFrame(() => - window.tsSsgTabs?.refresh('#tabs-playground-preview'), - ) - } - return { - selected, - tone, - shell, - controls, - panel, - mode, - icons, - header, - teamDisabled, - refresh, - tones: [ - 'neutral', - 'accent', - 'secondary', - 'info', - 'success', - 'warning', - 'danger', - 'feature', - 'custom', - 'ghost', - ].map((value) => ({ label: value, value })), - variants: [ - 'none', - 'solid', - 'surface', - 'surfaceAlt', - 'spotlight', - 'glass', - 'flat', - 'flatAlt', - 'flatSolid', - 'outlineFill', - 'outline', - 'subtle', - 'subtleBtn', - 'link', - 'sheen', - 'underline', - 'rail', - 'bracket', - ].map((value) => ({ label: value, value })), - modes: ['stateless', 'stateful'].map((value) => ({ - label: value, - value, - })), - selectionOptions: computed(() => [ - { label: 'Overview', value: 'tabs-lab-overview' }, - { label: 'Build', value: 'tabs-lab-source' }, - { label: 'Team', value: 'tabs-lab-team', disabled: teamDisabled() }, - ]), - configuration: computed(() => - JSON.stringify( - { - Tabs: { - group: 'tabs-playground-views', - selectedTab: selected(), - tone: tone(), - variant: shell(), - tabVariant: controls(), - variantMode: mode(), - ariaLabel: 'Project workspace', - }, - TabPane: { variant: panel(), disabled: teamDisabled() }, - showIcons: icons(), - showHeader: header(), - }, - null, - 2, - ), - ), - reset: () => { - selected('tabs-lab-overview') - tone('neutral') - shell('surface') - controls('underline') - panel('none') - mode('stateless') - icons(true) - header(true) - teamDisabled(true) - refresh() - }, - } - }, -}) -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'tabler:layout-grid': tabler_layout_grid, - 'tabler:code': tabler_code, - 'tabler:users': tabler_users, -} -createApp( - { - components: { - TabsPlayground: tabsPlayground, - ...defineTabsComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...definePanelComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineFormSelectField(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#tabs-playground', template: html`` }, -) -window.tsSsgTabs?.refresh('#tabs-playground-preview') -``` +
@@ -344,131 +108,7 @@ Edit the release name and channel, review the draft, then save it locally. The b ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormInputField, - defineFormSelectField, - defineIconComponents, - definePanelComponents, - defineTabsComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, - type SRef, - sref, -} from 'regor' - -export interface ReleaseWorkspace { - selected: Ref - name: Ref - channel: Ref - saved: SRef - canSave: ComputedRef - channels: FormSelectOption[] - edit: () => void - review: () => void - save: () => void -} - -const releaseWorkspaceTemplate = html` - - - - - - -

Your draft stays mounted when you switch panes. Changes are stored in this demo until the page reloads.

- Review draft -
-
- - -

Ready for review

{{ name.trim() || 'Untitled release' }}

-

Channel: {{ channel }}

- Save draft locallyBack to details -
-
- -

No saved drafts yet. Name a release, review it, then save it here.

-
  • {{ entry }}
- Continue editing -
-
-

Shared selection: {{ selected }}

-
` - -const releaseWorkspace = defineComponent( - releaseWorkspaceTemplate, - { - context: () => { - const selected = ref('release-details') - const name = ref('Studio component library') - const channel = ref('Preview') - const saved = sref([]) - const canSave = computed(() => name().trim().length > 0) - const select = (id: string) => { - selected(id) - requestAnimationFrame(() => - window.tsSsgTabs?.refresh('#release-workspace-tabs'), - ) - } - return { - selected, - name, - channel, - saved, - canSave, - channels: ['Preview', 'Stable', 'Internal'].map((value) => ({ - label: value, - value, - })), - edit: () => select('release-details'), - review: () => { - if (canSave()) select('release-review') - }, - save: () => { - if (!canSave()) return - saved([`${name().trim()} · ${channel()}`, ...saved()]) - select('release-history') - }, - } - }, - }, -) -createApp( - { - components: { - ReleaseWorkspace: releaseWorkspace, - ...defineTabsComponents(), - ...defineFlexComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...definePanelComponents(), - ...defineIconComponents((name) => - name === 'lucide:chevron-down' ? lucide_chevron_down : '', - ), - }, - }, - { selector: 'app#release-workspace', template: html`` }, -) -window.tsSsgTabs?.refresh('#release-workspace-tabs') -``` +
@@ -509,130 +149,7 @@ Change the example's width without resizing the page. On desktop, the More tabs ``` -```typescript -import { - defineBadgeComponents, - defineFlexComponents, - defineFormSelectField, - defineIconComponents, - defineTabsComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { lucide_chevron_down } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface TabsOverflow { - width: Ref - selected: Ref - widths: FormSelectOption[] - topics: { - id: string - label: string - eyebrow: string - title: string - description: string - disabled?: boolean - }[] -} - -const tabsOverflowTemplate = html` - -
- - -

{{ topic.eyebrow }}

{{ topic.title }}

-

{{ topic.description }}

-
-
-
- {{ width }} containerSelected: {{ selected }} -

On a desktop viewport, narrow the container and use More tabs. At 640px viewport width or below, the native selector replaces the row. Billing remains disabled in either control.

-
` - -const tabsOverflow = defineComponent(tabsOverflowTemplate, { - context: () => ({ - width: ref('360px'), - selected: ref('overflow-configuration'), - widths: ['280px', '360px', '520px', '100%'].map((value) => ({ - label: value === '100%' ? 'Full available width' : value, - value, - })), - topics: [ - { - id: 'overflow-configuration', - label: 'Configuration', - eyebrow: 'Project foundation', - title: 'One shared configuration.', - description: - 'Define the site identity, content roots and output paths together.', - }, - { - id: 'overflow-content', - label: 'Content library', - eyebrow: 'Editorial workflow', - title: 'Pages with a clear home.', - description: - 'Organize guides into folders and keep each example beside its page.', - }, - { - id: 'overflow-components', - label: 'Components', - eyebrow: 'Reusable interfaces', - title: 'Compose the page.', - description: - 'Build panels, forms and navigation from typed components.', - }, - { - id: 'overflow-themes', - label: 'Theme settings', - eyebrow: 'Visual system', - title: 'Two themes. One identity.', - description: - 'Semantic tones keep controls and surfaces consistent in light and dark.', - }, - { - id: 'overflow-build', - label: 'Build history', - eyebrow: 'Publishing', - title: 'A repeatable release.', - description: - 'Render content, bundle the browser apps and index the finished pages.', - }, - { - id: 'overflow-members', - label: 'Team members', - eyebrow: 'Collaboration', - title: 'Give every contributor a place.', - description: 'Keep ownership and project resources close to the work.', - }, - { - id: 'overflow-billing', - label: 'Billing', - eyebrow: 'Unavailable', - title: 'Billing', - description: 'This pane is disabled in the demonstration.', - disabled: true, - }, - ], - }), -}) -createApp( - { - components: { - TabsOverflow: tabsOverflow, - ...defineTabsComponents(), - ...defineFlexComponents(), - ...defineBadgeComponents(), - ...defineFormSelectField(), - ...defineIconComponents((name) => - name === 'lucide:chevron-down' ? lucide_chevron_down : '', - ), - }, - }, - { selector: 'app#tabs-overflow', template: html`` }, -) -window.tsSsgTabs?.refresh('#tabs-overflow-preview') -``` +
@@ -664,67 +181,7 @@ Select Production, switch the outer group to Source, then return to Environments ``` -```typescript -import { - defineBadgeComponents, - defineFlexComponents, - defineIconComponents, - defineTabsComponents, -} from '@purestack/ts-components' -import { tabler_code, tabler_rocket } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html, type Ref, ref } from 'regor' - -export interface TabsComposition { - resource: Ref - environment: Ref -} - -const tabsCompositionTemplate = html` - - - -

A home for the implementation.

Components, theme styles and content live together in the project.

-
- -

This pane overrides its tone, surface and control treatment. Its inner tabs have their own selection.

- - Review in progress

Share the preview with your team before publishing.

- Published

The approved release is available to everyone.

-
-
-
-

Resource: {{ resource }} · Environment: {{ environment }}

-
` - -const tabsComposition = defineComponent( - tabsCompositionTemplate, - { - context: () => ({ - resource: ref('resource-deploy'), - environment: ref('environment-preview'), - }), - }, -) -const icons: Record = { - 'tabler:code': tabler_code, - 'tabler:rocket': tabler_rocket, -} -createApp( - { - components: { - TabsComposition: tabsComposition, - ...defineTabsComponents(), - ...defineFlexComponents(), - ...defineBadgeComponents(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#tabs-composition', template: html`` }, -) -window.tsSsgTabs?.refresh('#resource-tabs, #environment-tabs') -``` +
diff --git a/frontend/purestack.studio/components/runtime/page-script/page-script.mdx b/frontend/purestack.studio/components/runtime/page-script/page-script.mdx index 1b57e96b..8327d628 100644 --- a/frontend/purestack.studio/components/runtime/page-script/page-script.mdx +++ b/frontend/purestack.studio/components/runtime/page-script/page-script.mdx @@ -44,29 +44,7 @@ Move the reading-speed slider to recalculate the estimate. This plain DOM script ``` -```typescript -// Query each enhancement root; do not bind one global input to several widgets. -for (const region of document.querySelectorAll( - '[data-reading-example]', -)) { - const text = region.querySelector('[data-reading-content]') - const output = region.querySelector( - '[data-reading-result]', - ) - const rate = region.querySelector('[data-reading-rate]') - if (!text || !output || !rate) continue - const update = () => { - const words = (text.textContent ?? '') - .trim() - .split(/\s+/) - .filter(Boolean).length - const wordsPerMinute = Math.max(1, Number(rate.value) || 200) - output.textContent = `${words} words · about ${Math.max(1, Math.ceil(words / wordsPerMinute))} minute${Math.ceil(words / wordsPerMinute) > 1 ? 's' : ''} to read` - } - rate.addEventListener('input', update) - update() -} -``` + diff --git a/frontend/purestack.studio/components/runtime/regor-app/regor-app.mdx b/frontend/purestack.studio/components/runtime/regor-app/regor-app.mdx index 4820c6b5..21ab1e4e 100644 --- a/frontend/purestack.studio/components/runtime/regor-app/regor-app.mdx +++ b/frontend/purestack.studio/components/runtime/regor-app/regor-app.mdx @@ -32,85 +32,7 @@ Complete and undo tasks, change the goal, add a note, and pause changes. The sta ``` -```typescript -import { - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - definePanelComponents, -} from '@purestack/ts-components' -import { batch, computed, createApp, defineComponent, html, ref } from 'regor' - -const reviewTemplate = html` - -

INDEPENDENT BROWSER APP

{{ title }}

- {{ completed }} of {{ target }} tasks complete · {{ completed >= target ? 'Ready for review' : 'Work in progress' }} - - - -

{{ note || 'No review note yet.' }}

- - - Complete a task - Undo task - Reset review - -
-
` - -// One module may serve several mounts. Each createApp call owns fresh refs. -for (const mount of document.querySelectorAll( - 'app[data-review-counter]', -)) { - createApp( - { - components: { - ReviewCounter: defineComponent(reviewTemplate, { - context: () => { - const completed = ref(0) - const goal = ref('5') - const note = ref('') - const locked = ref(false) - const target = computed(() => - Math.max(1, Math.min(12, Math.floor(Number(goal()) || 5))), - ) - return { - prefix: mount.id, - title: mount.dataset.reviewCounter || 'Release review', - completed, - goal, - note, - locked, - target, - complete: () => { - if (!locked() && completed() < target()) - completed(completed() + 1) - }, - undo: () => { - if (!locked() && completed() > 0) completed(completed() - 1) - }, - reset: () => - batch(() => { - completed(0) - goal('5') - note('') - locked(false) - }), - } - }, - }), - ...defineFlexComponents(), - ...definePanelComponents(), - ...defineButtonComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - }, - }, - { element: mount, template: html`` }, - ) -} -``` +
diff --git a/frontend/purestack.studio/components/site/classic-logo/classic-logo.mdx b/frontend/purestack.studio/components/site/classic-logo/classic-logo.mdx index f119fac7..319fd129 100644 --- a/frontend/purestack.studio/components/site/classic-logo/classic-logo.mdx +++ b/frontend/purestack.studio/components/site/classic-logo/classic-logo.mdx @@ -32,151 +32,7 @@ This starts with the original Studio identity and its original colors. Edit eith ``` -```typescript -import { - type ClassicLogoConfig, - defineButtonComponents, - defineClassicLogoComponents, - defineFlexComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - defineLogoComponents, - definePanelComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { - lucide_chevron_down, - tabler_device_desktop_analytics, -} from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface ClassicLogoPlayground { - brand: Ref - subtitle: Ref - letterColors: Ref - subtitleLetterColors: Ref - accent: Ref - component: Ref - componentOptions: FormSelectOption[] - config: ComputedRef - source: ComputedRef - reset: () => void -} - -const classicLogoPlaygroundTemplate = html` - -

Original Studio identity

-
-

The original container, letter spacing, icon and responsive sizes. Switch the site theme to compare both skins.

-
- - - - - - - - -

Palette: 0 = subtle text, 1 = normal text, 2 = accent, 3 = white. Each digit colors one non-space character; the final digit repeats.

- Restore original - -

siteConfig.json · logo

-
{{ source }}
-
-
` - -const classicLogoPlayground = defineComponent( - classicLogoPlaygroundTemplate, - { - context: () => { - const brand = ref('PureStack') - const subtitle = ref('AI-Native Frontend') - const letterColors = ref('111122') - const subtitleLetterColors = ref('0') - const accent = ref( - 'var(--ps-semantic-tone-accent-button-rest-background)', - ) - const component = ref('ClassicLogo') - const config = computed(() => ({ - brand: brand(), - subtitle: subtitle(), - letterColors: letterColors(), - subtitleLetterColors: subtitleLetterColors(), - colors: [ - 'var(--ps-current-text-subtle)', - 'var(--ps-current-text-default)', - accent(), - '#fdfdfd', - ], - logoBackground: 2, - logoForeground: 3, - icon: 'tabler:device-desktop-analytics', - href: '/', - })) - return { - brand, - subtitle, - letterColors, - subtitleLetterColors, - accent, - component, - config, - componentOptions: ['ClassicLogo', 'SiteLogo'].map((value) => ({ - label: value, - value, - })), - source: computed(() => - JSON.stringify({ component: component(), ...config() }, null, 2), - ), - reset: () => { - brand('PureStack') - subtitle('AI-Native Frontend') - letterColors('111122') - subtitleLetterColors('0') - accent('var(--ps-semantic-tone-accent-button-rest-background)') - component('ClassicLogo') - }, - } - }, - }, -) - -const icons: Record = { - 'tabler:device-desktop-analytics': tabler_device_desktop_analytics, - 'lucide:chevron-down': lucide_chevron_down, -} - -createApp( - { - components: { - ClassicLogoPlayground: classicLogoPlayground, - ...defineClassicLogoComponents(), - ...defineLogoComponents(), - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...definePanelComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { - selector: 'app#classic-logo-playground', - template: html``, - }, -) -``` +
diff --git a/frontend/purestack.studio/components/site/consent/consent.mdx b/frontend/purestack.studio/components/site/consent/consent.mdx index c2df8d69..287b118a 100644 --- a/frontend/purestack.studio/components/site/consent/consent.mdx +++ b/frontend/purestack.studio/components/site/consent/consent.mdx @@ -32,148 +32,7 @@ Accept, reject or choose optional categories, then reopen settings. Reset clears ``` -```typescript -import fs from 'node:fs/promises' -import path from 'node:path' -import type { SiteConfig, TsSsgContext } from '@purestack/ts-common' -import { defineComponents } from '@purestack/ts-components' -import { - buildConsentScript, - buildThemeSwitchScript, -} from '@purestack/ts-page-scripts' -import { renderApp } from '@purestack/ts-render' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { withBasePath } from '@purestack/ts-util' -import { defineComponent, html } from 'regor' - -export interface ConsentPreview { - previewHref: string -} - -const consentPreviewTemplate = html`` -const exampleTemplate = html` - - - - Reset this example -` - -async function createConsentPreviewDocument(site: SiteConfig): Promise { - const context: TsSsgContext = { - site: { ...site }, - pageInfo: { - relPath: 'components/site/consent/consent.mdx', - urlPath: '/components/site/consent/', - frontmatter: { - template: 'doc', - hidden: false, - draft: false, - nav: { hidden: false }, - layout: { - showNav: false, - showToc: false, - showFooter: false, - fullWidth: false, - navMode: 'sidebar', - tocCollapsed: false, - }, - }, - }, - theme: site.style.theme, - basePath: site.basePath, - locales: site.i18n.locales, - defaultLocale: site.i18n.defaultLocale, - resolveLocaleHref: () => undefined, - resolvePublicHref: (href) => withBasePath(site.basePath, href), - recordScriptEntrypoint: () => {}, - recordRuntimeEmbed: () => {}, - } - context.site.consent = { - ...context.site.consent, - enabled: true, - storageKey: 'purestack-docs-consent-example', - policyVersion: 'example-1', - bannerTitle: 'Example privacy choices', - bannerDescription: 'This isolated example has no tracking services.', - privacyPolicyUrl: '/components/site/consent/', - privacyPolicyLabel: 'Read the consent guide', - categories: [ - { - id: 'necessary', - label: 'Necessary', - required: true, - description: 'Required for this example’s saved choice.', - }, - { - id: 'preferences', - label: 'Preferences', - description: 'Illustrative optional interface preferences.', - }, - { - id: 'analytics', - label: 'Analytics', - description: 'Illustrative optional category; no service is loaded.', - }, - { - id: 'marketing', - label: 'Marketing', - description: 'Illustrative optional category; no service is loaded.', - }, - ], - services: [], - } - const body = await renderApp(exampleTemplate, { - components: defineComponents(getSvgIcon), - context, - }) - const scripts = - buildThemeSwitchScript(['light', 'dark']) + - buildConsentScript(context.site.consent) - const documentHtml = `
${body}
` - return documentHtml -} - -export function defineConsentPreviewComponent(site: SiteConfig) { - return defineComponent(consentPreviewTemplate, { - context: () => ({ - previewHref: withBasePath( - site.basePath, - '/components/site/consent/preview.html', - ), - }), - }) -} - -export async function writeConsentPreview(site: SiteConfig) { - const output = path.join( - site.outDir, - 'components', - 'site', - 'consent', - 'preview.html', - ) - const documentHtml = await createConsentPreviewDocument(site) - await fs.mkdir(path.dirname(output), { recursive: true }) - await fs.writeFile(output, documentHtml, 'utf8') -} -``` +
diff --git a/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx b/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx index 97ec1e9a..95da05a6 100644 --- a/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx +++ b/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx @@ -39,337 +39,7 @@ Switch between a documentation tree and an application menu, then pick the curre ``` -```typescript -import type { NavItem } from '@purestack/ts-common' -import { - type ComponentVariant, - type ComponentVariantMode, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - defineNavigationComponents, - definePanelComponents, - defineSignInComponents, - type FormSelectOption, -} from '@purestack/ts-components' -import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style' -import { - iconoir_pin, - iconoir_pin_slash, - lucide_chevron_down, - tabler_bell, - tabler_book, - tabler_chart_bar, - tabler_chevron_down, - tabler_components, - tabler_credit_card, - tabler_file_invoice, - tabler_home, - tabler_key, - tabler_layout_dashboard, - tabler_package, - tabler_palette, - tabler_rocket, - tabler_settings, - tabler_users, -} from '@purestack/ts-svg-icons' -import { - batch, - type ComputedRef, - computed, - createApp, - defineComponent, - html, - observe, - type Ref, - ref, -} from 'regor' - -type TreeName = 'docs' | 'app' -type Backdrop = 'page' | 'spotlight' - -interface PageOption { - label: string - value: string -} - -export interface NavMenuPlayground { - treeName: Ref - currentUrl: Ref - showIcons: Ref - tone: Ref - variant: Ref - variantMode: Ref - backdrop: Ref - items: ComputedRef - pages: ComputedRef - stageClass: ComputedRef - trees: FormSelectOption[] - tones: FormSelectOption[] - variants: FormSelectOption[] - modes: FormSelectOption[] - backdrops: FormSelectOption[] - followLink: (event: Event) => void - reset: () => void -} - -const TREES: Record = { - docs: [ - { title: 'Home', url: '/', icon: 'tabler:home' }, - { - title: 'Getting started', - icon: 'tabler:rocket', - children: [ - { title: 'Installation', url: '/start/install/' }, - { title: 'Your first page', url: '/start/first-page/' }, - ], - }, - { - title: 'Guides', - icon: 'tabler:book', - children: [ - { title: 'Themes', url: '/guides/themes/', icon: 'tabler:palette' }, - { - title: 'Components', - icon: 'tabler:components', - children: [ - { title: 'Buttons', url: '/guides/components/buttons/' }, - { title: 'Navigation', url: '/guides/components/navigation/' }, - ], - }, - { title: 'Deployment', url: '/guides/deploy/' }, - ], - }, - { title: 'Reference', url: '/reference/', icon: 'tabler:package' }, - ], - app: [ - { title: 'Dashboard', url: '/app/', icon: 'tabler:layout-dashboard' }, - { title: 'Customers', url: '/app/customers/', icon: 'tabler:users' }, - { - title: 'Reports', - icon: 'tabler:chart-bar', - children: [ - { title: 'Revenue', url: '/app/reports/revenue/' }, - { title: 'Usage', url: '/app/reports/usage/' }, - ], - }, - { - title: 'Billing', - icon: 'tabler:credit-card', - children: [ - { - title: 'Invoices', - url: '/app/billing/invoices/', - icon: 'tabler:file-invoice', - }, - { title: 'Plans', url: '/app/billing/plans/' }, - ], - }, - { - title: 'Settings', - icon: 'tabler:settings', - children: [ - { title: 'API keys', url: '/app/settings/keys/', icon: 'tabler:key' }, - { - title: 'Notifications', - url: '/app/settings/notifications/', - icon: 'tabler:bell', - }, - ], - }, - ], -} - -const DEFAULTS = { - treeName: 'docs' as TreeName, - currentUrl: '/guides/themes/', - showIcons: true, - tone: 'neutral' as SemanticTone, - variant: 'flat' as ComponentVariant, - variantMode: 'stateless' as ComponentVariantMode, - backdrop: 'page' as Backdrop, -} - -const navMenuPlaygroundTemplate = html` -
-
- -
-
-

- Links stay in the playground: choose one to make it the current page. -

- - - - - - - - - - - Reset playground - -
` - -function withoutIcons(items: NavItem[]): NavItem[] { - return items.map(({ icon: _icon, children, ...item }) => - children ? { ...item, children: withoutIcons(children) } : item, - ) -} - -function listPages(items: NavItem[], trail = ''): PageOption[] { - return items.flatMap((item) => { - const label = trail ? `${trail} / ${item.title}` : item.title - const page = item.url ? [{ label, value: item.url }] : [] - return [...page, ...listPages(item.children ?? [], label)] - }) -} - -function options(values: readonly string[]): FormSelectOption[] { - return values.map((value) => ({ label: value, value })) -} - -function createNavMenuPlayground(): NavMenuPlayground { - const treeName = ref(DEFAULTS.treeName) - const currentUrl = ref(DEFAULTS.currentUrl) - const showIcons = ref(DEFAULTS.showIcons) - const tone = ref(DEFAULTS.tone) - const variant = ref(DEFAULTS.variant) - const variantMode = ref(DEFAULTS.variantMode) - const backdrop = ref(DEFAULTS.backdrop) - - const pages = computed(() => listPages(TREES[treeName()])) - // A new tree starts on its first page, so the menu always shows one. - observe(treeName, () => { - const first = pages()[0] - if (first) currentUrl(first.value) - }) - - return { - treeName, - currentUrl, - showIcons, - tone, - variant, - variantMode, - backdrop, - items: computed(() => { - const tree = TREES[treeName()] - return showIcons() ? tree : withoutIcons(tree) - }), - pages, - stageClass: computed(() => - backdrop() === 'spotlight' - ? 'tone--accent tone-fill-spotlight' - : 'b-1 b-subtle', - ), - trees: [ - { label: 'Documentation site', value: 'docs' }, - { label: 'Application', value: 'app' }, - ], - tones: options(SEMANTIC_TONES), - variants: options([ - 'flat', - 'flatAlt', - 'flatSolid', - 'surface', - 'surfaceAlt', - 'solid', - 'glass', - 'spotlight', - 'outlineFill', - 'outline', - 'subtle', - 'none', - ]), - modes: options(['stateless', 'stateful']), - backdrops: [ - { label: 'Page', value: 'page' }, - { label: 'Spotlight (for glass)', value: 'spotlight' }, - ], - followLink: (event) => { - const link = (event.target as Element | null)?.closest('a[href]') - if (!link) return - event.preventDefault() - currentUrl(link.getAttribute('href') ?? '') - }, - reset: () => - batch(() => { - treeName(DEFAULTS.treeName) - currentUrl(DEFAULTS.currentUrl) - showIcons(DEFAULTS.showIcons) - tone(DEFAULTS.tone) - variant(DEFAULTS.variant) - variantMode(DEFAULTS.variantMode) - backdrop(DEFAULTS.backdrop) - }), - } -} - -const icons: Record = { - 'lucide:chevron-down': lucide_chevron_down, - 'tabler:chevron-down': tabler_chevron_down, - 'iconoir:pin': iconoir_pin, - 'iconoir:pin-slash': iconoir_pin_slash, - 'tabler:home': tabler_home, - 'tabler:rocket': tabler_rocket, - 'tabler:book': tabler_book, - 'tabler:palette': tabler_palette, - 'tabler:components': tabler_components, - 'tabler:package': tabler_package, - 'tabler:layout-dashboard': tabler_layout_dashboard, - 'tabler:users': tabler_users, - 'tabler:chart-bar': tabler_chart_bar, - 'tabler:credit-card': tabler_credit_card, - 'tabler:file-invoice': tabler_file_invoice, - 'tabler:settings': tabler_settings, - 'tabler:key': tabler_key, - 'tabler:bell': tabler_bell, -} - -const component = defineComponent( - navMenuPlaygroundTemplate, - { context: createNavMenuPlayground }, -) - -createApp( - { - components: { - NavMenuPlayground: component, - ...defineButtonComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineFormComponents(), - ...defineFormSelectField(), - ...defineNavigationComponents(), - ...definePanelComponents(), - ...defineSignInComponents(), - ...defineIconComponents((name) => { - if (!icons[name]) throw new Error(`Icon is not registered: ${name}`) - return icons[name] - }), - }, - }, - { - selector: 'app#nav-menu-playground', - template: html``, - }, -) -``` +
@@ -430,124 +100,7 @@ This preview renders the menu exactly as the SSG does: from the site navigation, ``` -```typescript -import fs from 'node:fs/promises' -import path from 'node:path' -import type { SiteConfig, TsSsgContext } from '@purestack/ts-common' -import { defineComponents } from '@purestack/ts-components' -import { - buildMenuRuntimeScript, - buildNavMenuScript, - buildThemeSwitchScript, -} from '@purestack/ts-page-scripts' -import { renderApp } from '@purestack/ts-render' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { withBasePath } from '@purestack/ts-util' -import { defineComponent, html } from 'regor' - -export interface NavMenuPreview { - previewHref: string -} - -const navMenuPreviewTemplate = html`` -const exampleTemplate = html`` - -async function createNavMenuPreviewDocument(site: SiteConfig): Promise { - const context: TsSsgContext = { - site: { ...site }, - pageInfo: { - relPath: 'components/site/nav-menu/nav-menu.mdx', - urlPath: '/components/site/nav-menu/', - frontmatter: { - template: 'doc', - hidden: false, - draft: false, - nav: { hidden: false }, - layout: { - showNav: false, - showToc: false, - showFooter: false, - fullWidth: false, - navMode: 'sidebar', - tocCollapsed: false, - }, - }, - }, - theme: site.style.theme, - basePath: site.basePath, - locales: site.i18n.locales, - defaultLocale: site.i18n.defaultLocale, - resolveLocaleHref: () => undefined, - resolvePublicHref: (href) => withBasePath(site.basePath, href), - recordScriptEntrypoint: () => {}, - recordRuntimeEmbed: () => {}, - } - context.site.pagefind = { ...context.site.pagefind, enabled: false } - context.site.auth = { ...context.site.auth, enabled: false } - context.pageInfo = { - ...context.pageInfo, - urlPath: '/components/site/nav-menu/', - } - context.navigation = { - root: '/components/', - items: [ - { - title: 'Navigation', - children: [ - { title: 'NavMenu', url: '/components/site/nav-menu/' }, - { title: 'NavList', url: '/components/site/nav-list/' }, - ], - }, - { - title: 'Layout', - children: [ - { title: 'Flex', url: '/components/layout/flex/' }, - { title: 'Grid', url: '/components/layout/grid/' }, - ], - }, - ], - } - const body = await renderApp(exampleTemplate, { - components: defineComponents(getSvgIcon), - context, - }) - const scripts = - buildThemeSwitchScript(['light', 'dark']) + - buildNavMenuScript() + - buildMenuRuntimeScript() - const documentHtml = `
${body}
` - return documentHtml -} - -export function defineNavMenuPreviewComponent(site: SiteConfig) { - return defineComponent(navMenuPreviewTemplate, { - context: () => ({ - previewHref: withBasePath( - site.basePath, - '/components/site/nav-menu/preview.html', - ), - }), - }) -} - -export async function writeNavMenuPreview(site: SiteConfig) { - const output = path.join( - site.outDir, - 'components', - 'site', - 'nav-menu', - 'preview.html', - ) - const documentHtml = await createNavMenuPreviewDocument(site) - await fs.mkdir(path.dirname(output), { recursive: true }) - await fs.writeFile(output, documentHtml, 'utf8') -} -``` +
diff --git a/frontend/purestack.studio/components/site/page-toc/page-toc.mdx b/frontend/purestack.studio/components/site/page-toc/page-toc.mdx index 595e1f6e..5c636c74 100644 --- a/frontend/purestack.studio/components/site/page-toc/page-toc.mdx +++ b/frontend/purestack.studio/components/site/page-toc/page-toc.mdx @@ -32,114 +32,7 @@ The example uses a small outline in an isolated document. Each link resolves to ``` -```typescript -import fs from 'node:fs/promises' -import path from 'node:path' -import type { SiteConfig, TsSsgContext } from '@purestack/ts-common' -import { defineComponents } from '@purestack/ts-components' -import { - buildPageTocScript, - buildThemeSwitchScript, -} from '@purestack/ts-page-scripts' -import { renderApp } from '@purestack/ts-render' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { withBasePath } from '@purestack/ts-util' -import { defineComponent, html } from 'regor' - -export interface PageTocPreview { - previewHref: string -} - -const pageTocPreviewTemplate = html`` -const exampleTemplate = html` -
-

Introduction

-

A table of contents points to real section IDs.

-
-
-

Details

-

Contract

-

Keep headings descriptive and ordered.

-
` - -async function createPageTocPreviewDocument(site: SiteConfig): Promise { - const context: TsSsgContext = { - site: { ...site }, - pageInfo: { - relPath: 'components/site/page-toc/page-toc.mdx', - urlPath: '/components/site/page-toc/', - frontmatter: { - template: 'doc', - hidden: false, - draft: false, - nav: { hidden: false }, - layout: { - showNav: false, - showToc: false, - showFooter: false, - fullWidth: false, - navMode: 'sidebar', - tocCollapsed: false, - }, - }, - }, - theme: site.style.theme, - basePath: site.basePath, - locales: site.i18n.locales, - defaultLocale: site.i18n.defaultLocale, - resolveLocaleHref: () => undefined, - resolvePublicHref: (href) => withBasePath(site.basePath, href), - recordScriptEntrypoint: () => {}, - recordRuntimeEmbed: () => {}, - } - context.outline = [ - { id: 'preview-introduction', title: 'Introduction', depth: 2 }, - { - id: 'preview-details', - title: 'Details', - depth: 2, - children: [{ id: 'preview-contract', title: 'Contract', depth: 3 }], - }, - ] - const body = await renderApp(exampleTemplate, { - components: defineComponents(getSvgIcon), - context, - }) - const scripts = - buildThemeSwitchScript(['light', 'dark']) + buildPageTocScript() - const documentHtml = `
${body}
` - return documentHtml -} - -export function definePageTocPreviewComponent(site: SiteConfig) { - return defineComponent(pageTocPreviewTemplate, { - context: () => ({ - previewHref: withBasePath( - site.basePath, - '/components/site/page-toc/preview.html', - ), - }), - }) -} - -export async function writePageTocPreview(site: SiteConfig) { - const output = path.join( - site.outDir, - 'components', - 'site', - 'page-toc', - 'preview.html', - ) - const documentHtml = await createPageTocPreviewDocument(site) - await fs.mkdir(path.dirname(output), { recursive: true }) - await fs.writeFile(output, documentHtml, 'utf8') -} -``` +
diff --git a/frontend/purestack.studio/components/site/sign-in/sign-in.mdx b/frontend/purestack.studio/components/site/sign-in/sign-in.mdx index 45d2e437..319a2e5b 100644 --- a/frontend/purestack.studio/components/site/sign-in/sign-in.mdx +++ b/frontend/purestack.studio/components/site/sign-in/sign-in.mdx @@ -32,115 +32,7 @@ The isolated preview enables the account component locally and uses working docu ``` -```typescript -import fs from 'node:fs/promises' -import path from 'node:path' -import type { SiteConfig, TsSsgContext } from '@purestack/ts-common' -import { defineComponents } from '@purestack/ts-components' -import { - buildMenuRuntimeScript, - buildThemeSwitchScript, -} from '@purestack/ts-page-scripts' -import { renderApp } from '@purestack/ts-render' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { withBasePath } from '@purestack/ts-util' -import { defineComponent, html } from 'regor' - -export interface SignInPreview { - previewHref: string -} - -const signInPreviewTemplate = html`` -const exampleTemplate = html` - Example workspace - - - Component library - - Workspace branding - - - - -

- Open the account menu. These custom destinations are documentation links; this - preview does not sign you in. -

` - -async function createSignInPreviewDocument(site: SiteConfig): Promise { - const context: TsSsgContext = { - site: { ...site }, - pageInfo: { - relPath: 'components/site/sign-in/sign-in.mdx', - urlPath: '/components/site/sign-in/', - frontmatter: { - template: 'doc', - hidden: false, - draft: false, - nav: { hidden: false }, - layout: { - showNav: false, - showToc: false, - showFooter: false, - fullWidth: false, - navMode: 'sidebar', - tocCollapsed: false, - }, - }, - }, - theme: site.style.theme, - basePath: site.basePath, - locales: site.i18n.locales, - defaultLocale: site.i18n.defaultLocale, - resolveLocaleHref: () => undefined, - resolvePublicHref: (href) => withBasePath(site.basePath, href), - recordScriptEntrypoint: () => {}, - recordRuntimeEmbed: () => {}, - } - context.site.auth = { ...context.site.auth, enabled: true, signUp: false } - const body = await renderApp(exampleTemplate, { - components: defineComponents(getSvgIcon), - context, - }) - const scripts = - buildThemeSwitchScript(['light', 'dark']) + buildMenuRuntimeScript() - const documentHtml = `
${body}
` - return documentHtml -} - -export function defineSignInPreviewComponent(site: SiteConfig) { - return defineComponent(signInPreviewTemplate, { - context: () => ({ - previewHref: withBasePath( - site.basePath, - '/components/site/sign-in/preview.html', - ), - }), - }) -} - -export async function writeSignInPreview(site: SiteConfig) { - const output = path.join( - site.outDir, - 'components', - 'site', - 'sign-in', - 'preview.html', - ) - const documentHtml = await createSignInPreviewDocument(site) - await fs.mkdir(path.dirname(output), { recursive: true }) - await fs.writeFile(output, documentHtml, 'utf8') -} -``` +
diff --git a/frontend/purestack.studio/components/site/site-logo/site-logo.mdx b/frontend/purestack.studio/components/site/site-logo/site-logo.mdx index b989882b..de12d41a 100644 --- a/frontend/purestack.studio/components/site/site-logo/site-logo.mdx +++ b/frontend/purestack.studio/components/site/site-logo/site-logo.mdx @@ -36,238 +36,7 @@ Use **Mark-to-title spacing** to bring the icon and brand name closer together o ``` -```typescript -import { - defineBadgeComponents, - defineButtonComponents, - defineFlexComponents, - defineFormComponents, - defineFormInputField, - defineFormSelectField, - defineGridComponents, - defineIconComponents, - defineLogoComponents, - definePanelComponents, - type FormSelectOption, - type LogoConfig, -} from '@purestack/ts-components' -import { lucide_chevron_down, tabler_stack_2 } from '@purestack/ts-svg-icons' -import { - type ComputedRef, - computed, - createApp, - defineComponent, - html, - type Ref, - ref, -} from 'regor' - -export interface LogoPlayground { - brandName: Ref - tagline: Ref - punctuation: Ref - logoLayout: Ref> - logoSize: Ref> - treatment: Ref> - markTreatment: Ref> - wordmarkTreatment: Ref> - markShape: Ref> - logoTone: Ref> - markSource: Ref - customAccent: Ref - spacing: Ref - linked: Ref - previewConfig: ComputedRef - configSource: ComputedRef - layouts: FormSelectOption[] - sizes: FormSelectOption[] - spacings: FormSelectOption[] - treatments: FormSelectOption[] - marks: FormSelectOption[] - wordmarks: FormSelectOption[] - shapes: FormSelectOption[] - tones: FormSelectOption[] - sources: FormSelectOption[] - reset: () => void -} - -const logoPlaygroundTemplate = html` - - -

Your brand, your presentation

- Live preview -
- - - -

Switch the site theme to see the same logo in light and dark.

-
- - - - - - - - - - - - - - - - - - Reset playground - - -

Live configuration

-
{{ configSource }}
-
-
` - -function createLogoPlayground(): LogoPlayground { - const brandName = ref('PureStack') - const tagline = ref('') - const punctuation = ref('.') - const logoLayout = ref>('horizontal') - const logoSize = ref>('md') - const treatment = ref>('plain') - const markTreatment = ref>('plain') - const wordmarkTreatment = - ref>('plain') - const markShape = ref>('rounded') - const logoTone = ref>('accent') - const markSource = ref('icon') - const customAccent = ref('') - const spacing = ref('') - const linked = ref(false) - const previewConfig = computed(() => ({ - brand: brandName(), - subtitle: tagline(), - suffix: punctuation(), - href: linked() ? '/' : null, - layout: logoLayout(), - size: logoSize(), - appearance: treatment(), - markStyle: markTreatment(), - wordmarkStyle: wordmarkTreatment(), - shape: markShape(), - tone: logoTone(), - icon: markSource() === 'icon' ? 'tabler:stack-2' : undefined, - imageSrc: - markSource() === 'image' ? '/assets/pure-stack-logo.png' : undefined, - accentColor: customAccent() || undefined, - gap: spacing() || undefined, - })) - return { - brandName, - tagline, - punctuation, - logoLayout, - logoSize, - treatment, - markTreatment, - wordmarkTreatment, - markShape, - logoTone, - markSource, - customAccent, - spacing, - linked, - previewConfig, - configSource: computed(() => JSON.stringify(previewConfig(), null, 2)), - layouts: ['horizontal', 'stacked', 'wordmark', 'mark'].map((value) => ({ - label: value, - value, - })), - sizes: ['sm', 'md', 'lg', 'xl'].map((value) => ({ label: value, value })), - spacings: [ - { label: 'Default (compact)', value: '' }, - { label: 'None · 0px', value: '0px' }, - { label: 'Tight · 4px', value: '0.25rem' }, - { label: 'Compact · 6px', value: '0.375rem' }, - { label: 'Comfortable · 8px', value: '0.5rem' }, - { label: 'Relaxed · 12px', value: '0.75rem' }, - { label: 'Wide · 16px', value: '1rem' }, - ], - treatments: ['plain', 'badge', 'outline'].map((value) => ({ - label: value, - value, - })), - marks: ['plain', 'soft', 'solid', 'outline'].map((value) => ({ - label: value, - value, - })), - wordmarks: ['plain', 'accent', 'gradient'].map((value) => ({ - label: value, - value, - })), - shapes: ['rounded', 'square', 'circle'].map((value) => ({ - label: value, - value, - })), - tones: [ - 'accent', - 'neutral', - 'secondary', - 'info', - 'success', - 'warning', - 'danger', - 'feature', - ].map((value) => ({ label: value, value })), - sources: [ - { label: 'Registered icon', value: 'icon' }, - { label: 'Image', value: 'image' }, - { label: 'Monogram', value: 'monogram' }, - ], - reset: () => { - brandName('PureStack') - tagline('') - punctuation('.') - logoLayout('horizontal') - logoSize('md') - treatment('plain') - markTreatment('plain') - wordmarkTreatment('plain') - markShape('rounded') - logoTone('accent') - markSource('icon') - customAccent('') - spacing('') - linked(false) - }, - } -} - -const logoPlayground = defineComponent(logoPlaygroundTemplate, { - context: createLogoPlayground, -}) -const icons: Record = { - 'tabler:stack-2': tabler_stack_2, - 'lucide:chevron-down': lucide_chevron_down, -} -createApp( - { - components: { - LogoPlayground: logoPlayground, - ...defineLogoComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...defineBadgeComponents(), - ...defineButtonComponents(), - ...definePanelComponents(), - ...defineFormComponents(), - ...defineFormInputField(), - ...defineFormSelectField(), - ...defineIconComponents((name) => icons[name] ?? ''), - }, - }, - { selector: 'app#logo-playground', template: html`` }, -) -``` +
@@ -288,91 +57,7 @@ Keep the identity consistent while adapting its presentation. These examples sha ``` -```typescript -import { - defineFlexComponents, - defineGridComponents, - defineIconComponents, - defineLogoComponents, - definePanelComponents, - type LogoConfig, -} from '@purestack/ts-components' -import { tabler_stack_2 } from '@purestack/ts-svg-icons' -import { createApp, defineComponent, html } from 'regor' - -export interface LogoAppearances { - identity: LogoConfig -} - -const logoAppearancesTemplate = html` - -

Navigation

- -

A quiet horizontal signature.

-
- -

Brand badge

- -

A contained identity for cards and embeds.

-
- -

Centered signature

- -
- -

Wordmark

- -

Typography with a theme-aware accent.

-
- -

Compact marks

- - - - - -

Full accessible names, even without a wordmark.

-
- -

Custom artwork

- - - -
-
` - -const logoAppearances = defineComponent( - logoAppearancesTemplate, - { - context: () => ({ - identity: { - brand: 'PureStack', - suffix: '.', - icon: 'tabler:stack-2', - href: null, - }, - }), - }, -) -createApp( - { - components: { - LogoAppearances: logoAppearances, - ...defineLogoComponents(), - ...defineFlexComponents(), - ...defineGridComponents(), - ...definePanelComponents(), - ...defineIconComponents((name) => - name === 'tabler:stack-2' ? tabler_stack_2 : '', - ), - }, - }, - { selector: 'app#logo-appearances', template: html`` }, -) -``` +
diff --git a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx index 25cd3376..03e06672 100644 --- a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx +++ b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx @@ -39,101 +39,7 @@ This preview reads the same logo configuration as the site. Search is disabled i ``` -```typescript -import fs from 'node:fs/promises' -import path from 'node:path' -import type { SiteConfig, TsSsgContext } from '@purestack/ts-common' -import { defineComponents } from '@purestack/ts-components' -import { - buildMenuRuntimeScript, - buildThemeSwitchScript, -} from '@purestack/ts-page-scripts' -import { renderApp } from '@purestack/ts-render' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { withBasePath } from '@purestack/ts-util' -import { defineComponent, html } from 'regor' - -export interface TopBarPreview { - previewHref: string -} - -const topBarPreviewTemplate = html`` -const exampleTemplate = html` -

- A complete header in its own document. The logo links home and the theme control - changes this preview. -

` - -async function createTopBarPreviewDocument(site: SiteConfig): Promise { - const context: TsSsgContext = { - site: { ...site }, - pageInfo: { - relPath: 'components/site/top-bar/top-bar.mdx', - urlPath: '/components/site/top-bar/', - frontmatter: { - template: 'doc', - hidden: false, - draft: false, - nav: { hidden: false }, - layout: { - showNav: false, - showToc: false, - showFooter: false, - fullWidth: false, - navMode: 'sidebar', - tocCollapsed: false, - }, - }, - }, - theme: site.style.theme, - basePath: site.basePath, - locales: site.i18n.locales, - defaultLocale: site.i18n.defaultLocale, - resolveLocaleHref: () => undefined, - resolvePublicHref: (href) => withBasePath(site.basePath, href), - recordScriptEntrypoint: () => {}, - recordRuntimeEmbed: () => {}, - } - context.site.pagefind = { ...context.site.pagefind, enabled: false } - const body = await renderApp(exampleTemplate, { - components: defineComponents(getSvgIcon), - context, - }) - const scripts = - buildThemeSwitchScript(['light', 'dark']) + buildMenuRuntimeScript() - const documentHtml = `
${body}
` - return documentHtml -} - -export function defineTopBarPreviewComponent(site: SiteConfig) { - return defineComponent(topBarPreviewTemplate, { - context: () => ({ - previewHref: withBasePath( - site.basePath, - '/components/site/top-bar/preview.html', - ), - }), - }) -} - -export async function writeTopBarPreview(site: SiteConfig) { - const output = path.join( - site.outDir, - 'components', - 'site', - 'top-bar', - 'preview.html', - ) - const documentHtml = await createTopBarPreviewDocument(site) - await fs.mkdir(path.dirname(output), { recursive: true }) - await fs.writeFile(output, documentHtml, 'utf8') -} -``` +
diff --git a/frontend/purestack.studio/guides/_nav.json b/frontend/purestack.studio/guides/_nav.json index 1fd20ff1..e2ab5952 100644 --- a/frontend/purestack.studio/guides/_nav.json +++ b/frontend/purestack.studio/guides/_nav.json @@ -6,6 +6,8 @@ "purestack-cli.mdx", "site-config.mdx", "links.mdx", + "code-blocks.mdx", + "shared-content.mdx", "vscode-extension.mdx", "regor.mdx", "typography.mdx", diff --git a/frontend/purestack.studio/guides/code-blocks.mdx b/frontend/purestack.studio/guides/code-blocks.mdx new file mode 100644 index 00000000..131ad3f5 --- /dev/null +++ b/frontend/purestack.studio/guides/code-blocks.mdx @@ -0,0 +1,72 @@ +--- +title: Code blocks +description: Show code in a page, written by hand or imported from a file, so the code a page shows always matches the file it comes from. +template: doc +layout: + showNav: true + showToc: true + showFooter: true +nav: + order: 17 + icon: tabler:code +--- + +# Code blocks + +Write short code in the page itself. When the code lives in a file, such as a playground's source, import it instead: the page then shows exactly what the file holds, and never falls out of date. + +## Write a code block + +Put the code between fences, with its language after the opening fence: + +````md +```ts +const answer = 42 +``` +```` + +The language selects the highlighting. The [site configuration](./site-config#markdown-and-code-blocks) chooses the highlighter. + +## Show a file's code + +`` puts a file's code in the page, as if you had written the code block yourself: + +```mdx + +``` + +| Attribute | Value | +| --- | --- | +| `src` | The file, relative to your page, or to the [shared file](./shared-content) the tag is in. It must be inside the content folder. | +| `lang` | Optional. The language, when the file's extension does not tell it, such as `lang="scss"`. | + +It works anywhere a code block works: in Markdown, in component markup, and in headers and footers. A playground's source is the common case, next to the playground itself: + +```mdx + + + + + + + + +``` + +- **It is not a component.** It becomes a code block before the page renders, so it takes plain attributes only, not bindings such as `:src`. Write it self-closing. +- **Code stays code.** A tag inside a code block or inline code is shown as written, as on this page. +- **Edits show up.** While `purestack serve` runs, saving the file updates every page that shows it. + +## When something is wrong + +The build stops and names the page and the tag: + +```text +Code block import "./playgound.ts" in "components/actions/buttons/buttons.mdx" does not match any file. +``` + +While `purestack serve` runs, a page with this error shows it in place of its content and renders again as soon as the file exists. Most fixes are one of these: + +- **A typo or different capitals.** `./Playground.ts` doesn't find `playground.ts`. +- **The file is outside the content folder.** Move it in, next to the page that shows it. +- **The tag is not self-closing.** Write ``, not a pair of tags. diff --git a/frontend/purestack.studio/guides/index.mdx b/frontend/purestack.studio/guides/index.mdx index d42c3bd8..32f3c967 100644 --- a/frontend/purestack.studio/guides/index.mdx +++ b/frontend/purestack.studio/guides/index.mdx @@ -1,6 +1,6 @@ --- title: Guides -description: Learn how to build and style a PureStack site with the CLI, site configuration, links, VS Code extension, Regor, typography, utility classes, semantic tones, icons, themes, and plugins. +description: Learn how to build and style a PureStack site with the CLI, site configuration, links, code blocks, shared content, VS Code extension, Regor, typography, utility classes, semantic tones, icons, themes, and plugins. template: doc layout: showNav: true @@ -31,6 +31,16 @@ These guides follow the path from a content folder to a finished interface. Star

Link pages and sections relative to your file, and let every build check them.

Write links + +

Code blocks

+

Show code in a page, or import it from a file so it always matches the source.

+ Show code +
+ +

Shared content

+

Write a passage once and show it on every page it belongs to.

+ Share a passage +

VS Code extension

Find component source, complete props and frontmatter, and format markup on save.

diff --git a/frontend/purestack.studio/guides/purestack-cli.mdx b/frontend/purestack.studio/guides/purestack-cli.mdx index 4ac80839..eddf582b 100644 --- a/frontend/purestack.studio/guides/purestack-cli.mdx +++ b/frontend/purestack.studio/guides/purestack-cli.mdx @@ -84,7 +84,7 @@ Routes follow filenames and folders: `guide/index.md` and `guide/guide.mdx` are alternative names for the same route; do not keep both. The build reports duplicate routes as an error. Page frontmatter supplies a page's title, description, template, navigation order, and layout choices. If a page omits `template`, PureStack uses the documentation template. -Files such as `assets/logo.png` are copied into the output folder with their relative paths intact. The root `siteConfig.json` and navigation files are inputs to the build, not public assets. You can place `_nav.json` in a folder for custom or hybrid navigation, and `header.mdx` or `footer.mdx` for shared page sections. The [Site configuration guide](/guides/site-config/) shows how to enable and order navigation. +Files such as `assets/logo.png` are copied into the output folder with their relative paths intact. The root `siteConfig.json` and navigation files are inputs to the build, not public assets. You can place `_nav.json` in a folder for custom or hybrid navigation, and `header.mdx` or `footer.mdx` for shared page sections. The [Site configuration guide](/guides/site-config/) shows how to enable and order navigation. A Markdown or MDX file whose name, or a folder above it, starts with `_` is never a page; pages show it with [``](/guides/shared-content/). A `purestack.config.ts` beside `siteConfig.json` adds code of your own, such as a brand skin, components, templates, or generated pages, through [plugins](/guides/plugins/). Every command loads it when it is present; like other `.ts` files in the content folder, it is never copied to the output. diff --git a/frontend/purestack.studio/guides/shared-content.mdx b/frontend/purestack.studio/guides/shared-content.mdx new file mode 100644 index 00000000..2a2dfc4d --- /dev/null +++ b/frontend/purestack.studio/guides/shared-content.mdx @@ -0,0 +1,66 @@ +--- +title: Shared content +description: Write a passage once and show it on many pages, from a shared file that is never published on its own. +template: doc +layout: + showNav: true + showToc: true + showFooter: true +nav: + order: 18 + icon: tabler:files +--- + +# Shared content + +Some passages belong on many pages: install steps, a note about a beta feature, a support link. Write such a passage once, in a file whose name starts with `_`, and show it wherever it belongs with ``. + +## Share a passage + +Put the passage in its own file: + +```text +content/ + _install.mdx + guides/ + getting-started.mdx + upgrade.mdx +``` + +```md +Install PureStack with `yarn add purestack`, then start the site with `yarn purestack serve --content ./content`. +``` + +Then show it in each page, with a path relative to the page: + +```mdx +## Install + + +``` + +The page shows the passage as if you had written it there. Markdown and components work, and its headings join the page's table of contents. + +## How shared files work + +- **They are not pages.** A file whose name, or the name of a folder above it, starts with `_` is never published on its own, such as `_install.mdx` or `_shared/beta-note.mdx`. +- **They have no frontmatter.** The page that shows a passage sets its title, template, and layout. +- **Links work from the page.** A link inside a passage resolves from the page that shows it. When pages in different folders show the passage, start its links at the site root, such as `/guides/themes/`. +- **They can import too.** A shared file can show another shared file, or a file's [code](./code-blocks), with paths relative to the shared file itself. +- **Inside a component, markup rules apply.** `` works inside component markup too, such as a `TabPane`. There, the passage is read like anything else written inside a component: HTML, components, and code blocks work, but other Markdown, such as `**bold**`, lists, and `[links](./page)`, stays as written. Write passages meant for components in HTML. +- **Edits show up.** While `purestack serve` runs, saving a shared file updates every page that shows it. + +## When something is wrong + +The build stops and names the page and the tag: + +```text +Content import "./_instal.mdx" in "guides/upgrade.mdx" does not match any file. +``` + +Most fixes are one of these: + +- **A typo or different capitals.** `./_Install.mdx` doesn't find `_install.mdx`. +- **The file is not shared.** Rename it so its name, or a folder above it, starts with `_`. A page cannot be imported. +- **The file has frontmatter.** Remove it; the page's frontmatter applies. +- **Two files import each other.** The message shows the chain, such as `guides/upgrade.mdx → _install.mdx → _requirements.mdx → _install.mdx`. diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 5d1fab0d..c583f5c8 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -92,6 +92,7 @@ a real launch. ## Content Model - Content files: `.md`, `.mdx`, `.rmdx` +- Shared content: a content file whose name, or a folder above it, starts with `_` is never a page. Pages show it with ``, which inserts its Markdown before compilation. It has no frontmatter, its own import tags resolve from it, and an import cycle fails the build. - Regor MDX files can use either `.mdx` or `.rmdx`. Use `.mdx` if you prefer the familiar MDX extension. Use `.rmdx` if you want to make the Regor-specific dialect explicit. @@ -423,6 +424,7 @@ Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` h - Regor MDX (`.mdx`, `.rmdx`): `remark-parse` + `remark-gfm` with Regor component markup preservation - Plugin remark and rehype plugins, in plugin order - HTML output via HAST + rehype +- `` becomes a code block holding that file before compilation, anywhere a code block works; `src` resolves from the file the tag is in and must stay inside the content folder, and `lang` overrides the language inferred from the extension. Tags inside code stay as written, and the dev server re-renders the pages, headers, and footers that show a file when it changes. - H2/H3 outline extraction for page TOC - Code highlighting with highlight.js or Shiki diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 477384c1..059d7e5b 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -45,6 +45,7 @@ "@purestack/ts-common": "workspace:^", "@purestack/ts-components": "workspace:^", "@purestack/ts-html": "workspace:^", + "@purestack/ts-minidom": "workspace:^", "@purestack/ts-page-scripts": "workspace:^", "@purestack/ts-render": "workspace:^", "@purestack/ts-style": "workspace:^", diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index d94e6398..a0d89505 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -1,5 +1,6 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' +import { toPosixPath } from '@purestack/ts-util' import type { Logger } from 'logpot' import { buildTranslationsByKey, @@ -65,6 +66,8 @@ export class IncrementalContentState { private readonly dirtyPages = new Set() private markedPages = 0 private generatedPages = new Map() + /** The files each page imports, by page. */ + private importedFilesByPage = new Map>() private readonly renderInFlight = new Map>() private readonly contentIndex: ManifestContentIndex @@ -92,6 +95,7 @@ export class IncrementalContentState { } async renderAllPages(contentFiles: ResolvedContentFile[]) { + this.importedFilesByPage.clear() let pages = 0 for (const file of contentFiles) { if (await this.writePageWithHooks(file)) pages += 1 @@ -110,6 +114,7 @@ export class IncrementalContentState { await hooks.onPageStart?.(context, file) const page = await renderPageFromFile(context, file, hooks) this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) + this.importedFilesByPage.set(file.relPath, new Set(page.importedFiles)) await hooks.onPageRendered?.(context, page) await writePage(page, config.html.minify) await hooks.onPageWritten?.(context, page) @@ -236,6 +241,22 @@ export class IncrementalContentState { return contentFiles } + /** + * Marks the pages that import a changed file, and refreshes the headers and + * footers when one of them imports it. + */ + async refreshImporters(relPath: string) { + const changed = toPosixPath(relPath) + if (this.input.context.partialImportedFiles?.has(changed)) { + await this.refreshPartials() + } + const importers: string[] = [] + for (const [page, imports] of this.importedFilesByPage) { + if (imports.has(changed)) importers.push(page) + } + this.markPagesDirty(importers) + } + /** * Compiles the header and footer partials again. Only pages whose nearest * header or footer changed become dirty, so an edit in one folder leaves @@ -269,6 +290,7 @@ export class IncrementalContentState { delete this.input.getManifest().content[relPath] this.contentIndex.remove(relPath) this.dirtyPages.delete(relPath) + this.importedFilesByPage.delete(relPath) result.deletedPages += 1 } @@ -326,6 +348,7 @@ export class IncrementalContentState { await this.input.persistManifest() } this.dirtyPages.delete(relPath) + this.importedFilesByPage.delete(relPath) return false } diff --git a/packages/ts-ssg/src/build/incremental/incremental.md b/packages/ts-ssg/src/build/incremental/incremental.md index 0a5c859a..4168535d 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.md +++ b/packages/ts-ssg/src/build/incremental/incremental.md @@ -86,6 +86,11 @@ so the dev server can reload the browser. - **A page or asset was added or removed** ➜ every page, since any content URL may now resolve differently or fail. - **A header or footer changed** ➜ the pages that show it. +- **A file imported with `` or `` changed** + ➜ the pages that import it, directly or through shared content, and the + headers and footers that import it (`refreshImporters`). A page that + failed because the file was missing records it too, so creating the file + renders it again. - **A script bundle's hashed name changed** ➜ the pages that load it, rendered right away. diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index d580f3da..3142e54c 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -942,6 +942,104 @@ describe('incremental builder', () => { }) }) + describe('code imports', () => { + const text = (html: string) => html.replace(/<[^>]+>/g, '') + + it.each(['auto', 'none'] as const)( + 'shows an imported file as code and renders its pages again when it changes (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createSite(base, mode, { + 'index.mdx': + '
\n \n
', + 'demo.ts': 'export const answer = 1', + }) + expect(text(await site.read('/'))).toContain( + 'export const answer = 1', + ) + + const result = await site.change('demo.ts', 'export const answer = 2') + + expect(result.markedPages).toBeGreaterThan(0) + expect(await site.renderIfDirty('/')).toBe(true) + expect(text(await site.read('/'))).toContain( + 'export const answer = 2', + ) + }) + }, + ) + + it('renders a page again once the file it imports exists', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '', + }) + expect(await site.read('/')).toContain( + 'Code block import "./later.ts" in "index.mdx" does not match any file.', + ) + + const result = await site.change('later.ts', 'const later = true') + + expect(result.markedPages).toBeGreaterThan(0) + expect(await site.renderIfDirty('/')).toBe(true) + expect(text(await site.read('/'))).toContain('const later = true') + }) + }) + + it('refreshes the headers that import a changed file', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'header.mdx': '', + 'index.mdx': '# Home', + 'snippet.ts': 'const version = 1', + }) + expect(text(await site.read('/'))).toContain('const version = 1') + + await site.change('snippet.ts', 'const version = 2') + + expect(await site.renderIfDirty('/')).toBe(true) + expect(text(await site.read('/'))).toContain('const version = 2') + }) + }) + + it.each(['auto', 'none'] as const)( + 'shows shared content, which never becomes a page, and renders its pages again when it changes (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createSite(base, mode, { + 'index.mdx': '# Home\n\n', + '_shared/note.mdx': 'Shared note, version 1.', + '_intro.mdx': 'An unused shared file.', + }) + expect(text(await site.read('/'))).toContain( + 'Shared note, version 1.', + ) + expect(await fileExists(site.outPath('/_shared/note/'))).toBe(false) + expect(await fileExists(site.outPath('/_intro/'))).toBe(false) + + const result = await site.change( + '_shared/note.mdx', + 'Shared note, version 2.', + ) + + expect(result.markedPages).toBeGreaterThan(0) + expect(result.changedPages).toBe(0) + expect(await site.renderIfDirty('/')).toBe(true) + expect(text(await site.read('/'))).toContain( + 'Shared note, version 2.', + ) + expect(await fileExists(site.outPath('/_shared/note/'))).toBe(false) + const manifest = await readManifest(path.join(base, 'out')) + expect( + Object.keys(manifest?.content ?? {}).some((relPath) => + relPath.includes('_'), + ), + ).toBe(false) + }) + }, + ) + }) + describe('generated pages', () => { /** One page per tag in the posts' frontmatter, listing their titles. */ const tagPages: PureStackPlugin = { diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index 46eb7d90..37290b80 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -6,6 +6,7 @@ import { getLogger, type Logger } from 'logpot' import { isDefaultFooterFile, isDefaultHeaderFile, + isSharedContentFile, isSiteConfigFile, type StaticAssetFile, } from '../../discover/content' @@ -415,9 +416,11 @@ class IncrementalRuntime { this.contentState.takeMarkedPageCount() if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { await this.contentState.refreshPartials() - } else { + } else if (!isSharedContentFile(relPath)) { + // Shared content is never a page; its importers refresh below. await this.changeApplier.applyFileChange(filePath, relPath, result) } + await this.contentState.refreshImporters(relPath) result.markedPages = this.contentState.takeMarkedPageCount() return result } diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index b171d124..90f5e261 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -30,6 +30,7 @@ import { resolveContentFile, resolvePlainContentFile, } from '../i18n/content' +import { expandImports } from '../mdx/imports' import { compileMarkdown } from '../mdx/md' import { compileMdx, type MdxRenderOptions } from '../mdx/mdx' import { @@ -50,6 +51,8 @@ export interface BuildContext { contentRoutes: ContentRouteIndex headerHtmlByDir?: Map footerHtmlByDir?: Map + /** Content paths of the files headers and footers import. */ + partialImportedFiles?: ReadonlySet writeErrorPages?: boolean components?: Record templates?: PageTemplateMap @@ -82,6 +85,11 @@ export interface PageRenderResult { pageInfo: PageInfo outline?: PageOutlineItem[] scriptEntrypoints: string[] + /** + * Content paths of the files the page imports with import-codeblock and + * import-content, nested imports included. + */ + importedFiles: string[] } export async function writePage( @@ -118,12 +126,19 @@ export async function renderPageFromFile( const renderStart = process.hrtime.bigint() const { urlPath } = resolveRouteInfo(file) const outPath = resolveOutPath(context.config.outDir, file) + // Kept on an error page too, so creating a missing file renders it again. + const importedFiles = new Set() try { const source = await readContentSource(file) const parsedContent = parseFrontmatterSource(source, file.relPath, { defaultShowToc: context.config.pageToc.enabled, }) - const compiled = await compilePageContent(context, file, parsedContent.body) + const compiled = await compilePageContent( + context, + file, + parsedContent.body, + importedFiles, + ) const frontmatter = resolvePageFrontmatterTitle( parsedContent.frontmatter, compiled.outline, @@ -189,6 +204,7 @@ export async function renderPageFromFile( scriptEntrypoints: [...scriptEntrypoints].sort((a, b) => a.localeCompare(b), ), + importedFiles: [...importedFiles], } } catch (error) { if (isError(error)) getLogger().warn(error as Error) @@ -198,11 +214,12 @@ export async function renderPageFromFile( outPath, }) if (context.writeErrorPages) { - return await writePageError(context, file, errorWithContext, { + const errorPage = await writePageError(context, file, errorWithContext, { outPath, urlPath, renderStart, }) + return { ...errorPage, importedFiles: [...importedFiles] } } throw errorWithContext } @@ -211,27 +228,38 @@ export async function renderPageFromFile( type ContentCompileContext = Pick export async function resolveHeaderFooterHtml(context: BuildContext) { - context.headerHtmlByDir = await resolveHeaderHtmlByDirectory(context) - context.footerHtmlByDir = await resolveFooterHtmlByDirectory(context) + const importedFiles = new Set() + context.headerHtmlByDir = await resolveHeaderHtmlByDirectory( + context, + importedFiles, + ) + context.footerHtmlByDir = await resolveFooterHtmlByDirectory( + context, + importedFiles, + ) + context.partialImportedFiles = importedFiles } export async function resolveFooterHtmlByDirectory( context: ContentCompileContext, + importedFiles = new Set(), ): Promise> { const footers = await discoverDefaultFooters(context.config.contentDir) - return await resolveSpecialHtmlByDirectory(context, footers) + return await resolveSpecialHtmlByDirectory(context, footers, importedFiles) } export async function resolveHeaderHtmlByDirectory( context: ContentCompileContext, + importedFiles = new Set(), ): Promise> { const headers = await discoverDefaultHeaders(context.config.contentDir) - return await resolveSpecialHtmlByDirectory(context, headers) + return await resolveSpecialHtmlByDirectory(context, headers, importedFiles) } async function resolveSpecialHtmlByDirectory( context: ContentCompileContext, files: ContentFile[], + importedFiles: Set, ): Promise> { const htmlByDir = new Map() for (const file of files) { @@ -242,6 +270,7 @@ async function resolveSpecialHtmlByDirectory( context, localizedFile, parsedContent.body, + importedFiles, ) const dirKey = toDirKey(localizedFile.relPath) // Links in a partial resolve from the partial, not from each page. @@ -318,20 +347,30 @@ function resolvePageTranslations( return translations.length > 0 ? { translations } : {} } -function compilePageContent( +/** + * Compiles a page or partial body, after its import tags expand into what they + * name. `importedFiles` collects the files they name. + */ +async function compilePageContent( context: ContentCompileContext, file: ResolvedContentFile, sourceBody: string, + importedFiles: Set, ) { const { config, mdx } = context + const body = await expandImports(sourceBody, { + contentDir: config.contentDir, + sourceRelPath: file.relPath, + onImport: (relPath) => importedFiles.add(relPath), + }) const options: MdxRenderOptions = { ...(mdx ?? {}), sourceRelPath: file.relPath, } const compileMdAsMdx = mdx?.compileMdAsMdx ?? config.mdx.compileMdAsMdx return isRegorMdxContentExt(file.ext) || compileMdAsMdx - ? compileMdx(sourceBody, options) - : compileMarkdown(sourceBody, options) + ? compileMdx(body, options) + : compileMarkdown(body, options) } type RenderPageShellInput = { @@ -585,6 +624,7 @@ export async function writePageError( }, outline: [], scriptEntrypoints: [], + importedFiles: [], } } diff --git a/packages/ts-ssg/src/discover/content.ts b/packages/ts-ssg/src/discover/content.ts index 67ed2ef1..895e1b7f 100644 --- a/packages/ts-ssg/src/discover/content.ts +++ b/packages/ts-ssg/src/discover/content.ts @@ -52,6 +52,21 @@ export function isContentFile(_relPath: string, ext: string) { return isContentExt(ext) } +/** + * A content file whose name, or the name of a folder above it, starts with _ + * is shared content: pages show it with import-content, and it is never a + * page itself. + */ +export function isSharedContentFile(relPath: string) { + return ( + isContentExt(path.extname(relPath)) && + relPath + .replaceAll('\\', '/') + .split('/') + .some((name) => name.startsWith('_')) + ) +} + export function isAgentsFile(relPath: string) { return path.basename(relPath).toUpperCase() === 'AGENTS.MD' } @@ -86,6 +101,7 @@ export async function discoverContent( files, (relPath, ext) => isContentFile(relPath, ext) && + !isSharedContentFile(relPath) && !isDefaultFooterFile(relPath) && !isDefaultHeaderFile(relPath) && !isAgentsFile(relPath), diff --git a/packages/ts-ssg/src/mdx/imports.test.ts b/packages/ts-ssg/src/mdx/imports.test.ts new file mode 100644 index 00000000..8c421149 --- /dev/null +++ b/packages/ts-ssg/src/mdx/imports.test.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { makeRepoTempDir } from '../test/repoTempDir' +import { expandImports } from './imports' + +describe('expandImports', () => { + let contentDir: string + + beforeAll(async () => { + contentDir = await makeRepoTempDir('.tmp-ts-ssg-import-codeblock-') + const files: Record = { + 'guides/demo.ts': 'export const answer = 42\n', + 'guides/styles.css': '.demo { color: red; }\n', + 'shared/fence.md': 'Use a fence:\r\n\r\n```ts\r\nconst x = 1\r\n```\r\n', + 'guides/data.unknown': 'plain', + 'shared/_note.mdx': + 'A **shared** note.\r\n\r\n\r\n', + 'shared/snippet.ts': 'const shared = true', + '_partials/install.md': 'Run the installer.', + 'guides/_front.mdx': '---\ntitle: Shared\n---\nBody', + 'guides/_a.mdx': '', + 'guides/_b.mdx': '', + 'guides/notes.mdx': 'A page of its own.', + 'guides/_rule.mdx': '---\n\nAfter a rule.', + } + for (const [relPath, contents] of Object.entries(files)) { + await fs.mkdir(path.join(contentDir, path.dirname(relPath)), { + recursive: true, + }) + await fs.writeFile(path.join(contentDir, relPath), contents) + } + }) + + afterAll(async () => { + await fs.rm(contentDir, { recursive: true, force: true }) + }) + + const expand = (source: string, onImport?: (relPath: string) => void) => + expandImports(source, { + contentDir, + sourceRelPath: path.join('guides', 'page.mdx'), + onImport, + }) + + it('replaces the tag with a code block holding the file, its language from the extension', async () => { + expect( + await expand('Before\n\nAfter'), + ).toBe('Before\n\n```typescript\nexport const answer = 42\n```\n\nAfter') + }) + + it('takes the language from lang when given, and none for an unknown extension', async () => { + expect( + await expand(''), + ).toBe('\n```scss\n.demo { color: red; }\n```\n') + expect(await expand('')).toBe( + '\n```\nplain\n```\n', + ) + }) + + it('resolves src from the page, reports each import, and keeps line endings uniform', async () => { + const imports: string[] = [] + + const expanded = await expand( + '', + (relPath) => imports.push(relPath), + ) + + expect(imports).toEqual(['shared/fence.md']) + // A longer fence keeps the file's own fence inside the block. + expect(expanded).toBe( + '\n````markdown\nUse a fence:\n\n```ts\nconst x = 1\n```\n````\n', + ) + }) + + it('puts the code block on lines of its own inside indented Regor markup', async () => { + expect( + await expand( + '\n \n', + ), + ).toBe( + '\n \n```typescript\nexport const answer = 42\n```\n\n', + ) + }) + + it('leaves tags inside code as written', async () => { + const source = [ + 'Write `` in a page:', + '', + '```mdx', + '', + '```', + ].join('\n') + + expect(await expand(source)).toBe(source) + }) + + it.each([ + [ + '', + 'Code block import in "guides/page.mdx" must be self-closing: .', + ], + [ + '', + 'Code block import in "guides/page.mdx" has an unknown attribute "lines". It takes plain src and lang attributes.', + ], + [ + '', + 'Code block import in "guides/page.mdx" has an unknown attribute ":src". It takes plain src and lang attributes.', + ], + [ + '', + 'Code block import in "guides/page.mdx" needs a src, such as .', + ], + [ + '', + 'Code block import "../../outside.ts" in "guides/page.mdx" needs a relative path that stays inside the content folder.', + ], + [ + '', + 'Code block import "/guides/demo.ts" in "guides/page.mdx" needs a relative path that stays inside the content folder.', + ], + [ + '', + 'Code block import "./missing.ts" in "guides/page.mdx" does not match any file.', + ], + // Even on a disk that ignores case, so the page builds the same everywhere. + [ + '', + 'Code block import "./Demo.ts" in "guides/page.mdx" does not match any file.', + ], + [ + '', + 'Code block import "../Guides/demo.ts" in "guides/page.mdx" does not match any file.', + ], + ])('rejects %s', async (source, message) => { + await expect(expand(source)).rejects.toThrow(message) + }) + + it('reports a missing file as an import, so its page can render again once it exists', async () => { + const imports: string[] = [] + + await expect( + expand('', (relPath) => + imports.push(relPath), + ), + ).rejects.toThrow() + + expect(imports).toEqual(['guides/missing.ts']) + }) + + describe('import-content', () => { + it('puts shared Markdown in the page as blocks, expanding its own tags from its file', async () => { + const imports: string[] = [] + + const expanded = await expand( + 'Intro\n\nOutro', + (relPath) => imports.push(relPath), + ) + + expect(expanded).toBe( + 'Intro\n\n\nA **shared** note.\n\n\n```typescript\nconst shared = true\n```\n\n\n\nOutro', + ) + expect(imports).toEqual(['shared/_note.mdx', 'shared/snippet.ts']) + }) + + it('takes shared content from a folder whose name starts with _', async () => { + expect( + await expand(''), + ).toBe('\n\nRun the installer.\n\n') + }) + + it('treats a passage that opens with a rule as Markdown, not frontmatter', async () => { + expect(await expand('')).toBe( + '\n\n---\n\nAfter a rule.\n\n', + ) + }) + + it.each([ + [ + '', + 'Content import "./notes.mdx" in "guides/page.mdx" needs shared content: a file whose name, or a folder above it, starts with _, such as ./_shared.mdx.', + ], + [ + '', + 'Content import "./demo.ts" in "guides/page.mdx" needs a Markdown or MDX file: .md, .mdx, or .rmdx.', + ], + [ + '', + 'Content import "./_front.mdx" in "guides/page.mdx" names a file with frontmatter; shared content uses the frontmatter of the page that shows it.', + ], + [ + '', + 'Content import "./_a.mdx" in "guides/_b.mdx" imports itself: guides/page.mdx → guides/_a.mdx → guides/_b.mdx → guides/_a.mdx.', + ], + [ + '', + 'Content import "./_missing.mdx" in "guides/page.mdx" does not match any file.', + ], + [ + '', + 'Content import in "guides/page.mdx" has an unknown attribute "lang". It takes a plain src attribute.', + ], + [ + '', + 'Content import in "guides/page.mdx" must be self-closing: .', + ], + ])('rejects %s', async (source, message) => { + await expect(expand(source)).rejects.toThrow(message) + }) + }) +}) diff --git a/packages/ts-ssg/src/mdx/imports.ts b/packages/ts-ssg/src/mdx/imports.ts new file mode 100644 index 00000000..f0c3f3a2 --- /dev/null +++ b/packages/ts-ssg/src/mdx/imports.ts @@ -0,0 +1,250 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { parseFragment } from '@purestack/ts-minidom' +import { toPosixPath } from '@purestack/ts-util' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { isSharedContentFile } from '../discover/content' +import { isContentExt } from '../discover/contentExtensions' +import { findMarkupTags } from './regorMarkup' + +const IMPORT_TAGS = { + 'import-codeblock': { + label: 'Code block import', + attributes: ['src', 'lang'], + attributesText: 'plain src and lang attributes', + example: './example.ts', + }, + 'import-content': { + label: 'Content import', + attributes: ['src'], + attributesText: 'a plain src attribute', + example: './_shared.mdx', + }, +} as const + +type ImportTagName = keyof typeof IMPORT_TAGS + +const IMPORT_TAG_NAMES = Object.keys(IMPORT_TAGS) as ImportTagName[] + +const LANGUAGE_BY_EXTENSION: Record = { + '.cjs': 'javascript', + '.css': 'css', + '.cts': 'typescript', + '.html': 'html', + '.js': 'javascript', + '.json': 'json', + '.jsx': 'jsx', + '.md': 'markdown', + '.mdx': 'mdx', + '.mjs': 'javascript', + '.mts': 'typescript', + '.rmdx': 'mdx', + '.sh': 'bash', + '.ts': 'typescript', + '.tsx': 'tsx', + '.yaml': 'yaml', + '.yml': 'yaml', +} + +const parser = unified().use(remarkParse).use(remarkGfm) + +export interface ImportOptions { + contentDir: string + /** The page's path in the content folder; its tags resolve from it. */ + sourceRelPath: string + /** Receives each imported file's content path, before the file is read. */ + onImport?: (relPath: string) => void +} + +/** + * Expands the import tags in a page's source, as if the page held what they + * name: `` becomes a code block holding a file, and + * `` becomes the Markdown of a shared file, whose own tags + * expand in turn. Tags inside code stay as written. + */ +export function expandImports( + source: string, + options: ImportOptions, +): Promise { + const page = toPosixPath(options.sourceRelPath) + return expandSource(source, page, options, [page]) +} + +async function expandSource( + source: string, + sourceRelPath: string, + options: ImportOptions, + chain: readonly string[], +): Promise { + if (!IMPORT_TAG_NAMES.some((name) => source.includes(`<${name}`))) { + return source + } + const tags = findMarkupTags( + source, + (text) => parser.parse(text), + IMPORT_TAG_NAMES, + ) + let expanded = '' + let lastIndex = 0 + for (const tag of tags) { + expanded += source.slice(lastIndex, tag.start) + expanded += await expandTag( + tag.name as ImportTagName, + source.slice(tag.start, tag.end), + tag.selfClosing, + sourceRelPath, + options, + chain, + ) + lastIndex = tag.end + } + return expanded + source.slice(lastIndex) +} + +async function expandTag( + name: ImportTagName, + markup: string, + selfClosing: boolean, + sourceRelPath: string, + options: ImportOptions, + chain: readonly string[], +) { + const { label, attributes, attributesText, example } = IMPORT_TAGS[name] + const failure = (problem: string, src?: string) => + new Error( + `${label}${src ? ` "${src}"` : ''} in "${sourceRelPath}" ${problem}`, + ) + if (!selfClosing) { + throw failure(`must be self-closing: <${name} src="${example}"/>.`) + } + const element = parseFragment(markup).firstElementChild + for (const attribute of element?.getAttributeNames() ?? []) { + if (!(attributes as readonly string[]).includes(attribute)) { + throw failure( + `has an unknown attribute "${attribute}". It takes ${attributesText}.`, + ) + } + } + const src = element?.getAttribute('src')?.trim() + if (!src) { + throw failure(`needs a src, such as <${name} src="${example}"/>.`) + } + const relPath = resolveImportPath(src, sourceRelPath) + if (!relPath) { + throw failure( + 'needs a relative path that stays inside the content folder.', + src, + ) + } + + if (name === 'import-content') { + if (!isContentExt(path.posix.extname(relPath))) { + throw failure('needs a Markdown or MDX file: .md, .mdx, or .rmdx.', src) + } + if (!isSharedContentFile(relPath)) { + throw failure( + 'needs shared content: a file whose name, or a folder above it, starts with _, such as ./_shared.mdx.', + src, + ) + } + if (chain.includes(relPath)) { + throw failure(`imports itself: ${[...chain, relPath].join(' → ')}.`, src) + } + } + + options.onImport?.(relPath) + const text = await readImportedFile(options.contentDir, relPath, () => + failure('does not match any file.', src), + ) + if (name === 'import-codeblock') { + const lang = + element?.getAttribute('lang')?.trim() || + LANGUAGE_BY_EXTENSION[path.posix.extname(relPath).toLowerCase()] || + '' + return toCodeFence(text, lang) + } + const markdown = text.replaceAll('\r\n', '\n').trim() + if (startsWithFrontmatter(markdown)) { + throw failure( + 'names a file with frontmatter; shared content uses the frontmatter of the page that shows it.', + src, + ) + } + const content = await expandSource(markdown, relPath, options, [ + ...chain, + relPath, + ]) + // Blank lines keep the content in blocks of its own. + return `\n\n${content}\n\n` +} + +/** + * Frontmatter is a block between two `---` lines at the very start. A passage + * that only opens with a `---` rule has no closing line, so it is Markdown. + */ +function startsWithFrontmatter(markdown: string) { + const [first, ...rest] = markdown.split('\n') + return ( + first.trimEnd() === '---' && rest.some((line) => line.trimEnd() === '---') + ) +} + +/** The content path `src` names, or undefined when it leaves the folder. */ +function resolveImportPath(src: string, sourceRelPath: string) { + const posixSrc = toPosixPath(src) + if ( + posixSrc.startsWith('/') || + path.win32.isAbsolute(src) || + posixSrc.includes('://') + ) { + return undefined + } + const relPath = path.posix.normalize( + path.posix.join(path.posix.dirname(sourceRelPath), posixSrc), + ) + if (relPath === '..' || relPath.startsWith('../')) return undefined + return relPath +} + +/** + * Reads the file only when each part of its path matches in case too, so a + * page that builds on a case-insensitive disk builds on every disk. + */ +async function readImportedFile( + contentDir: string, + relPath: string, + missing: () => Error, +) { + try { + let filePath = contentDir + for (const name of relPath.split('/')) { + if (!(await fs.readdir(filePath)).includes(name)) throw missing() + filePath = path.join(filePath, name) + } + return await fs.readFile(filePath, 'utf8') + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { + throw missing() + } + throw error + } +} + +/** + * A fenced code block on lines of its own, even when the tag is indented or + * inline, with a fence longer than any backtick run in the code. + */ +function toCodeFence(code: string, lang: string) { + const text = code.replaceAll('\r\n', '\n').trimEnd() + let longestRun = 0 + let run = 0 + for (const char of text) { + run = char === '`' ? run + 1 : 0 + longestRun = Math.max(longestRun, run) + } + const fence = '`'.repeat(Math.max(3, longestRun + 1)) + return `\n${fence}${lang}\n${text}\n${fence}\n` +} diff --git a/packages/ts-ssg/src/mdx/regorMarkup.ts b/packages/ts-ssg/src/mdx/regorMarkup.ts index abf8c038..15af3464 100644 --- a/packages/ts-ssg/src/mdx/regorMarkup.ts +++ b/packages/ts-ssg/src/mdx/regorMarkup.ts @@ -146,6 +146,23 @@ export function maskRegorMarkup( return { segments, source: next } } +/** + * Finds the opening tags named exactly one of `tagNames` outside code, skipping fenced + * code first and then the code Markdown parses, as Regor markup is found. + */ +export function findMarkupTags( + source: string, + parse: (source: string) => unknown, + tagNames: readonly string[], +) { + const fenceRanges = collectFencedCodeRanges(source) + const root = parse(maskIgnoredRanges(source, fenceRanges)) + const ignoredRanges = [...fenceRanges, ...collectIgnoredRanges(root)] + return scanTagTokens(maskIgnoredRanges(source, ignoredRanges)).filter( + (token) => token.kind === 'opening' && tagNames.includes(token.name), + ) +} + export function restoreRegorMarkup(root: unknown, segments: MarkupSegment[]) { if (segments.length === 0) return const byPlaceholder = new Map( diff --git a/packages/ts-ssg/src/plugins/generated-pages.test.ts b/packages/ts-ssg/src/plugins/generated-pages.test.ts index fd63a2f3..93a467d9 100644 --- a/packages/ts-ssg/src/plugins/generated-pages.test.ts +++ b/packages/ts-ssg/src/plugins/generated-pages.test.ts @@ -106,6 +106,10 @@ describe('generated pages', () => { () => [{ path: 'feed.xml', source: '' }], 'Plugin "bad" needs the generated page "feed.xml" to end in .md, .mdx, or .rmdx.', ], + [ + () => [{ path: '_shared/note.mdx', source: '' }], + 'Plugin "bad" cannot generate "_shared/note.mdx"; a name starting with _ marks shared content, not a page.', + ], [ () => [{ path: 'guides/header.mdx', source: '' }], 'Plugin "bad" cannot generate "guides/header.mdx"; headers and footers are written as files.', diff --git a/packages/ts-ssg/src/plugins/generated-pages.ts b/packages/ts-ssg/src/plugins/generated-pages.ts index 6a6cc409..1f932043 100644 --- a/packages/ts-ssg/src/plugins/generated-pages.ts +++ b/packages/ts-ssg/src/plugins/generated-pages.ts @@ -5,6 +5,7 @@ import { type ContentFile, isDefaultFooterFile, isDefaultHeaderFile, + isSharedContentFile, } from '../discover/content' import { isContentExt } from '../discover/contentExtensions' import type { ResolvedContentFile } from '../i18n/content' @@ -135,6 +136,11 @@ function readGeneratedPage(pluginName: string, page: GeneratedPage) { `needs the generated page "${pagePath}" to end in .md, .mdx, or .rmdx.`, ) } + if (isSharedContentFile(relPath)) { + fail( + `cannot generate "${pagePath}"; a name starting with _ marks shared content, not a page.`, + ) + } if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { fail( `cannot generate "${pagePath}"; headers and footers are written as files.`, diff --git a/yarn.lock b/yarn.lock index cda24828..bf79b327 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,6 +1227,7 @@ __metadata: "@purestack/ts-common": "workspace:^" "@purestack/ts-components": "workspace:^" "@purestack/ts-html": "workspace:^" + "@purestack/ts-minidom": "workspace:^" "@purestack/ts-page-scripts": "workspace:^" "@purestack/ts-render": "workspace:^" "@purestack/ts-style": "workspace:^"