@bakery-framework/plugin-vue 1.2.3 → 2.0.0-alpha.2
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/package.json +3 -2
- package/src/chunks.ts +26 -3
- package/src/client.ts +203 -0
- package/src/compile.ts +68 -17
- package/src/handler.ts +212 -10
- package/src/setup.ts +6 -2
- package/src/types.d.ts +29 -0
- package/src/utils.ts +38 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-vue",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.2",
|
|
4
4
|
"description": "Bakery vue plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"main": "./src/index.ts",
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./src/index.ts",
|
|
28
|
+
"./client": "./src/client.ts",
|
|
28
29
|
"./package.json": "./package.json",
|
|
29
30
|
"./vue.d.ts": "./src/vue.d.ts"
|
|
30
31
|
},
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"vue": "^3.5.38"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@bakery-framework/core": "^1.
|
|
46
|
+
"@bakery-framework/core": "^1.2.3"
|
|
46
47
|
},
|
|
47
48
|
"engines": {
|
|
48
49
|
"bun": ">=1.3.14"
|
package/src/chunks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
|
2
2
|
import { Logger } from '@bakery-framework/core/logger'
|
|
3
3
|
import { fs, response } from '@bakery-framework/core/utils'
|
|
4
4
|
import { ETag } from '@bakery-framework/core/utils/http'
|
|
5
|
+
import { vueBuildVariant } from './compile'
|
|
5
6
|
import { VUE_VERSION } from './utils'
|
|
6
7
|
|
|
7
8
|
const logger = new Logger('vue')
|
|
@@ -14,21 +15,43 @@ const BUNDLE_DEFINES = {
|
|
|
14
15
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
const VUE_ENTRIES = {
|
|
19
|
+
runtime: 'vue/dist/vue.runtime.esm-bundler.js',
|
|
20
|
+
full: 'vue/dist/vue.esm-bundler.js',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one canonical URL for the served Vue build — the import-map alias
|
|
25
|
+
* (`setup.ts`) and the request check below both read it, so they cannot drift.
|
|
26
|
+
*
|
|
27
|
+
* The variant is part of the filename, not just of the entry choice, because
|
|
28
|
+
* the chunk cache is keyed on this name with the *source's* mtime: flipping
|
|
29
|
+
* `build` in `server.config.ts` does not touch `vue.esm-bundler.js` on disk,
|
|
30
|
+
* so a shared name would keep serving the previous variant out of cache
|
|
31
|
+
* indefinitely.
|
|
32
|
+
*/
|
|
33
|
+
export function vueChunkPath(): string {
|
|
34
|
+
return `${VUE_CHUNK_PREFIX}${VUE_VERSION}.${vueBuildVariant()}.js`
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
/** Serves the self-hosted Vue runtime that `Bakery.config.importMap` points at. */
|
|
18
38
|
export async function serveVueChunk(
|
|
19
39
|
path: string,
|
|
20
40
|
req: Request,
|
|
21
41
|
): Promise<Response> {
|
|
22
|
-
if (path !==
|
|
42
|
+
if (path !== vueChunkPath()) {
|
|
23
43
|
return response.error('Not Found', 404)
|
|
24
44
|
}
|
|
25
45
|
|
|
46
|
+
const variant = vueBuildVariant()
|
|
26
47
|
const dir = fs.resolve(Bakery.cacheDir, 'vue-official', 'chunks')
|
|
27
|
-
|
|
48
|
+
// Derived from the URL, not restated: the cache name and the request path
|
|
49
|
+
// must agree on the variant, and one writer is how they keep agreeing.
|
|
50
|
+
const fileName = path.slice(VUE_CHUNK_PREFIX.length)
|
|
28
51
|
|
|
29
52
|
let sourcePath = ''
|
|
30
53
|
try {
|
|
31
|
-
sourcePath = Bun.resolveSync(
|
|
54
|
+
sourcePath = Bun.resolveSync(VUE_ENTRIES[variant], Bakery.root)
|
|
32
55
|
} catch {
|
|
33
56
|
return response.error('Vue not found', 404)
|
|
34
57
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineLayout()` — browser-side navigation for catch-all pages.
|
|
3
|
+
*
|
|
4
|
+
* A catch-all page (`[...slug].vue`, `[...slug!].vue`) owns every URL under
|
|
5
|
+
* its directory: whatever the path, the server serves the same file. That
|
|
6
|
+
* invariant is what makes client-side navigation *safe* here — swapping
|
|
7
|
+
* content on a URL change can never disagree with what a hard reload would
|
|
8
|
+
* serve — and it is why this API is only available on catch-all pages: on any
|
|
9
|
+
* other page, two URLs mean two different files, and intercepting the
|
|
10
|
+
* navigation would show the wrong one. The server stamps the route's shape
|
|
11
|
+
* into `globalThis.__vue_route`; `defineLayout()` throws without it.
|
|
12
|
+
*
|
|
13
|
+
* The page becomes its subtree's layout: it reads `segments` and renders
|
|
14
|
+
* whichever of its own components the path means — no `<slot />`, no extra
|
|
15
|
+
* file. Clicks on same-origin links under the base are intercepted and become
|
|
16
|
+
* a `pushState` plus a reactive update; links that leave the base navigate
|
|
17
|
+
* normally; back/forward is handled the same way, falling back to a real
|
|
18
|
+
* navigation when history leaves the subtree.
|
|
19
|
+
*
|
|
20
|
+
* Imported from `@bakery-framework/plugin-vue/client`, which the browser
|
|
21
|
+
* resolves through the import map like any installed package. `vue` stays a
|
|
22
|
+
* bare import, so this shares the page's Vue instance.
|
|
23
|
+
*/
|
|
24
|
+
import { type Ref, ref } from 'vue'
|
|
25
|
+
|
|
26
|
+
/** What the server stamps on the page — see `handleHtml` in `handler.ts`. */
|
|
27
|
+
type StampedRoute = {
|
|
28
|
+
catchAll: boolean
|
|
29
|
+
/** URL prefix owned by the page: '' for a root catch-all, else '/admin'. */
|
|
30
|
+
base: string
|
|
31
|
+
/** The catch-all's param name (`slug` in `[...slug!]`). */
|
|
32
|
+
param: string | null
|
|
33
|
+
/**
|
|
34
|
+
* First path segments the catch-all's *siblings* claim — `faculty` when
|
|
35
|
+
* `faculty/[id].vue` sits beside the catch-all. Those URLs belong to more
|
|
36
|
+
* specific routes, so they get real navigations, not soft ones.
|
|
37
|
+
*/
|
|
38
|
+
claimed?: string[]
|
|
39
|
+
/** True when a `[param]` sibling claims every single-segment path. */
|
|
40
|
+
claimedSingle?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LayoutNavigation = {
|
|
44
|
+
/** Path segments under the base — `[]` on the bare directory. */
|
|
45
|
+
readonly segments: Ref<string[]>
|
|
46
|
+
/** The URL prefix this page owns. */
|
|
47
|
+
readonly base: string
|
|
48
|
+
/** Navigate within the subtree; segments or a path, `/`-prefixed or not. */
|
|
49
|
+
navigate(to: string | string[]): void
|
|
50
|
+
/**
|
|
51
|
+
* Listen for navigations. Return `false` from a listener to cancel one —
|
|
52
|
+
* cancellation applies to clicks and `navigate()`; back/forward cannot be
|
|
53
|
+
* cancelled, only observed, because the history entry has already moved.
|
|
54
|
+
*/
|
|
55
|
+
on(listener: LayoutListener): () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LayoutListener = (
|
|
59
|
+
next: string[],
|
|
60
|
+
prev: string[],
|
|
61
|
+
cause: 'click' | 'navigate' | 'history',
|
|
62
|
+
) => boolean | undefined | void
|
|
63
|
+
|
|
64
|
+
/** Is `path` the base itself or inside it? Prefix-safe: `/admin` ≠ `/admini`. */
|
|
65
|
+
export function isUnderBase(base: string, path: string): boolean {
|
|
66
|
+
if (base === '') return path.startsWith('/')
|
|
67
|
+
return path === base || path.startsWith(`${base}/`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The segments of `path` below `base` — `[]` for the base itself. */
|
|
71
|
+
export function segmentsUnder(base: string, path: string): string[] {
|
|
72
|
+
const rest = base === '' ? path : path.slice(base.length)
|
|
73
|
+
return rest.split('/').filter(Boolean)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pathFor(base: string, to: string | string[]): string {
|
|
77
|
+
if (Array.isArray(to)) {
|
|
78
|
+
const joined = to.filter(Boolean).join('/')
|
|
79
|
+
return joined ? `${base}/${joined}` : base || '/'
|
|
80
|
+
}
|
|
81
|
+
if (to.startsWith('/')) return to
|
|
82
|
+
return to ? `${base}/${to}` : base || '/'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function defineLayout(): LayoutNavigation {
|
|
86
|
+
const route = (globalThis as any).__vue_route as StampedRoute | undefined
|
|
87
|
+
|
|
88
|
+
// The guard is the contract, not a formality — see the module comment.
|
|
89
|
+
if (!route?.catchAll) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
'defineLayout() is only available on catch-all pages ' +
|
|
92
|
+
'([...slug].vue or [...slug!].vue): only there does every URL under ' +
|
|
93
|
+
'the page resolve back to the same file on a full load.',
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const base = route.base
|
|
98
|
+
const claimed = new Set(route.claimed ?? [])
|
|
99
|
+
const claimedSingle = Boolean(route.claimedSingle)
|
|
100
|
+
const listeners = new Set<LayoutListener>()
|
|
101
|
+
|
|
102
|
+
// The catch-all owns only what nothing else claims. A sibling route under
|
|
103
|
+
// the base — `faculty/[id].vue` beside `[...slug].vue` — wins those URLs on
|
|
104
|
+
// the server, so a soft-nav there would render this page where a hard load
|
|
105
|
+
// renders that one.
|
|
106
|
+
function claimedElsewhere(next: string[]): boolean {
|
|
107
|
+
if (next.length === 1 && claimedSingle) return true
|
|
108
|
+
return next.length > 0 && claimed.has(next[0])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const initial =
|
|
112
|
+
typeof location !== 'undefined'
|
|
113
|
+
? segmentsUnder(base, location.pathname)
|
|
114
|
+
: []
|
|
115
|
+
const segments = ref<string[]>(initial)
|
|
116
|
+
|
|
117
|
+
function fire(next: string[], cause: Parameters<LayoutListener>[2]): boolean {
|
|
118
|
+
const prev = segments.value
|
|
119
|
+
let allowed = true
|
|
120
|
+
for (const listener of listeners) {
|
|
121
|
+
if (listener(next, prev, cause) === false) allowed = false
|
|
122
|
+
}
|
|
123
|
+
return allowed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function go(to: string | string[], cause: 'click' | 'navigate'): void {
|
|
127
|
+
const path = pathFor(base, to)
|
|
128
|
+
if (!isUnderBase(base, path)) {
|
|
129
|
+
// Leaving the subtree is a real navigation — the next URL belongs to a
|
|
130
|
+
// different file, and pretending otherwise would render a lie.
|
|
131
|
+
if (typeof location !== 'undefined') location.href = path
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const next = segmentsUnder(base, path)
|
|
135
|
+
if (claimedElsewhere(next)) {
|
|
136
|
+
// Under the base, but a more specific route's territory — real
|
|
137
|
+
// navigation, same reasoning as leaving the base.
|
|
138
|
+
if (typeof location !== 'undefined') location.href = path
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (!fire(next, cause)) return
|
|
142
|
+
if (typeof history !== 'undefined') history.pushState(null, '', path)
|
|
143
|
+
segments.value = next
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof document !== 'undefined') {
|
|
147
|
+
document.addEventListener('click', event => {
|
|
148
|
+
if (event.defaultPrevented) return
|
|
149
|
+
if (event.button !== 0) return
|
|
150
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
|
154
|
+
if (!anchor) return
|
|
155
|
+
if (anchor.getAttribute('target')) return
|
|
156
|
+
if (anchor.hasAttribute('download')) return
|
|
157
|
+
|
|
158
|
+
const href = anchor.getAttribute('href') ?? ''
|
|
159
|
+
// Same-document and external schemes stay the browser's business.
|
|
160
|
+
if (href.startsWith('#')) return
|
|
161
|
+
const url = new URL(href, location.href)
|
|
162
|
+
if (url.origin !== location.origin) return
|
|
163
|
+
if (!isUnderBase(base, url.pathname)) return
|
|
164
|
+
if (
|
|
165
|
+
url.pathname === location.pathname &&
|
|
166
|
+
url.search === location.search
|
|
167
|
+
) {
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
go(url.pathname + url.search, 'click')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
window.addEventListener('popstate', () => {
|
|
177
|
+
const path = location.pathname
|
|
178
|
+
if (!isUnderBase(base, path)) {
|
|
179
|
+
// History walked out of the subtree; the entry is already current, so
|
|
180
|
+
// the only honest move is loading what that URL actually serves.
|
|
181
|
+
location.reload()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const next = segmentsUnder(base, path)
|
|
185
|
+
if (claimedElsewhere(next)) {
|
|
186
|
+
location.reload()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
fire(next, 'history') // observable, not cancellable — see `on`
|
|
190
|
+
segments.value = next
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
segments,
|
|
196
|
+
base,
|
|
197
|
+
navigate: to => go(to, 'navigate'),
|
|
198
|
+
on(listener) {
|
|
199
|
+
listeners.add(listener)
|
|
200
|
+
return () => listeners.delete(listener)
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/compile.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { compileText } from '@bakery-framework/core/compiler'
|
|
2
|
-
import { Logger } from '@bakery-framework/core/logger'
|
|
3
2
|
import type { SFCStyleCompileResults } from '@vue/compiler-sfc'
|
|
4
3
|
|
|
5
|
-
const logger = new Logger('vue')
|
|
6
|
-
|
|
7
4
|
import type {
|
|
8
5
|
AssembleComponentOptions,
|
|
9
6
|
CompileScriptOptions,
|
|
@@ -36,6 +33,21 @@ export function setVuePluginOptions(opts?: VuePluginOptions) {
|
|
|
36
33
|
if (opts) vuePluginOptions = opts
|
|
37
34
|
}
|
|
38
35
|
|
|
36
|
+
/**
|
|
37
|
+
* `'runtime'` unless the app opted into the full build.
|
|
38
|
+
*
|
|
39
|
+
* `customElements` deliberately does *not* force `'full'`: for SFCs the
|
|
40
|
+
* custom-element decision is made server-side, in `compileTemplateBlock`'s
|
|
41
|
+
* `isCustomElement`, and arrives in the browser already baked into the render
|
|
42
|
+
* function. Verified against a live runtime-only page: a configured tag
|
|
43
|
+
* renders as a plain element, reactively, with no "Failed to resolve
|
|
44
|
+
* component" warning. Only browser-compiled `template:` strings need the full
|
|
45
|
+
* build, and only the app knows whether it has any.
|
|
46
|
+
*/
|
|
47
|
+
export function vueBuildVariant(): 'runtime' | 'full' {
|
|
48
|
+
return vuePluginOptions.build === 'full' ? 'full' : 'runtime'
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export function resolveIsCustomElement(tag: string): boolean {
|
|
40
52
|
const ce = vuePluginOptions?.customElements
|
|
41
53
|
const userFn = vuePluginOptions?.compilerOptions?.isCustomElement
|
|
@@ -102,7 +114,7 @@ export async function compileScriptBlock(
|
|
|
102
114
|
|
|
103
115
|
export async function compileTemplateBlock(
|
|
104
116
|
options: CompileTemplateOptions,
|
|
105
|
-
): Promise<string | null> {
|
|
117
|
+
): Promise<{ code: string; errors: string[] } | null> {
|
|
106
118
|
const { descriptor, id, filename, bindings } = options
|
|
107
119
|
if (!descriptor.template) return null
|
|
108
120
|
const { compileTemplate } = await loadCompiler()
|
|
@@ -122,7 +134,16 @@ export async function compileTemplateBlock(
|
|
|
122
134
|
if (node.type !== 5) return
|
|
123
135
|
const content = node.content
|
|
124
136
|
|
|
125
|
-
// Simple expression: `{{ value }}
|
|
137
|
+
// Simple expression: `{{ value }}`.
|
|
138
|
+
//
|
|
139
|
+
// Comments inside an interpolation are not handled specially,
|
|
140
|
+
// deliberately: Vue itself cannot parse `{{ total // pesos }}` —
|
|
141
|
+
// measured against bare `compileTemplate`, which reports the same
|
|
142
|
+
// SyntaxError and emits the raw broken expression. Stripping the
|
|
143
|
+
// comment here just traded that for an unprefixed binding, because
|
|
144
|
+
// Vue's own expression pass had already failed. What Bakery adds
|
|
145
|
+
// instead is failing the compile loudly (see `compileVueFile`)
|
|
146
|
+
// rather than serving a module that cannot parse.
|
|
126
147
|
if (content.type === 4) {
|
|
127
148
|
const rawContent = content.content.trim()
|
|
128
149
|
if (rawContent && !rawContent.startsWith('_ctx.$fmt(')) {
|
|
@@ -143,14 +164,19 @@ export async function compileTemplateBlock(
|
|
|
143
164
|
},
|
|
144
165
|
})
|
|
145
166
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
167
|
+
// Reported to the caller, not merely logged: a template Vue cannot compile
|
|
168
|
+
// emits the raw unparseable expression into the render function, so the
|
|
169
|
+
// module *cannot run* — `{{ total // pesos }}` is the measured example, and
|
|
170
|
+
// Vue-alone behaves identically. Serving it anyway was a browser-side
|
|
171
|
+
// SyntaxError with a healthy-looking 200.
|
|
172
|
+
const errors = (result.errors ?? []).map(e =>
|
|
173
|
+
e instanceof Error ? e.message : String(e),
|
|
174
|
+
)
|
|
149
175
|
|
|
150
176
|
let code = result.code
|
|
151
177
|
code = code.replace(/^export\s+/m, '')
|
|
152
178
|
code = await compileText(code)
|
|
153
|
-
return code
|
|
179
|
+
return { code, errors }
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
export async function compileStyleBlock(
|
|
@@ -167,7 +193,7 @@ export async function compileStyleBlock(
|
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
export function assembleComponent(options: AssembleComponentOptions): string {
|
|
170
|
-
const { scriptCode, renderCode, isRoot, scopeId } = options
|
|
196
|
+
const { scriptCode, renderCode, isRoot, scopeId, layoutRoute } = options
|
|
171
197
|
const COMPONENT_VAR = '__sfc__'
|
|
172
198
|
let output = scriptCode.replace(
|
|
173
199
|
/\bexport\s+default\s*/,
|
|
@@ -186,10 +212,30 @@ export function assembleComponent(options: AssembleComponentOptions): string {
|
|
|
186
212
|
output += `\nexport default ${COMPONENT_VAR};`
|
|
187
213
|
|
|
188
214
|
if (isRoot) {
|
|
215
|
+
if (layoutRoute) {
|
|
216
|
+
// The page renders into the layout's default <slot />. The import is a
|
|
217
|
+
// plain `.vue` specifier so `rewriteVueImports` gives it the same
|
|
218
|
+
// `?__vue_script=module` treatment as any component import — the layout
|
|
219
|
+
// is just a component that happens to be discovered by convention.
|
|
220
|
+
output +=
|
|
221
|
+
`\nimport { createApp, h as __h } from 'vue';` +
|
|
222
|
+
`\nimport __layout from ${JSON.stringify(layoutRoute)};` +
|
|
223
|
+
`\nconst __app = createApp({ render: () => __h(__layout, null, { default: () => __h(${COMPONENT_VAR}) }) });`
|
|
224
|
+
} else {
|
|
225
|
+
output +=
|
|
226
|
+
`\nimport { createApp } from 'vue';` +
|
|
227
|
+
`\nconst __app = createApp(${COMPONENT_VAR});`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Full build only. `app.config.compilerOptions` is read exclusively by the
|
|
231
|
+
// in-browser template compiler, which the runtime build does not carry —
|
|
232
|
+
// there, the assignment does nothing except make Vue log a warning about
|
|
233
|
+
// itself on every page, even for apps that configured nothing.
|
|
234
|
+
if (vueBuildVariant() === 'full') {
|
|
235
|
+
output += `\n__app.config.compilerOptions.isCustomElement = ${buildRuntimeCustomElementCheck()};`
|
|
236
|
+
}
|
|
237
|
+
|
|
189
238
|
output +=
|
|
190
|
-
`\nimport { createApp } from 'vue';` +
|
|
191
|
-
`\nconst __app = createApp(${COMPONENT_VAR});` +
|
|
192
|
-
`\n__app.config.compilerOptions.isCustomElement = ${buildRuntimeCustomElementCheck()};` +
|
|
193
239
|
`\n__app.config.globalProperties.$fmt = (v) => globalThis.$fmt ? globalThis.$fmt(v) : v;` +
|
|
194
240
|
`\n__app.mount('#app');`
|
|
195
241
|
}
|
|
@@ -216,7 +262,7 @@ function buildRuntimeCustomElementCheck(): string {
|
|
|
216
262
|
export async function compileVueFile(
|
|
217
263
|
options: CompileVueFileOptions,
|
|
218
264
|
): Promise<CompileVueFileResult> {
|
|
219
|
-
const { content, filename, id, isRootScript } = options
|
|
265
|
+
const { content, filename, id, isRootScript, layoutRoute } = options
|
|
220
266
|
const { descriptor, errors: parseErrors } = await parseVue({
|
|
221
267
|
content,
|
|
222
268
|
filename,
|
|
@@ -226,7 +272,7 @@ export async function compileVueFile(
|
|
|
226
272
|
descriptor,
|
|
227
273
|
id,
|
|
228
274
|
})
|
|
229
|
-
const
|
|
275
|
+
const template = await compileTemplateBlock({
|
|
230
276
|
descriptor,
|
|
231
277
|
id,
|
|
232
278
|
filename,
|
|
@@ -234,14 +280,19 @@ export async function compileVueFile(
|
|
|
234
280
|
})
|
|
235
281
|
const code = assembleComponent({
|
|
236
282
|
scriptCode,
|
|
237
|
-
renderCode,
|
|
283
|
+
renderCode: template?.code ?? null,
|
|
238
284
|
isRoot: isRootScript,
|
|
239
285
|
scopeId: hasScoped ? id : undefined,
|
|
286
|
+
layoutRoute,
|
|
240
287
|
})
|
|
241
288
|
|
|
242
289
|
const styles = await Promise.all(
|
|
243
290
|
descriptor.styles.map(style => compileStyleBlock({ style, id })),
|
|
244
291
|
)
|
|
245
292
|
|
|
246
|
-
return {
|
|
293
|
+
return {
|
|
294
|
+
code,
|
|
295
|
+
styles,
|
|
296
|
+
errors: [...parseErrors, ...(template?.errors ?? [])],
|
|
297
|
+
}
|
|
247
298
|
}
|
package/src/handler.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
1
2
|
import { LRUCache } from '@bakery-framework/core/cache/lru'
|
|
2
3
|
import { Bakery, hostKey } from '@bakery-framework/core/core/bakery'
|
|
3
4
|
import type { Handler } from '@bakery-framework/core/handlers'
|
|
@@ -5,8 +6,10 @@ import {
|
|
|
5
6
|
beginPageRoute,
|
|
6
7
|
DynamicErrorHandler,
|
|
7
8
|
DynamicHandler,
|
|
9
|
+
RX_CATCHALL,
|
|
10
|
+
RX_DYNAMIC,
|
|
11
|
+
RX_OPT_CATCHALL,
|
|
8
12
|
} from '@bakery-framework/core/handlers'
|
|
9
|
-
import { Logger } from '@bakery-framework/core/logger'
|
|
10
13
|
import {
|
|
11
14
|
fs,
|
|
12
15
|
JsonResponseData,
|
|
@@ -15,17 +18,20 @@ import {
|
|
|
15
18
|
} from '@bakery-framework/core/utils'
|
|
16
19
|
import { ETag, injectIfHtml } from '@bakery-framework/core/utils/http'
|
|
17
20
|
|
|
18
|
-
const logger = new Logger('vue')
|
|
19
|
-
|
|
20
21
|
import {
|
|
21
22
|
resolveActionTarget,
|
|
22
23
|
validateActionRequest,
|
|
23
24
|
validateActionTarget,
|
|
24
25
|
} from './actions'
|
|
25
26
|
import { serveVueChunk, VUE_CHUNK_PREFIX } from './chunks'
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
compileStyleBlock,
|
|
29
|
+
compileVueFile,
|
|
30
|
+
parseVue,
|
|
31
|
+
vueBuildVariant,
|
|
32
|
+
} from './compile'
|
|
27
33
|
import { VUE_HTML_SHELL } from './shell'
|
|
28
|
-
import type { ParsedCacheEntry } from './types'
|
|
34
|
+
import type { ParsedCacheEntry, VueMeta } from './types'
|
|
29
35
|
import {
|
|
30
36
|
cacheDir,
|
|
31
37
|
collectExportedFunctionNames,
|
|
@@ -34,6 +40,7 @@ import {
|
|
|
34
40
|
extractServerScripts,
|
|
35
41
|
getServerResponse,
|
|
36
42
|
parsedCache,
|
|
43
|
+
parseSkeleton,
|
|
37
44
|
parseVueMeta,
|
|
38
45
|
RX_EXPORT_BRACE,
|
|
39
46
|
RX_EXPORT_HANGING,
|
|
@@ -75,6 +82,96 @@ function buildActionStub(fn: string, relPath: string) {
|
|
|
75
82
|
)
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Route path of the nearest `layout.vue`, walking from the page **file** up
|
|
87
|
+
* to the serve root — the file, not the URL, so a catch-all page
|
|
88
|
+
* (`admin/[...slug!].vue`) is wrapped by `admin/layout.vue` no matter how
|
|
89
|
+
* deep the request path goes. Null when nothing is found, when the page
|
|
90
|
+
* opted out with `<meta no-layout />`, or when the page *is* a layout —
|
|
91
|
+
* layouts do not nest in v1, deliberately: nesting needs an ordering story
|
|
92
|
+
* (which slot, whose styles win) that should be designed, not implied.
|
|
93
|
+
*/
|
|
94
|
+
function findLayoutRoute(filePath: string, meta: VueMeta): string | null {
|
|
95
|
+
if (!meta.layout) return null
|
|
96
|
+
|
|
97
|
+
const root = fs.resolve(Bakery.serveRoot)
|
|
98
|
+
const file = fs.resolve(filePath)
|
|
99
|
+
if (!file.startsWith(`${root}/`)) return null
|
|
100
|
+
if (file.endsWith('/layout.vue')) return null
|
|
101
|
+
|
|
102
|
+
let dir = file.slice(0, file.lastIndexOf('/'))
|
|
103
|
+
while (dir === root || dir.startsWith(`${root}/`)) {
|
|
104
|
+
const candidate = `${dir}/layout.vue`
|
|
105
|
+
if (fs.isFileSync(candidate)) {
|
|
106
|
+
return `/${fs.relative(root, candidate)}`
|
|
107
|
+
}
|
|
108
|
+
if (dir === root) break
|
|
109
|
+
dir = dir.slice(0, dir.lastIndexOf('/'))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What the catch-all's siblings claim, for the `defineLayout()` stamp.
|
|
117
|
+
*
|
|
118
|
+
* A catch-all owns only *what nothing else claims*: with
|
|
119
|
+
* `admin/[...slug].vue` beside `admin/faculty/[id].vue`, the URL
|
|
120
|
+
* `/admin/faculty/7` is under the base but belongs to `[id].vue` — so the
|
|
121
|
+
* client-side router must yield it to a real navigation, or a soft-nav shows
|
|
122
|
+
* the catch-all's rendering where a hard reload shows a different page.
|
|
123
|
+
*
|
|
124
|
+
* First-level granularity is exactly the server's precedence boundary: every
|
|
125
|
+
* more-specific route — an exact sibling file, a child index, a deeper
|
|
126
|
+
* catch-all — lives inside some sibling entry, so excluding the entry
|
|
127
|
+
* excludes the whole claim. A `[param]` sibling claims *every* single-segment
|
|
128
|
+
* path, which is what `claimedSingle` carries. `layout.vue` claims nothing (it
|
|
129
|
+
* is not routable), and the catch-all file itself is the page being served.
|
|
130
|
+
*
|
|
131
|
+
* Computed per page request, so files added or removed in dev are seen on the
|
|
132
|
+
* next load without cache ceremony.
|
|
133
|
+
*/
|
|
134
|
+
export function claimedBeside(catchAllFile: string): {
|
|
135
|
+
claimed: string[]
|
|
136
|
+
claimedSingle: boolean
|
|
137
|
+
} {
|
|
138
|
+
const claimed = new Set<string>()
|
|
139
|
+
let claimedSingle = false
|
|
140
|
+
|
|
141
|
+
const dir = fs.resolve(catchAllFile).replace(/\/[^/]*$/, '')
|
|
142
|
+
const self = fs.resolve(catchAllFile).slice(dir.length + 1)
|
|
143
|
+
|
|
144
|
+
let entries: import('node:fs').Dirent[]
|
|
145
|
+
try {
|
|
146
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
147
|
+
} catch {
|
|
148
|
+
// Unreadable directory: no visible siblings means nothing extra claimed,
|
|
149
|
+
// and a hard load still routes correctly — the stamp is an optimisation
|
|
150
|
+
// of honesty, not the source of it.
|
|
151
|
+
return { claimed: [], claimedSingle: false }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const name = entry.name
|
|
156
|
+
if (name === self || name === 'layout.vue') continue
|
|
157
|
+
if (name.startsWith('.')) continue
|
|
158
|
+
|
|
159
|
+
if (RX_CATCHALL.test(name) || RX_OPT_CATCHALL.test(name)) continue
|
|
160
|
+
if (RX_DYNAMIC.test(name)) {
|
|
161
|
+
claimedSingle = true
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
claimed.add(name)
|
|
166
|
+
// `reports.vue` also claims `/base/reports` — the extensionless spelling
|
|
167
|
+
// is the one links actually use.
|
|
168
|
+
const stem = name.replace(/\.[^.]+$/, '')
|
|
169
|
+
if (stem && stem !== name) claimed.add(stem)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { claimed: [...claimed], claimedSingle }
|
|
173
|
+
}
|
|
174
|
+
|
|
78
175
|
export class VueHandler extends DynamicHandler {
|
|
79
176
|
static get config() {
|
|
80
177
|
return {
|
|
@@ -108,8 +205,9 @@ export class VueHandler extends DynamicHandler {
|
|
|
108
205
|
|
|
109
206
|
const rawText = await diskFile.text()
|
|
110
207
|
const { meta, clean: metaCleaned } = parseVueMeta(rawText)
|
|
208
|
+
const { skeleton, clean: withoutSkeleton } = parseSkeleton(metaCleaned)
|
|
111
209
|
const { script: serverScript, clean: withoutServer } =
|
|
112
|
-
extractServerScripts(
|
|
210
|
+
extractServerScripts(withoutSkeleton)
|
|
113
211
|
let cleanContent = withoutServer
|
|
114
212
|
|
|
115
213
|
if (serverScript.trim()) {
|
|
@@ -151,6 +249,20 @@ export class VueHandler extends DynamicHandler {
|
|
|
151
249
|
cleanContent = `<script${langAttr}>\n${scriptInjections.join(
|
|
152
250
|
'\n',
|
|
153
251
|
)}\n</script>\n${cleanContent}`
|
|
252
|
+
|
|
253
|
+
// A component with a server block but no `<script setup>` used to
|
|
254
|
+
// render blank: the injected block above has no `export default`, so
|
|
255
|
+
// `assembleComponent` had nothing to rewrite into `const __sfc__ =`
|
|
256
|
+
// and the module died with `ReferenceError: __sfc__ is not defined`.
|
|
257
|
+
// The documented workaround was a setup block — so inject one. The
|
|
258
|
+
// comment inside is load-bearing: the SFC parser *discards* a block
|
|
259
|
+
// whose content is only whitespace, which is also why the workaround
|
|
260
|
+
// had to be a non-empty block. It also makes the server exports
|
|
261
|
+
// template-visible — compileScript only records plain-script bindings
|
|
262
|
+
// when a setup block exists.
|
|
263
|
+
if (!/<script\s[^>]*\bsetup\b|<script\s+setup/i.test(cleanContent)) {
|
|
264
|
+
cleanContent += `\n<script setup${langAttr}>\n// injected: carries the server-data bindings above\n</script>\n`
|
|
265
|
+
}
|
|
154
266
|
}
|
|
155
267
|
}
|
|
156
268
|
|
|
@@ -182,6 +294,8 @@ export class VueHandler extends DynamicHandler {
|
|
|
182
294
|
styles: descriptor.styles,
|
|
183
295
|
hasCss: descriptor.styles.length > 0,
|
|
184
296
|
meta,
|
|
297
|
+
skeleton,
|
|
298
|
+
layoutRoute: findLayoutRoute(filePath, meta),
|
|
185
299
|
}
|
|
186
300
|
parsedCache.set(id, parsed)
|
|
187
301
|
return parsed
|
|
@@ -200,10 +314,18 @@ export class VueHandler extends DynamicHandler {
|
|
|
200
314
|
filename: routePath,
|
|
201
315
|
id: scopeId || id,
|
|
202
316
|
isRootScript,
|
|
317
|
+
layoutRoute: isRootScript ? parsed.layoutRoute : null,
|
|
203
318
|
})
|
|
204
319
|
|
|
205
320
|
if (compiled.errors.length) {
|
|
206
|
-
|
|
321
|
+
// Thrown, not logged-and-served: a template Vue could not compile has
|
|
322
|
+
// the raw unparseable expression in its render function, so serving it
|
|
323
|
+
// is a browser-side SyntaxError behind a 200 and an empty page — the
|
|
324
|
+
// report that surfaced this described exactly that. The throw lands in
|
|
325
|
+
// the error registry as a 500 that names the file and the error.
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Vue compile failed (${routePath}): ${compiled.errors.join('; ')}`,
|
|
328
|
+
)
|
|
207
329
|
}
|
|
208
330
|
|
|
209
331
|
let code = compiled.code
|
|
@@ -246,7 +368,21 @@ export class VueHandler extends DynamicHandler {
|
|
|
246
368
|
// data, so the built file can live on disk.
|
|
247
369
|
if (!hasServerScript || isRootScript) {
|
|
248
370
|
const dir = fs.resolve(cacheDir, 'js')
|
|
249
|
-
|
|
371
|
+
// Root scripts carry the build variant in their name for the same reason
|
|
372
|
+
// the chunk does (`vueChunkPath`): the cache is keyed on the *source's*
|
|
373
|
+
// mtime, and flipping `build` in server.config.ts touches no source file
|
|
374
|
+
// — measured serving a root compiled under 'runtime' after the flip to
|
|
375
|
+
// 'full', missing the isCustomElement bridge the full build exists for.
|
|
376
|
+
// Only roots: the variant changes nothing in a subcomponent's output.
|
|
377
|
+
// The layout joins the name for the same reason the variant does: the
|
|
378
|
+
// cache is keyed on the page's mtime, and creating, deleting or moving
|
|
379
|
+
// a layout.vue touches no page file.
|
|
380
|
+
const layoutTag = parsed.layoutRoute
|
|
381
|
+
? `.${toHash(parsed.layoutRoute)}`
|
|
382
|
+
: ''
|
|
383
|
+
const fileName = isRootScript
|
|
384
|
+
? `${id}.root.${vueBuildVariant()}${layoutTag}.js`
|
|
385
|
+
: `${id}.js`
|
|
250
386
|
const replacement =
|
|
251
387
|
isRootScript && hasServerScript
|
|
252
388
|
? '(globalThis.__vue_server || {})'
|
|
@@ -295,12 +431,36 @@ export class VueHandler extends DynamicHandler {
|
|
|
295
431
|
})
|
|
296
432
|
}
|
|
297
433
|
|
|
434
|
+
/**
|
|
435
|
+
* The layout's stylesheet link, or ''. Emitted ahead of the page's own link
|
|
436
|
+
* so a page can override its layout the way source order implies.
|
|
437
|
+
*/
|
|
438
|
+
private static async layoutCssLink(parsed: ParsedCacheEntry) {
|
|
439
|
+
if (!parsed.layoutRoute) return ''
|
|
440
|
+
|
|
441
|
+
const layoutFile = fs.resolve(Bakery.serveRoot, `.${parsed.layoutRoute}`)
|
|
442
|
+
const layoutBun = Bun.file(layoutFile)
|
|
443
|
+
if (!fs.exists(layoutBun)) return ''
|
|
444
|
+
|
|
445
|
+
const layoutId = toHash(hostKey(parsed.layoutRoute.slice(1)))
|
|
446
|
+
const layoutParsed = await VueHandler.parseVueFile(
|
|
447
|
+
layoutId,
|
|
448
|
+
layoutBun,
|
|
449
|
+
layoutFile,
|
|
450
|
+
layoutBun.lastModified,
|
|
451
|
+
)
|
|
452
|
+
if (!layoutParsed.hasCss) return ''
|
|
453
|
+
|
|
454
|
+
return `<link rel="stylesheet" id="__vu_css_${layoutId}" href="${parsed.layoutRoute}?__vue_css=true">\n`
|
|
455
|
+
}
|
|
456
|
+
|
|
298
457
|
static async handleHtml(
|
|
299
458
|
id: string,
|
|
300
459
|
params: any,
|
|
301
460
|
routePath: string,
|
|
302
461
|
serverParams: any,
|
|
303
462
|
parsed: ParsedCacheEntry,
|
|
463
|
+
route?: { catchAll: boolean; base: string; param: string | null },
|
|
304
464
|
) {
|
|
305
465
|
const { hasCss, serverScript } = parsed
|
|
306
466
|
const hasServerData =
|
|
@@ -317,11 +477,30 @@ export class VueHandler extends DynamicHandler {
|
|
|
317
477
|
? `<script>globalThis.__vue_server = ${escapeScriptJson(payload)};</script>`
|
|
318
478
|
: ''
|
|
319
479
|
|
|
480
|
+
// The route's shape, for `defineLayout()` (`client.ts`): the guard that
|
|
481
|
+
// restricts it to catch-all pages reads `catchAll` from here, so the
|
|
482
|
+
// stamp is the enforcement, not a convenience. Stamped on every page —
|
|
483
|
+
// a non-catch-all page carries `catchAll: false`, which is what makes
|
|
484
|
+
// the client-side error message possible instead of a bare undefined.
|
|
485
|
+
const routeDecl = route
|
|
486
|
+
? `<script>globalThis.__vue_route = ${escapeScriptJson(route)};</script>`
|
|
487
|
+
: ''
|
|
488
|
+
|
|
320
489
|
let hydrated = VUE_HTML_SHELL.replace(
|
|
321
490
|
'/*__SERVER_VARIABLES__*/',
|
|
322
|
-
() => serverDecl,
|
|
491
|
+
() => serverDecl + routeDecl,
|
|
323
492
|
)
|
|
324
493
|
|
|
494
|
+
// Static markup, injected verbatim — see parseSkeleton for why it is
|
|
495
|
+
// never rendered. mount() replaces the container children, so it
|
|
496
|
+
// disappears the moment the real component is up.
|
|
497
|
+
if (parsed.skeleton) {
|
|
498
|
+
hydrated = hydrated.replace(
|
|
499
|
+
'<div id="app"></div>',
|
|
500
|
+
() => `<div id="app">${parsed.skeleton}</div>`,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
325
504
|
if (parsed.meta.title) {
|
|
326
505
|
const title = escapeHtml(parsed.meta.title)
|
|
327
506
|
hydrated = hydrated.replace(
|
|
@@ -331,6 +510,7 @@ export class VueHandler extends DynamicHandler {
|
|
|
331
510
|
}
|
|
332
511
|
|
|
333
512
|
const prio =
|
|
513
|
+
(await VueHandler.layoutCssLink(parsed)) +
|
|
334
514
|
(hasCss
|
|
335
515
|
? `<link rel="stylesheet" id="__vu_css_${id}" href="${routePath}?__vue_css=true">\n`
|
|
336
516
|
: '') +
|
|
@@ -432,6 +612,13 @@ async function sharedHandler(
|
|
|
432
612
|
return response.error('Not Found', 404)
|
|
433
613
|
}
|
|
434
614
|
|
|
615
|
+
// A layout is scaffolding, not a destination: /admin/layout must not render
|
|
616
|
+
// as a page. Script and css requests pass — they are how the root script of
|
|
617
|
+
// every page under it imports the thing.
|
|
618
|
+
if (routePath.endsWith('/layout.vue') && !isScript && !isCss) {
|
|
619
|
+
return response.error('Not Found', 404)
|
|
620
|
+
}
|
|
621
|
+
|
|
435
622
|
// page-only: block module imports (allow root scripts, page, and css)
|
|
436
623
|
if (parsed.meta.pageOnly && isScript && vueScriptParam === 'module') {
|
|
437
624
|
return response.error('Not Found', 404)
|
|
@@ -534,5 +721,20 @@ async function sharedHandler(
|
|
|
534
721
|
return VueHandler.handleScript(id, routePath, false, parsed, serverValues)
|
|
535
722
|
}
|
|
536
723
|
|
|
537
|
-
|
|
724
|
+
// `base` is the URL prefix the page owns: the file's directory. For
|
|
725
|
+
// `wiki/[...page!].vue` that is `/wiki`; for a root-level catch-all it is
|
|
726
|
+
// the empty string, which `defineLayout` treats as "everything".
|
|
727
|
+
return VueHandler.handleHtml(
|
|
728
|
+
id,
|
|
729
|
+
finalParams,
|
|
730
|
+
routePath,
|
|
731
|
+
serverParams,
|
|
732
|
+
parsed,
|
|
733
|
+
{
|
|
734
|
+
catchAll: Boolean(info.catchAll),
|
|
735
|
+
base: routePath.slice(0, routePath.lastIndexOf('/')),
|
|
736
|
+
param: info.params.length ? info.params[info.params.length - 1] : null,
|
|
737
|
+
...claimedBeside(diskFile.name ?? ''),
|
|
738
|
+
},
|
|
739
|
+
)
|
|
538
740
|
}
|
package/src/setup.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
2
2
|
import { Logger } from '@bakery-framework/core/logger'
|
|
3
|
+
import { vueChunkPath } from './chunks'
|
|
3
4
|
import { VueErrorHandler, VueHandler } from './handler'
|
|
4
|
-
import { initVueVersion
|
|
5
|
+
import { initVueVersion } from './utils'
|
|
5
6
|
|
|
6
7
|
const logger = new Logger('vue')
|
|
7
8
|
|
|
@@ -19,5 +20,8 @@ export function setupVue() {
|
|
|
19
20
|
initVueVersion()
|
|
20
21
|
Bakery.handlers.fetch.set(VueHandler, 58)
|
|
21
22
|
Bakery.handlers.error.set(VueErrorHandler, 18)
|
|
22
|
-
|
|
23
|
+
// `vueChunkPath` is the single writer of this URL — it carries the build
|
|
24
|
+
// variant (`<version>.runtime.js` / `<version>.full.js`), and the serving
|
|
25
|
+
// check in `chunks.ts` reads the same function.
|
|
26
|
+
Bakery.config.importMap.vue = vueChunkPath()
|
|
23
27
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface AssembleComponentOptions {
|
|
|
41
41
|
renderCode: string | null
|
|
42
42
|
isRoot: boolean
|
|
43
43
|
scopeId?: string
|
|
44
|
+
/** Route path of the layout to wrap a root component in, if any. */
|
|
45
|
+
layoutRoute?: string | null
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface CompileVueFileOptions {
|
|
@@ -48,6 +50,8 @@ export interface CompileVueFileOptions {
|
|
|
48
50
|
filename: string
|
|
49
51
|
id: string
|
|
50
52
|
isRootScript: boolean
|
|
53
|
+
/** Route path of the nearest layout.vue; only read for root scripts. */
|
|
54
|
+
layoutRoute?: string | null
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export interface CompileVueFileResult {
|
|
@@ -60,6 +64,8 @@ export interface VueMeta {
|
|
|
60
64
|
moduleOnly: boolean
|
|
61
65
|
pageOnly: boolean
|
|
62
66
|
title: string | null
|
|
67
|
+
/** False when the page opted out with `<meta no-layout />`. */
|
|
68
|
+
layout: boolean
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export interface ParsedCacheEntry {
|
|
@@ -71,6 +77,17 @@ export interface ParsedCacheEntry {
|
|
|
71
77
|
styles: SFCStyleBlock[]
|
|
72
78
|
hasCss: boolean
|
|
73
79
|
meta: VueMeta
|
|
80
|
+
/**
|
|
81
|
+
* Inner markup of a `<template skeleton>` block, or null. Static by
|
|
82
|
+
* construction — extracted before compilation, never rendered on the
|
|
83
|
+
* server — so nothing request- or user-derived can reach it.
|
|
84
|
+
*/
|
|
85
|
+
skeleton: string | null
|
|
86
|
+
/**
|
|
87
|
+
* Route path of the nearest `layout.vue` (e.g. `/admin/layout.vue`), or
|
|
88
|
+
* null when there is none or the page opted out.
|
|
89
|
+
*/
|
|
90
|
+
layoutRoute: string | null
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
export interface ServerResponseOptions {
|
|
@@ -89,4 +106,16 @@ export type CustomElementsOption = string[] | ((tag: string) => boolean)
|
|
|
89
106
|
export interface VuePluginOptions {
|
|
90
107
|
customElements?: CustomElementsOption
|
|
91
108
|
compilerOptions?: Record<string, any>
|
|
109
|
+
/**
|
|
110
|
+
* Which Vue build the plugin serves at `/_vue/<version>.<build>.js`.
|
|
111
|
+
*
|
|
112
|
+
* `'runtime'` (the default) is ~170KB smaller and is all a Bakery app
|
|
113
|
+
* normally needs: SFC templates are compiled to render functions on the
|
|
114
|
+
* server, and `customElements` is applied there too, so the browser never
|
|
115
|
+
* compiles a template. Opt into `'full'` only for components that hand Vue a
|
|
116
|
+
* raw `template:` string at runtime — those are compiled in the browser and
|
|
117
|
+
* fail on the runtime build with Vue's "runtime compilation is not
|
|
118
|
+
* supported" error.
|
|
119
|
+
*/
|
|
120
|
+
build?: 'runtime' | 'full'
|
|
92
121
|
}
|
package/src/utils.ts
CHANGED
|
@@ -570,8 +570,44 @@ export function rewriteVueImports(code: string): string {
|
|
|
570
570
|
export const RX_VUE_META = /^<meta(?![\w-])((?:"[^"]*"|'[^']*'|[^>])*?)\/>/i
|
|
571
571
|
const RX_META_SKIPPABLE = /^\s+|^<!--[\s\S]*?-->/
|
|
572
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Extract a `<template skeleton>` block: its inner markup goes into the HTML
|
|
575
|
+
* shell's `#app` so the user sees something before the bundle hydrates, and
|
|
576
|
+
* the block is removed from the SFC — the compiler allows only one template.
|
|
577
|
+
*
|
|
578
|
+
* **Static by design, and the design is a security decision.** The markup is
|
|
579
|
+
* injected verbatim: never compiled, never rendered on the server, so
|
|
580
|
+
* interpolations do not evaluate and nothing request- or session-derived can
|
|
581
|
+
* end up in it. A server-rendered skeleton cached across requests would serve
|
|
582
|
+
* one user's data to another. Scoped styles do not reach it either — the
|
|
583
|
+
* scope attributes are stamped by the compiler this block never meets.
|
|
584
|
+
*
|
|
585
|
+
* One block per file; nested `<template>` elements inside it are not
|
|
586
|
+
* supported (the lazy match ends at the first closing tag).
|
|
587
|
+
*/
|
|
588
|
+
export function parseSkeleton(raw: string): {
|
|
589
|
+
skeleton: string | null
|
|
590
|
+
clean: string
|
|
591
|
+
} {
|
|
592
|
+
const match = raw.match(
|
|
593
|
+
/<template\s+skeleton(?:\s(?:"[^"]*"|'[^']*'|[^>])*)?>([\s\S]*?)<\/template>/i,
|
|
594
|
+
)
|
|
595
|
+
if (!match) return { skeleton: null, clean: raw }
|
|
596
|
+
|
|
597
|
+
const skeleton = match[1].trim()
|
|
598
|
+
return {
|
|
599
|
+
skeleton: skeleton || null,
|
|
600
|
+
clean: raw.replace(match[0], ''),
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
573
604
|
export function parseVueMeta(raw: string): { meta: VueMeta; clean: string } {
|
|
574
|
-
const meta: VueMeta = {
|
|
605
|
+
const meta: VueMeta = {
|
|
606
|
+
moduleOnly: false,
|
|
607
|
+
pageOnly: false,
|
|
608
|
+
title: null,
|
|
609
|
+
layout: true,
|
|
610
|
+
}
|
|
575
611
|
|
|
576
612
|
// Directives live in the file prologue only. Walking forward from the start
|
|
577
613
|
// (rather than scanning the whole file) keeps a `<meta />` inside a template
|
|
@@ -595,6 +631,7 @@ export function parseVueMeta(raw: string): { meta: VueMeta; clean: string } {
|
|
|
595
631
|
const attrs = tag[1]
|
|
596
632
|
if (/\bmodule-only\b/i.test(attrs)) meta.moduleOnly = true
|
|
597
633
|
if (/\bpage-only\b/i.test(attrs)) meta.pageOnly = true
|
|
634
|
+
if (/\bno-layout\b/i.test(attrs)) meta.layout = false
|
|
598
635
|
|
|
599
636
|
const titleMatch = attrs.match(
|
|
600
637
|
/\btitle\s*=\s*"([^"]*)"|\btitle\s*=\s*'([^']*)'/i,
|