flamefront 0.0.0 → 0.1.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/src/vite.ts ADDED
@@ -0,0 +1,1030 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import { compile as compileOctane } from "octane/compiler"
5
+ import type { Features, HastPluginList, MdastPluginList } from "satteri"
6
+ import vitePluginSatteri, { type MdxOptions } from "vite-plugin-satteri"
7
+ import type {
8
+ AppDefinition,
9
+ GeneratedHydration,
10
+ GeneratedRouteMetadata,
11
+ NormalizedRoutingOptions,
12
+ RouteConfig,
13
+ RouteDefinition,
14
+ } from "./index.ts"
15
+ import { generate, parse, traverse, type Babel } from "./babel.ts"
16
+ import { expandGlob, globDirectory, setGlobRoot } from "./glob.ts"
17
+ import { removeExports } from "./remove-exports.ts"
18
+ import { writeRouteImportMap } from "./typegen.ts"
19
+ import {
20
+ resolveFlamefrontOutput,
21
+ type FlamefrontOutputOptions,
22
+ type ResolvedFlamefrontOutput,
23
+ } from "./output.ts"
24
+
25
+ export type {
26
+ FlamefrontAdapter,
27
+ FlamefrontOutputOptions,
28
+ FlamefrontTarget,
29
+ ResolvedFlamefrontOutput,
30
+ } from "./output.ts"
31
+
32
+ export const remixRoutesId = "virtual:flamefront/remix-routes"
33
+ const resolvedRemixRoutesId = `\0${remixRoutesId}`
34
+
35
+ export const serverRoutesId = "virtual:flamefront/server-routes"
36
+ const resolvedServerRoutesId = `\0${serverRoutesId}`
37
+
38
+ export const serverEntryId = "virtual:flamefront/server-entry"
39
+ const resolvedServerEntryId = `\0${serverEntryId}`
40
+ const hydrationRouteId = "/@flamefront/hydration-route.tsrx"
41
+ const resolvedHydrationRoutePrefix = `${hydrationRouteId}?`
42
+ const markdownRouteId = "/@flamefront/markdown-route.tsrx"
43
+ const resolvedMarkdownRoutePrefix = `${markdownRouteId}?`
44
+ const SERVER_ONLY_ROUTE_EXPORTS = ["loader"] as const
45
+ const serverFilePattern = /\.server(?:\.[cm]?[jt]sx?|\.tsrx)$/
46
+ const serverDirectoryPattern = /\/\.server\//
47
+
48
+ export interface FlamefrontOptions extends FlamefrontOutputOptions {
49
+ /** Project-root route manifest module. */
50
+ readonly routes?: string
51
+ /** Built-in Markdown and MDX compiler configuration. */
52
+ readonly markdown?: false | MarkdownOptions
53
+ }
54
+
55
+ export interface MarkdownOptions {
56
+ /** Parser feature toggles. GFM and frontmatter are enabled by default. */
57
+ readonly features?: Features
58
+ /** MDAST plugins shared by Markdown and MDX entries. */
59
+ readonly mdastPlugins?: MdastPluginList
60
+ /** HAST plugins shared by Markdown and MDX entries. */
61
+ readonly hastPlugins?: HastPluginList
62
+ /** MDX compiler options; its JSX import source is always `octane`. */
63
+ readonly mdx?:
64
+ boolean | Omit<MdxOptions, "jsxImportSource" | "jsx" | "jsxRuntime">
65
+ }
66
+
67
+ function quote(value: string): string {
68
+ return JSON.stringify(value)
69
+ }
70
+
71
+ function lazyLayout(entry: string, metadata: GeneratedRouteMetadata): string {
72
+ return `async () => { const routeModule = await import(${quote(entry)}); return { Component: createRouteBoundary(routeModule.default, ${JSON.stringify(metadata)}) }; }`
73
+ }
74
+
75
+ function generatesHydrationBoundary(
76
+ routeDefinition: RouteDefinition,
77
+ ): routeDefinition is RouteDefinition & {
78
+ hydration: GeneratedHydration | "none"
79
+ } {
80
+ return (
81
+ (routeDefinition.render === "server" ||
82
+ routeDefinition.render === "static") &&
83
+ (routeDefinition.hydration === "none" ||
84
+ typeof routeDefinition.hydration === "object")
85
+ )
86
+ }
87
+
88
+ function hydrationComponentId(
89
+ entry: string,
90
+ hydration: GeneratedHydration | "none",
91
+ ): string {
92
+ const parameters = new URLSearchParams({
93
+ entry,
94
+ hydration: JSON.stringify(hydration),
95
+ })
96
+
97
+ return `${hydrationRouteId}?${parameters}`
98
+ }
99
+
100
+ function markdownComponentId(entry: string): string {
101
+ const parameters = new URLSearchParams({
102
+ entry,
103
+ "flamefront-markdown": "1",
104
+ })
105
+
106
+ return `${markdownRouteId}?${parameters}`
107
+ }
108
+
109
+ function browserRouteModuleId(routeDefinition: RouteDefinition): string {
110
+ const componentEntry =
111
+ routeDefinition.content === "markdown"
112
+ ? markdownComponentId(routeDefinition.entry)
113
+ : routeDefinition.entry
114
+
115
+ return generatesHydrationBoundary(routeDefinition)
116
+ ? hydrationComponentId(componentEntry, routeDefinition.hydration)
117
+ : componentEntry
118
+ }
119
+
120
+ function generatedRouteId(kind: "layout" | "route", location: string): string {
121
+ return `flamefront:${kind}:${location}`
122
+ }
123
+
124
+ function generatedRouteMetadata(
125
+ config: RouteConfig,
126
+ location: string,
127
+ parent: string,
128
+ ): GeneratedRouteMetadata {
129
+ if ("children" in config) {
130
+ const id = generatedRouteId("layout", location)
131
+
132
+ return {
133
+ id,
134
+ boundary: id,
135
+ kind: "layout",
136
+ entry: config.entry,
137
+ parent,
138
+ navigation: "router",
139
+ }
140
+ }
141
+
142
+ const id = generatedRouteId("route", location)
143
+
144
+ return {
145
+ id,
146
+ boundary: id,
147
+ kind: "route",
148
+ entry: config.entry,
149
+ parent,
150
+ path: config.path,
151
+ render: config.render,
152
+ navigation: config.render === "client" ? "router" : "fragment",
153
+ hydration: config.hydration,
154
+ }
155
+ }
156
+
157
+ function generateRoutePreloaders(routeTree: readonly RouteConfig[]): string {
158
+ const preloaders = new Map<string, string[]>()
159
+
160
+ const visit = (
161
+ configs: readonly RouteConfig[],
162
+ layoutEntries: readonly string[],
163
+ ) => {
164
+ for (const config of configs) {
165
+ if ("children" in config) {
166
+ visit(config.children, [...layoutEntries, config.entry])
167
+ continue
168
+ }
169
+
170
+ if (config.render !== "client") {
171
+ continue
172
+ }
173
+
174
+ const imports = [...layoutEntries, browserRouteModuleId(config)]
175
+ const existing = preloaders.get(config.entry) ?? []
176
+
177
+ for (const entry of imports) {
178
+ if (!existing.includes(entry)) {
179
+ existing.push(entry)
180
+ }
181
+ }
182
+
183
+ preloaders.set(config.entry, existing)
184
+ }
185
+ }
186
+
187
+ visit(routeTree, [])
188
+ const entries = [...preloaders.entries()]
189
+ .map(([entry, imports]) => {
190
+ const preload = imports
191
+ .map((moduleId) => `import(${quote(moduleId)})`)
192
+ .join(", ")
193
+
194
+ return `\t${quote(entry)}: () => Promise.all([${preload}])`
195
+ })
196
+ .join(",\n")
197
+
198
+ return `const routePreloaders = {\n${entries}\n};\n\nexport function preloadRoute(entry) {\n\tconst preload = routePreloaders[entry];\n\treturn preload ? preload().then(() => undefined) : Promise.resolve();\n}\n`
199
+ }
200
+
201
+ function lazyRoute(
202
+ routeDefinition: RouteDefinition,
203
+ routing: NormalizedRoutingOptions,
204
+ metadata: GeneratedRouteMetadata,
205
+ ): string {
206
+ const { entry } = routeDefinition
207
+ const browserLoader =
208
+ routeDefinition.render === "client" ? "loadRouteData" : "loadRouteFragment"
209
+ const browserLoaderExpression =
210
+ routeDefinition.render === "client"
211
+ ? `(args) => ${browserLoader}(args, ${JSON.stringify(routing)})`
212
+ : `(args) => ${browserLoader}(args, ${JSON.stringify(routing)}, ${quote(routeDefinition.render)})`
213
+
214
+ if (routeDefinition.render !== "client") {
215
+ const componentId = browserRouteModuleId(routeDefinition)
216
+
217
+ return `async () => { if (import.meta.env.SSR) { const [routeModule, componentModule] = await Promise.all([import(${quote(entry)}), import(${quote(componentId)})]); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: routeModule.loader }; } const componentModule = await import(${quote(componentId)}); return { Component: createRouteFragmentRoute({ metadata: ${JSON.stringify(metadata)}, routing: ${JSON.stringify(routing)}, policy: ${quote(routeDefinition.render)}, fallbackComponent: componentModule.default }), loader: ${browserLoaderExpression} }; }`
218
+ }
219
+
220
+ if (routeDefinition.render === "client") {
221
+ if (routeDefinition.content === "markdown") {
222
+ const componentId = browserRouteModuleId(routeDefinition)
223
+
224
+ return `async () => { if (import.meta.env.SSR) return {}; const componentModule = await import(${quote(componentId)}); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression} }; }`
225
+ }
226
+
227
+ return `async () => { if (import.meta.env.SSR) return {}; const routeModule = await import(${quote(entry)}); return { Component: createRouteBoundary(routeModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression} }; }`
228
+ }
229
+
230
+ throw new Error(`Unsupported route render mode ${routeDefinition.render}.`)
231
+ }
232
+
233
+ function collectRouteMetadata(
234
+ routeTree: readonly RouteConfig[],
235
+ shell: string,
236
+ shellHydration: AppDefinition["shellHydration"],
237
+ ): readonly GeneratedRouteMetadata[] {
238
+ const rootId = "flamefront:shell:root"
239
+ const metadata: GeneratedRouteMetadata[] = [
240
+ {
241
+ id: rootId,
242
+ boundary: rootId,
243
+ kind: "shell",
244
+ entry: shell,
245
+ navigation: "router",
246
+ hydration: shellHydration,
247
+ },
248
+ ]
249
+
250
+ const visit = (
251
+ configs: readonly RouteConfig[],
252
+ parent: string,
253
+ locationPrefix = "",
254
+ ) => {
255
+ configs.forEach((config, index) => {
256
+ const location = locationPrefix
257
+ ? `${locationPrefix}.${index}`
258
+ : String(index)
259
+ const node = generatedRouteMetadata(config, location, parent)
260
+
261
+ metadata.push(node)
262
+ if ("children" in config) {
263
+ visit(config.children, node.id, location)
264
+ }
265
+ })
266
+ }
267
+
268
+ visit(routeTree, rootId)
269
+ return metadata
270
+ }
271
+
272
+ function generateConfigs(
273
+ configs: readonly RouteConfig[],
274
+ routing: NormalizedRoutingOptions,
275
+ depth = 1,
276
+ parent = "flamefront:shell:root",
277
+ locationPrefix = "",
278
+ ): string {
279
+ const indent = "\t".repeat(depth)
280
+ const childIndent = "\t".repeat(depth + 1)
281
+
282
+ return configs
283
+ .map((config, index) => {
284
+ const location = locationPrefix
285
+ ? `${locationPrefix}.${index}`
286
+ : String(index)
287
+ const metadata = generatedRouteMetadata(config, location, parent)
288
+
289
+ if ("children" in config) {
290
+ return `${indent}{\n${childIndent}id: ${quote(metadata.id)},\n${childIndent}lazy: ${lazyLayout(config.entry, metadata)},\n${childIndent}handle: { flamefront: ${JSON.stringify(metadata)} },\n${childIndent}children: [\n${generateConfigs(config.children, routing, depth + 2, metadata.id, location)}\n${childIndent}],\n${indent}}`
291
+ }
292
+
293
+ return `${indent}{\n${childIndent}id: ${quote(metadata.id)},\n${childIndent}path: ${quote(config.path)},\n${childIndent}lazy: ${lazyRoute(config, routing, metadata)},\n${childIndent}handle: { flamefront: ${JSON.stringify(metadata)} },\n${indent}}`
294
+ })
295
+ .join(",\n")
296
+ }
297
+
298
+ export function generateRemixRoutes(
299
+ app: Pick<
300
+ AppDefinition,
301
+ "shell" | "shellHydration" | "routeTree" | "routing"
302
+ >,
303
+ ): string {
304
+ const rootId = "flamefront:shell:root"
305
+ const routeMetadata = collectRouteMetadata(
306
+ app.routeTree,
307
+ app.shell,
308
+ app.shellHydration,
309
+ )
310
+
311
+ return `// Generated by Flamefront.\nimport Shell from ${quote(app.shell)};\nimport { RouterDocument } from 'flamefront/octane/router-document';\nimport { createRouteBoundary, createRouteFragmentRoute } from 'flamefront/fragment';\nimport { loadRouteData, loadRouteFragment } from 'flamefront/remix-router/data';\n\nexport { RouterDocument };\nexport const routing = ${JSON.stringify(app.routing)};\nexport const routeMetadata = ${JSON.stringify(routeMetadata)};\n\nexport const routes = [\n\t{\n\t\tid: ${quote(rootId)},\n\t\tComponent: createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])}),\n\t\thandle: { flamefront: ${JSON.stringify(routeMetadata[0])} },\n\t\tchildren: [\n${generateConfigs(app.routeTree, app.routing, 3)}\n\t\t],\n\t},\n];\n\n${generateRoutePreloaders(app.routeTree)}`
312
+ }
313
+
314
+ /** Generate the server-only route-module importer used by loader endpoints. */
315
+ export function generateServerRoutes(
316
+ app: Pick<AppDefinition, "routes">,
317
+ ): string {
318
+ const entries = [...new Set(app.routes.map((route) => route.entry))]
319
+ const imports = entries
320
+ .map((entry) => `\t${quote(entry)}: () => import(${quote(entry)})`)
321
+ .join(",\n")
322
+
323
+ return `// Generated by Flamefront.\nconst routeModules = {\n${imports}\n};\n\nexport async function importRoute(entry) {\n\tconst importModule = routeModules[entry];\n\tif (!importModule) throw new Error(\`No Vite route module was generated for \${entry}.\`);\n\treturn importModule();\n}\n`
324
+ }
325
+
326
+ /** Generate the server-entry import selected by `flamefront({...})`. */
327
+ export function generateServerEntry(output: ResolvedFlamefrontOutput): string {
328
+ if (output.adapter === "srvx") {
329
+ return `// Generated by Flamefront.\nexport { createSrvxServerEntry as createServerEntry } from "flamefront/srvx";\n`
330
+ }
331
+
332
+ return `// Generated by Flamefront.\nexport { createFetchServerEntry as createServerEntry } from "flamefront/fetch";\n`
333
+ }
334
+
335
+ function hydrationStrategy(hydration: GeneratedHydration | "none"): {
336
+ readonly importName: string
337
+ readonly expression: string
338
+ } {
339
+ if (hydration === "none") {
340
+ return { importName: "never", expression: "never()" }
341
+ }
342
+
343
+ const { when, ...options } = hydration
344
+
345
+ switch (when) {
346
+ case "idle":
347
+ return {
348
+ importName: "idle",
349
+ expression: `idle(${JSON.stringify(options)})`,
350
+ }
351
+ case "visible":
352
+ return {
353
+ importName: "visible",
354
+ expression: `visible(${JSON.stringify(options)})`,
355
+ }
356
+ case "interaction":
357
+ return {
358
+ importName: "interaction",
359
+ expression: `interaction(${JSON.stringify(options)})`,
360
+ }
361
+ case "media":
362
+ return {
363
+ importName: "media",
364
+ expression: `media(${quote(hydration.query)})`,
365
+ }
366
+ }
367
+ }
368
+
369
+ /** Generate the component adapter used when a route entry exports HTML. */
370
+ export function generateMarkdownRoute(entry: string): string {
371
+ return `import html from ${quote(entry)};\n\nexport default function MarkdownRoute() @{\n\t<div dangerouslySetInnerHTML={{ __html: html }} />\n}\n`
372
+ }
373
+
374
+ export function generateHydrationRoute(
375
+ entry: string,
376
+ hydration: GeneratedHydration | "none",
377
+ ): string {
378
+ const strategy = hydrationStrategy(hydration)
379
+
380
+ return `import { Hydrate } from 'octane';\nimport { ${strategy.importName} } from 'octane/hydration';\nimport Component from ${quote(entry)};\n\nexport default function HydrationRoute(props) @{\n\t<Hydrate when={${strategy.expression}}>\n\t\t<Component {...props} />\n\t</Hydrate>\n}\n`
381
+ }
382
+
383
+ interface TransformOptions {
384
+ readonly ssr?: boolean
385
+ }
386
+
387
+ interface ResolveOptions extends TransformOptions {
388
+ readonly scan?: boolean
389
+ custom?: Record<string, unknown>
390
+ }
391
+
392
+ interface PluginContext {
393
+ readonly environment?: {
394
+ readonly config?: { readonly command?: string; readonly consumer?: string }
395
+ }
396
+ resolve(
397
+ id: string,
398
+ importer: string | undefined,
399
+ options: ResolveOptions,
400
+ ): Promise<{ readonly id: string } | null>
401
+ }
402
+
403
+ interface OutputAsset {
404
+ readonly type: "asset"
405
+ readonly fileName: string
406
+ source: string | Uint8Array
407
+ }
408
+
409
+ interface OutputChunk {
410
+ readonly type: "chunk"
411
+ map?: SourceMapLike | null
412
+ }
413
+
414
+ type OutputBundle = Record<string, OutputAsset | OutputChunk>
415
+
416
+ interface SourceMapLike {
417
+ sources?: string[]
418
+ sourcesContent?: (string | null)[]
419
+ }
420
+
421
+ function cleanModuleId(id: string): string {
422
+ return id.split("?", 1)[0].replaceAll("\\", "/")
423
+ }
424
+
425
+ interface ManifestGlobTransform {
426
+ readonly code: string
427
+ readonly map: null
428
+ readonly directories: readonly string[]
429
+ }
430
+
431
+ interface TextReplacement {
432
+ readonly end: number
433
+ readonly start: number
434
+ readonly text: string
435
+ }
436
+
437
+ function staticStringArgument(
438
+ argument: Babel.Node | undefined,
439
+ ): string | undefined {
440
+ if (!argument) {
441
+ return undefined
442
+ }
443
+
444
+ if (argument.type === "StringLiteral") {
445
+ return argument.value
446
+ }
447
+
448
+ if (
449
+ argument.type === "TemplateLiteral" &&
450
+ argument.expressions.length === 0
451
+ ) {
452
+ return argument.quasis[0]?.value.cooked ?? ""
453
+ }
454
+
455
+ return undefined
456
+ }
457
+
458
+ function transformManifestGlobs(
459
+ source: string,
460
+ id: string,
461
+ root: string,
462
+ ): ManifestGlobTransform | null {
463
+ const ast = parse(source, {
464
+ sourceFilename: id,
465
+ sourceType: "module",
466
+ plugins: ["typescript", "jsx"],
467
+ })
468
+ const globBindings = new Set<string>()
469
+
470
+ traverse(ast, {
471
+ ImportDeclaration(path) {
472
+ if (path.node.source.value !== "flamefront") {
473
+ return
474
+ }
475
+
476
+ for (const specifier of path.node.specifiers) {
477
+ if (specifier.type !== "ImportSpecifier") {
478
+ continue
479
+ }
480
+
481
+ const imported = specifier.imported
482
+ const importedName =
483
+ imported.type === "Identifier" ? imported.name : imported.value
484
+
485
+ if (importedName === "glob") {
486
+ globBindings.add(specifier.local.name)
487
+ }
488
+ }
489
+ },
490
+ })
491
+
492
+ if (globBindings.size === 0) {
493
+ return null
494
+ }
495
+
496
+ const replacements: TextReplacement[] = []
497
+ const directories = new Set<string>()
498
+
499
+ traverse(ast, {
500
+ CallExpression(path) {
501
+ const callee = path.node.callee
502
+
503
+ if (callee.type !== "Identifier" || !globBindings.has(callee.name)) {
504
+ return
505
+ }
506
+
507
+ const argument = path.node.arguments[0]
508
+ const pattern = staticStringArgument(argument)
509
+
510
+ if (pattern === undefined) {
511
+ throw new TypeError(
512
+ `flamefront glob() in ${id} requires a string-literal pattern.`,
513
+ )
514
+ }
515
+
516
+ if (
517
+ typeof argument?.start !== "number" ||
518
+ typeof argument.end !== "number"
519
+ ) {
520
+ throw new TypeError(`flamefront glob() in ${id} has no source range.`)
521
+ }
522
+
523
+ const files = expandGlob(root, pattern)
524
+
525
+ replacements.push({
526
+ start: argument.start,
527
+ end: argument.end,
528
+ text: JSON.stringify(files),
529
+ })
530
+ directories.add(globDirectory(root, pattern))
531
+ },
532
+ })
533
+
534
+ if (replacements.length === 0) {
535
+ return null
536
+ }
537
+
538
+ const code = [...replacements]
539
+ .sort((left, right) => right.start - left.start)
540
+ .reduce(
541
+ (result, replacement) =>
542
+ `${result.slice(0, replacement.start)}${replacement.text}${result.slice(replacement.end)}`,
543
+ source,
544
+ )
545
+
546
+ return { code, map: null, directories: [...directories] }
547
+ }
548
+
549
+ function isPathWithinDirectory(directory: string, candidate: string): boolean {
550
+ const relative = path.relative(directory, candidate)
551
+
552
+ return (
553
+ relative === "" ||
554
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
555
+ )
556
+ }
557
+
558
+ function isServerEnvironment(
559
+ context: PluginContext,
560
+ options?: TransformOptions,
561
+ ): boolean {
562
+ return (
563
+ options?.ssr === true || context.environment?.config?.consumer === "server"
564
+ )
565
+ }
566
+
567
+ function resolveRouteEntry(root: string, entry: string): string {
568
+ const relativeEntry = entry.startsWith("/") ? `.${entry}` : entry
569
+
570
+ return cleanModuleId(path.resolve(root, relativeEntry))
571
+ }
572
+
573
+ function routeSourceSuffix(entry: string): string {
574
+ return cleanModuleId(entry).replace(/^\.?(?:\/|$)/, "")
575
+ }
576
+
577
+ export function omitRouteSourceContent(
578
+ bundle: OutputBundle,
579
+ routes: readonly Pick<RouteDefinition, "entry">[],
580
+ ): void {
581
+ const routeSuffixes = new Set(
582
+ routes.map((route) => routeSourceSuffix(route.entry)),
583
+ )
584
+ const omitFromSourceMap = (sourceMap: SourceMapLike): boolean => {
585
+ if (!sourceMap.sources || !sourceMap.sourcesContent) {
586
+ return false
587
+ }
588
+
589
+ let changed = false
590
+
591
+ for (let index = 0; index < sourceMap.sources.length; index += 1) {
592
+ const source = cleanModuleId(sourceMap.sources[index]).replace(
593
+ /^(?:\.\.\/)+/,
594
+ "",
595
+ )
596
+
597
+ if (!routeSuffixes.has(source)) {
598
+ continue
599
+ }
600
+
601
+ if (sourceMap.sourcesContent[index] === null) {
602
+ continue
603
+ }
604
+
605
+ sourceMap.sourcesContent[index] = null
606
+ changed = true
607
+ }
608
+
609
+ return changed
610
+ }
611
+
612
+ for (const output of Object.values(bundle)) {
613
+ if (output.type === "chunk") {
614
+ if (output.map) {
615
+ omitFromSourceMap(output.map)
616
+ }
617
+
618
+ continue
619
+ }
620
+
621
+ if (output.type !== "asset" || !output.fileName.endsWith(".map")) {
622
+ continue
623
+ }
624
+
625
+ const serializedSourceMap =
626
+ typeof output.source === "string"
627
+ ? output.source
628
+ : new TextDecoder().decode(output.source)
629
+ const sourceMap = JSON.parse(serializedSourceMap) as SourceMapLike
630
+
631
+ if (omitFromSourceMap(sourceMap)) {
632
+ output.source = JSON.stringify(sourceMap)
633
+ }
634
+ }
635
+ }
636
+
637
+ function omitRouteSourceContentFromDirectory(
638
+ directory: string,
639
+ routes: readonly Pick<RouteDefinition, "entry">[],
640
+ ): void {
641
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
642
+ const entryPath = path.join(directory, entry.name)
643
+
644
+ if (entry.isDirectory()) {
645
+ omitRouteSourceContentFromDirectory(entryPath, routes)
646
+ continue
647
+ }
648
+
649
+ if (!entry.name.endsWith(".map")) {
650
+ continue
651
+ }
652
+
653
+ const serializedSourceMap = fs.readFileSync(entryPath, "utf8")
654
+ const asset: OutputAsset = {
655
+ type: "asset",
656
+ fileName: entry.name,
657
+ source: serializedSourceMap,
658
+ }
659
+ const bundle = { [entry.name]: asset }
660
+
661
+ omitRouteSourceContent(bundle, routes)
662
+ if (
663
+ typeof asset.source === "string" &&
664
+ asset.source !== serializedSourceMap
665
+ ) {
666
+ fs.writeFileSync(entryPath, asset.source)
667
+ }
668
+ }
669
+ }
670
+
671
+ export function removeServerRouteExports(source: string, id = "route.js") {
672
+ const ast = parse(source, { sourceType: "module" })
673
+
674
+ if (!removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS)) {
675
+ return null
676
+ }
677
+
678
+ return generate(ast, {
679
+ sourceMaps: true,
680
+ filename: id,
681
+ sourceFileName: cleanModuleId(id),
682
+ })
683
+ }
684
+
685
+ export function flamefront(options: FlamefrontOptions = {}) {
686
+ const output = resolveFlamefrontOutput(options)
687
+ let root = process.cwd()
688
+ let serverBuild = false
689
+ let appPromise: Promise<AppDefinition> | undefined
690
+ let manifestRevision = 0
691
+ let manifestGlobDirectories: readonly string[] = []
692
+ const manifestId = options.routes ?? "/src/app.ts"
693
+ const manifestPath = () =>
694
+ path.resolve(
695
+ root,
696
+ manifestId.startsWith("/") ? `.${manifestId}` : manifestId,
697
+ )
698
+ const loadApp = async () => {
699
+ const manifestUrl = new URL(pathToFileURL(manifestPath()))
700
+
701
+ manifestUrl.searchParams.set("flamefront", String(manifestRevision))
702
+ setGlobRoot(root)
703
+ appPromise ??= import(manifestUrl.href).then((module) => {
704
+ const app = module.app ?? module.default
705
+
706
+ if (!app?.routeTree || typeof app.shell !== "string") {
707
+ throw new TypeError(
708
+ `Flamefront route manifest ${manifestId} must export an app with a shell.`,
709
+ )
710
+ }
711
+
712
+ return app as AppDefinition
713
+ })
714
+ return appPromise
715
+ }
716
+
717
+ const loadRoutes = async () => (await loadApp()).routes
718
+ const loadRouteModuleIds = async () =>
719
+ new Set(
720
+ (await loadRoutes()).map((route) => resolveRouteEntry(root, route.entry)),
721
+ )
722
+ const generateTypes = async () =>
723
+ writeRouteImportMap(await loadApp(), { root })
724
+ const configureRoot = (config: { readonly root: string }) => {
725
+ root = config.root
726
+ }
727
+
728
+ const frameworkModulesPlugin = {
729
+ name: "flamefront:framework-modules",
730
+ enforce: "pre" as const,
731
+ configResolved: configureRoot,
732
+ async buildStart() {
733
+ await generateTypes()
734
+ },
735
+ transform(source: string, id: string) {
736
+ if (cleanModuleId(id) !== manifestPath()) {
737
+ return null
738
+ }
739
+
740
+ const transformed = transformManifestGlobs(source, id, root)
741
+
742
+ manifestGlobDirectories = transformed?.directories ?? []
743
+ return transformed
744
+ ? { code: transformed.code, map: transformed.map }
745
+ : null
746
+ },
747
+ async handleHotUpdate(context: {
748
+ file: string
749
+ server: {
750
+ moduleGraph: {
751
+ getModuleById(id: string): unknown
752
+ invalidateModule(module: unknown): void
753
+ }
754
+ }
755
+ }) {
756
+ const manifestChanged = context.file === manifestPath()
757
+ const globChanged = manifestGlobDirectories.some((directory) =>
758
+ isPathWithinDirectory(directory, context.file),
759
+ )
760
+
761
+ if (!manifestChanged && !globChanged) {
762
+ return
763
+ }
764
+
765
+ manifestRevision += 1
766
+ appPromise = undefined
767
+ try {
768
+ await generateTypes()
769
+ } catch {
770
+ // Keep the previous declarations while an edited manifest is invalid.
771
+ // Vite will report the manifest error when the virtual route modules
772
+ // are requested, but a stale type file must not block editing.
773
+ }
774
+
775
+ if (globChanged) {
776
+ const manifestModule =
777
+ context.server.moduleGraph.getModuleById(manifestPath())
778
+
779
+ if (manifestModule) {
780
+ context.server.moduleGraph.invalidateModule(manifestModule)
781
+ }
782
+ }
783
+
784
+ for (const moduleId of [
785
+ resolvedRemixRoutesId,
786
+ resolvedServerRoutesId,
787
+ resolvedServerEntryId,
788
+ ]) {
789
+ const generatedModule =
790
+ context.server.moduleGraph.getModuleById(moduleId)
791
+
792
+ if (generatedModule) {
793
+ context.server.moduleGraph.invalidateModule(generatedModule)
794
+ }
795
+ }
796
+ },
797
+ async resolveId(
798
+ this: PluginContext,
799
+ id: string,
800
+ importer?: string,
801
+ resolveOptions: ResolveOptions = {},
802
+ ) {
803
+ if (id === remixRoutesId) {
804
+ return resolvedRemixRoutesId
805
+ }
806
+
807
+ if (id === serverRoutesId) {
808
+ return resolvedServerRoutesId
809
+ }
810
+
811
+ if (id === serverEntryId) {
812
+ return resolvedServerEntryId
813
+ }
814
+
815
+ if (id === "flamefront/entry") {
816
+ return resolvedServerEntryId
817
+ }
818
+
819
+ if (
820
+ id === "./hydration-route.tsrx?octane-hydrate=0" &&
821
+ importer?.startsWith(resolvedHydrationRoutePrefix)
822
+ ) {
823
+ const parameters = new URLSearchParams(
824
+ importer.slice(importer.indexOf("?") + 1),
825
+ )
826
+
827
+ parameters.set("octane-hydrate", "0")
828
+ return `${hydrationRouteId}?${parameters}`
829
+ }
830
+
831
+ if (id.startsWith(resolvedHydrationRoutePrefix)) {
832
+ return id
833
+ }
834
+
835
+ if (id.startsWith(resolvedMarkdownRoutePrefix)) {
836
+ return id
837
+ }
838
+
839
+ if (
840
+ resolveOptions.scan ||
841
+ isServerEnvironment(this, resolveOptions) ||
842
+ resolveOptions.custom?.["flamefront:server-module"]
843
+ ) {
844
+ return null
845
+ }
846
+
847
+ const nestedOptions: ResolveOptions = {
848
+ ...resolveOptions,
849
+ custom: { ...resolveOptions.custom, "flamefront:server-module": true },
850
+ }
851
+ const resolved = await this.resolve(id, importer, nestedOptions)
852
+
853
+ if (!resolved) {
854
+ return null
855
+ }
856
+
857
+ const resolvedId = cleanModuleId(resolved.id)
858
+
859
+ if (
860
+ !serverFilePattern.test(resolvedId) &&
861
+ !serverDirectoryPattern.test(resolvedId)
862
+ ) {
863
+ return null
864
+ }
865
+
866
+ if (!importer || importer.endsWith(".html")) {
867
+ return null
868
+ }
869
+
870
+ const importerId = cleanModuleId(importer)
871
+ const importerLabel = path.relative(root, importerId) || importerId
872
+ const routeHint = (await loadRouteModuleIds()).has(importerId)
873
+ ? " Flamefront removes server imports used exclusively by `loader`, but this import is still referenced by client code."
874
+ : ""
875
+
876
+ throw new Error(
877
+ `Server-only module ${JSON.stringify(id)} was referenced by client module ${JSON.stringify(importerLabel)}.${routeHint}`,
878
+ )
879
+ },
880
+ async load(id: string) {
881
+ if (id === resolvedRemixRoutesId) {
882
+ return generateRemixRoutes(await loadApp())
883
+ }
884
+
885
+ if (id === resolvedServerRoutesId) {
886
+ return generateServerRoutes(await loadApp())
887
+ }
888
+
889
+ if (id === resolvedServerEntryId) {
890
+ return generateServerEntry(output)
891
+ }
892
+
893
+ if (id.startsWith(resolvedHydrationRoutePrefix)) {
894
+ const parameters = new URLSearchParams(id.slice(id.indexOf("?") + 1))
895
+ const entry = parameters.get("entry")
896
+ const serializedHydration = parameters.get("hydration")
897
+
898
+ if (!entry || !serializedHydration) {
899
+ throw new TypeError(
900
+ "Flamefront hydration route is missing its configuration.",
901
+ )
902
+ }
903
+
904
+ return generateHydrationRoute(
905
+ entry,
906
+ JSON.parse(serializedHydration) as GeneratedHydration | "none",
907
+ )
908
+ }
909
+
910
+ if (id.startsWith(resolvedMarkdownRoutePrefix)) {
911
+ const parameters = new URLSearchParams(id.slice(id.indexOf("?") + 1))
912
+ const entry = parameters.get("entry")
913
+
914
+ if (!entry) {
915
+ throw new TypeError("Flamefront Markdown route is missing its entry.")
916
+ }
917
+
918
+ return generateMarkdownRoute(entry)
919
+ }
920
+
921
+ return null
922
+ },
923
+ }
924
+
925
+ const routeModulePlugin = {
926
+ name: "flamefront:route-modules",
927
+ enforce: "post" as const,
928
+ configResolved(config: {
929
+ readonly root: string
930
+ readonly build?: { readonly ssr?: unknown }
931
+ }) {
932
+ configureRoot(config)
933
+ serverBuild = Boolean(config.build?.ssr)
934
+ },
935
+ async generateBundle(_outputOptions: unknown, bundle: OutputBundle) {
936
+ if (!serverBuild) {
937
+ omitRouteSourceContent(bundle, await loadRoutes())
938
+ }
939
+ },
940
+ async writeBundle(outputOptions: { readonly dir?: string }) {
941
+ // Rollup serializes chunk maps after generateBundle, and other plugins can
942
+ // emit late client chunks. Scrub the completed output as the final guard.
943
+ if (!serverBuild && outputOptions.dir) {
944
+ omitRouteSourceContentFromDirectory(
945
+ outputOptions.dir,
946
+ await loadRoutes(),
947
+ )
948
+ }
949
+ },
950
+ async transform(
951
+ this: PluginContext,
952
+ source: string,
953
+ id: string,
954
+ transformOptions?: TransformOptions,
955
+ ) {
956
+ if (isServerEnvironment(this, transformOptions)) {
957
+ return null
958
+ }
959
+
960
+ if (!(await loadRouteModuleIds()).has(cleanModuleId(id))) {
961
+ return null
962
+ }
963
+
964
+ const transformed = removeServerRouteExports(source, id)
965
+
966
+ if (!transformed) {
967
+ return null
968
+ }
969
+
970
+ return { code: transformed.code, map: transformed.map }
971
+ },
972
+ }
973
+
974
+ const markdownPlugin =
975
+ options.markdown === false
976
+ ? undefined
977
+ : vitePluginSatteri({
978
+ ...(options.markdown ?? {}),
979
+ features: {
980
+ gfm: true,
981
+ frontmatter: true,
982
+ ...options.markdown?.features,
983
+ },
984
+ mdx:
985
+ options.markdown?.mdx === false
986
+ ? false
987
+ : {
988
+ ...(typeof options.markdown?.mdx === "object"
989
+ ? options.markdown.mdx
990
+ : {}),
991
+ jsx: true,
992
+ jsxImportSource: "octane",
993
+ jsxRuntime: "automatic",
994
+ },
995
+ })
996
+
997
+ const mdxCompilerPlugin =
998
+ options.markdown === false || options.markdown?.mdx === false
999
+ ? undefined
1000
+ : {
1001
+ name: "flamefront:markdown-mdx",
1002
+ async transform(
1003
+ this: PluginContext,
1004
+ source: string,
1005
+ id: string,
1006
+ transformOptions?: TransformOptions,
1007
+ ): Promise<{ code: string; map: object | null } | null> {
1008
+ if (!cleanModuleId(id).endsWith(".mdx")) {
1009
+ return null
1010
+ }
1011
+
1012
+ const server = isServerEnvironment(this, transformOptions)
1013
+ const command = this.environment?.config?.command
1014
+ const compiled = compileOctane(source, id, {
1015
+ dev: command === "serve",
1016
+ hmr: command === "serve" && !server ? "vite" : false,
1017
+ mode: server ? "server" : "client",
1018
+ })
1019
+
1020
+ return { code: compiled.code, map: compiled.map }
1021
+ },
1022
+ }
1023
+
1024
+ return [
1025
+ frameworkModulesPlugin,
1026
+ routeModulePlugin,
1027
+ markdownPlugin,
1028
+ mdxCompilerPlugin,
1029
+ ] as const
1030
+ }