From 5b8e5f3b16423d9262e53c95e5cac159da49c15c Mon Sep 17 00:00:00 2001 From: Julia Ortiz Date: Wed, 26 Aug 2026 22:51:44 -0300 Subject: [PATCH 1/3] feat(snippet): extract embeddable element from labs New framework-free @silk-effect/snippet package: CodeMirror-based editor core with compiler-driven highlighting and opt-in language-server semantics (diagnostics, hover, inlay hints) compiled lazily in the browser per snippet, exactly as doctest verifies them. The labs workbench editor becomes a thin React wrapper over the shared core; Hover.tsx's React renderer is replaced by a DOM renderer in the package. The static documentation site emits for silk fences (silk,ignore degrades to highlight-only) and ships the self-registering element bundle with generated pages, keeping the renderer's no-workspace-imports boundary: only the CLI shell resolves the bundle, as opaque file contents. --- apps/docs/app/labs/Hover.test.tsx | 31 - apps/docs/app/labs/Hover.tsx | 127 ---- apps/docs/app/labs/editor.tsx | 342 +-------- apps/docs/app/labs/workbench.module.css | 14 + apps/docs/package.json | 1 + packages/documentation-site/package.json | 1 + packages/documentation-site/src/Cli.ts | 20 + packages/documentation-site/src/Prose.ts | 19 + packages/documentation-site/src/Site.ts | 33 +- packages/documentation-site/test/Cli.test.ts | 11 + .../documentation-site/test/Prose.test.ts | 33 +- packages/documentation-site/test/Site.test.ts | 39 +- packages/snippet/LICENSE | 21 + packages/snippet/package.json | 82 +++ packages/snippet/scripts/bundle.mjs | 24 + packages/snippet/src/Editor.ts | 438 +++++++++++ packages/snippet/src/Element.ts | 219 ++++++ packages/snippet/src/HoverContent.ts | 137 ++++ packages/snippet/src/index.ts | 3 + packages/snippet/src/register.ts | 4 + packages/snippet/test/Element.test.ts | 141 ++++ packages/snippet/test/HoverContent.test.ts | 40 + packages/snippet/tsconfig.json | 10 + packages/snippet/tsconfig.test.json | 9 + packages/snippet/vitest.config.ts | 8 + pnpm-lock.yaml | 686 +++++++++++++++++- 26 files changed, 2006 insertions(+), 487 deletions(-) delete mode 100644 apps/docs/app/labs/Hover.test.tsx delete mode 100644 apps/docs/app/labs/Hover.tsx create mode 100644 packages/snippet/LICENSE create mode 100644 packages/snippet/package.json create mode 100644 packages/snippet/scripts/bundle.mjs create mode 100644 packages/snippet/src/Editor.ts create mode 100644 packages/snippet/src/Element.ts create mode 100644 packages/snippet/src/HoverContent.ts create mode 100644 packages/snippet/src/index.ts create mode 100644 packages/snippet/src/register.ts create mode 100644 packages/snippet/test/Element.test.ts create mode 100644 packages/snippet/test/HoverContent.test.ts create mode 100644 packages/snippet/tsconfig.json create mode 100644 packages/snippet/tsconfig.test.json create mode 100644 packages/snippet/vitest.config.ts diff --git a/apps/docs/app/labs/Hover.test.tsx b/apps/docs/app/labs/Hover.test.tsx deleted file mode 100644 index 85f173f0..00000000 --- a/apps/docs/app/labs/Hover.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import * as Hover from './Hover' - -describe('Content', () => { - it('renders a documented Silk hover as highlighted code followed by Markdown prose', () => { - const markup = renderToStaticMarkup( - , - ) - - expect(markup).not.toContain('```') - expect(markup).toContain('Allocates owned storage') - }) - - it('renders safe links and does not make executable Markdown links interactive', () => { - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('href="https://example.com"') - expect(markup).not.toContain('href="javascript:') - }) -}) diff --git a/apps/docs/app/labs/Hover.tsx b/apps/docs/app/labs/Hover.tsx deleted file mode 100644 index cfbc165c..00000000 --- a/apps/docs/app/labs/Hover.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import * as SilkCodeMirror from '@silk-effect/language/CodeMirror' -import type { BlockContent, PhrasingContent, RootContent } from 'mdast' -import { fromMarkdown } from 'mdast-util-from-markdown' -import { createElement, Fragment, type ReactNode } from 'react' - -const safeLink = (destination: string): string | undefined => { - const normalized = destination.trim() - if (normalized.startsWith('//')) return undefined - const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalized)?.[1]?.toLowerCase() - return scheme === undefined || scheme === 'http' || scheme === 'https' || scheme === 'mailto' - ? destination - : undefined -} - -const inlines = (children: ReadonlyArray): ReadonlyArray => - children.map((child, index) => inline(child, index)) - -const inline = (node: PhrasingContent, key: number): ReactNode => { - switch (node.type) { - case 'text': - return {node.value} - case 'inlineCode': - return {node.value} - case 'emphasis': - return {inlines(node.children)} - case 'strong': - return {inlines(node.children)} - case 'link': { - const href = safeLink(node.url) - return href === undefined ? ( - {inlines(node.children)} - ) : ( - - {inlines(node.children)} - - ) - } - case 'linkReference': - return {inlines(node.children)} - case 'break': - return
- case 'image': { - const href = safeLink(node.url) - return href === undefined ? ( - {node.alt} - ) : ( - - {node.alt} - - ) - } - case 'imageReference': - return {node.alt} - case 'html': - return {node.value} - default: - return null - } -} - -const silk = (value: string): ReadonlyArray => { - const result: Array = [] - let offset = 0 - for (const range of SilkCodeMirror.highlightRanges(value)) { - if (range.from < offset || range.to <= range.from) continue - if (range.from > offset) result.push(value.slice(offset, range.from)) - result.push( - - {value.slice(range.from, range.to)} - , - ) - offset = range.to - } - if (offset < value.length) result.push(value.slice(offset)) - return result -} - -const block = (node: RootContent | BlockContent, key: number): ReactNode => { - switch (node.type) { - case 'paragraph': - return

{inlines(node.children)}

- case 'heading': - return createElement(`h${node.depth}`, { key }, inlines(node.children)) - case 'code': - return ( -
-          {node.lang?.toLowerCase() === 'silk' ? silk(node.value) : node.value}
-        
- ) - case 'blockquote': - return ( -
- {node.children.map((child, index) => block(child, index))} -
- ) - case 'list': { - const List = node.ordered ? 'ol' : 'ul' - return ( - - {node.children.map((item, itemIndex) => ( -
  • - {item.children.map((child, childIndex) => block(child, childIndex))} -
  • - ))} -
    - ) - } - case 'thematicBreak': - return
    - case 'html': - return

    {node.value}

    - case 'definition': - return null - default: - return null - } -} - -/** Safely renders the CommonMark payload supplied by one language-server hover. */ -export function Content(props: { readonly markdown: string }) { - const root = fromMarkdown(props.markdown) - return ( -
    - {root.children.map((child, index) => block(child, index))} -
    - ) -} diff --git a/apps/docs/app/labs/editor.tsx b/apps/docs/app/labs/editor.tsx index 4904485e..afe10121 100644 --- a/apps/docs/app/labs/editor.tsx +++ b/apps/docs/app/labs/editor.tsx @@ -1,138 +1,20 @@ 'use client' /** - * The workbench source editor: CodeMirror with lexer-driven Silk highlighting. + * The workbench source editor: a thin React wrapper over the shared snippet editor core. * - * The compiler's spans are byte-addressed while CodeMirror's are UTF-16, so selections are - * translated before they reach the shared span cursor. Token colors come from the stable - * `cm-silk-*` classes styled in workbench.module.css — the workbench is always dark, so it does - * not use the extension's light default highlight style. + * The core owns CodeMirror, highlighting, diagnostics, and hover; this wrapper owns everything + * workbench-shaped — the shared span cursor, the format command handle, and the snapshot the whole + * workbench shares. Colors ride the `--silk-snippet-*` custom properties mapped to workbench + * variables in workbench.module.css. */ -import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' -import { setDiagnostics } from '@codemirror/lint' -import { Annotation, EditorState, StateEffect, StateField } from '@codemirror/state' -import type { DecorationSet } from '@codemirror/view' -import { Decoration, EditorView, hoverTooltip, keymap } from '@codemirror/view' import * as Analysis from '@silk-effect/compiler/Analysis' -import * as SilkCodeMirror from '@silk-effect/language/CodeMirror' -import * as LspDocument from '@silk-effect/lsp/Document' -import * as Effect from 'effect/Effect' -import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' -import { createRoot } from 'react-dom/client' +import * as SnippetEditor from '@silk-effect/snippet/Editor' +import { type MutableRefObject, useEffect, useRef } from 'react' import type { Span } from '@silk-effect/inspector' -import * as Hover from './Hover' const encoder = new TextEncoder() -const decoder = new TextDecoder() - -/** One language-server view of the active module, rebuilt with the shared snapshot. */ -interface LspSession { - readonly document: LspDocument.Document - readonly snapshot: Analysis.Snapshot - readonly source: string -} - -/** Translates one protocol position into a clamped CodeMirror character offset. */ -const lspOffset = ( - state: EditorState, - position: { readonly line: number; readonly character: number }, -): number => { - const line = state.doc.line(Math.min(position.line + 1, state.doc.lines)) - return Math.min(line.from + position.character, line.to) -} - -/** Marks transactions that reconcile external state, so the update listener does not echo them. */ -const External = Annotation.define() - -/** - * The shared span cursor drawn into the editor, so a row click in any pane lights up the same - * bytes here — the editor is a phase like any other, in both directions. - */ -const setSpanCursor = StateEffect.define<{ readonly from: number; readonly to: number } | null>() - -const spanCursorMark = Decoration.mark({ class: 'cm-silk-span-cursor' }) - -const spanCursorField = StateField.define({ - create: () => Decoration.none, - update: (value, transaction) => { - let next = value.map(transaction.changes) - for (const effect of transaction.effects) { - if (effect.is(setSpanCursor)) { - next = - effect.value === null - ? Decoration.none - : Decoration.set([spanCursorMark.range(effect.value.from, effect.value.to)]) - } - } - return next - }, - provide: (self) => EditorView.decorations.from(self), -}) - -const theme = EditorView.theme({ - '&': { height: '100%', fontSize: '11.5px', backgroundColor: 'transparent' }, - '.cm-scroller': { fontFamily: 'var(--wb-font)', lineHeight: '19px' }, - '.cm-content': { padding: '5px 0 5px 8px', caretColor: 'var(--wb-ink)' }, - '&.cm-focused': { outline: 'none' }, - '.cm-cursor': { borderLeftColor: 'var(--wb-ink)' }, - '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { - backgroundColor: 'rgba(198, 166, 120, 0.22)', - }, - '.cm-lintRange-error': { - backgroundImage: 'none', - textDecoration: 'underline wavy var(--wb-error) 1px', - textUnderlineOffset: '3px', - }, - '.cm-tooltip': { - backgroundColor: 'var(--wb-bg-pane)', - border: '1px solid var(--wb-hairline-strong)', - color: 'var(--wb-ink-2)', - fontFamily: 'var(--wb-font)', - fontSize: '11px', - }, - '.cm-tooltip.cm-tooltip-hover': { padding: '3px 7px' }, - '.cm-tooltip-lint': { padding: '0' }, - '.cm-diagnostic': { borderLeft: 'none', padding: '3px 7px' }, - '.cm-diagnostic-error': { borderLeft: '2px solid var(--wb-error)' }, - '.cm-silk-type-tooltip': { - boxSizing: 'border-box', - maxWidth: 'min(560px, calc(100vw - 24px))', - lineHeight: '1.45', - }, - '.cm-silk-type-tooltip > *': { margin: '0' }, - '.cm-silk-type-tooltip > * + *': { marginTop: '8px' }, - '.cm-silk-type-tooltip pre': { - overflowX: 'auto', - padding: '2px 0 5px', - borderBottom: '1px solid var(--wb-hairline-strong)', - color: 'var(--wb-ink)', - whiteSpace: 'pre', - }, - '.cm-silk-type-tooltip :not(pre) > code': { - padding: '1px 3px', - borderRadius: '2px', - backgroundColor: 'var(--wb-active-strong)', - color: 'var(--wb-ink)', - }, - '.cm-silk-type-tooltip h1, .cm-silk-type-tooltip h2, .cm-silk-type-tooltip h3, .cm-silk-type-tooltip h4, .cm-silk-type-tooltip h5, .cm-silk-type-tooltip h6': { - color: 'var(--wb-ink)', - fontSize: 'inherit', - fontWeight: '600', - }, - '.cm-silk-type-tooltip ul, .cm-silk-type-tooltip ol': { - marginBottom: '0', - paddingLeft: '20px', - }, - '.cm-silk-type-tooltip li + li': { marginTop: '3px' }, - '.cm-silk-type-tooltip blockquote': { - paddingLeft: '8px', - borderLeft: '2px solid var(--wb-hairline-strong)', - color: 'var(--wb-ink-3)', - }, - '.cm-silk-type-tooltip a': { color: 'var(--wb-violet)', textDecoration: 'underline' }, - '.cm-silk-type-tooltip hr': { border: '0', borderTop: '1px solid var(--wb-hairline)' }, -}) export function SilkEditor(props: { readonly value: string @@ -147,202 +29,58 @@ export function SilkEditor(props: { readonly className?: string }) { const containerRef = useRef(null) - const viewRef = useRef(null) + const handleRef = useRef(null) const initialRef = useRef(props.value) const callbacksRef = useRef({ onChange: props.onChange, onSelect: props.onSelect }) callbacksRef.current = { onChange: props.onChange, onSelect: props.onSelect } - // The language-server session mirrors the snapshot the whole workbench shares. - const session = useMemo( - () => { - const source = Analysis.sources(props.snapshot).get(props.module) - const bytes = - source === undefined ? encoder.encode(props.value) : Uint8Array.from(source.bytes) - return { - document: LspDocument.make({ - uri: props.module, - version: 0, - workspace: `labs:${props.module}`, - module: props.module, - sourceRoot: '/', - // During a typing burst, the editor value is ahead of analysis. Keep LSP document bytes - // paired with their snapshot; the fallback covers a newly added module until it settles. - bytes, - }), - snapshot: props.snapshot, - source: decoder.decode(bytes), - } - }, - [props.module, props.snapshot], - ) - const sessionRef = useRef(session) - sessionRef.current = session - - // Canonical formatting through the language server's Document actor. Reads only refs, so the - // one instance created on mount stays valid for the editor's whole life. - const formatRef = useRef<() => boolean>(() => false) - formatRef.current = () => { - const view = viewRef.current - if (view === null) return false - const { document: lspDocument, snapshot } = sessionRef.current - if (view.state.doc.toString() !== sessionRef.current.source) return false - const edit = Effect.runSync(LspDocument.format(lspDocument, snapshot))[0] - if (edit === undefined) return false - view.dispatch({ - changes: { - from: lspOffset(view.state, edit.range.start), - to: lspOffset(view.state, edit.range.end), - insert: edit.newText, - }, - }) - return true - } - if (props.formatRef !== undefined) props.formatRef.current = () => formatRef.current() - useEffect(() => { const container = containerRef.current if (container === null) return - const listener = EditorView.updateListener.of((update) => { - const external = update.transactions.some( - (transaction) => transaction.annotation(External) === true, - ) - if (external) return - if (update.docChanged) callbacksRef.current.onChange(update.state.doc.toString()) - if (update.selectionSet) { - const range = update.state.selection.main - if (range.empty) return - // Selecting text moves the same span cursor a row click moves, translated to bytes. - const doc = update.state.doc.toString() - callbacksRef.current.onSelect({ - start: SilkCodeMirror.charOffsetToByteOffset(doc, range.from), - end: SilkCodeMirror.charOffsetToByteOffset(doc, range.to), - }) - } - }) - // Hover asks the language server's Document actor for its structured compiler presentation. - const typeHover = hoverTooltip((view, position) => { - const { document: lspDocument, snapshot } = sessionRef.current - if (view.state.doc.toString() !== sessionRef.current.source) return null - const line = view.state.doc.lineAt(position) - const hover = LspDocument.hover(lspDocument, snapshot, { - line: line.number - 1, - character: position - line.from, - }) - if ( - hover?.range === undefined || - typeof hover.contents !== 'object' || - !('value' in hover.contents) - ) - return null - const hoverText = hover.contents.value - return { - pos: lspOffset(view.state, hover.range.start), - end: lspOffset(view.state, hover.range.end), - above: true, - create: () => { - const dom = document.createElement('div') - const root = createRoot(dom) - root.render() - return { dom, destroy: () => root.unmount() } - }, - } - }) - const view = new EditorView({ + const handle = SnippetEditor.mount({ parent: container, - state: EditorState.create({ - doc: initialRef.current, - extensions: [ - history(), - keymap.of([ - { key: 'Shift-Alt-f', run: () => formatRef.current() }, - // Format on save: there is nothing to save, but the muscle memory is universal. - // Always handled, so the browser's save dialog never opens over the workbench. - { - key: 'Mod-s', - run: () => { - formatRef.current() - return true - }, - }, - ...defaultKeymap, - ...historyKeymap, - ]), - SilkCodeMirror.extension(), - spanCursorField, - typeHover, - theme, - EditorView.contentAttributes.of({ 'aria-label': 'Silk source code' }), - listener, - ], - }), + doc: initialRef.current, + editable: true, + features: { diagnostics: true, hover: true }, + onChange: (value) => callbacksRef.current.onChange(value), + onSelect: (range) => callbacksRef.current.onSelect(range), }) - viewRef.current = view + handleRef.current = handle return () => { - viewRef.current = null - view.destroy() + handleRef.current = null + handle.destroy() } }, []) + if (props.formatRef !== undefined) + props.formatRef.current = () => handleRef.current?.format() ?? false + // Module switches and preset/URL loads replace the document wholesale. useEffect(() => { - const view = viewRef.current - if (view === null) return - const current = view.state.doc.toString() - if (current !== props.value) { - view.dispatch({ - changes: { from: 0, to: current.length, insert: props.value }, - annotations: External.of(true), - }) - } + handleRef.current?.setValue(props.value) }, [props.value]) - // Diagnostics ride the shared snapshot: every edit re-analyzes, every analysis re-lints. - // Runs after the sync effect above, so the view's doc always matches the linted value. + // The language-server session mirrors the snapshot the whole workbench shares. During a typing + // burst the editor value is ahead of analysis; the core goes quiet until the snapshot catches up. useEffect(() => { - const view = viewRef.current - if (view === null || view.state.doc.toString() !== session.source) return - const diagnostics = LspDocument.diagnostics(session.document, session.snapshot, () => undefined) - view.dispatch( - setDiagnostics( - view.state, - diagnostics.map((diagnostic) => ({ - from: lspOffset(view.state, diagnostic.range.start), - to: lspOffset(view.state, diagnostic.range.end), - severity: 'error' as const, - message: - typeof diagnostic.code === 'string' - ? `${diagnostic.code}: ${diagnostic.message}` - : diagnostic.message, - })), - ), - ) - }, [session]) - - // Reflect the shared span cursor, scrolling to it only when it came from another pane. A cursor - // in a different module draws nothing here — its offsets belong to another file's bytes. - const cursor = props.cursor !== undefined && props.cursor.module === props.module - ? props.cursor - : undefined + const handle = handleRef.current + if (handle === null) return + const source = Analysis.sources(props.snapshot).get(props.module) + const bytes = + source === undefined ? encoder.encode(props.value) : Uint8Array.from(source.bytes) + handle.setSession(SnippetEditor.session(props.module, bytes, props.snapshot)) + // props.value is deliberately not a dependency: sessions pair bytes with their snapshot. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.module, props.snapshot]) + + // Reflect the shared span cursor. A cursor in a different module draws nothing here — its + // offsets belong to another file's bytes. + const cursor = + props.cursor !== undefined && props.cursor.module === props.module ? props.cursor : undefined useEffect(() => { - const view = viewRef.current - if (view === null) return - if (cursor === undefined) { - view.dispatch({ effects: setSpanCursor.of(null) }) - return - } - const doc = view.state.doc.toString() - const from = SilkCodeMirror.byteOffsetToCharOffset(doc, cursor.start) - const to = SilkCodeMirror.byteOffsetToCharOffset(doc, cursor.end) - if (to <= from) { - view.dispatch({ effects: setSpanCursor.of(null) }) - return - } - const selection = view.state.selection.main - const effects: Array> = [setSpanCursor.of({ from, to })] - if (selection.from !== from || selection.to !== to) { - effects.push(EditorView.scrollIntoView(from)) - } - view.dispatch({ effects }) + handleRef.current?.setSpanHighlight( + cursor === undefined ? null : { start: cursor.start, end: cursor.end }, + ) }, [cursor]) return
    diff --git a/apps/docs/app/labs/workbench.module.css b/apps/docs/app/labs/workbench.module.css index 5a9fc4dc..ba516164 100644 --- a/apps/docs/app/labs/workbench.module.css +++ b/apps/docs/app/labs/workbench.module.css @@ -662,6 +662,20 @@ overflow: hidden; color: var(--wb-ink); tab-size: 2; + /* The shared snippet editor core reads --silk-snippet-* properties; the workbench is always + dark, so its own palette is mapped onto them here. */ + --silk-snippet-font: var(--wb-font); + --silk-snippet-font-size: 11.5px; + --silk-snippet-line-height: 19px; + --silk-snippet-padding: 5px 0 5px 8px; + --silk-snippet-ink: var(--wb-ink); + --silk-snippet-ink-muted: var(--wb-ink-2); + --silk-snippet-selection: rgba(198, 166, 120, 0.22); + --silk-snippet-error: var(--wb-error); + --silk-snippet-tooltip-bg: var(--wb-bg-pane); + --silk-snippet-border: var(--wb-hairline-strong); + --silk-snippet-code-bg: var(--wb-active-strong); + --silk-snippet-accent: var(--wb-violet); } .editor :global(.cm-editor) { diff --git a/apps/docs/package.json b/apps/docs/package.json index 2ac123af..f638455f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -25,6 +25,7 @@ "@silk-effect/language": "workspace:*", "@silk-effect/lsp": "workspace:*", "@silk-effect/platform-webcontainer": "workspace:*", + "@silk-effect/snippet": "workspace:*", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dockview": "^7.0.4", diff --git a/packages/documentation-site/package.json b/packages/documentation-site/package.json index 4ca6ea7e..b7c0a3f2 100644 --- a/packages/documentation-site/package.json +++ b/packages/documentation-site/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.103", + "@silk-effect/snippet": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/documentation-site/src/Cli.ts b/packages/documentation-site/src/Cli.ts index d95635eb..4bb9c4c6 100644 --- a/packages/documentation-site/src/Cli.ts +++ b/packages/documentation-site/src/Cli.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url' import * as Console from 'effect/Console' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' @@ -57,8 +58,27 @@ export const run = Effect.fn('Cli.run')(function* ( return 2 } + // The `` element script ships with every generated site so Silk fences come + // alive when served statically. It is resolved here in the command shell, never in the + // renderer: the renderer reads the documentation JSON and nothing else, and the bundle crosses + // this boundary as opaque file contents. + const bundle = yield* Effect.result( + Effect.flatMap( + Effect.try({ + try: () => fileURLToPath(import.meta.resolve('@silk-effect/snippet/bundle')), + catch: (cause) => cause, + }), + (bundlePath) => fileSystem.readFileString(bundlePath), + ), + ) + if (Result.isFailure(bundle)) { + yield* Console.error('Cannot load the silk-snippet element bundle from @silk-effect/snippet') + return 2 + } + const site = Site.render(decoded.documentation, { ...(options.title === undefined ? {} : { title: options.title }), + snippetBundle: bundle.success, }) const written = yield* Effect.result( Effect.gen(function* () { diff --git a/packages/documentation-site/src/Prose.ts b/packages/documentation-site/src/Prose.ts index e7e7a27b..4bc44ca2 100644 --- a/packages/documentation-site/src/Prose.ts +++ b/packages/documentation-site/src/Prose.ts @@ -45,6 +45,23 @@ export const inline = (nodes: ReadonlyArray, links: Links = noLink const blockSequence = (nodes: ReadonlyArray, links: Links): string => nodes.map((node) => block(node, links)).join('') +/** + * Renders one Silk fence as the live snippet element. + * + * The fence's language token carries its comma-delimited attributes exactly as authored + * (`silk,ignore`). A plain `silk` fence gets diagnostics and hover; a fence with any attribute — + * `ignore` foremost — degrades to a highlight-only element whose content is never compiled in the + * reader's browser. The leading newline is the element's authoring convenience and is trimmed on + * upgrade; without JavaScript the element shows its text as-is. + */ +const silkSnippet = (language: string, value: string): string | undefined => { + const parts = language.split(',').map((part) => part.trim()) + if (parts.at(0)?.toLocaleLowerCase() !== 'silk') return undefined + const attributes = parts.slice(1).filter((part) => part !== '') + const flags = attributes.length === 0 ? ' diagnostics hover' : '' + return `\n${Html.escapeText(value)}` +} + /** Renders one validated block node. */ export const block = (node: Model.Block, links: Links = noLinks): string => { switch (node._tag) { @@ -57,6 +74,8 @@ export const block = (node: Model.Block, links: Links = noLinks): string => { return `${inline(node.children, links)}` } case 'CodeBlock': { + const snippet = node.language === undefined ? undefined : silkSnippet(node.language, node.value) + if (snippet !== undefined) return snippet const attribute = node.language === undefined ? '' diff --git a/packages/documentation-site/src/Site.ts b/packages/documentation-site/src/Site.ts index d7a71dd9..d898cca1 100644 --- a/packages/documentation-site/src/Site.ts +++ b/packages/documentation-site/src/Site.ts @@ -19,6 +19,12 @@ export interface Site { export interface Options { /** Shown in the page title and the index heading. */ readonly title?: string + /** + * Contents of the self-registering `` element script. When present it is shipped + * as `silk-snippet.js` and every page loads it relatively, so Silk fences come alive; rendering + * stays a pure function of its inputs either way. + */ + readonly snippetBundle?: string } export const defaultTitle = 'Silk documentation' @@ -167,6 +173,20 @@ header nav { color: var(--muted); font-size: 0.9rem; } .summary { color: var(--muted); } .modules { list-style: none; padding: 0; } .modules li { border-top: 1px solid var(--line); padding: 0.6rem 0; } +/* A snippet element that never upgrades — no JavaScript, or no element script — still reads as a + code block. Once defined, its shadow styles take over and this fallback withdraws. */ +silk-snippet:not(:defined) { + display: block; + background: var(--code); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.75rem 1rem; + overflow-x: auto; + white-space: pre; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; +} +silk-snippet { margin: 1em 0; } ` const searchWidget = `