brustjs 0.1.38-alpha → 0.1.40-alpha

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.
@@ -0,0 +1,347 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import type { ComponentType } from 'react'
5
+ import type { Route } from '../routes.ts'
6
+ import { type MdFile, scanMdDir } from './scan.ts'
7
+
8
+ /**
9
+ * Content-addressed jinja template name for an md page:
10
+ * `Md_<sanitized relPath>_<8hex(sha256 relPath)>`.
11
+ *
12
+ * Sanitizes `[^A-Za-z0-9_]` to `_`; the hash (same scheme as
13
+ * `islandChunkBasename` in runtime/islands/chunk-id.ts) keeps two relPaths
14
+ * that sanitize identically (e.g. `a-b.md` vs `a_b.md`) from colliding.
15
+ */
16
+ export function mdTemplateName(relPath: string): string {
17
+ const sanitized = relPath.replace(/[^A-Za-z0-9_]/g, '_')
18
+ const hash = createHash('sha256').update(relPath).digest('hex').slice(0, 8)
19
+ return `Md_${sanitized}_${hash}`
20
+ }
21
+
22
+ /**
23
+ * Maps a content-relative md path to its URL under `prefix`.
24
+ * `index.md` maps to the prefix itself; `guide/index.md` maps to
25
+ * `<prefix>/guide`; `query/where.md` maps to `<prefix>/query/where`.
26
+ * Trailing slashes on `prefix` are normalized away (`/docs/` == `/docs`).
27
+ */
28
+ export function mdUrlPath(relPath: string, prefix: string): string {
29
+ let base = prefix.replace(/\/+$/, '')
30
+ if (base !== '' && !base.startsWith('/')) base = `/${base}`
31
+ let route = relPath.replace(/\.md$/, '')
32
+ if (route === 'index') route = ''
33
+ else if (route.endsWith('/index')) route = route.slice(0, -'/index'.length)
34
+ const url = route === '' ? base : `${base}/${route}`
35
+ return url === '' ? '/' : url
36
+ }
37
+
38
+ // ── Task 2.6: mdRoutes / mdNav / frozen manifest ────────────────────────────
39
+
40
+ /** Build-time source info attached to every md leaf route. Plain field on the
41
+ * Route node, so it survives `flattenRoutes` into `FlatRoute.chain` (the chain
42
+ * holds the node objects); the emit step filters chains whose leaf has it. */
43
+ export interface MdRouteSource {
44
+ absPath: string
45
+ relPath: string
46
+ contentDir: string
47
+ frontmatter: MdFile['frontmatter']
48
+ components: Record<string, ComponentType<any>>
49
+ layoutName?: string
50
+ }
51
+
52
+ /** An md leaf route — an ordinary native Route plus the `__mdSource` marker. */
53
+ export type MdRoute = Route & { __mdSource: MdRouteSource }
54
+
55
+ export interface MdRoutesOptions {
56
+ /** URL prefix the pages mount under. Default `'/'`. */
57
+ prefix?: string
58
+ /** Optional layout component — when set, mdRoutes returns ONE parent route
59
+ * `{ path: prefix, Component: layout, children: [...mdLeaves] }`. */
60
+ layout?: ComponentType<any>
61
+ /** Component-tag registry for `<Name />` tags inside the md body. */
62
+ components?: Record<string, ComponentType<any>>
63
+ }
64
+
65
+ /** One frozen-manifest entry (everything route construction needs per page).
66
+ * `contentDir` is only present when the entry belongs to a DIFFERENT content
67
+ * dir than the manifest's top-level `contentDir` (apps mounting two or more
68
+ * `mdRoutes()` dirs share ONE manifest file per dist) — additive, so version-1
69
+ * manifests without it stay readable. */
70
+ export interface MdManifestEntry {
71
+ relPath: string
72
+ templateName: string
73
+ urlPath: string
74
+ frontmatter: MdFile['frontmatter']
75
+ contentDir?: string
76
+ }
77
+
78
+ export interface MdManifest {
79
+ version: 1
80
+ contentDir: string
81
+ entries: MdManifestEntry[]
82
+ }
83
+
84
+ export const MD_MANIFEST_FILENAME = 'md-manifest.json'
85
+
86
+ /** Write the frozen md manifest as `<dir>/md-manifest.json` (creates `dir`).
87
+ * Returns the absolute file path written. */
88
+ export function writeMdManifest(
89
+ dir: string,
90
+ entries: MdManifestEntry[],
91
+ contentDir: string,
92
+ ): string {
93
+ mkdirSync(dir, { recursive: true })
94
+ const file = path.join(dir, MD_MANIFEST_FILENAME)
95
+ const manifest: MdManifest = { version: 1, contentDir, entries }
96
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`)
97
+ return path.resolve(file)
98
+ }
99
+
100
+ /** Read + schema-check a frozen md manifest. Throws on a missing file, bad
101
+ * JSON, or an unsupported version (fail loudly — the manifest is build output). */
102
+ export function readMdManifest(file: string): MdManifest {
103
+ let parsed: Partial<MdManifest>
104
+ try {
105
+ parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<MdManifest>
106
+ } catch (err) {
107
+ throw new Error(`md manifest ${file}: invalid JSON — ${(err as Error).message}`)
108
+ }
109
+ if (parsed?.version !== 1) {
110
+ throw new Error(`md manifest ${file}: unsupported version ${String(parsed?.version)}`)
111
+ }
112
+ if (typeof parsed.contentDir !== 'string' || !Array.isArray(parsed.entries)) {
113
+ throw new Error(`md manifest ${file}: malformed (expected { contentDir, entries })`)
114
+ }
115
+ return parsed as MdManifest
116
+ }
117
+
118
+ /** Prebuilt-dist detection: the SAME signal index.ts uses to resolve jinjaDir
119
+ * at boot (`BRUST_PREBUILT === '1'` + `BRUST_DIST_DIR`, set by the dist bundle
120
+ * banner — see runtime/cli/build.ts). In a prebuilt run, md routes come from
121
+ * the frozen `<distDir>/md-manifest.json` (the content dir may not ship);
122
+ * everywhere else we fs-scan. A manifest written for a DIFFERENT content dir
123
+ * is ignored (falls back to scan). */
124
+ function loadPrebuiltMdManifest(contentDir: string): MdManifest | null {
125
+ // Deliberately prebuilt-ONLY: in source mode the fs scan is the live truth
126
+ // (the content dir exists by definition) — reading a `.brust/` manifest there
127
+ // would serve stale routes after md adds/removes. The `.brust/` copy exists
128
+ // for jinja-staleness checks (it records contentDir), not route resolution.
129
+ if (process.env.BRUST_PREBUILT !== '1') return null
130
+ const distDir = process.env.BRUST_DIST_DIR
131
+ if (!distDir) return null
132
+ const file = path.join(distDir, MD_MANIFEST_FILENAME)
133
+ if (!existsSync(file)) return null
134
+ let manifest: MdManifest
135
+ try {
136
+ manifest = readMdManifest(file)
137
+ } catch (err) {
138
+ console.warn(
139
+ `[brust] md manifest unreadable, falling back to fs scan: ${(err as Error).message}`,
140
+ )
141
+ return null
142
+ }
143
+ // Multi-dir manifests: an entry belongs to `e.contentDir ?? manifest.contentDir`.
144
+ // Keep only the entries for the REQUESTED dir; if the dir matches nothing at
145
+ // all, fall back to the fs scan (same semantics as the old whole-manifest
146
+ // contentDir mismatch).
147
+ const wanted = path.resolve(contentDir)
148
+ const baseMatches = path.resolve(manifest.contentDir) === wanted
149
+ const entries = manifest.entries.filter((e) =>
150
+ e.contentDir !== undefined ? path.resolve(e.contentDir) === wanted : baseMatches,
151
+ )
152
+ if (entries.length === 0 && !baseMatches) return null
153
+ return { ...manifest, entries }
154
+ }
155
+
156
+ /** The slice of a FlatRoute the manifest derivation reads (structural — avoids
157
+ * a circular import with runtime/md/emit.ts's FlatRouteLike). `Component` is
158
+ * declared (as unknown — never read here) so the all-optional shape shares a
159
+ * property with Route and FlatRoute[] passes TS weak-type detection. */
160
+ export interface MdManifestFlatRouteLike {
161
+ fullPath?: string
162
+ nativeTemplate?: string
163
+ chain?: Array<{ Component?: unknown; __mdSource?: MdRouteSource }>
164
+ }
165
+
166
+ /** Derive the frozen md manifest from the FLAT ROUTE TABLE (single source of
167
+ * truth — never re-scan the fs at manifest-write time). Returns `null` when the
168
+ * app has no md routes, so callers can skip ALL md output (the `brust build`
169
+ * byte-identical invariant for md-free apps). With multiple `mdRoutes()` dirs,
170
+ * the first dir seen is the manifest's top-level `contentDir`; entries from
171
+ * other dirs carry a per-entry `contentDir`. */
172
+ export function mdManifestFromFlatRoutes(
173
+ flatRoutes: MdManifestFlatRouteLike[],
174
+ ): { contentDir: string; entries: MdManifestEntry[] } | null {
175
+ let primary: string | undefined
176
+ const entries: MdManifestEntry[] = []
177
+ for (const r of flatRoutes) {
178
+ const src = r.chain?.[r.chain.length - 1]?.__mdSource
179
+ if (src === undefined || typeof r.nativeTemplate !== 'string') continue
180
+ if (typeof r.fullPath !== 'string') {
181
+ throw new Error(
182
+ `md route "${r.nativeTemplate}" has no fullPath — pass flattened routes (defineRoutes output)`,
183
+ )
184
+ }
185
+ primary ??= src.contentDir
186
+ const entry: MdManifestEntry = {
187
+ relPath: src.relPath,
188
+ templateName: r.nativeTemplate,
189
+ urlPath: r.fullPath,
190
+ frontmatter: src.frontmatter,
191
+ }
192
+ if (path.resolve(src.contentDir) !== path.resolve(primary)) entry.contentDir = src.contentDir
193
+ entries.push(entry)
194
+ }
195
+ return primary === undefined ? null : { contentDir: primary, entries }
196
+ }
197
+
198
+ /** mdNav needs the prefix mdRoutes mounted a content dir under; record it at
199
+ * mdRoutes() time (routes.tsx runs in the same process). Keyed by resolved
200
+ * content dir. mdNav falls back to '/' when mdRoutes wasn't called. */
201
+ const mdNavPrefixes = new Map<string, string>()
202
+
203
+ /** Resolve the page list for a content dir: frozen manifest in a prebuilt
204
+ * run, fs scan otherwise. */
205
+ function resolveMdPages(contentDir: string, prefix: string): MdManifestEntry[] {
206
+ const manifest = loadPrebuiltMdManifest(contentDir)
207
+ if (manifest) {
208
+ // urlPath is recomputed from relPath + the caller's prefix so the routes
209
+ // stay deterministic even if the manifest was written with another prefix.
210
+ return manifest.entries.map((e) => ({ ...e, urlPath: mdUrlPath(e.relPath, prefix) }))
211
+ }
212
+ return scanMdDir(contentDir).map((f) => ({
213
+ relPath: f.relPath,
214
+ templateName: mdTemplateName(f.relPath),
215
+ urlPath: mdUrlPath(f.relPath, prefix),
216
+ frontmatter: f.frontmatter,
217
+ }))
218
+ }
219
+
220
+ /** Turn a directory of `.md` files into native Route entries. Each file gets a
221
+ * synthetic named component (name = its jinja template name, satisfying
222
+ * `validateRoute`'s native checks) and a loader exposing the frontmatter as
223
+ * `{ __md: { title, description } }`. With `layout`, returns ONE parent route
224
+ * at `prefix` whose children carry prefix-relative paths; without, the leaves
225
+ * carry the full prefixed path. */
226
+ export function mdRoutes(contentDir: string, opts: MdRoutesOptions = {}): Route[] {
227
+ const prefix = opts.prefix ?? '/'
228
+ const resolvedDir = path.resolve(contentDir)
229
+ const existingPrefix = mdNavPrefixes.get(resolvedDir)
230
+ if (existingPrefix !== undefined && existingPrefix !== prefix) {
231
+ console.warn(
232
+ `[brust] mdRoutes: content dir "${contentDir}" already mounted under prefix "${existingPrefix}"; mdNav will use "${prefix}"`,
233
+ )
234
+ }
235
+ mdNavPrefixes.set(resolvedDir, prefix)
236
+ const components = opts.components ?? {}
237
+ if (opts.layout !== undefined && !opts.layout.name) {
238
+ throw new Error(
239
+ 'mdRoutes: layout component must be a NAMED component (its name keys the native template)',
240
+ )
241
+ }
242
+ const layoutName = opts.layout?.name
243
+ // Normalized prefix URL ('/docs' for '/docs/', '/' for '').
244
+ const basePath = mdUrlPath('index.md', prefix)
245
+
246
+ const leaves: MdRoute[] = resolveMdPages(contentDir, prefix).map((page) => {
247
+ const C = () => null
248
+ // validateRoute requires a NAMED component for native routes; the name is
249
+ // also what flattenRoutes captures as `nativeTemplate`, so it must equal
250
+ // the emitted jinja template name.
251
+ Object.defineProperty(C, 'name', { value: page.templateName })
252
+ const title = typeof page.frontmatter.title === 'string' ? page.frontmatter.title : undefined
253
+ const description =
254
+ typeof page.frontmatter.description === 'string' ? page.frontmatter.description : undefined
255
+ return {
256
+ path: opts.layout ? relativeToBase(page.urlPath, basePath) : page.urlPath,
257
+ native: true,
258
+ Component: C,
259
+ // Uniform loader (chained AND standalone): head metadata only.
260
+ loader: async () => ({ __md: { title, description } }),
261
+ __mdSource: {
262
+ absPath: path.resolve(contentDir, page.relPath),
263
+ relPath: page.relPath,
264
+ contentDir,
265
+ frontmatter: page.frontmatter,
266
+ components,
267
+ layoutName,
268
+ },
269
+ }
270
+ })
271
+
272
+ if (opts.layout === undefined) return leaves
273
+ return [{ path: basePath, native: true, Component: opts.layout, children: leaves }]
274
+ }
275
+
276
+ /** Strip the mount base off a full url path (index page → `''`, which
277
+ * `joinPath` composes back onto the parent path unchanged). */
278
+ function relativeToBase(urlPath: string, basePath: string): string {
279
+ if (urlPath === basePath) return ''
280
+ return basePath === '/' ? urlPath.slice(1) : urlPath.slice(basePath.length + 1)
281
+ }
282
+
283
+ export interface MdNavItem {
284
+ title: string
285
+ path: string
286
+ order?: number
287
+ }
288
+
289
+ export interface MdNavGroup {
290
+ group: string | null
291
+ items: MdNavItem[]
292
+ }
293
+
294
+ /** Navigation model for a content dir: items grouped by `frontmatter.nav.group`
295
+ * (ungrouped pages land in a `group: null` top-level bucket), sorted by
296
+ * `nav.order` (missing order sorts last) then title. Paths use the prefix the
297
+ * dir was mounted under by `mdRoutes` (frozen-manifest urlPaths in a prebuilt
298
+ * run; `'/'` if mdRoutes was never called for the dir). Group order follows
299
+ * the first appearance of each group in the sorted item sequence. */
300
+ export function mdNav(contentDir: string): MdNavGroup[] {
301
+ const manifest = loadPrebuiltMdManifest(contentDir)
302
+ const navPrefix = mdNavPrefixes.get(path.resolve(contentDir)) ?? '/'
303
+ const pages: MdManifestEntry[] = manifest
304
+ ? // urlPath recomputed against the LIVE prefix (same rule as mdRoutes) so
305
+ // nav links can never diverge from the routes when prefixes change
306
+ // between build and boot.
307
+ manifest.entries.map((e) => ({ ...e, urlPath: mdUrlPath(e.relPath, navPrefix) }))
308
+ : scanMdDir(contentDir).map((f) => ({
309
+ relPath: f.relPath,
310
+ templateName: mdTemplateName(f.relPath),
311
+ urlPath: mdUrlPath(f.relPath, mdNavPrefixes.get(path.resolve(contentDir)) ?? '/'),
312
+ frontmatter: f.frontmatter,
313
+ }))
314
+
315
+ const items = pages.map((p) => {
316
+ const nav = p.frontmatter.nav
317
+ return {
318
+ group: typeof nav?.group === 'string' ? nav.group : null,
319
+ title:
320
+ typeof p.frontmatter.title === 'string' ? p.frontmatter.title : defaultTitle(p.relPath),
321
+ path: p.urlPath,
322
+ order: typeof nav?.order === 'number' ? nav.order : undefined,
323
+ }
324
+ })
325
+ items.sort(
326
+ (a, b) =>
327
+ (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY) ||
328
+ a.title.localeCompare(b.title),
329
+ )
330
+
331
+ const groups = new Map<string | null, MdNavItem[]>()
332
+ for (const item of items) {
333
+ let bucket = groups.get(item.group)
334
+ if (bucket === undefined) {
335
+ bucket = []
336
+ groups.set(item.group, bucket)
337
+ }
338
+ bucket.push({ title: item.title, path: item.path, order: item.order })
339
+ }
340
+ return [...groups].map(([group, groupItems]) => ({ group, items: groupItems }))
341
+ }
342
+
343
+ /** Title fallback for pages without a frontmatter title: the file stem. */
344
+ function defaultTitle(relPath: string): string {
345
+ const stem = relPath.replace(/\.md$/, '')
346
+ return stem.split('/').pop() ?? stem
347
+ }
@@ -0,0 +1,175 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface MdFile {
5
+ /** Posix-separated path relative to the content dir, e.g. 'query/where.md'. */
6
+ relPath: string
7
+ absPath: string
8
+ frontmatter: {
9
+ title?: string
10
+ description?: string
11
+ nav?: { group?: string; order?: number }
12
+ [k: string]: unknown
13
+ }
14
+ /** Markdown source after the frontmatter block is stripped. */
15
+ body: string
16
+ }
17
+
18
+ /**
19
+ * Recursively scans `contentDir` for `.md` files, sorted by `relPath`.
20
+ *
21
+ * Frontmatter is a leading `---` … `---` block parsed as a hand-rolled YAML
22
+ * subset (NO yaml dependency):
23
+ * - `key: value` lines; keys match `[A-Za-z0-9_-]+`
24
+ * - values: double-quoted strings (JSON escapes), single-quoted strings (no
25
+ * escapes), bare strings, numbers, booleans
26
+ * - one-level nested maps via the INLINE-BRACES form only, e.g.
27
+ * `nav: { group: "Getting Started", order: 1 }` — indented child keys are
28
+ * NOT supported and throw
29
+ * - blank lines inside the block are ignored
30
+ *
31
+ * Files without a frontmatter block get `frontmatter: {}` and the whole file
32
+ * as `body`. Malformed frontmatter throws with `<absPath>:<line>`. CRLF line
33
+ * endings are tolerated.
34
+ */
35
+ export function scanMdDir(contentDir: string): MdFile[] {
36
+ const relPaths: string[] = []
37
+ collectMd(contentDir, '', relPaths)
38
+ relPaths.sort()
39
+ return relPaths.map((relPath) => {
40
+ const absPath = join(contentDir, relPath)
41
+ const source = readFileSync(absPath, 'utf8')
42
+ const { frontmatter, body } = splitFrontmatter(source, absPath)
43
+ return { relPath, absPath, frontmatter, body }
44
+ })
45
+ }
46
+
47
+ function collectMd(dir: string, relPrefix: string, out: string[]): void {
48
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
49
+ const rel = relPrefix === '' ? entry.name : `${relPrefix}/${entry.name}`
50
+ if (entry.isDirectory()) collectMd(join(dir, entry.name), rel, out)
51
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(rel)
52
+ }
53
+ }
54
+
55
+ function splitFrontmatter(
56
+ source: string,
57
+ absPath: string,
58
+ ): { frontmatter: MdFile['frontmatter']; body: string } {
59
+ // BOM would otherwise make the opening fence read '---' and the whole
60
+ // block silently fall through as body — common with Windows editors.
61
+ const lines = source.replace(/^\uFEFF/, '').split('\n')
62
+ // Fences tolerate trailing whitespace (and CRLF) — `--- ` is still a fence.
63
+ const isFence = (line: string) => line.trimEnd() === '---'
64
+ if (!isFence(lines[0] ?? '')) return { frontmatter: {}, body: source }
65
+
66
+ let closeIdx = -1
67
+ for (let i = 1; i < lines.length; i++) {
68
+ if (isFence(lines[i] ?? '')) {
69
+ closeIdx = i
70
+ break
71
+ }
72
+ }
73
+ if (closeIdx === -1) {
74
+ throw new Error(`${absPath}:1 unterminated frontmatter block (missing closing ---)`)
75
+ }
76
+
77
+ const frontmatter: MdFile['frontmatter'] = {}
78
+ for (let i = 1; i < closeIdx; i++) {
79
+ const line = stripCr(lines[i] ?? '')
80
+ const fileLine = i + 1
81
+ if (line.trim() === '') continue
82
+ const m = /^([A-Za-z0-9_-]+):(.*)$/.exec(line)
83
+ if (m === null) {
84
+ throw new Error(
85
+ `${absPath}:${fileLine} malformed frontmatter line (expected 'key: value'): ${line.trim()}`,
86
+ )
87
+ }
88
+ const key = m[1] as string
89
+ const raw = (m[2] as string).trim()
90
+ frontmatter[key] = raw.startsWith('{')
91
+ ? parseInlineMap(raw, absPath, fileLine)
92
+ : parseScalar(raw, absPath, fileLine)
93
+ }
94
+
95
+ const body = lines.slice(closeIdx + 1).join('\n')
96
+ return { frontmatter, body }
97
+ }
98
+
99
+ function stripCr(line: string): string {
100
+ return line.endsWith('\r') ? line.slice(0, -1) : line
101
+ }
102
+
103
+ function parseScalar(raw: string, absPath: string, fileLine: number): unknown {
104
+ if (raw.startsWith('"')) {
105
+ if (raw.length < 2 || !raw.endsWith('"')) {
106
+ throw new Error(`${absPath}:${fileLine} unterminated double-quoted string: ${raw}`)
107
+ }
108
+ try {
109
+ return JSON.parse(raw)
110
+ } catch {
111
+ throw new Error(`${absPath}:${fileLine} invalid double-quoted string: ${raw}`)
112
+ }
113
+ }
114
+ if (raw.startsWith("'")) {
115
+ if (raw.length < 2 || !raw.endsWith("'")) {
116
+ throw new Error(`${absPath}:${fileLine} unterminated single-quoted string: ${raw}`)
117
+ }
118
+ return raw.slice(1, -1)
119
+ }
120
+ if (raw === 'true') return true
121
+ if (raw === 'false') return false
122
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
123
+ return raw
124
+ }
125
+
126
+ /** Parses the inline-braces map form: `{ key: value, key2: value2 }` (one level). */
127
+ function parseInlineMap(raw: string, absPath: string, fileLine: number): Record<string, unknown> {
128
+ if (!raw.endsWith('}')) {
129
+ throw new Error(`${absPath}:${fileLine} unterminated inline map (missing closing }): ${raw}`)
130
+ }
131
+ const inner = raw.slice(1, -1).trim()
132
+ const map: Record<string, unknown> = {}
133
+ if (inner === '') return map
134
+ for (const entry of splitTopLevel(inner, absPath, fileLine)) {
135
+ const m = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*:(.*)$/.exec(entry)
136
+ if (m === null) {
137
+ throw new Error(
138
+ `${absPath}:${fileLine} malformed inline map entry (expected 'key: value'): ${entry.trim()}`,
139
+ )
140
+ }
141
+ const key = (m[1] ?? m[2] ?? m[3]) as string
142
+ const value = (m[4] as string).trim()
143
+ if (value === '') {
144
+ throw new Error(`${absPath}:${fileLine} inline map entry "${key}" has no value`)
145
+ }
146
+ map[key] = parseScalar(value, absPath, fileLine)
147
+ }
148
+ return map
149
+ }
150
+
151
+ /** Splits map entries on commas that sit outside quoted strings. */
152
+ function splitTopLevel(inner: string, absPath: string, fileLine: number): string[] {
153
+ const parts: string[] = []
154
+ let current = ''
155
+ let quote: '"' | "'" | null = null
156
+ for (const ch of inner) {
157
+ if (quote !== null) {
158
+ current += ch
159
+ if (ch === quote) quote = null
160
+ } else if (ch === '"' || ch === "'") {
161
+ current += ch
162
+ quote = ch
163
+ } else if (ch === ',') {
164
+ parts.push(current)
165
+ current = ''
166
+ } else {
167
+ current += ch
168
+ }
169
+ }
170
+ if (quote !== null) {
171
+ throw new Error(`${absPath}:${fileLine} unterminated string inside inline map`)
172
+ }
173
+ parts.push(current)
174
+ return parts
175
+ }
@@ -5,6 +5,14 @@ import { scanImports } from '../cli/native-routes-emit.ts'
5
5
 
6
6
  const BEHAVIOR_RE = /export\s+const\s+behavior\b/
7
7
 
8
+ /** The single island-vs-behavior classifier: a component source is a native
9
+ * behavior component iff it has `export const behavior`. Shared by
10
+ * `scanDirectiveComponents` and the md emit step (runtime/md/emit.ts) so the
11
+ * two paths can never diverge. */
12
+ export function isBehaviorSource(src: string): boolean {
13
+ return BEHAVIOR_RE.test(src)
14
+ }
15
+
8
16
  /** Deterministic, app-unique directive name = camelCase(basename) + "_" + 8 hex of
9
17
  * sha256(cwd-relative path). The SINGLE name contract: chunk filename, runtime
10
18
  * registry key, and the compiler-emitted `x-data` all derive from this. */
@@ -36,7 +44,7 @@ export function scanDirectiveComponents(routesEntryFile: string): Map<string, st
36
44
  for (const dep of scanImports(filePath).values()) {
37
45
  if (!visited.has(dep)) queue.push(dep)
38
46
  }
39
- if (BEHAVIOR_RE.test(src)) {
47
+ if (isBehaviorSource(src)) {
40
48
  const name = directiveName(filePath, process.cwd())
41
49
  const existing = found.get(name)
42
50
  if (existing && existing !== filePath) {
@@ -1,3 +1,6 @@
1
1
  // brustjs/native — directive runtime registration surface. React-free, dom-only.
2
- // Authors do NOT import this directly; the build-generated _directives.js entry does.
2
+ // Authors do NOT import the runtime FUNCTIONS directly; the build-generated
3
+ // _directives.js entry does. The TYPES below ARE for authors — annotate a
4
+ // `behavior`'s ctx (e.g. `({ effect }: BehaviorCtx) => …`).
3
5
  export { register, start } from './runtime.ts'
6
+ export type { Behavior, BehaviorCtx, Instance } from './runtime.ts'
@@ -4,7 +4,22 @@ import { batch, effect, isComputed, isSignal, signal } from '../store/index.ts'
4
4
  import type { Signal } from '../store/index.ts'
5
5
 
6
6
  export type Instance = Record<string, unknown>
7
- export type Behavior = (ctx: { el: HTMLElement; props: unknown }) => Instance
7
+
8
+ /** What a `behavior` receives. Beyond `el`/`props`, two lifecycle helpers whose
9
+ * teardown auto-joins the component's disposer set (run on unmount / SPA-nav swap):
10
+ * - `effect(fn)` — a reactive effect (React `useEffect` semantics: `fn` may return
11
+ * a cleanup that runs before each re-run and on unmount). Use it
12
+ * for side-effects on signal change (sync localStorage, the DOM
13
+ * outside the component, timers). Returns the disposer too.
14
+ * - `onCleanup(fn)` — register a one-shot teardown for unmount (e.g. removeEventListener). */
15
+ export interface BehaviorCtx {
16
+ el: HTMLElement
17
+ props: unknown
18
+ // biome-ignore lint/suspicious/noConfusingVoidType: React useEffect return shape (`void | Destructor`) — see store `effect`.
19
+ effect: (fn: () => void | (() => void)) => () => void
20
+ onCleanup: (fn: () => void) => void
21
+ }
22
+ export type Behavior = (ctx: BehaviorCtx) => Instance
8
23
 
9
24
  interface Mounted {
10
25
  disposers: Array<() => void>
@@ -79,9 +94,25 @@ function mountElement(el: HTMLElement): void {
79
94
  console.warn(`[brust] x-props on "${name}" is not valid JSON`)
80
95
  }
81
96
  }
82
- const instance = behavior({ el, props })
97
+ // Build the disposer set and register the element as mounted BEFORE invoking the
98
+ // behavior, so (a) the ctx `effect`/`onCleanup` helpers can register teardown
99
+ // (a ctx effect runs immediately during behavior(), pushing its disposer here),
100
+ // and (b) a behavior that synchronously triggers a re-entrant mount of THIS
101
+ // element (e.g. a ctx effect whose signal write reaches scanAndMount) hits the
102
+ // `mounted.has(el)` guard above instead of creating a second, leaked Mounted.
83
103
  const m: Mounted = { disposers: [] }
84
104
  mounted.set(el, m)
105
+ // ctxEffect/onCleanup typed via BehaviorCtx so the `void | Destructor` shape is
106
+ // declared in one place (the interface) — no inline void-union to suppress here.
107
+ const ctxEffect: BehaviorCtx['effect'] = (fn) => {
108
+ const dispose = effect(fn)
109
+ m.disposers.push(dispose)
110
+ return dispose
111
+ }
112
+ const onCleanup: BehaviorCtx['onCleanup'] = (fn) => {
113
+ m.disposers.push(fn)
114
+ }
115
+ const instance = behavior({ el, props, effect: ctxEffect, onCleanup })
85
116
  bindTree(el, instance, m.disposers)
86
117
  if (typeof instance.init === 'function') {
87
118
  try {
package/runtime/routes.ts CHANGED
@@ -27,6 +27,19 @@ import type { EndpointDef } from './define-actions.ts'
27
27
  import { isRespondSentinel, makeRespond } from './define-actions.ts'
28
28
  import { validate } from './standard-schema.ts'
29
29
 
30
+ // Markdown pages (task 2.11): the md API is part of the `brustjs/routes`
31
+ // surface — apps spread `...mdRoutes('content/docs', …)` into defineRoutes and
32
+ // render sidebars from `mdNav(...)`. Re-exported here so user code never
33
+ // imports runtime-internal paths.
34
+ export { mdNav, mdRoutes } from './md/routes.ts'
35
+ export type {
36
+ MdNavGroup,
37
+ MdNavItem,
38
+ MdRoute,
39
+ MdRoutesOptions,
40
+ MdRouteSource,
41
+ } from './md/routes.ts'
42
+
30
43
  // S2 + B3 — unified per-request scope. The request scope (B3 cookies/context) is
31
44
  // the OUTERMOST layer, then the request-scoped loader cache/dedupe (S2: one Map
32
45
  // per request for `cachedFetch`/`dedupe` across loaders AND render), then the