@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-vue",
3
- "version": "1.2.3",
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.0.0"
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 !== `${VUE_CHUNK_PREFIX}${VUE_VERSION}.js`) {
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
- const fileName = `${VUE_VERSION}.js`
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('vue/dist/vue.esm-bundler.js', Bakery.root)
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
- if (result.errors?.length) {
147
- logger.log(`Template compile errors: ${result.errors.join(', ')}`, 'error')
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 renderCode = await compileTemplateBlock({
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 { code, styles, errors: parseErrors }
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 { compileStyleBlock, compileVueFile, parseVue } from './compile'
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(metaCleaned)
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
- logger.log(`Compile errors: ${compiled.errors.join(', ')}`, 'error')
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
- const fileName = `${id}${isRootScript ? '.root' : ''}.js`
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
- return VueHandler.handleHtml(id, finalParams, routePath, serverParams, parsed)
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, VUE_VERSION } from './utils'
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
- Bakery.config.importMap.vue = `/_vue/${VUE_VERSION}.js`
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 = { moduleOnly: false, pageOnly: false, title: null }
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,