flamefront 0.1.3 → 0.1.4
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 +1 -0
- package/package.json +2 -1
- package/src/document-assets.ts +106 -0
- package/src/document-template.ts +22 -0
- package/src/document.tsx +68 -0
- package/src/index.ts +6 -0
- package/src/octane-client-core.ts +13 -5
- package/src/octane-client.ts +9 -2
- package/src/octane-router-document.ts +10 -1
- package/src/octane.tsx +42 -6
- package/src/prerender-artifacts.ts +19 -3
- package/src/prerender.ts +2 -0
- package/src/remix-router.ts +8 -3
- package/src/vite.ts +62 -3
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Licensed under [MIT](./LICENSE.md).
|
|
|
76
76
|
|
|
77
77
|
- [How Flamefront fits](./docs/index.md)
|
|
78
78
|
- [Route rendering and data](./docs/routes.md)
|
|
79
|
+
- [Render the whole document](./docs/document.md)
|
|
79
80
|
- [Forms and mutations](./docs/forms-and-mutations.md)
|
|
80
81
|
- [Build and deployment](./docs/deployment.md)
|
|
81
82
|
- [Migrating an existing app](./docs/brownfield-migration.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flamefront",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Typed centralized route manifests for Octane.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
".": "./src/index.ts",
|
|
24
24
|
"./action-client": "./src/action-client.ts",
|
|
25
25
|
"./entry": "./src/entry.ts",
|
|
26
|
+
"./document": "./src/document.tsx",
|
|
26
27
|
"./fetch": "./src/fetch.ts",
|
|
27
28
|
"./fragment": "./src/fragment.tsx",
|
|
28
29
|
"./octane": "./src/octane.tsx",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Internal, serializable description of a framework-owned document asset. */
|
|
2
|
+
export interface DocumentAsset {
|
|
3
|
+
readonly tag: "link" | "style" | "script"
|
|
4
|
+
readonly attributes: Readonly<Record<string, string>>
|
|
5
|
+
readonly content?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DocumentAssets {
|
|
9
|
+
readonly head: readonly DocumentAsset[]
|
|
10
|
+
readonly scripts: readonly DocumentAsset[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const documentAssetAttribute = "data-flamefront-asset"
|
|
14
|
+
|
|
15
|
+
function decodeAttribute(value: string): string {
|
|
16
|
+
return value.replace(
|
|
17
|
+
/&(#x[\da-f]+|#\d+|amp|quot|apos|lt|gt);/gi,
|
|
18
|
+
(entity, name: string) => {
|
|
19
|
+
if (name[0] === "#") {
|
|
20
|
+
const code =
|
|
21
|
+
name[1].toLowerCase() === "x"
|
|
22
|
+
? parseInt(name.slice(2), 16)
|
|
23
|
+
: Number(name.slice(1))
|
|
24
|
+
|
|
25
|
+
return code > 0 && code <= 0x10ffff
|
|
26
|
+
? String.fromCodePoint(code)
|
|
27
|
+
: "\ufffd"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
(
|
|
32
|
+
{ amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" } as Record<
|
|
33
|
+
string,
|
|
34
|
+
string
|
|
35
|
+
>
|
|
36
|
+
)[name.toLowerCase()] ?? entity
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Extract only asset tags; document metadata belongs to the document component. */
|
|
43
|
+
export function documentAssets(
|
|
44
|
+
template: string,
|
|
45
|
+
hydrationScript: string,
|
|
46
|
+
): DocumentAssets {
|
|
47
|
+
const head: DocumentAsset[] = []
|
|
48
|
+
const scripts: DocumentAsset[] = []
|
|
49
|
+
// Consume comments and raw-text elements as units so their contents cannot
|
|
50
|
+
// masquerade as asset tags. Quoted attribute values may contain angle brackets.
|
|
51
|
+
const tags =
|
|
52
|
+
/<!--[\s\S]*?-->|<(script|style|title|textarea|template|link)\b((?:[^>"']|"[^"]*"|'[^']*')*)>(?:([\s\S]*?)<\/\1\s*>)?/gi
|
|
53
|
+
|
|
54
|
+
for (const match of `${template}${hydrationScript}`.matchAll(tags)) {
|
|
55
|
+
const tag = match[1]?.toLowerCase()
|
|
56
|
+
|
|
57
|
+
if (tag !== "script" && tag !== "style" && tag !== "link") {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const attributes: Record<string, string> = {}
|
|
62
|
+
|
|
63
|
+
for (const attribute of match[2].matchAll(
|
|
64
|
+
/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g,
|
|
65
|
+
)) {
|
|
66
|
+
attributes[attribute[1].toLowerCase()] = decodeAttribute(
|
|
67
|
+
attribute[2] ?? attribute[3] ?? attribute[4] ?? "",
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
attributes[documentAssetAttribute] = tag === "script" ? "scripts" : "head"
|
|
72
|
+
const asset: DocumentAsset = {
|
|
73
|
+
tag,
|
|
74
|
+
attributes,
|
|
75
|
+
...(tag === "link" ? {} : { content: match[3] ?? "" }),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
;(tag === "script" ? scripts : head).push(asset)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { head, scripts }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Snapshot server assets before hydration; no scripts are re-executed. */
|
|
85
|
+
export function readDocumentAssets(document: Document): DocumentAssets {
|
|
86
|
+
const read = (slot: string): DocumentAsset[] =>
|
|
87
|
+
Array.from(
|
|
88
|
+
document.querySelectorAll(`[${documentAssetAttribute}="${slot}"]`),
|
|
89
|
+
(element) => ({
|
|
90
|
+
tag: element.localName as DocumentAsset["tag"],
|
|
91
|
+
attributes: Object.fromEntries(
|
|
92
|
+
Array.from(element.attributes, ({ name, value }) => [
|
|
93
|
+
name,
|
|
94
|
+
name === "nonce"
|
|
95
|
+
? ((element as HTMLElement).nonce ?? value)
|
|
96
|
+
: value,
|
|
97
|
+
]),
|
|
98
|
+
),
|
|
99
|
+
...(element.localName === "link"
|
|
100
|
+
? {}
|
|
101
|
+
: { content: element.textContent ?? "" }),
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return { head: read("head"), scripts: read("scripts") }
|
|
106
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Vite still processes an HTML entry to discover and hash client assets. */
|
|
5
|
+
export async function readProjectTemplate(
|
|
6
|
+
root: string,
|
|
7
|
+
hasDocument: boolean,
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
try {
|
|
10
|
+
return await readFile(resolve(root, "index.html"), "utf8")
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (!hasDocument || (error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return '<!doctype html><html><head></head><body><script type="module" src="/src/main.ts"></script></body></html>'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Vite's evaluated SSR modules do not share the plugin's module cache. Keep
|
|
21
|
+
// development-only callbacks scoped by project, and release them on close.
|
|
22
|
+
export const devTemplateLoadersKey = "flamefront:dev-template-loaders"
|
package/src/document.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
useContext,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "octane"
|
|
7
|
+
import type { DocumentAsset, DocumentAssets } from "./document-assets.ts"
|
|
8
|
+
|
|
9
|
+
/** Props supplied to the optional app document component. */
|
|
10
|
+
export interface DocumentProps {
|
|
11
|
+
/** The persistent shell and matched route, already inside router context. */
|
|
12
|
+
readonly children?: ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @internal Framework assets shared by server rendering and hydration. */
|
|
16
|
+
export const documentAssetsContext = createContext<DocumentAssets | null>(null)
|
|
17
|
+
|
|
18
|
+
function renderAssets(assets: readonly DocumentAsset[]) {
|
|
19
|
+
return assets.map(({ tag, attributes, content }) =>
|
|
20
|
+
createElement(tag, {
|
|
21
|
+
...attributes,
|
|
22
|
+
...(content === undefined
|
|
23
|
+
? {}
|
|
24
|
+
: { dangerouslySetInnerHTML: { __html: content } }),
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Render Vite styles and asset links. Place once inside the document's head. */
|
|
30
|
+
export function Head() {
|
|
31
|
+
const assets = useContext(documentAssetsContext)
|
|
32
|
+
|
|
33
|
+
if (!assets) {
|
|
34
|
+
throw new Error("Head must be rendered inside a Flamefront document.")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return renderAssets(assets.head)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render hydration data and client scripts. Place once at the end of body. */
|
|
41
|
+
export function Scripts() {
|
|
42
|
+
const assets = useContext(documentAssetsContext)
|
|
43
|
+
|
|
44
|
+
if (!assets) {
|
|
45
|
+
throw new Error("Scripts must be rendered inside a Flamefront document.")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return renderAssets(assets.scripts)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @internal Keep the document outside independently hydrated shell boundaries. */
|
|
52
|
+
export function createDocumentShell(
|
|
53
|
+
Document: (props: DocumentProps) => unknown,
|
|
54
|
+
Shell: (props: Record<string, unknown>) => unknown,
|
|
55
|
+
) {
|
|
56
|
+
return (props: Record<string, unknown>) => {
|
|
57
|
+
const assets = useContext(documentAssetsContext)
|
|
58
|
+
|
|
59
|
+
// Fragment renders use a separate router root and return only the shell.
|
|
60
|
+
return assets ? (
|
|
61
|
+
<Document>
|
|
62
|
+
<Shell {...props} />
|
|
63
|
+
</Document>
|
|
64
|
+
) : (
|
|
65
|
+
<Shell {...props} />
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -463,6 +463,8 @@ export type RouteDestination<Path extends string = RoutePath> =
|
|
|
463
463
|
}
|
|
464
464
|
|
|
465
465
|
export interface AppDefinition<T extends RouteDefinition = RouteDefinition> {
|
|
466
|
+
/** Optional project-root module ID for the component owning html, head, and body. */
|
|
467
|
+
readonly document?: string
|
|
466
468
|
/** Octane/Vite project-root module ID for the persistent app shell. */
|
|
467
469
|
readonly shell: string
|
|
468
470
|
/** Hydration policy for the persistent shell region. */
|
|
@@ -1130,6 +1132,7 @@ function matchRoutes<T extends RouteDefinition>(
|
|
|
1130
1132
|
/** Normalize and validate the application's explicit route graph. */
|
|
1131
1133
|
export function defineApp<
|
|
1132
1134
|
const T extends {
|
|
1135
|
+
readonly document?: string
|
|
1133
1136
|
readonly shell: string
|
|
1134
1137
|
readonly routes: readonly RouteConfig[]
|
|
1135
1138
|
readonly shellHydration?: HydrationMode
|
|
@@ -1149,6 +1152,9 @@ export function defineApp<
|
|
|
1149
1152
|
}
|
|
1150
1153
|
|
|
1151
1154
|
assertString(options.shell, "app shell entry")
|
|
1155
|
+
if (options.document !== undefined) {
|
|
1156
|
+
assertString(options.document, "app document entry")
|
|
1157
|
+
}
|
|
1152
1158
|
|
|
1153
1159
|
const hydrationDefaults = normalizeHydrationDefaults(
|
|
1154
1160
|
options.hydrationDefaults,
|
|
@@ -3,7 +3,7 @@ import type { RouterDocument, RouterDocumentProps } from "./octane.tsx"
|
|
|
3
3
|
import { shellIdentifierPrefix } from "./identifier-prefix.ts"
|
|
4
4
|
|
|
5
5
|
export type OctaneClientApp<Route extends RouteDefinition = RouteDefinition> =
|
|
6
|
-
Pick<AppDefinition<Route>, "match" | "prefetch">
|
|
6
|
+
Pick<AppDefinition<Route>, "match" | "prefetch" | "document">
|
|
7
7
|
|
|
8
8
|
export interface OctaneClientRouter {
|
|
9
9
|
readonly state: { readonly initialized: boolean }
|
|
@@ -14,10 +14,10 @@ export interface OctaneClientRouter {
|
|
|
14
14
|
|
|
15
15
|
export interface StartOctaneClientOptions<
|
|
16
16
|
Route extends RouteDefinition = RouteDefinition,
|
|
17
|
-
Container = Element,
|
|
17
|
+
Container = Element | Document,
|
|
18
18
|
> {
|
|
19
19
|
readonly app: OctaneClientApp<Route>
|
|
20
|
-
/** Defaults to
|
|
20
|
+
/** Defaults to `document` for app documents, otherwise the `#root` element. */
|
|
21
21
|
readonly root?: Container | null
|
|
22
22
|
/** Use the same override in `createOctaneDocuments` on the server. */
|
|
23
23
|
readonly routerDocument?: RouterDocument
|
|
@@ -39,6 +39,7 @@ export interface OctaneClientRuntime<
|
|
|
39
39
|
readonly pathname: string
|
|
40
40
|
readonly defaultRoot: Container | null
|
|
41
41
|
readonly routerDocument: RouterDocument
|
|
42
|
+
readonly documentAssets?: RouterDocumentProps["documentAssets"]
|
|
42
43
|
consumeHydrationData(): HydrationData
|
|
43
44
|
createRoutePrefetcher(app: OctaneClientApp<Route>): Prefetch
|
|
44
45
|
createClientRouter(options: {
|
|
@@ -121,14 +122,21 @@ export async function startOctaneClientWithRuntime<
|
|
|
121
122
|
hydrationData,
|
|
122
123
|
prefetch: runtime.createRoutePrefetcher(options.app),
|
|
123
124
|
})
|
|
124
|
-
const shouldHydrate =
|
|
125
|
+
const shouldHydrate =
|
|
126
|
+
Boolean(options.app.document) || routeMatch.data.render !== "client"
|
|
125
127
|
|
|
126
128
|
if (shouldHydrate) {
|
|
127
129
|
await waitForRouterInitialization(router)
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
const routerDocument = options.routerDocument ?? runtime.routerDocument
|
|
131
|
-
const props: RouterDocumentProps = {
|
|
133
|
+
const props: RouterDocumentProps = {
|
|
134
|
+
router,
|
|
135
|
+
context: undefined,
|
|
136
|
+
...(runtime.documentAssets
|
|
137
|
+
? { documentAssets: runtime.documentAssets }
|
|
138
|
+
: {}),
|
|
139
|
+
}
|
|
132
140
|
const clientRoot = shouldHydrate
|
|
133
141
|
? runtime.hydrateRoot(root, routerDocument, props, {
|
|
134
142
|
identifierPrefix: shellIdentifierPrefix,
|
package/src/octane-client.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { DataRouter } from "@octanejs/remix-router"
|
|
|
2
2
|
import { createRoot, hydrateRoot, type ComponentBody, type Root } from "octane"
|
|
3
3
|
import type { RouteDefinition } from "./index.ts"
|
|
4
4
|
import type { RouterDocumentProps } from "./octane.tsx"
|
|
5
|
+
import { readDocumentAssets } from "./document-assets.ts"
|
|
5
6
|
import {
|
|
6
7
|
startOctaneClientWithRuntime,
|
|
7
8
|
type StartOctaneClientOptions,
|
|
@@ -26,9 +27,15 @@ export function startOctaneClient<Route extends RouteDefinition>(
|
|
|
26
27
|
): Promise<StartedOctaneClient<DataRouter, Root>> {
|
|
27
28
|
return startOctaneClientWithRuntime(options, {
|
|
28
29
|
pathname: window.location.pathname,
|
|
29
|
-
defaultRoot: document
|
|
30
|
+
defaultRoot: options.app.document
|
|
31
|
+
? document
|
|
32
|
+
: document.getElementById("root"),
|
|
33
|
+
...(options.app.document
|
|
34
|
+
? { documentAssets: readDocumentAssets(document) }
|
|
35
|
+
: {}),
|
|
30
36
|
routerDocument: RouterDocument,
|
|
31
|
-
consumeHydrationData:
|
|
37
|
+
consumeHydrationData: () =>
|
|
38
|
+
consumeStaticRouterHydrationData(Boolean(options.app.document)),
|
|
32
39
|
createRoutePrefetcher,
|
|
33
40
|
createClientRouter,
|
|
34
41
|
renderRoot(root, component, props, options) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RouterProvider } from "@octanejs/remix-router/dom"
|
|
2
2
|
import { createElement } from "octane"
|
|
3
3
|
import { routeOutletHtmlContext } from "./fragment.tsx"
|
|
4
|
+
import { documentAssetsContext } from "./document.tsx"
|
|
4
5
|
import type {
|
|
5
6
|
RouterDocument as RouterDocumentComponent,
|
|
6
7
|
RouterDocumentProps,
|
|
@@ -14,9 +15,17 @@ export const RouterDocument: RouterDocumentComponent = (
|
|
|
14
15
|
readonly router: unknown
|
|
15
16
|
}) => unknown
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const content = createElement(
|
|
18
19
|
routeOutletHtmlContext.Provider,
|
|
19
20
|
{ value: props.outletHtml ?? null },
|
|
20
21
|
createElement(Router, { router: props.router }),
|
|
21
22
|
)
|
|
23
|
+
|
|
24
|
+
return props.documentAssets
|
|
25
|
+
? createElement(
|
|
26
|
+
documentAssetsContext.Provider,
|
|
27
|
+
{ value: props.documentAssets },
|
|
28
|
+
content,
|
|
29
|
+
)
|
|
30
|
+
: content
|
|
22
31
|
}
|
package/src/octane.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
RouteDefinition,
|
|
5
5
|
RouteParams,
|
|
6
6
|
} from "./index.ts"
|
|
7
|
+
import { documentAssets, type DocumentAssets } from "./document-assets.ts"
|
|
7
8
|
import type {
|
|
8
9
|
DocumentMode,
|
|
9
10
|
RouteRuntime,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
export type { DocumentMode, RenderedDocument } from "./server.ts"
|
|
29
30
|
|
|
30
31
|
export interface RouterDocumentProps {
|
|
32
|
+
readonly documentAssets?: DocumentAssets
|
|
31
33
|
readonly router: unknown
|
|
32
34
|
readonly context: unknown
|
|
33
35
|
/** Server-only markup for the independently-owned routed outlet. */
|
|
@@ -231,6 +233,28 @@ export function composeDefaultDocument(
|
|
|
231
233
|
.replace("</body>", `${hydrationScript}</body>`)
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
function composeComponentDocument(body: string, css: string): string {
|
|
237
|
+
if (
|
|
238
|
+
!/<html\b/i.test(body) ||
|
|
239
|
+
!body.includes("</head>") ||
|
|
240
|
+
!body.includes("</body>")
|
|
241
|
+
) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
"The app document must render html, head, and body elements.",
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const scripts = body.split(`id="${staticRouterHydrationScriptId}"`).length - 1
|
|
248
|
+
|
|
249
|
+
if (scripts !== 1) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
"The app document must render Scripts exactly once inside body.",
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return `<!doctype html>${body.replace(/^\s*<!doctype html>/i, "").replace("</head>", `${css}</head>`)}`
|
|
256
|
+
}
|
|
257
|
+
|
|
234
258
|
async function loadDefaultRouter(): Promise<DocumentRouter> {
|
|
235
259
|
const module = await import("./remix-router.ts")
|
|
236
260
|
|
|
@@ -534,6 +558,7 @@ export function createOctaneDocuments<
|
|
|
534
558
|
mode: DocumentMode,
|
|
535
559
|
route: RouteDefinition | null,
|
|
536
560
|
renderOutlet = false,
|
|
561
|
+
template = "",
|
|
537
562
|
): Promise<{
|
|
538
563
|
readonly router: DocumentRouter
|
|
539
564
|
readonly dataRouter: unknown
|
|
@@ -586,6 +611,14 @@ export function createOctaneDocuments<
|
|
|
586
611
|
const documentProps: RouterDocumentProps = {
|
|
587
612
|
router: dataRouter,
|
|
588
613
|
context,
|
|
614
|
+
...(options.app.document
|
|
615
|
+
? {
|
|
616
|
+
documentAssets: documentAssets(
|
|
617
|
+
template,
|
|
618
|
+
staticRouterHydrationScript(context),
|
|
619
|
+
),
|
|
620
|
+
}
|
|
621
|
+
: {}),
|
|
589
622
|
...(outlet ? { outletHtml: outlet.html } : {}),
|
|
590
623
|
}
|
|
591
624
|
const rendered = renderer.renderToString(routerDocument, documentProps, {
|
|
@@ -620,6 +653,7 @@ export function createOctaneDocuments<
|
|
|
620
653
|
mode,
|
|
621
654
|
route,
|
|
622
655
|
true,
|
|
656
|
+
template,
|
|
623
657
|
)
|
|
624
658
|
const status = staticContext.statusCode ?? 200
|
|
625
659
|
const compositionContext: DocumentCompositionContext<Route> = {
|
|
@@ -639,12 +673,14 @@ export function createOctaneDocuments<
|
|
|
639
673
|
}
|
|
640
674
|
const html = await (options.composeDocument
|
|
641
675
|
? options.composeDocument(parts, compositionContext)
|
|
642
|
-
:
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
676
|
+
: options.app.document
|
|
677
|
+
? composeComponentDocument(parts.body, parts.css)
|
|
678
|
+
: composeDefaultDocument(
|
|
679
|
+
parts.template,
|
|
680
|
+
parts.body,
|
|
681
|
+
parts.css,
|
|
682
|
+
parts.hydrationScript,
|
|
683
|
+
))
|
|
648
684
|
|
|
649
685
|
const headers = actionResponseHeaders(staticContext)
|
|
650
686
|
|
|
@@ -138,10 +138,26 @@ export function assembleStaticRouteArtifact(
|
|
|
138
138
|
return null
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
let
|
|
141
|
+
let index = 0
|
|
142
|
+
let valid = true
|
|
143
|
+
const html = artifact.html.replace(
|
|
144
|
+
/((?:src|href)=)(['"])(\/assets\/[^'"]+)\2/g,
|
|
145
|
+
(match, attribute: string, quote: string, asset: string) => {
|
|
146
|
+
if (index >= previous.length) {
|
|
147
|
+
return match
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (asset !== previous[index]) {
|
|
151
|
+
valid = false
|
|
152
|
+
return match
|
|
153
|
+
}
|
|
142
154
|
|
|
143
|
-
|
|
144
|
-
|
|
155
|
+
return `${attribute}${quote}${current.assets[index++]}${quote}`
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if (!valid || index !== previous.length) {
|
|
160
|
+
return null
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
return { ...artifact, html, template: current }
|
package/src/prerender.ts
CHANGED
|
@@ -296,6 +296,7 @@ export async function renderingFingerprint(
|
|
|
296
296
|
): Promise<string> {
|
|
297
297
|
const entries = [
|
|
298
298
|
app.shell,
|
|
299
|
+
...(app.document ? [app.document] : []),
|
|
299
300
|
...(layoutEntries(app.routeTree, route) ?? []),
|
|
300
301
|
route.entry,
|
|
301
302
|
"/src/entry-server.ts",
|
|
@@ -335,6 +336,7 @@ export async function renderingFingerprint(
|
|
|
335
336
|
hydration: route.hydration,
|
|
336
337
|
},
|
|
337
338
|
shell: app.shell,
|
|
339
|
+
document: app.document,
|
|
338
340
|
shellHydration: app.shellHydration,
|
|
339
341
|
routing: app.routing,
|
|
340
342
|
compiler: stableValue(options),
|
package/src/remix-router.ts
CHANGED
|
@@ -40,15 +40,20 @@ export type {
|
|
|
40
40
|
export const staticRouterHydrationScriptId =
|
|
41
41
|
"flamefront-static-router-hydration"
|
|
42
42
|
|
|
43
|
-
/** Read
|
|
44
|
-
export function consumeStaticRouterHydrationData(
|
|
43
|
+
/** Read hydration data; preserve its script when the document owns that node. */
|
|
44
|
+
export function consumeStaticRouterHydrationData(
|
|
45
|
+
preserveScript = false,
|
|
46
|
+
): HydrationState | undefined {
|
|
45
47
|
const data = (
|
|
46
48
|
window as typeof window & {
|
|
47
49
|
__staticRouterHydrationData?: unknown
|
|
48
50
|
}
|
|
49
51
|
).__staticRouterHydrationData
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
if (!preserveScript) {
|
|
54
|
+
document.getElementById(staticRouterHydrationScriptId)?.remove()
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
return data as HydrationState | undefined
|
|
53
58
|
}
|
|
54
59
|
|
package/src/vite.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
|
+
import type { ViteDevServer } from "vite"
|
|
3
|
+
import {
|
|
4
|
+
readProjectTemplate,
|
|
5
|
+
devTemplateLoadersKey,
|
|
6
|
+
} from "./document-template.ts"
|
|
2
7
|
import path from "node:path"
|
|
3
8
|
import { pathToFileURL } from "node:url"
|
|
4
9
|
import { compile as compileOctane } from "octane/compiler"
|
|
@@ -319,7 +324,7 @@ function generateConfigs(
|
|
|
319
324
|
export function generateRemixRoutes(
|
|
320
325
|
app: Pick<
|
|
321
326
|
AppDefinition,
|
|
322
|
-
"shell" | "shellHydration" | "routeTree" | "routing"
|
|
327
|
+
"shell" | "shellHydration" | "routeTree" | "routing" | "document"
|
|
323
328
|
>,
|
|
324
329
|
): string {
|
|
325
330
|
const rootId = "flamefront:shell:root"
|
|
@@ -328,8 +333,15 @@ export function generateRemixRoutes(
|
|
|
328
333
|
app.shell,
|
|
329
334
|
app.shellHydration,
|
|
330
335
|
)
|
|
331
|
-
|
|
332
|
-
|
|
336
|
+
const documentImport = app.document
|
|
337
|
+
? `import Document from ${quote(app.document)};\nimport { createDocumentShell } from 'flamefront/document';\n`
|
|
338
|
+
: ""
|
|
339
|
+
const shellComponent = `createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])})`
|
|
340
|
+
const rootComponent = app.document
|
|
341
|
+
? `createDocumentShell(Document, ${shellComponent})`
|
|
342
|
+
: shellComponent
|
|
343
|
+
|
|
344
|
+
return `// Generated by Flamefront.\n${documentImport}import 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';\nimport { submitRouteAction } 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: ${rootComponent},\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)}`
|
|
333
345
|
}
|
|
334
346
|
|
|
335
347
|
/** Generate the server-only route-module importer used by loader endpoints. */
|
|
@@ -725,6 +737,8 @@ export function flamefront(options: FlamefrontOptions = {}) {
|
|
|
725
737
|
})
|
|
726
738
|
let root = process.cwd()
|
|
727
739
|
let serverBuild = false
|
|
740
|
+
let devServer: ViteDevServer | undefined
|
|
741
|
+
let releaseDevTemplate: (() => void) | undefined
|
|
728
742
|
let appPromise: Promise<AppDefinition> | undefined
|
|
729
743
|
let manifestRevision = 0
|
|
730
744
|
let manifestGlobDirectories: readonly string[] = []
|
|
@@ -773,6 +787,29 @@ export function flamefront(options: FlamefrontOptions = {}) {
|
|
|
773
787
|
name: "flamefront:framework-modules",
|
|
774
788
|
enforce: "pre" as const,
|
|
775
789
|
configResolved: configureRoot,
|
|
790
|
+
configureServer(server: ViteDevServer) {
|
|
791
|
+
devServer = server
|
|
792
|
+
const registry = globalThis as typeof globalThis & {
|
|
793
|
+
[key: symbol]: Map<string, (url: string) => Promise<string>> | undefined
|
|
794
|
+
}
|
|
795
|
+
const key = Symbol.for(devTemplateLoadersKey)
|
|
796
|
+
const loaders = (registry[key] ??= new Map())
|
|
797
|
+
const loadTemplate = async (url: string) =>
|
|
798
|
+
server.transformIndexHtml(
|
|
799
|
+
url,
|
|
800
|
+
await readProjectTemplate(root, Boolean((await loadApp()).document)),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
loaders.set(root, loadTemplate)
|
|
804
|
+
releaseDevTemplate = () => {
|
|
805
|
+
if (loaders.get(root) === loadTemplate) {
|
|
806
|
+
loaders.delete(root)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
closeBundle() {
|
|
811
|
+
releaseDevTemplate?.()
|
|
812
|
+
},
|
|
776
813
|
async buildStart() {
|
|
777
814
|
await generateTypes()
|
|
778
815
|
},
|
|
@@ -881,6 +918,14 @@ export function flamefront(options: FlamefrontOptions = {}) {
|
|
|
881
918
|
importer?: string,
|
|
882
919
|
resolveOptions: ResolveOptions = {},
|
|
883
920
|
) {
|
|
921
|
+
if (
|
|
922
|
+
id === path.resolve(root, "index.html") &&
|
|
923
|
+
!fs.existsSync(id) &&
|
|
924
|
+
(await loadApp()).document
|
|
925
|
+
) {
|
|
926
|
+
return id
|
|
927
|
+
}
|
|
928
|
+
|
|
884
929
|
if (id === remixRoutesId) {
|
|
885
930
|
return resolvedRemixRoutesId
|
|
886
931
|
}
|
|
@@ -979,6 +1024,14 @@ export function flamefront(options: FlamefrontOptions = {}) {
|
|
|
979
1024
|
)
|
|
980
1025
|
},
|
|
981
1026
|
async load(id: string) {
|
|
1027
|
+
if (
|
|
1028
|
+
id === path.resolve(root, "index.html") &&
|
|
1029
|
+
!fs.existsSync(id) &&
|
|
1030
|
+
(await loadApp()).document
|
|
1031
|
+
) {
|
|
1032
|
+
return readProjectTemplate(root, true)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
982
1035
|
if (id === resolvedRemixRoutesId) {
|
|
983
1036
|
return generateRemixRoutes(await loadApp())
|
|
984
1037
|
}
|
|
@@ -988,6 +1041,12 @@ export function flamefront(options: FlamefrontOptions = {}) {
|
|
|
988
1041
|
}
|
|
989
1042
|
|
|
990
1043
|
if (id === resolvedServerEntryId) {
|
|
1044
|
+
if (devServer && (await loadApp()).document) {
|
|
1045
|
+
// Development renders static routes too; production artifacts may be
|
|
1046
|
+
// stale and must not bypass Vite's current document or asset graph.
|
|
1047
|
+
return `import { createFetchServerEntry } from 'flamefront/fetch';\nexport function createServerEntry(options) {\n const entry = createFetchServerEntry({ ...options, ${output.adapter === "srvx" ? "middleware: undefined," : ""} assets: { loadTemplate: options.assets.loadTemplate ?? (({ request }) => globalThis[Symbol.for(${quote(devTemplateLoadersKey)})].get(${quote(root)})(new URL(request.url).pathname)) } });\n return ${output.adapter === "srvx" ? "{ ...entry, middleware: options.middleware }" : "entry"};\n}`
|
|
1048
|
+
}
|
|
1049
|
+
|
|
991
1050
|
return generateServerEntry(output)
|
|
992
1051
|
}
|
|
993
1052
|
|