@abide/abide 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +293 -219
- package/CHANGELOG.md +46 -0
- package/README.md +191 -67
- package/package.json +2 -2
- package/src/abideLsp.ts +8 -4
- package/src/abideResolverPlugin.ts +11 -1
- package/src/build.ts +9 -0
- package/src/buildDisconnected.ts +2 -0
- package/src/devEntry.ts +1 -0
- package/src/lib/bundle/probeAbideServer.ts +1 -1
- package/src/lib/cli/parseArgvForRpc.ts +4 -2
- package/src/lib/cli/types/CliManifest.ts +6 -4
- package/src/lib/mcp/annotationsForMethod.ts +1 -1
- package/src/lib/mcp/toolResultFromResponse.ts +1 -1
- package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
- package/src/lib/server/AppModule.ts +1 -1
- package/src/lib/server/cli/handleCliDownload.ts +7 -5
- package/src/lib/server/cli/installScript.ts +3 -3
- package/src/lib/server/prompts/promptRegistry.ts +1 -1
- package/src/lib/server/prompts/types/Prompt.ts +2 -1
- package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
- package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
- package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
- package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
- package/src/lib/server/runtime/createServer.ts +21 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
- package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
- package/src/lib/server/runtime/crossOriginGate.ts +1 -1
- package/src/lib/server/runtime/devHotModuleResponse.ts +2 -2
- package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
- package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
- package/src/lib/server/runtime/streamFromIterator.ts +2 -2
- package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
- package/src/lib/server/socket.ts +1 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
- package/src/lib/server/sockets/socketRegistry.ts +2 -2
- package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
- package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
- package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
- package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
- package/src/lib/shared/bodyValueForKind.ts +35 -0
- package/src/lib/shared/cache.ts +1 -1
- package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
- package/src/lib/shared/createPushIterator.ts +1 -1
- package/src/lib/shared/decodeResponse.ts +11 -8
- package/src/lib/shared/hasReplayableRequest.ts +17 -0
- package/src/lib/shared/html.ts +7 -26
- package/src/lib/shared/manifestModule.ts +2 -2
- package/src/lib/shared/online.ts +1 -1
- package/src/lib/shared/outboxProbeSlot.ts +1 -1
- package/src/lib/shared/pageUrlForFile.ts +1 -1
- package/src/lib/shared/routeParamsShape.ts +20 -0
- package/src/lib/shared/snapshotShippable.ts +22 -0
- package/src/lib/shared/types/CacheEntry.ts +1 -1
- package/src/lib/shared/types/CacheInvalidation.ts +3 -3
- package/src/lib/shared/types/ClientFlags.ts +5 -3
- package/src/lib/shared/types/LastConnection.ts +1 -1
- package/src/lib/shared/url.ts +4 -1
- package/src/lib/shared/withBase.ts +1 -2
- package/src/lib/shared/writeDts.ts +2 -2
- package/src/lib/shared/writeRoutesDts.ts +3 -31
- package/src/lib/test/createTestSocketChannel.ts +3 -3
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
- package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
- package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +3 -1
- package/src/lib/ui/compile/awaitPlan.ts +14 -2
- package/src/lib/ui/compile/catchBinding.ts +8 -0
- package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
- package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
- package/src/lib/ui/compile/compileComponent.ts +4 -4
- package/src/lib/ui/compile/compileModule.ts +20 -6
- package/src/lib/ui/compile/compileSSR.ts +12 -10
- package/src/lib/ui/compile/compileShadow.ts +107 -16
- package/src/lib/ui/compile/composeProps.ts +3 -3
- package/src/lib/ui/compile/createShadowLanguageService.ts +30 -2
- package/src/lib/ui/compile/createShadowProgram.ts +2 -1
- package/src/lib/ui/compile/createShadowScope.ts +42 -0
- package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
- package/src/lib/ui/compile/desugarSignals.ts +12 -7
- package/src/lib/ui/compile/eachPlan.ts +61 -0
- package/src/lib/ui/compile/elementPlan.ts +55 -0
- package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
- package/src/lib/ui/compile/generateBuild.ts +150 -127
- package/src/lib/ui/compile/generateSSR.ts +112 -73
- package/src/lib/ui/compile/ifPlan.ts +7 -1
- package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
- package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
- package/src/lib/ui/compile/isTextLeaf.ts +8 -7
- package/src/lib/ui/compile/lowerContext.ts +36 -46
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
- package/src/lib/ui/compile/lowerScript.ts +1 -1
- package/src/lib/ui/compile/markupTokens.ts +313 -0
- package/src/lib/ui/compile/pagePropsType.ts +23 -0
- package/src/lib/ui/compile/parseTemplate.ts +131 -61
- package/src/lib/ui/compile/reactiveBinding.ts +47 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
- package/src/lib/ui/compile/skeletonable.ts +5 -5
- package/src/lib/ui/compile/snippetPlan.ts +28 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
- package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
- package/src/lib/ui/compile/switchPlan.ts +7 -1
- package/src/lib/ui/compile/tryPlan.ts +8 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
- package/src/lib/ui/compile/types/Binding.ts +24 -0
- package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
- package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
- package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
- package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +8 -8
- package/src/lib/ui/compile/withBindings.ts +45 -0
- package/src/lib/ui/createScope.ts +2 -2
- package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
- package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
- package/src/lib/ui/dom/appendText.ts +1 -1
- package/src/lib/ui/dom/applyResolved.ts +1 -1
- package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
- package/src/lib/ui/dom/cloneStatic.ts +3 -2
- package/src/lib/ui/dom/each.ts +26 -7
- package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
- package/src/lib/ui/dom/mountChild.ts +8 -1
- package/src/lib/ui/dom/readCall.ts +6 -4
- package/src/lib/ui/dom/scopeLabel.ts +3 -3
- package/src/lib/ui/dom/switchBlock.ts +3 -1
- package/src/lib/ui/dom/types/EachRow.ts +4 -0
- package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
- package/src/lib/ui/history.ts +1 -2
- package/src/lib/ui/html.ts +28 -0
- package/src/lib/ui/installHotBridge.ts +0 -2
- package/src/lib/ui/renderChain.ts +16 -3
- package/src/lib/ui/router.ts +66 -15
- package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
- package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
- package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
- package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/createEffectNode.ts +5 -5
- package/src/lib/ui/runtime/flushEffects.ts +22 -9
- package/src/lib/ui/runtime/historyEntries.ts +1 -1
- package/src/lib/ui/runtime/track.ts +4 -2
- package/src/lib/ui/runtime/trigger.ts +34 -20
- package/src/lib/ui/runtime/types/Route.ts +2 -2
- package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
- package/src/lib/ui/runtime/types/UiProps.ts +9 -12
- package/src/lib/ui/runtime/walkPath.ts +15 -3
- package/src/lib/ui/startClient.ts +41 -14
- package/src/lib/ui/state.ts +3 -3
- package/src/lib/ui/types/Scope.ts +3 -3
- package/src/serverBuildPlugins.ts +11 -5
- package/src/zodCjsPlugin.ts +36 -0
- package/template/.zed/settings.json +7 -1
- package/template/package.json +3 -0
- package/template/src/ui/pages/layout.abide +3 -5
|
@@ -3,7 +3,7 @@ import type { PromptRegistryEntry } from './types/PromptRegistryEntry.ts'
|
|
|
3
3
|
/*
|
|
4
4
|
Process-wide registry of every prompt declared in the app. definePrompt
|
|
5
5
|
inserts on first construction (eagerly when the registry loader walks the
|
|
6
|
-
prompts manifest
|
|
6
|
+
prompts manifest on first enumeration request). The MCP server reads this to build its
|
|
7
7
|
`prompts/list` + `prompts/get` responses.
|
|
8
8
|
*/
|
|
9
9
|
export const promptRegistry = new Map<string, PromptRegistryEntry>()
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
An MCP prompt declared by a markdown file under `src/mcp/prompts/`. The
|
|
3
3
|
resolver plugin parses the file's frontmatter + body and generates a call
|
|
4
4
|
to definePrompt, stamping in the `name` from the file path; `render(args)`
|
|
5
|
-
interpolates the body's `{{name}}` placeholders
|
|
5
|
+
interpolates the body's `{{name}}` placeholders and returns the rendered
|
|
6
|
+
string; `renderPrompt` in mcpSurface.ts then wraps it as the single user
|
|
6
7
|
message returned by `prompts/get`. Prompts are MCP-only — there is no
|
|
7
8
|
client-side counterpart, so the shape carries no ClientFlags.
|
|
8
9
|
*/
|
|
@@ -7,8 +7,8 @@ import { runWithRequestScope } from '../runtime/runWithRequestScope.ts'
|
|
|
7
7
|
Runs a rpc in-process: synthesizes the rpc Request from the remote's own
|
|
8
8
|
method + url and pipes it through remote.fetch — the same handler/validation/
|
|
9
9
|
error path the HTTP router uses, no network hop. The single in-process
|
|
10
|
-
dispatch every consumer surface (the CLI client
|
|
11
|
-
|
|
10
|
+
dispatch every consumer surface (the CLI client and the MCP tool dispatcher)
|
|
11
|
+
routes through, so they can't drift on how a rpc is invoked.
|
|
12
12
|
Takes the RemoteFunction directly — invocation never reads the registry
|
|
13
13
|
entry's schemas/clients (validation is closed over inside the remote), so the
|
|
14
14
|
entry is not a dependency here. `baseUrl` gives the synthetic Request its
|
|
@@ -34,7 +34,7 @@ type MutatingRpcOpts = RpcBaseOpts & {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/*
|
|
37
|
-
Shared signature for every rpc helper (GET / POST / …).
|
|
37
|
+
Shared signature for every rpc helper (GET / POST / …). Four overloads:
|
|
38
38
|
|
|
39
39
|
- `Rpc(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
|
|
40
40
|
from `InferInput<InputSchema>`, the handler receives
|
|
@@ -18,8 +18,8 @@ sources, picked at construction:
|
|
|
18
18
|
|
|
19
19
|
Returns a server fn that resolves to `undefined` when no public file
|
|
20
20
|
matches the request path, so the caller falls through to its own 404 /
|
|
21
|
-
middleware path. The path-traversal guard
|
|
22
|
-
|
|
21
|
+
middleware path. The path-traversal guard uses the same `containsTraversal`
|
|
22
|
+
check against encoded `..` segments in the raw URL.
|
|
23
23
|
|
|
24
24
|
Async because disk mode globs `publicDir` once at construction to build a
|
|
25
25
|
Set of the available paths: every page nav and RPC falls through here, so
|
|
@@ -36,7 +36,7 @@ function methodNotAllowed(allow: string): Response {
|
|
|
36
36
|
|
|
37
37
|
/*
|
|
38
38
|
Owns route dispatch: deciding, per registered URL, whether a request hits an
|
|
39
|
-
|
|
39
|
+
an rpc, a page render, or nothing — and the method-matching that picks the
|
|
40
40
|
status. Page URLs (under src/ui/pages/) serve GET/HEAD by rendering; rpc
|
|
41
41
|
URLs (under src/server/rpc/, `/rpc/...`) dispatch to the single declared rpc,
|
|
42
42
|
405 on method mismatch; an unregistered URL is 404. Page and rpc URLs are
|
|
@@ -481,6 +481,27 @@ export async function createServer({
|
|
|
481
481
|
/* Component hot module — the browser imports one edited `.abide`'s
|
|
482
482
|
hot build here instead of reloading (dev component HMR). */
|
|
483
483
|
if (clientFingerprint !== undefined && url.pathname.startsWith(DEV_HOT_PREFIX)) {
|
|
484
|
+
/* This endpoint serves `application/javascript` for the browser's
|
|
485
|
+
`import()`. A TOP-LEVEL NAVIGATION to it — clicking the module link in a
|
|
486
|
+
stack trace, or opening the URL — would DOWNLOAD the file, since browsers
|
|
487
|
+
can't render JS as a document. A navigation sends `Accept: text/html`;
|
|
488
|
+
`import()` sends a wildcard Accept. Redirect a navigation back to a real
|
|
489
|
+
page (the referring page when same-origin, else the mount root) so the
|
|
490
|
+
error surfaces in context as a normal render instead of saving a file. */
|
|
491
|
+
if ((req.headers.get('accept') ?? '').includes('text/html')) {
|
|
492
|
+
const referer = req.headers.get('referer')
|
|
493
|
+
const page =
|
|
494
|
+
referer !== null &&
|
|
495
|
+
URL.canParse(referer) &&
|
|
496
|
+
new URL(referer).origin === url.origin &&
|
|
497
|
+
!new URL(referer).pathname.startsWith(DEV_HOT_PREFIX)
|
|
498
|
+
? referer
|
|
499
|
+
: base || '/'
|
|
500
|
+
return new Response(null, {
|
|
501
|
+
status: 302,
|
|
502
|
+
headers: { Location: page, 'Cache-Control': NO_STORE },
|
|
503
|
+
})
|
|
504
|
+
}
|
|
484
505
|
return devHotModuleResponse(
|
|
485
506
|
decodeURIComponent(url.pathname.slice(DEV_HOT_PREFIX.length)),
|
|
486
507
|
)
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { appNameSlot } from '../../shared/appNameSlot.ts'
|
|
2
2
|
import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
3
3
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
4
|
-
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
5
4
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
6
5
|
import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
|
|
7
|
-
import
|
|
6
|
+
import { snapshotShippable } from '../../shared/snapshotShippable.ts'
|
|
8
7
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
9
8
|
import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
|
|
10
9
|
import { renderChain } from '../../ui/renderChain.ts'
|
|
@@ -23,6 +22,9 @@ type LoadPage = () => Promise<{ default: UiComponent }>
|
|
|
23
22
|
|
|
24
23
|
const SSR_MARKER = /<!--ssr:(head|body|state)-->/g
|
|
25
24
|
const BODY_MARKER = '<!--ssr:body-->'
|
|
25
|
+
/* Compiled once — both fill the shell per page render. */
|
|
26
|
+
const HEAD_STATE_MARKER = /<!--ssr:(head|state)-->/g
|
|
27
|
+
const HEAD_CLOSE_TAG = /<\/head>/i
|
|
26
28
|
|
|
27
29
|
function wantsJson(request: Request): boolean {
|
|
28
30
|
return (request.headers.get('accept') ?? '').includes('application/json')
|
|
@@ -140,7 +142,7 @@ export function createUiPageRenderer({
|
|
|
140
142
|
build-time injector). A no-op when there are none or the shell carries no </head>. */
|
|
141
143
|
function injectRoutePreloads(html: string, routeUrl: string): string {
|
|
142
144
|
const links = routePreloadLinks(routeUrl)
|
|
143
|
-
return links === '' ? html : html.replace(
|
|
145
|
+
return links === '' ? html : html.replace(HEAD_CLOSE_TAG, `${links}</head>`)
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
async function renderPage(
|
|
@@ -212,9 +214,7 @@ export function createUiPageRenderer({
|
|
|
212
214
|
`<script>${SSR_SWAP_SCRIPT}${CACHE_RESOLVE_SCRIPT}</script>` +
|
|
213
215
|
`${await stateTag(routeUrl, params, store, inline)}`
|
|
214
216
|
const filled = injectRoutePreloads(
|
|
215
|
-
shell.replace(
|
|
216
|
-
key === 'head' ? head : '',
|
|
217
|
-
),
|
|
217
|
+
shell.replace(HEAD_STATE_MARKER, (_match, key: string) => (key === 'head' ? head : '')),
|
|
218
218
|
routeUrl,
|
|
219
219
|
)
|
|
220
220
|
const [before, after] = filled.split(BODY_MARKER)
|
|
@@ -245,11 +245,7 @@ export function createUiPageRenderer({
|
|
|
245
245
|
bundle, so startClient seeds the store and the block's subscription read
|
|
246
246
|
is warm. Skip keys already shipped inline in __SSR__. */
|
|
247
247
|
const streamedEntries = Array.from(store.cache.entries.values()).filter(
|
|
248
|
-
(entry
|
|
249
|
-
entry.settled === true &&
|
|
250
|
-
entry.request !== undefined &&
|
|
251
|
-
isReplayableMethod(entry.request.method.toUpperCase()) &&
|
|
252
|
-
!inlinedKeys.has(entry.key),
|
|
248
|
+
(entry) => snapshotShippable(entry) && !inlinedKeys.has(entry.key),
|
|
253
249
|
)
|
|
254
250
|
for await (const resolution of streamCacheResolutions(
|
|
255
251
|
store.cache,
|
|
@@ -3,8 +3,8 @@ import { TEXT_PLAIN } from '../../shared/TEXT_PLAIN.ts'
|
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
The framework's CSRF refusal, shared by every same-origin gate — the socket
|
|
6
|
-
upgrade, the socket REST publish face, the MCP endpoint, and mutating
|
|
7
|
-
|
|
6
|
+
upgrade, the socket REST publish face, the MCP endpoint, and mutating rpcs
|
|
7
|
+
— so the 403 body can't drift between surfaces. `hint` appends a
|
|
8
8
|
surface-specific remedy; rpc names its `crossOrigin: true` opt-out so the
|
|
9
9
|
first developer this 403s can self-serve.
|
|
10
10
|
*/
|
|
@@ -6,7 +6,7 @@ import { isCrossOriginRequest } from './isCrossOriginRequest.ts'
|
|
|
6
6
|
/*
|
|
7
7
|
The framework's CSRF/CSWSH posture in one place: a cross-origin browser
|
|
8
8
|
request to a mutating framework endpoint is refused. Every endpoint that
|
|
9
|
-
parses bodies ignoring Content-Type (
|
|
9
|
+
parses bodies ignoring Content-Type (rpcs, socket publish, MCP JSON-RPC)
|
|
10
10
|
must gate here — a hostile page's text/plain form trick could otherwise
|
|
11
11
|
smuggle a payload in with the visitor's ambient cookies; non-browser clients
|
|
12
12
|
send no Origin and pass. Returns the 403 to send, or undefined to proceed.
|
|
@@ -11,8 +11,8 @@ mode — runtime sourced from `window.__abide`, self-invoking `hotReplace` — a
|
|
|
11
11
|
transpiles the embedded author TypeScript to plain JS the browser can import
|
|
12
12
|
directly (the normal pipeline relies on the bundler's `ts` loader for this). The
|
|
13
13
|
browser imports this in place of a reload; on load it swaps the component's live
|
|
14
|
-
instances. The id is guarded as a project-relative `.abide` under cwd; a bad
|
|
15
|
-
missing file 404s so the client falls back to a reload.
|
|
14
|
+
instances. The id is guarded as a project-relative `.abide` under cwd; a bad
|
|
15
|
+
module ID returns 400; a missing file 404s so the client falls back to a reload.
|
|
16
16
|
*/
|
|
17
17
|
export async function devHotModuleResponse(moduleId: string): Promise<Response> {
|
|
18
18
|
const root = process.cwd()
|
|
@@ -13,7 +13,7 @@ than `body instanceof ReadableStream`, since every bodied Response exposes one.
|
|
|
13
13
|
A streamed SSR page (`text/html`) is deliberately not opted out: it inherits the
|
|
14
14
|
configured `idleTimeout` as a bounded cap on how long it can hold the connection,
|
|
15
15
|
and the client re-fetches any placeholder left unresolved by a cut stream (see
|
|
16
|
-
|
|
16
|
+
streamCacheResolutions, which yields { key, miss } markers the client re-fetches). Non-stream responses pass through.
|
|
17
17
|
*/
|
|
18
18
|
export function disableIdleTimeoutForStream(
|
|
19
19
|
server: Server<unknown>,
|
|
@@ -83,7 +83,7 @@ exposed on, so abide's routing and multimodal-by-default exposure are auditable
|
|
|
83
83
|
rather than implicit. Three aligned tables — scan a column to spot a missing
|
|
84
84
|
surface, a row to see one declaration's reach:
|
|
85
85
|
|
|
86
|
-
- pages: each route
|
|
86
|
+
- pages: each route key.
|
|
87
87
|
- sockets: name + client surfaces (schema/browser/mcp/cli) and whether clients
|
|
88
88
|
may publish.
|
|
89
89
|
- rpcs: method+path (headed `http` since http/openapi are unconditional) +
|
|
@@ -95,9 +95,8 @@ then the surface columns, so the glyphs line up vertically across the tables.
|
|
|
95
95
|
rpc folds its method into a left-aligned prefix of the identifier cell, so paths
|
|
96
96
|
still start at a shared column. For sockets and rpcs the `schema`
|
|
97
97
|
column leads: it's what unlocks the non-browser surfaces, so a missing schema
|
|
98
|
-
reddens to flag the gated declaration. Loads the full registry
|
|
99
|
-
|
|
100
|
-
eager imports in production. Best-effort: enumeration failures are swallowed,
|
|
98
|
+
reddens to flag the gated declaration. Loads the full registry. Runs once at boot; suppressed only via DEBUG=-abide
|
|
99
|
+
(the always-on logging opt-out). Best-effort: enumeration failures are swallowed,
|
|
101
100
|
this is diagnostic only.
|
|
102
101
|
*/
|
|
103
102
|
export async function logExposedSurfaces(routing: { pages: Pages }): Promise<void> {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { snapshotShippable } from '../../shared/snapshotShippable.ts'
|
|
2
2
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
3
3
|
import type { CacheStore } from '../../shared/types/CacheStore.ts'
|
|
4
4
|
import { snapshotEntryFromCache } from './snapshotEntryFromCache.ts'
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
Snapshots the request-scoped cache for SSR at a single instant: every replayable
|
|
8
|
-
(GET
|
|
8
|
+
(GET-only) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
|
|
9
9
|
client seeds its store from. Unsettled and non-replayable entries are skipped; a body
|
|
10
10
|
that can't ship (binary / streaming / rejected) drops out via snapshotEntryFromCache.
|
|
11
11
|
|
|
@@ -21,12 +21,7 @@ over the wire — see its post-stream `__abideResolve` pass. So for a streaming
|
|
|
21
21
|
render-return snapshot is typically empty; the warm cache arrives over the stream.
|
|
22
22
|
*/
|
|
23
23
|
export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
|
|
24
|
-
const settled = Array.from(store.entries.values()).filter(
|
|
25
|
-
(entry) =>
|
|
26
|
-
entry.settled === true &&
|
|
27
|
-
entry.request !== undefined &&
|
|
28
|
-
isReplayableMethod(entry.request.method.toUpperCase()),
|
|
29
|
-
)
|
|
24
|
+
const settled = Array.from(store.entries.values()).filter(snapshotShippable)
|
|
30
25
|
const snapshots = await Promise.all(
|
|
31
26
|
settled.map((entry) => snapshotEntryFromCache(store, entry)),
|
|
32
27
|
)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { contentTypeOf } from '../../shared/contentTypeOf.ts'
|
|
2
|
-
import {
|
|
2
|
+
import { hasReplayableRequest } from '../../shared/hasReplayableRequest.ts'
|
|
3
3
|
import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
|
|
4
4
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
5
5
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
6
6
|
import type { CacheStore } from '../../shared/types/CacheStore.ts'
|
|
7
|
+
import type { ReplayableMethod } from '../../shared/types/ReplayableMethod.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
Awaits one cache entry and turns it into a wire-safe snapshot, or undefined
|
|
@@ -25,14 +26,16 @@ export async function snapshotEntryFromCache(
|
|
|
25
26
|
store: CacheStore,
|
|
26
27
|
entry: CacheEntry,
|
|
27
28
|
): Promise<CacheSnapshotEntry | undefined> {
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!isReplayableMethod(method)) {
|
|
29
|
+
/* The request half of the shared shippability gate: a replayable wire request.
|
|
30
|
+
Producer entries (no request) and non-replayable methods never snapshot — the
|
|
31
|
+
streaming-drain caller hands still-pending entries here, so `settled` is NOT gated
|
|
32
|
+
(the caller's snapshotShippable filter already required it for the entries it picks). */
|
|
33
|
+
if (!hasReplayableRequest(entry) || !entry.request) {
|
|
34
34
|
return undefined
|
|
35
35
|
}
|
|
36
|
+
/* `hasReplayableRequest` already verified the uppercased method is replayable; assert it
|
|
37
|
+
for the snapshot's typed `method` field rather than re-running the same Set check. */
|
|
38
|
+
const method = entry.request.method.toUpperCase() as ReplayableMethod
|
|
36
39
|
const response = await readSettled(entry.promise as Promise<Response>)
|
|
37
40
|
if (!response) {
|
|
38
41
|
return undefined
|
|
@@ -4,7 +4,7 @@ import { messageFromError } from '../../shared/messageFromError.ts'
|
|
|
4
4
|
Shared body builder for the streaming respond helpers (`jsonl`, `sse`).
|
|
5
5
|
Both flow the same shape — pull from an AsyncIterator, encode each frame
|
|
6
6
|
to bytes, emit a sentinel `error` frame on a generator throw, and route
|
|
7
|
-
ReadableStream's `cancel` into `
|
|
7
|
+
ReadableStream's `cancel` into `iterator.return()` so the handler's
|
|
8
8
|
`for await` exits via its normal control path. Only the per-frame
|
|
9
9
|
encoding and the optional keepalive payload differ between the two.
|
|
10
10
|
|
|
@@ -85,7 +85,7 @@ export function streamFromIterator<T>(
|
|
|
85
85
|
rejection from its cleanup: a `finally` that throws on `.return()`
|
|
86
86
|
would otherwise surface as an unhandled rejection (process-fatal
|
|
87
87
|
under Bun's default) on a client disconnect — a path every
|
|
88
|
-
sse/jsonl
|
|
88
|
+
sse/jsonl stream hits routinely.
|
|
89
89
|
*/
|
|
90
90
|
return iterator.return?.(reason)?.then(
|
|
91
91
|
() => undefined,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
One RPC
|
|
3
|
-
serializable facts the UI renders — where it mounts, its method, which
|
|
2
|
+
One declared RPC projected for the inspector: the registry entry reduced to
|
|
3
|
+
the serializable facts the UI renders — where it mounts, its method, which
|
|
4
4
|
non-browser surfaces advertise it, and its argument/result shapes as JSON
|
|
5
5
|
Schema. Schemas project through jsonSchemaForSchema (same as MCP/OpenAPI), so
|
|
6
6
|
a rpc whose library can't render a schema still lists with an opaque shape.
|
package/src/lib/server/socket.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { SocketOptions } from './sockets/types/SocketOptions.ts'
|
|
|
6
6
|
Declares a Socket inside a file under `src/server/sockets/`. Each file contains
|
|
7
7
|
exactly one export, named after the file (e.g. `chat.ts` →
|
|
8
8
|
`export const chat = socket<ChatMessage>(...)`). The bundler reads the
|
|
9
|
-
// @documentation sockets
|
|
10
9
|
export name from the filename and the socket name from the file path
|
|
11
10
|
under `src/server/sockets/`, then rewrites this call to bind the name into the
|
|
12
11
|
runtime implementation (defineSocket on the server, socketProxy on the
|
|
@@ -21,6 +20,7 @@ to browser-only when schemaless, all surfaces when a schema is present.
|
|
|
21
20
|
This function exists only for the type signature; calling it directly
|
|
22
21
|
means the bundler plugin didn't process the file, which throws.
|
|
23
22
|
*/
|
|
23
|
+
// @documentation sockets
|
|
24
24
|
export function socket<Schema extends StandardSchemaV1>(
|
|
25
25
|
opts: SocketOptions<Schema> & { schema: Schema },
|
|
26
26
|
): Socket<StandardSchemaV1.InferOutput<Schema>>
|
|
@@ -220,7 +220,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
220
220
|
}
|
|
221
221
|
/*
|
|
222
222
|
publish() runs the topic's optional Standard Schema synchronously
|
|
223
|
-
and throws on failure (see defineSocket.
|
|
223
|
+
and throws on failure (see validateSync in defineSocket.ts). The
|
|
224
224
|
dispatcher invokes us via `void handlePub(...)`, so an unhandled
|
|
225
225
|
throw would surface as an unhandled promise rejection on every
|
|
226
226
|
malformed client frame. Catch + log so a buggy client can't take
|
|
@@ -317,7 +317,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
317
317
|
try {
|
|
318
318
|
/* ref-json from abide's own client; a non-abide client sends a plain-JSON
|
|
319
319
|
frame, which decodeRefJson rejects (a frame is always an object, never the
|
|
320
|
-
`[
|
|
320
|
+
`[rootValue, slots]` envelope) — fall back to JSON.parse so raw WS clients work. */
|
|
321
321
|
try {
|
|
322
322
|
frame = decodeRefJson(text) as SocketClientFrame
|
|
323
323
|
} catch {
|
|
@@ -3,7 +3,7 @@ import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
|
|
|
3
3
|
/*
|
|
4
4
|
Process-wide registry of every Socket declared in the app. defineSocket
|
|
5
5
|
inserts on first construction; the dispatcher reads on every `sub` /
|
|
6
|
-
`pub` frame so it can find the right Socket by
|
|
7
|
-
opted-in `allowClientPublish` policy.
|
|
6
|
+
`pub` frame and every REST request so it can find the right Socket by
|
|
7
|
+
name and check the opted-in `allowClientPublish` policy.
|
|
8
8
|
*/
|
|
9
9
|
export const socketRegistry = new Map<string, SocketRegistryEntry>()
|
|
@@ -9,9 +9,9 @@ the dispatcher checks the topic's `clientPublish` flag before fanning
|
|
|
9
9
|
out.
|
|
10
10
|
|
|
11
11
|
`sub` is the per-subscription id minted client-side; the server treats
|
|
12
|
-
it as opaque and routes inbound `
|
|
13
|
-
id
|
|
14
|
-
|
|
12
|
+
it as opaque and routes inbound `replay|err|end` frames back to the same
|
|
13
|
+
id — `msg` frames are broadcast keyed by socket name; the client
|
|
14
|
+
demuxes them against every local sub for that socket.
|
|
15
15
|
*/
|
|
16
16
|
export type SocketClientFrame =
|
|
17
17
|
| { type: 'sub'; sub: string; socket: string; replay?: number }
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
Brand on the invoker cache() returns; its property value is the wrapped fn,
|
|
3
3
|
so inspection shows what a wrapper wraps. Detection is certain (set by us,
|
|
4
4
|
never heuristic), which is why misuse throws instead of warning: a wrapper
|
|
5
|
-
used as a selector matches nothing (no url/method, no producer id), and
|
|
6
|
-
re-wrapped wrapper silently
|
|
5
|
+
used as a selector matches nothing (no url/method, no producer id), and
|
|
6
|
+
without detection a re-wrapped wrapper would silently downgrade a remote to
|
|
7
|
+
an anonymous producer (no url/method, no shared key, no SSR snapshot).
|
|
7
8
|
*/
|
|
8
9
|
export const CACHE_WRAPPED: unique symbol = Symbol('abide.cacheWrapped')
|
|
@@ -3,6 +3,6 @@ Brand set by createRemoteFunction on both the decoded callable and its raw
|
|
|
3
3
|
sibling — the exact answer to "is this a abide remote?" where structural
|
|
4
4
|
checks ('url' in fn) would misclassify any user function that happens to
|
|
5
5
|
carry a url property. Read by cache()'s remote/producer discriminator,
|
|
6
|
-
|
|
6
|
+
selectorPrefix's identity branch, and the route dispatcher's export scan.
|
|
7
7
|
*/
|
|
8
8
|
export const REMOTE_FUNCTION: unique symbol = Symbol('abide.remoteFunction')
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Content-Type prefixes abide treats as streaming bodies — SSE for the
|
|
3
3
|
`sse()` helper, JSONL / NDJSON for the `jsonl()` helper. Used by
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
contentBodyKind (called by decodeResponse and the one-shot path in
|
|
5
|
+
streamResponse) to classify content, and by streamResponse to select the
|
|
6
|
+
JSONL frame parser.
|
|
6
7
|
*/
|
|
7
8
|
export const STREAMING_CONTENT_TYPES = [
|
|
8
9
|
'text/event-stream',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContentBodyKind } from './contentBodyKind.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The single body-kind → value mapping shared by the live read (decodeResponse) and
|
|
5
|
+
the synchronous warm read (warmValueFromSnapshot), so neither can disagree about
|
|
6
|
+
which kind yields what — the divergence behind the streaming-guard-in-one-but-not-
|
|
7
|
+
the-other bug.
|
|
8
|
+
|
|
9
|
+
Generic over the extraction (`Value`) so both async and sync callers reuse it:
|
|
10
|
+
the live read passes Promise-returning accessors (response.json()/text()) and
|
|
11
|
+
handles the deferred kinds (streaming → throw, binary → blob); the warm read
|
|
12
|
+
passes sync accessors (JSON.parse(body)/body) and treats every deferred kind as
|
|
13
|
+
no-warm-value, leaving the live async path to throw/blob exactly as a fresh call
|
|
14
|
+
would. The json/text branches — the only kinds that produce a directly-usable
|
|
15
|
+
value on both sides — are decided here, once.
|
|
16
|
+
|
|
17
|
+
Returns the DEFER sentinel for `streaming` and `binary`: kinds whose handling is
|
|
18
|
+
side-specific (the live read throws on streaming and blobs on binary; the warm
|
|
19
|
+
read has no synchronous equivalent for either), so the caller branches on it.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFER: unique symbol = Symbol('defer')
|
|
22
|
+
|
|
23
|
+
export function bodyValueForKind<Value>(
|
|
24
|
+
kind: ContentBodyKind,
|
|
25
|
+
json: () => Value,
|
|
26
|
+
text: () => Value,
|
|
27
|
+
): Value | typeof DEFER {
|
|
28
|
+
if (kind === 'json') {
|
|
29
|
+
return json()
|
|
30
|
+
}
|
|
31
|
+
if (kind === 'text') {
|
|
32
|
+
return text()
|
|
33
|
+
}
|
|
34
|
+
return DEFER
|
|
35
|
+
}
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -92,7 +92,7 @@ request-scoped on the server, which keeps per-user data from leaking across
|
|
|
92
92
|
requests; on the client there is one tab store either way, so it is a no-op.
|
|
93
93
|
|
|
94
94
|
Reactivity is implicit: the invoker calls `store.subscribe(key)`, which
|
|
95
|
-
registers the surrounding
|
|
95
|
+
registers the surrounding scope().computed() / scope().effect() scope. Invalidating the key
|
|
96
96
|
then re-runs that scope, which calls cache() again and gets a fresh entry.
|
|
97
97
|
Outside a tracking scope subscribe() is a no-op, so cache() works the same
|
|
98
98
|
in server code and plain client code.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
|
|
1
2
|
import { contentBodyKind } from './contentBodyKind.ts'
|
|
2
3
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
3
4
|
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
@@ -32,31 +33,35 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
|
|
|
32
33
|
|
|
33
34
|
/*
|
|
34
35
|
Synchronously decodes a snapshot body so the warm entry reads without a
|
|
35
|
-
microtask hop on first render.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
what awaiting the Response would yield. Every
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
would.
|
|
36
|
+
microtask hop on first render. The json/text branches go through the shared
|
|
37
|
+
`bodyValueForKind` — the same mapping the live read (`decodeResponse`) uses — so
|
|
38
|
+
the two cannot disagree about how a body becomes a value, returning a value
|
|
39
|
+
identical to what awaiting the Response would yield. Every non-warmable case —
|
|
40
|
+
non-2xx, 204 (the status gate below), streaming, binary (the DEFER sentinel) —
|
|
41
|
+
yields no warm value and defers to the async path, which throws HttpError /
|
|
42
|
+
returns the streaming error / blobs exactly as a live call would.
|
|
42
43
|
*/
|
|
43
44
|
function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
|
|
45
|
+
/* Status gate is side-specific: an error/204 has no warm value here, but the live
|
|
46
|
+
read throws HttpError / returns undefined for the same status — so it stays out of
|
|
47
|
+
the shared kind mapping. */
|
|
44
48
|
if (status === 204 || status < 200 || status >= 300) {
|
|
45
49
|
return undefined
|
|
46
50
|
}
|
|
47
51
|
const kind = contentBodyKind(contentTypeOf(headers))
|
|
48
|
-
|
|
52
|
+
const value = bodyValueForKind(
|
|
53
|
+
kind,
|
|
49
54
|
/* The body may still be malformed JSON; fall back to the async path rather than
|
|
50
55
|
throwing a SyntaxError synchronously during hydration. */
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
() => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(body)
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
() => body,
|
|
64
|
+
)
|
|
60
65
|
/* streaming and binary defer to the async path (the live decode throws / blobs). */
|
|
61
|
-
return undefined
|
|
66
|
+
return value === DEFER ? undefined : value
|
|
62
67
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
2
|
+
Bounded-queue AsyncIterator factory shared by the in-process
|
|
3
3
|
socket fan-out (defineSocket) and the client-side ws proxy
|
|
4
4
|
(socketProxy). Callers push values, signal end, signal an error, or
|
|
5
5
|
signal a transport disconnect; the iterator drains a queue then awaits
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
|
|
1
2
|
import { contentBodyKind } from './contentBodyKind.ts'
|
|
2
3
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
3
4
|
import { httpErrorFor } from './httpErrorFor.ts'
|
|
@@ -6,7 +7,7 @@ import { httpErrorFor } from './httpErrorFor.ts'
|
|
|
6
7
|
Decodes a Response into the natural body value based on Content-Type:
|
|
7
8
|
application/json (or `*\/+json`) → parsed JSON
|
|
8
9
|
text/* → string
|
|
9
|
-
204 No Content
|
|
10
|
+
204 No Content → undefined
|
|
10
11
|
everything else → Blob
|
|
11
12
|
|
|
12
13
|
Non-2xx responses throw HttpError so the happy path never has to check
|
|
@@ -39,11 +40,13 @@ export async function decodeResponse(response: Response): Promise<unknown> {
|
|
|
39
40
|
`[abide] response at ${response.url} is a stream (${contentType}) — use tail(fn.stream(args)) for a reactive view, or fn.stream(args) for per-call iteration, instead of awaiting the bare call or cache()`,
|
|
40
41
|
)
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
/* json/text go through the shared mapping warmValueFromSnapshot also uses, so a warm
|
|
44
|
+
seed reads byte-identically to this live read; binary (the only DEFER kind left,
|
|
45
|
+
streaming already threw above) blobs. */
|
|
46
|
+
const value = bodyValueForKind(
|
|
47
|
+
kind,
|
|
48
|
+
() => response.json(),
|
|
49
|
+
() => response.text(),
|
|
50
|
+
)
|
|
51
|
+
return value === DEFER ? response.blob() : value
|
|
49
52
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isReplayableMethod } from './isReplayableMethod.ts'
|
|
2
|
+
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Whether a cache entry carries a wire request that can be replayed from a warm
|
|
6
|
+
snapshot — a replayable (GET-only) method, so the seed never re-fires a write or
|
|
7
|
+
loses a body. Producer entries (no request) and write methods fail it.
|
|
8
|
+
|
|
9
|
+
The request half of snapshot shippability, factored out so the two gates that
|
|
10
|
+
need it cannot drift: `snapshotShippable` composes it with `settled` for the
|
|
11
|
+
store-filter sites, and `snapshotEntryFromCache` calls it alone — its
|
|
12
|
+
streaming-drain caller hands it still-pending entries it then awaits, so it can't
|
|
13
|
+
require `settled` up front.
|
|
14
|
+
*/
|
|
15
|
+
export function hasReplayableRequest(entry: CacheEntry): boolean {
|
|
16
|
+
return entry.request !== undefined && isReplayableMethod(entry.request.method.toUpperCase())
|
|
17
|
+
}
|
package/src/lib/shared/html.ts
CHANGED
|
@@ -1,36 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
/* The registered brand for trusted raw markup. `Symbol.for` resolves through the
|
|
2
|
+
global registry, so this binding is the same symbol across module/bundle copies;
|
|
3
|
+
`html` (`abide/ui/html`) imports it to brand, `rawHtmlString` reads it back. */
|
|
4
|
+
export const RAW_HTML = Symbol.for('abide.rawHtml')
|
|
2
5
|
|
|
3
6
|
/* A value marked as trusted raw markup — a `{expr}` interpolation inserts it
|
|
4
7
|
verbatim (not escaped) on both sides. The brand is a registered Symbol, so it
|
|
5
|
-
survives across module/bundle copies.
|
|
8
|
+
survives across module/bundle copies. Branded by `html` (`abide/ui/html`); read
|
|
9
|
+
back by `rawHtmlString` here — the isomorphic reader both runtimes consume. */
|
|
6
10
|
export type RawHtml = { readonly [RAW_HTML]: string }
|
|
7
11
|
|
|
8
|
-
/*
|
|
9
|
-
Marks a string as trusted raw HTML so a `{expr}` interpolation inserts its nodes
|
|
10
|
-
instead of escaped text — the abide idiom for raw markup (no `{@html}` mustache).
|
|
11
|
-
Works two ways:
|
|
12
|
-
|
|
13
|
-
html(trustedString) // plain call — insert the string verbatim
|
|
14
|
-
html`<b>${name}</b>` // tagged — concatenate parts verbatim
|
|
15
|
-
|
|
16
|
-
Calling `html` is the explicit opt-in to raw insertion; plain `{value}` always
|
|
17
|
-
escapes. The tag does NOT auto-escape interpolations (it's raw by intent), so only
|
|
18
|
-
build markup from values you trust, or escape them yourself.
|
|
19
|
-
*/
|
|
20
|
-
// @documentation templating
|
|
21
|
-
export function html(strings: TemplateStringsArray | string, ...values: unknown[]): RawHtml {
|
|
22
|
-
if (typeof strings === 'string') {
|
|
23
|
-
return { [RAW_HTML]: strings }
|
|
24
|
-
}
|
|
25
|
-
let markup = strings[0] ?? ''
|
|
26
|
-
for (let index = 0; index < values.length; index += 1) {
|
|
27
|
-
markup += String(values[index]) + (strings[index + 1] ?? '')
|
|
28
|
-
}
|
|
29
|
-
return { [RAW_HTML]: markup }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
12
|
/* The raw markup of a `html`-branded value, or undefined for anything else
|
|
33
13
|
(so text bindings can fast-path plain values and only branded ones go raw). */
|
|
14
|
+
// @documentation plumbing
|
|
34
15
|
export function rawHtmlString(value: unknown): string | undefined {
|
|
35
16
|
return value !== null && typeof value === 'object' && RAW_HTML in value
|
|
36
17
|
? (value as RawHtml)[RAW_HTML]
|