Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 109 additions & 37 deletions apps/site/src/components/napl-example/napl-example-impl.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {useMemo, type ReactElement} from 'react'
import {useMemo, type ReactElement, type ReactNode} from 'react'
import {useQuery} from '@tanstack/react-query'
import {Callout} from 'fumadocs-ui/components/callout'
import {loadShowcaseModule} from '@/lib/fixtures'
import type {ShowcaseModule} from '@/lib/fixtures'
import {NaplPlaygroundClient} from '@/components/playground/napl-playground-impl'
import {Button} from '@/components/ui/button'
import {NaplExampleSkeleton} from './napl-example-skeleton'

export interface InlineFile {
name: string
Expand All @@ -17,65 +20,134 @@ export interface NaplExampleImplProps {
showGen?: boolean
}

const isPromptFile = (file: InlineFile): boolean => file.name.endsWith('.napl') || file.name.endsWith('.🧑')

const promptFile = (files: InlineFile[]): InlineFile | undefined => files.find(isPromptFile) ?? files[0]

const promptName = (prompt: InlineFile | undefined): string => prompt?.name ?? 'example.napl'

const promptContent = (prompt: InlineFile | undefined): string => prompt?.content ?? ''

const generatedFile = (file: InlineFile): ShowcaseModule['files'][number] => ({
path: file.name,
journalPath: file.name,
content: file.content,
language: 'text',
})

const synthModule = (files: InlineFile[]): ShowcaseModule => {
const prompt = files.find((f) => f.name.endsWith('.napl') || f.name.endsWith('.🧑')) ?? files[0]
const promptName = prompt?.name ?? 'example.napl'
const promptContent = prompt?.content ?? ''
const generated = files.filter((f) => f !== prompt)
const prompt = promptFile(files)
const name = promptName(prompt)
const content = promptContent(prompt)
const generated = files.filter((file) => file !== prompt)
return {
module: promptName.replace(/\.(napl|🧑)$/, ''),
module: name.replace(/\.(napl|🧑)$/, ''),
collection: 'example',
target: '',
targets: [],
prompt: {file: promptName, path: promptName, content: promptContent},
prompt: {file: name, path: name, content},
promptAtGen: null,
summary: '',
files: generated.map((f) => ({
path: f.name,
journalPath: f.name,
content: f.content,
language: 'text',
})),
files: generated.map(generatedFile),
attribution: [],
attributionYaml: '',
mapl: [],
maplContent: '',
maplFile: '',
journal: [],
lock: [],
session: {task: '', files: {[promptName]: promptContent}, events: []},
session: {task: '', files: {[name]: content}, events: []},
}
}

export function NaplExampleClient({
module: moduleName,
files,
compact = true,
readOnly = false,
showGen = false,
}: NaplExampleImplProps): ReactElement {
const inline = useMemo(() => (files ? synthModule(files) : null), [files])
const query = useQuery({
queryKey: ['showcase-module', moduleName],
queryFn: () => {
if (!moduleName) throw new Error('missing module')
return loadShowcaseModule(moduleName)
},
enabled: Boolean(moduleName) && !inline && typeof window !== 'undefined',
staleTime: Number.POSITIVE_INFINITY,
})

const data = inline ?? query.data ?? null
if (!data) {
return <div className="p-4 text-[13px] text-[var(--fd-muted-foreground)] bg-[var(--fd-card)]">Loading example…</div>
}
const synthOrNull = (files: InlineFile[] | undefined): ShowcaseModule | null => (files ? synthModule(files) : null)

const resolveData = (inline: ShowcaseModule | null, fetched: ShowcaseModule | undefined): ShowcaseModule | null =>
inline ?? fetched ?? null

const fetchShowcaseModule = (moduleName: string | undefined): Promise<ShowcaseModule> => {
if (!moduleName) throw new Error('missing module')
return loadShowcaseModule(moduleName)
}

const showcaseModuleQuery = (moduleName: string | undefined, hasInline: boolean) => ({
queryKey: ['showcase-module', moduleName],
queryFn: () => fetchShowcaseModule(moduleName),
enabled: Boolean(moduleName) && !hasInline && typeof window !== 'undefined',
staleTime: Number.POSITIVE_INFINITY,
retry: false,
})

const playgroundFlags = (props: NaplExampleImplProps): {compact: boolean; readOnly: boolean; showGen: boolean} => ({
compact: props.compact ?? true,
readOnly: props.readOnly ?? false,
showGen: props.showGen ?? false,
})

const isMissingFromBuild = (error: unknown): boolean =>
error instanceof Error && error.message.startsWith('unknown fixture module:')

const readFailure = (moduleName: string | undefined): string =>
moduleName ? `The example "${moduleName}" could not be read.` : 'The example could not be read.'

function ExampleNotice({title, children}: {title: string; children: ReactNode}): ReactElement {
return (
<Callout type="error" title={title} className="my-0">
{children}
</Callout>
)
}

function ReloadPageButton(): ReactElement {
return (
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={() => window.location.reload()}>
Reload the page
</Button>
)
}

function ExampleLoadError({moduleName, error}: {moduleName: string | undefined; error: unknown}): ReactElement {
const missing = isMissingFromBuild(error)
return (
<ExampleNotice title="This example did not load">
<span className="block">
{readFailure(moduleName)} {missing ? 'It is not part of this build.' : 'It did not finish downloading.'}
</span>
{missing ? null : <ReloadPageButton />}
</ExampleNotice>
)
}

function ExampleMissingSource(): ReactElement {
return <ExampleNotice title="This example has no source">Nothing was given for this example to show.</ExampleNotice>
}

interface ExamplePendingProps {
moduleName: string | undefined
isError: boolean
error: unknown
}

function ExamplePending({moduleName, isError, error}: ExamplePendingProps): ReactElement {
if (isError) return <ExampleLoadError moduleName={moduleName} error={error} />
if (!moduleName) return <ExampleMissingSource />
return <NaplExampleSkeleton />
}

export function NaplExampleClient(props: NaplExampleImplProps): ReactElement {
const {module: moduleName, files} = props
const inline = useMemo(() => synthOrNull(files), [files])
const query = useQuery(showcaseModuleQuery(moduleName, Boolean(inline)))
const data = resolveData(inline, query.data)

if (!data) return <ExamplePending moduleName={moduleName} isError={query.isError} error={query.error} />

return (
<div
className="[&_[data-napl-surface]_.cm-editor]:max-h-[440px]! [&_[data-napl-surface]]:shadow-[0_6px_18px_-12px_rgba(15,23,42,0.22)] [&_[data-testid=napl-playground-output]]:hidden"
data-testid="napl-example"
>
<NaplPlaygroundClient key={data.module} module={data} compact={compact} readOnly={readOnly} showGen={showGen} />
<NaplPlaygroundClient key={data.module} module={data} {...playgroundFlags(props)} />
</div>
)
}
73 changes: 73 additions & 0 deletions apps/site/src/components/napl-example/napl-example-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import '@napl-lang/editor/styles.css'
import {CHROME, DOT, DOT_AMBER, DOT_GREEN, DOT_RED, DOTS, EDITOR_PANE, PLAYGROUND} from '@napl-lang/editor'
import type {ReactElement} from 'react'
import {Skeleton} from '@/components/ui/skeleton'
import {PANE, PANE_DIVIDER, PANES, PANES_HOST, TAB_STRIP} from '@/components/playground/playground-classes'

const BAR = 'bg-[var(--napl-border)] motion-reduce:animate-none'

const PROMPT_LINES = ['w-[34%]', 'w-[72%]', 'w-[58%]', 'w-[66%]', 'w-[28%]', 'w-[70%]', 'w-[44%]', 'w-[61%]']

const GENERATED_LINES = ['w-[52%]', 'w-[36%]', 'w-[68%]', 'w-[45%]', 'w-[59%]']

const PROMPT_TABS = ['w-16', 'w-20']

const GENERATED_TABS = ['w-14', 'w-[68px]', 'w-[76px]']

function TabStripSkeleton({widths}: {widths: string[]}): ReactElement {
return (
<div className={TAB_STRIP} aria-hidden="true">
{widths.map((width) => (
<span key={width} className="inline-flex flex-none items-center gap-[7px] px-3.5 pt-2.5 pb-[11px]">
<span className="flex h-[13px] items-center">
<Skeleton className={`size-1.5 rounded-full ${BAR}`} />
</span>
<span className="flex h-[13px] items-center">
<Skeleton className={`h-[9px] ${width} ${BAR}`} />
</span>
</span>
))}
</div>
)
}

function LinesSkeleton({widths, height}: {widths: string[]; height: string}): ReactElement {
return (
<div className={EDITOR_PANE}>
<div className={`flex flex-col gap-[11px] px-4 py-3.5 ${height}`} aria-hidden="true">
{widths.map((width) => (
<Skeleton key={width} className={`h-[9px] ${width} ${BAR}`} />
))}
</div>
</div>
)
}

export function NaplExampleSkeleton(): ReactElement {
return (
<div className={`${PLAYGROUND} dark`} data-napl-surface role="status" aria-label="Loading example">
<div className={CHROME}>
<div className={DOTS} aria-hidden="true">
<span className={`${DOT} ${DOT_RED}`} />
<span className={`${DOT} ${DOT_AMBER}`} />
<span className={`${DOT} ${DOT_GREEN}`} />
</div>
<span className="flex h-7 items-center">
<Skeleton className={`h-[11px] w-28 ${BAR}`} />
</span>
</div>
<div className={PANES_HOST}>
<div className={PANES}>
<div className={PANE}>
<TabStripSkeleton widths={PROMPT_TABS} />
<LinesSkeleton widths={PROMPT_LINES} height="h-[440px]" />
</div>
<div className={`${PANE} ${PANE_DIVIDER}`}>
<TabStripSkeleton widths={GENERATED_TABS} />
<LinesSkeleton widths={GENERATED_LINES} height="h-[331px]" />
</div>
</div>
</div>
</div>
)
}
91 changes: 68 additions & 23 deletions apps/site/src/components/napl-example/napl-example.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import {Suspense, lazy, type ReactElement, type ReactNode} from 'react'
import {ClientOnly} from '@tanstack/react-router'
import {LazyMount} from '@/lib/lazy-mount'
import {loadShowcaseModule} from '@/lib/fixtures'
import {NaplExampleSkeleton} from './napl-example-skeleton'
import type {InlineFile} from './napl-example-impl'

const warmed = new Set<string | undefined>()

const warmNow = (moduleName: string | undefined): void => {
void import('./napl-example-impl').catch(() => undefined)
if (moduleName) void loadShowcaseModule(moduleName).catch(() => undefined)
}

const scheduleIdle = (run: () => void): void => {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(run)
return
}
window.setTimeout(run, 200)
}

const warmOnIdle = (moduleName: string | undefined): void => {
if (typeof window === 'undefined') return
if (warmed.has(moduleName)) return
warmed.add(moduleName)
scheduleIdle(() => warmNow(moduleName))
}

const NaplExampleClient = lazy(() =>
import('./napl-example-impl').then((module) => ({default: module.NaplExampleClient})),
)
Expand All @@ -24,50 +48,71 @@ export interface NaplExampleProps {

const toText = (value: string): string => value.replace(/\n$/, '')

const rawSource = (props: NaplExampleProps): string | null => {
if (typeof props.code === 'string') return props.code
if (typeof props.children === 'string') return props.children
return null
}

const resolveFiles = (props: NaplExampleProps): InlineFile[] | undefined => {
if (props.files) return props.files
const raw = typeof props.code === 'string' ? props.code : typeof props.children === 'string' ? props.children : null
const raw = rawSource(props)
if (raw === null) return undefined
return [{name: props.filename ?? 'example.napl', content: toText(raw)}]
}

function ExamplePlaceholder(): ReactElement {
const firstFile = (files: InlineFile[] | undefined): InlineFile | undefined => files?.[0]

const fallbackTitle = (filename: string | undefined, files: InlineFile[] | undefined): string | undefined =>
filename ?? firstFile(files)?.name

const fallbackName = (filename: string | undefined, files: InlineFile[] | undefined): string =>
fallbackTitle(filename, files) ?? 'example.napl'

const exampleSlug = (module: string | undefined, filename: string | undefined): string =>
module ?? filename ?? 'snippet'

interface ExampleFallbackProps {
files: InlineFile[] | undefined
filename: string | undefined
}

function ExampleFallback({files, filename}: ExampleFallbackProps): ReactElement {
const source = firstFile(files)?.content ?? ''
if (!source) return <NaplExampleSkeleton />
return (
<div className="border border-[var(--fd-border)] rounded-[12px] overflow-hidden">
<div className="p-4 text-[13px] text-[var(--fd-muted-foreground)] bg-[var(--fd-card)]">Loading example…</div>
</div>
<ClientOnly fallback={<NaplExampleSkeleton />}>
<Suspense fallback={<NaplExampleSkeleton />}>
<NaplExampleFallback
code={source}
name={fallbackName(filename, files)}
title={fallbackTitle(filename, files)}
/>
</Suspense>
</ClientOnly>
)
}

export function NaplExample(props: NaplExampleProps): ReactElement {
const {module, filename} = props
const inlineFiles = resolveFiles(props)
const fallbackSource = inlineFiles?.[0]?.content ?? ''
const fallbackName = filename ?? inlineFiles?.[0]?.name ?? 'example.napl'

const staticFallback = fallbackSource ? (
<ClientOnly fallback={<ExamplePlaceholder />}>
<Suspense fallback={<ExamplePlaceholder />}>
<NaplExampleFallback code={fallbackSource} name={fallbackName} title={filename ?? inlineFiles?.[0]?.name} />
</Suspense>
</ClientOnly>
) : (
<ExamplePlaceholder />
)
const warmKey = inlineFiles ? undefined : module
const fallback = <ExampleFallback files={inlineFiles} filename={filename} />

return (
<LazyMount
className="block my-5"
dataAttributes={{'data-napl-example': module ?? filename ?? 'snippet'}}
fallback={staticFallback}
dataAttributes={{'data-napl-example': exampleSlug(module, filename)}}
fallback={fallback}
onAttach={() => warmOnIdle(warmKey)}
>
<Suspense fallback={staticFallback}>
<Suspense fallback={fallback}>
<NaplExampleClient
module={module}
files={inlineFiles}
compact={props.compact ?? true}
readOnly={props.readOnly ?? false}
showGen={props.showGen ?? false}
compact={props.compact}
readOnly={props.readOnly}
showGen={props.showGen}
/>
</Suspense>
</LazyMount>
Expand Down
9 changes: 9 additions & 0 deletions apps/site/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {ComponentProps, ReactElement} from 'react'

import {cn} from '@/lib/utils'

function Skeleton({className, ...props}: ComponentProps<'div'>): ReactElement {
return <div data-slot="skeleton" className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}

export {Skeleton}
Loading
Loading