@bakery-framework/plugin-vue 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-vue",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Bakery vue plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -43,7 +43,7 @@
43
43
  "vue": "^3.5.38"
44
44
  },
45
45
  "dependencies": {
46
- "@bakery-framework/core": "^2.2.1"
46
+ "@bakery-framework/core": "^2.3.0"
47
47
  },
48
48
  "engines": {
49
49
  "bun": ">=1.4.0"
package/src/client.ts CHANGED
@@ -45,6 +45,12 @@ type StampedRoute = {
45
45
  export type LayoutNavigation = {
46
46
  /** Path segments under the base: `[]` on the bare directory. */
47
47
  readonly segments: Ref<string[]>
48
+ /**
49
+ * The query string, as `location.search` spells it: `''`, or `?` and the
50
+ * pairs. Updated with every navigation, a query-only one included, which
51
+ * changes no segment.
52
+ */
53
+ readonly search: Ref<string>
48
54
  /** The URL prefix this page owns. */
49
55
  readonly base: string
50
56
  /** Navigate within the subtree; segments or a path, `/`-prefixed or not. */
@@ -63,18 +69,39 @@ export type LayoutListener = (
63
69
  cause: 'click' | 'navigate' | 'history',
64
70
  ) => boolean | undefined | void
65
71
 
72
+ /**
73
+ * `path` without its query and hash: the part a route is matched on. A link
74
+ * to `/campus/ma/students?standing=pending` used to keep its query in the
75
+ * last segment and render a page named `students?standing=pending`.
76
+ */
77
+ function pathOnly(path: string): string {
78
+ const cut = path.search(/[?#]/)
79
+ return cut < 0 ? path : path.slice(0, cut)
80
+ }
81
+
66
82
  /** Is `path` the base itself or inside it? Prefix-safe: `/admin` ≠ `/admini`. */
67
83
  export function isUnderBase(base: string, path: string): boolean {
68
- if (base === '') return path.startsWith('/')
69
- return path === base || path.startsWith(`${base}/`)
84
+ const pathname = pathOnly(path)
85
+ if (base === '') return pathname.startsWith('/')
86
+ return pathname === base || pathname.startsWith(`${base}/`)
70
87
  }
71
88
 
72
89
  /** The segments of `path` below `base`: `[]` for the base itself. */
73
90
  export function segmentsUnder(base: string, path: string): string[] {
74
- const rest = base === '' ? path : path.slice(base.length)
91
+ const pathname = pathOnly(path)
92
+ const rest = base === '' ? pathname : pathname.slice(base.length)
75
93
  return rest.split('/').filter(Boolean)
76
94
  }
77
95
 
96
+ /** The query of `path`, `''` when it has none, as `location.search` has it. */
97
+ function searchOf(path: string): string {
98
+ const hash = path.indexOf('#')
99
+ const head = hash < 0 ? path : path.slice(0, hash)
100
+ const query = head.indexOf('?')
101
+ // A bare `?` is no query: `location.search` reads `''` for it.
102
+ return query < 0 || query === head.length - 1 ? '' : head.slice(query)
103
+ }
104
+
78
105
  function pathFor(base: string, to: string | string[]): string {
79
106
  if (Array.isArray(to)) {
80
107
  const joined = to.filter(Boolean).join('/')
@@ -110,11 +137,11 @@ export function defineLayout(): LayoutNavigation {
110
137
  return next.length > 0 && claimed.has(next[0])
111
138
  }
112
139
 
113
- const initial =
114
- typeof location !== 'undefined'
115
- ? segmentsUnder(base, location.pathname)
116
- : []
117
- const segments = ref<string[]>(initial)
140
+ const here = typeof location !== 'undefined'
141
+ const segments = ref<string[]>(
142
+ here ? segmentsUnder(base, location.pathname) : [],
143
+ )
144
+ const search = ref(here ? location.search : '')
118
145
 
119
146
  function fire(next: string[], cause: Parameters<LayoutListener>[2]): boolean {
120
147
  const prev = segments.value
@@ -141,8 +168,11 @@ export function defineLayout(): LayoutNavigation {
141
168
  return
142
169
  }
143
170
  if (!fire(next, cause)) return
171
+ // The whole path goes into history, query and hash included: a reload
172
+ // has to land on the same view.
144
173
  if (typeof history !== 'undefined') history.pushState(null, '', path)
145
174
  segments.value = next
175
+ search.value = searchOf(path)
146
176
  }
147
177
 
148
178
  if (typeof document !== 'undefined') {
@@ -167,12 +197,14 @@ export function defineLayout(): LayoutNavigation {
167
197
  url.pathname === location.pathname &&
168
198
  url.search === location.search
169
199
  ) {
200
+ // Only the hash differs: the browser's own same-document scroll.
201
+ if (url.hash && url.hash !== location.hash) return
170
202
  event.preventDefault()
171
203
  return
172
204
  }
173
205
 
174
206
  event.preventDefault()
175
- go(url.pathname + url.search, 'click')
207
+ go(url.pathname + url.search + url.hash, 'click')
176
208
  })
177
209
 
178
210
  window.addEventListener('popstate', () => {
@@ -190,11 +222,13 @@ export function defineLayout(): LayoutNavigation {
190
222
  }
191
223
  fire(next, 'history') // observable, not cancellable. See `on`
192
224
  segments.value = next
225
+ search.value = location.search
193
226
  })
194
227
  }
195
228
 
196
229
  return {
197
230
  segments,
231
+ search,
198
232
  base,
199
233
  navigate: to => go(to, 'navigate'),
200
234
  on(listener) {
package/src/handler.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  parseVue,
33
33
  vueBuildVariant,
34
34
  } from './compile'
35
- import { VUE_HTML_SHELL } from './shell'
35
+ import { declaresViewport, VIEWPORT_META, VUE_HTML_SHELL } from './shell'
36
36
  import type { ParsedCacheEntry, VueMeta } from './types'
37
37
  import {
38
38
  cacheDir,
@@ -709,6 +709,12 @@ export class VueHandler extends DynamicHandler {
709
709
  () => serverDecl + moduleDecl + routeDecl,
710
710
  )
711
711
 
712
+ // An app's own viewport, through `head`, would lose to the shell's: see
713
+ // `declaresViewport`.
714
+ if (declaresViewport(Bakery.config.head)) {
715
+ hydrated = hydrated.replace(VIEWPORT_META, '')
716
+ }
717
+
712
718
  // Static markup, injected verbatim. See parseSkeleton for why it is
713
719
  // never rendered. mount() replaces the container children, so it
714
720
  // disappears the moment the real component is up.
package/src/shell.ts CHANGED
@@ -1,9 +1,30 @@
1
+ /**
2
+ * The page's default viewport. Without one a phone lays the page out 980 px
3
+ * wide and no `max-width` media query applies (measured in Chrome under 390 px
4
+ * emulation: `innerWidth` 980, and 390 with this tag).
5
+ */
6
+ export const VIEWPORT_META =
7
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
8
+
9
+ /**
10
+ * Whether `head` markup declares a viewport of its own.
11
+ *
12
+ * The host's `head` is spliced in right after `<head>`, ahead of the shell's
13
+ * own tags, and of two viewport metas Chrome honors the later one (measured:
14
+ * `width=600` after `device-width` reads 600, before it 390). So the default
15
+ * above would override an app's own, and the handler drops it instead.
16
+ */
17
+ export function declaresViewport(head: string | undefined): boolean {
18
+ return !!head && /<meta\b[^>]*\sname\s*=\s*["']?viewport\b/i.test(head)
19
+ }
20
+
1
21
  export const VUE_HTML_SHELL = `
2
22
  <!DOCTYPE html>
3
23
  <html lang="en">
4
24
 
5
25
  <head>
6
26
  <meta charset="UTF-8">
27
+ ${VIEWPORT_META}
7
28
  <title>Vue App</title>
8
29
  /*__SERVER_VARIABLES__*/
9
30
  </head>