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/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml new file mode 100644 index 00000000..701445b8 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-26 diff --git a/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md new file mode 100644 index 00000000..1ecfd2ec --- /dev/null +++ b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md @@ -0,0 +1,124 @@ +# Design + +## Context + +See proposal.md — Why. Current state that shapes the approach: + +- `apps/docs/app/labs/editor.tsx` (`SilkEditor`) already does everything the element needs on the + main thread, synchronously: CodeMirror with `SilkCodeMirror.extension()` for highlighting, and + direct calls to `LspDocument.diagnostics`, `LspDocument.hover`, and `LspDocument.format` against + an `Analysis.Snapshot`. No worker, no protocol. +- React's real footprint is two things: hook-based lifecycle glue in `SilkEditor`, and + `Hover.tsx`, which renders hover CommonMark via `createRoot` into the CodeMirror tooltip. +- `LspDocument.inlayHints` exists (packages/lsp/src/Document.ts) with no consumer. +- Stdlib sources ship inside `@silk-effect/compiler` (`CompilerStdlib.sources`), so a browser + bundle of the compiler resolves stdlib imports with no fetching. Labs proves in-browser + compilation works today. +- Doctest compiles each fence as one standalone module via `Analysis.ofSourceRealized(identity, + bytes, target)` with default target `wasm32-unknown-unknown`. +- The static site (`packages/documentation-site`) renders fences as escaped `
    ` strings
    +  (Prose.ts) from documentation JSON, where a fence's `language` field carries the full comma-form
    +  token (`silk,ignore`).
    +- `packages/language` depends only on `compiler` and `documentation`; CodeMirror is a peer
    +  dependency there.
    +
    +## Goals / Non-Goals
    +
    +**Goals:**
    +
    +- One framework-free package owning the element; labs and the static site are both consumers.
    +- Behavior parity with doctest: what the element compiles is exactly what doctest verified.
    +- Keep the extraction mechanical — no new semantic engine, no worker, no protocol.
    +
    +**Non-Goals:**
    +
    +- Running snippets (evaluation/output) — display and editing semantics only.
    +- Multi-file or cross-snippet projects; one element is one standalone module.
    +- A worker architecture; revisit only if editable snippets measurably jank.
    +- Replacing the docs app's Shiki path for non-Silk fences.
    +- Publishing/versioning strategy for the element bundle beyond the site's own output.
    +
    +## Decisions
    +
    +**New package `@silk-effect/snippet`, not a `language` subpath.** The element needs
    +`@silk-effect/lsp`; `packages/language` deliberately has no `lsp` dependency and other consumers
    +(TextMate, vscode) should not inherit one. Dependencies: `compiler`, `lsp`, `language`,
    +CodeMirror packages, `mdast-util-from-markdown`. Two deliverables: an ESM library export (custom
    +element class + registration function) and a self-registering IIFE/ESM bundle for script-tag use
    +in generated sites.
    +
    +**Keep CodeMirror for read-only snippets.** Alternative — a hand-rolled span renderer for
    +read-only mode — was rejected: tooltips, squiggle decorations, byte↔UTF-16 translation, and the
    +editable upgrade path all already work in CodeMirror, and read-only is one
    +`EditorState.readOnly.of(true)` facet. Bundle cost is accepted until measured to matter.
    +
    +**Custom element with shadow DOM.** `connectedCallback` reads `textContent` as the source (HTML
    +entity decoding applies; generators escape normally), replaces it with the shadow-rendered editor,
    +and keeps the light-DOM text as the no-JS fallback until upgrade. Observed boolean attributes:
    +`diagnostics`, `hover`, `inlay-hints`, `editable`. Theming via `--silk-snippet-*` custom
    +properties with light/dark defaults keyed off `prefers-color-scheme`; the labs workbench keeps its
    +own dark values by setting the properties.
    +
    +**Compilation is per-element, lazy, main-thread.** Each element with at least one semantic
    +attribute compiles its own content with `Analysis.ofSourceRealized` (doctest's identity scheme and
    +default target) when an `IntersectionObserver` first reports it visible. Highlight-only elements
    +never compile. Editable elements recompile on a debounced document change, replacing the snapshot
    +the semantic providers read — the same shape as labs' snapshot-per-edit flow. No snapshot sharing
    +across elements: snippets are independent modules and correctness beats a cache.
    +
    +**Hover rendering moves from React to DOM construction.** `Hover.tsx`'s mdast walk is rewritten
    +node-for-node using `document.createElement`, preserving `safeLink` (http/https/mailto only) and
    +the highlighted rendering of nested `silk` code via `SilkCodeMirror.highlightRanges`. This DOM
    +renderer lives in the snippet package; labs deletes `Hover.tsx` and uses the element.
    +
    +**Inlay hints via CodeMirror widget decorations.** `LspDocument.inlayHints` results become inline
    +widget decorations (not document text), recomputed with the snapshot. This is the only genuinely
    +new feature code in the change.
    +
    +**Labs re-points, workbench API preserved.** `SilkEditor` keeps its current props signature but
    +delegates to the element (or directly to the extracted mounting API), keeping `workbench.tsx`
    +untouched. The span-cursor field and URL/format wiring stay in labs — they are workbench concerns,
    +exposed by the element as a small imperative surface (set span highlight, format, get/set value)
    +on the element instance.
    +
    +**Site emission via fence attributes.** Prose.ts branches on the parsed fence language token using
    +doctest's `Example.parseLanguage` convention: `silk` → element with `diagnostics hover`;
    +`silk,ignore` → element with no semantic attributes; anything else → existing `
    `. The
    +site build copies the element bundle into the output and references it with a relative `
    -    
    +    ${
    +      snippetScript ? `\n    ` : ''
    +    }
       
     
     `
    @@ -243,6 +267,7 @@ ${
         ? '    

    This module declares nothing.

    ' : module.items.map((item) => declaration(module, item, layout, links)).join('\n') }`, + options.snippetBundle !== undefined, ) return Html.file(path, contents) } @@ -277,6 +302,7 @@ const indexPage = (
      ${rows}
    `, + options.snippetBundle !== undefined, ), ) } @@ -296,6 +322,9 @@ export const render = (documentation: Model.Documentation, options: Options = {} ...documentation.modules.map((module) => modulePage(module, layout, links, options)), Html.file('style.css', style), Search.indexFile(entries), + ...(options.snippetBundle === undefined + ? [] + : [Html.file(snippetScriptPath, options.snippetBundle)]), ]), }) } diff --git a/packages/documentation-site/test/Cli.test.ts b/packages/documentation-site/test/Cli.test.ts index d49471bd..a9f24cfe 100644 --- a/packages/documentation-site/test/Cli.test.ts +++ b/packages/documentation-site/test/Cli.test.ts @@ -67,11 +67,22 @@ it.effect('writes a site from a documentation JSON file', () => 'index.html', 'project-main.html', 'search-index.js', + 'silk-snippet.js', 'style.css', ]) const page = readFileSync(join(output, 'project-main.html'), 'utf8') assert.include(page, 'Adds two values.') assert.include(page, 'pub fn add(left: i32, right: i32) -> i32') + assert.include( + page, + '', + 'every page loads the element script relatively', + ) + assert.include( + readFileSync(join(output, 'silk-snippet.js'), 'utf8'), + 'silk-snippet', + 'the shipped bundle registers the element', + ) assert.include(readFileSync(join(output, 'search-index.js'), 'utf8'), '"name":"add"') } finally { rmSync(root, { recursive: true, force: true }) diff --git a/packages/documentation-site/test/Prose.test.ts b/packages/documentation-site/test/Prose.test.ts index 696a4781..3f74df98 100644 --- a/packages/documentation-site/test/Prose.test.ts +++ b/packages/documentation-site/test/Prose.test.ts @@ -24,7 +24,7 @@ it('escapes a code block and its language', () => { const rendered = Prose.document([ { _tag: 'CodeBlock', - language: 'silk', + language: 'ebnf', value: 'let a = b < c && d > e', example: false, source, @@ -32,10 +32,39 @@ it('escapes a code block and its language', () => { ]) assert.strictEqual( rendered, - '
    let a = b < c && d > e
    ', + '
    let a = b < c && d > e
    ', + ) +}) + +it('renders a silk fence as a live snippet element with diagnostics and hover', () => { + const rendered = Prose.document([ + { + _tag: 'CodeBlock', + language: 'silk', + value: 'pub fn main() -> i32 { return a < b }', + example: true, + source, + }, + ]) + assert.strictEqual( + rendered, + '\npub fn main() -> i32 { return a < b }', ) }) +it('renders an ignore fence as a highlight-only snippet element', () => { + const rendered = Prose.document([ + { + _tag: 'CodeBlock', + language: 'silk,ignore', + value: 'fn incomplete(', + example: false, + source, + }, + ]) + assert.strictEqual(rendered, '\nfn incomplete(') +}) + it('renders the block kinds the emitter writes', () => { assert.strictEqual( Prose.document([ diff --git a/packages/documentation-site/test/Site.test.ts b/packages/documentation-site/test/Site.test.ts index f0a92869..a90e65f3 100644 --- a/packages/documentation-site/test/Site.test.ts +++ b/packages/documentation-site/test/Site.test.ts @@ -28,7 +28,7 @@ const sourceFiles = (directory: string): ReadonlyArray => * dependencies are exempt on purpose: the tests generate real documentation JSON to render, and * that generation never ships. */ -it('declares no workspace runtime dependency', () => { +it('declares no workspace runtime dependency beyond the snippet asset', () => { const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) assert.isTrue(typeof manifest === 'object' && manifest !== null) const dependencies = @@ -36,15 +36,22 @@ it('declares no workspace runtime dependency', () => { ? Object.keys(manifest.dependencies as Record) : [] assert.isAbove(dependencies.length, 0, 'the renderer is expected to have runtime dependencies') + // `@silk-effect/snippet` is the one deliberate exception: the command shell ships its prebuilt + // element bundle with generated sites as opaque file contents. No renderer module imports it, + // which the source scan below still enforces, so the renderer keeps reading the documentation + // JSON and nothing else. assert.deepStrictEqual( dependencies.filter((name) => name.startsWith('@silk-effect/')), - [], + ['@silk-effect/snippet'], 'the renderer must read the documentation JSON and nothing else', ) }) it('imports no compiler or workspace module from its own sources', () => { + // The command shell (Cli.ts) resolves the snippet element bundle's file path; every renderer + // module stays free of workspace imports so no compiler type can reach the rendering. const offenders = sourceFiles(join(packageRoot, 'src')) + .filter((file) => !file.endsWith('/Cli.ts')) .filter((file) => readFileSync(file, 'utf8').includes('@silk-effect/')) .map((file) => file.slice(packageRoot.length)) assert.deepStrictEqual(offenders, []) @@ -104,6 +111,34 @@ it.effect( 240_000, ) +it('ships the snippet element script and references it relatively when supplied', () => { + const decoded = Model.decode({ + schema: Model.schemaName, + experimental: true, + modules: [{ name: 'project/main', sourceId: 'project/main', items: [] }], + }) + assert.strictEqual(decoded._tag, 'Decoded') + if (decoded._tag !== 'Decoded') return + const bare = Site.render(decoded.documentation) + assert.notInclude( + bare.files.map((file) => file.path), + 'silk-snippet.js', + 'no bundle supplied, none shipped', + ) + assert.notInclude( + bare.files.find((file) => file.path === 'index.html')?.contents ?? '', + 'silk-snippet.js', + ) + const site = Site.render(decoded.documentation, { snippetBundle: 'registered()' }) + const shipped = site.files.find((file) => file.path === 'silk-snippet.js') + assert.strictEqual(shipped?.contents, 'registered()') + assert.include( + site.files.find((file) => file.path === 'index.html')?.contents ?? '', + '', + 'pages reference the shipped script relatively', + ) +}) + it.effect( 'renders the same bytes twice', () => diff --git a/packages/snippet/LICENSE b/packages/snippet/LICENSE new file mode 100644 index 00000000..c72823da --- /dev/null +++ b/packages/snippet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Julia Ortiz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/snippet/package.json b/packages/snippet/package.json new file mode 100644 index 00000000..235a58d4 --- /dev/null +++ b/packages/snippet/package.json @@ -0,0 +1,82 @@ +{ + "name": "@silk-effect/snippet", + "version": "0.0.0", + "description": "Embeddable framework-free custom element: compiler-driven highlighting with opt-in diagnostics, hover, and inlay hints.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/julia-script/silk-effect.git", + "directory": "packages/snippet" + }, + "homepage": "https://github.com/julia-script/silk-effect#readme", + "bugs": { + "url": "https://github.com/julia-script/silk-effect/issues" + }, + "keywords": [ + "silk", + "custom-element", + "web-component", + "syntax-highlighting", + "diagnostics" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./Element": { + "types": "./dist/Element.d.ts", + "import": "./dist/Element.js" + }, + "./Editor": { + "types": "./dist/Editor.d.ts", + "import": "./dist/Editor.js" + }, + "./HoverContent": { + "types": "./dist/HoverContent.d.ts", + "import": "./dist/HoverContent.js" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js" + }, + "./bundle": "./dist/silk-snippet.bundle.js" + }, + "scripts": { + "build": "pnpm clean && tsc -p tsconfig.json && node scripts/bundle.mjs", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json" + }, + "dependencies": { + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.1", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "@lezer/highlight": "^1.2.3", + "@silk-effect/compiler": "workspace:*", + "@silk-effect/language": "workspace:*", + "@silk-effect/lsp": "workspace:*", + "effect": "catalog:", + "mdast-util-from-markdown": "^2.0.3", + "vscode-languageserver-types": "3.17.5" + }, + "devDependencies": { + "@types/mdast": "^4.0.4", + "@types/node": "^24.10.1", + "esbuild": "^0.28.2", + "jsdom": "^30.0.1" + } +} diff --git a/packages/snippet/scripts/bundle.mjs b/packages/snippet/scripts/bundle.mjs new file mode 100644 index 00000000..74ed9c50 --- /dev/null +++ b/packages/snippet/scripts/bundle.mjs @@ -0,0 +1,25 @@ +/** + * Builds the self-registering browser bundle beside the library output. + * + * The bundle exists for generated documentation sites: one relative `