brustjs 0.1.39-alpha → 0.1.41-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.
- package/README.md +34 -0
- package/package.json +18 -8
- package/runtime/cli/build.ts +123 -26
- package/runtime/cli/dev.ts +21 -0
- package/runtime/cli/help.ts +19 -0
- package/runtime/cli/jinja-staleness.ts +55 -7
- package/runtime/cli/native-routes-emit.ts +29 -7
- package/runtime/cli/ssg.ts +257 -0
- package/runtime/dev/coordinator.ts +16 -4
- package/runtime/dev/watcher.ts +16 -5
- package/runtime/index.js +52 -52
- package/runtime/index.ts +68 -3
- package/runtime/islands/bootstrap.ts +23 -0
- package/runtime/islands/build.ts +23 -1
- package/runtime/islands/native-render.ts +16 -3
- package/runtime/md/emit.ts +544 -0
- package/runtime/md/render.ts +469 -0
- package/runtime/md/routes.ts +347 -0
- package/runtime/md/scan.ts +175 -0
- package/runtime/native/build.ts +9 -1
- package/runtime/routes.ts +13 -0
|
@@ -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
|
+
}
|
package/runtime/native/build.ts
CHANGED
|
@@ -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 (
|
|
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) {
|
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
|