Skip to content

Commit f0cca99

Browse files
authored
feat(plugin-og): follow the embedding page in the address bar (#438)
1 parent 357d3b3 commit f0cca99

7 files changed

Lines changed: 178 additions & 18 deletions

File tree

‎plugins/og/app/app/app.vue‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ onMounted(() => viewer.inspect())
2020
v-model:target="viewer.target.value"
2121
:loading="viewer.loading.value"
2222
:is-static="viewer.isStatic.value"
23+
:is-embedded="viewer.isEmbedded"
24+
:url="viewer.snapshot.value?.url"
25+
:status="viewer.snapshot.value?.status"
2326
@inspect="viewer.inspect()"
27+
@refresh="viewer.refresh()"
2428
/>
2529

2630
<div v-if="viewer.error.value" class="flex items-start gap-2 border-b border-base bg-red/8 px4 py2 color-red text-xs">
@@ -48,13 +52,6 @@ onMounted(() => viewer.inspect())
4852

4953
<div v-else class="grid min-h-full gap4 p4 xl:grid-cols-[minmax(0,1fr)_minmax(28rem,0.85fr)]">
5054
<div class="min-w-0 flex flex-col gap4">
51-
<div class="flex flex-wrap items-center gap-2 color-muted text-xs">
52-
<span class="i-ph-check-circle-duotone color-status-positive" />
53-
<a :href="viewer.snapshot.value.url" target="_blank" rel="noreferrer" class="min-w-0 truncate font-mono hover:color-active" :title="viewer.snapshot.value.url">
54-
{{ viewer.snapshot.value.url }}
55-
</a>
56-
<span class="ml-auto font-mono tabular-nums">HTTP {{ viewer.snapshot.value.status }}</span>
57-
</div>
5855
<MetadataTable :tags="viewer.snapshot.value.tags" />
5956
<MissingTags :tags="viewer.snapshot.value.tags" />
6057
</div>

‎plugins/og/app/app/components/ViewerToolbar.stories.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,18 @@ export const Idle: Story = {
1818
const target = ref('https://devfra.me/')
1919
return { target }
2020
},
21-
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" />`,
21+
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" :is-embedded="false" />`,
22+
}),
23+
}
24+
25+
export const Embedded: Story = {
26+
render: () => ({
27+
components: { ViewerToolbar },
28+
setup() {
29+
const target = ref('/guide/')
30+
return { target }
31+
},
32+
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" :is-embedded="true" url="http://localhost:5175/guide/" :status="200" />`,
2233
}),
2334
}
2435

@@ -29,7 +40,7 @@ export const Loading: Story = {
2940
const target = ref('https://devfra.me/')
3041
return { target }
3142
},
32-
template: `<ViewerToolbar v-model:target="target" :loading="true" :is-static="false" />`,
43+
template: `<ViewerToolbar v-model:target="target" :loading="true" :is-static="false" :is-embedded="false" />`,
3344
}),
3445
}
3546

@@ -40,6 +51,6 @@ export const Static: Story = {
4051
const target = ref('https://devfra.me/')
4152
return { target }
4253
},
43-
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="true" />`,
54+
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="true" :is-embedded="false" url="https://devfra.me/" :status="200" />`,
4455
}),
4556
}

‎plugins/og/app/app/components/ViewerToolbar.vue‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
<script setup lang="ts">
22
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
3+
import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue'
34
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'
45
56
defineProps<{
67
loading: boolean
78
isStatic: boolean
9+
/** The address takes a page path, resolved against the embedding page. */
10+
isEmbedded: boolean
11+
/** The final URL and HTTP status of the last inspected page. */
12+
url?: string
13+
status?: number
814
}>()
915
1016
const emit = defineEmits<{
1117
inspect: []
18+
refresh: []
1219
}>()
1320
1421
const target = defineModel<string>('target', { required: true })
@@ -30,16 +37,33 @@ const target = defineModel<string>('target', { required: true })
3037
v-model="target"
3138
class="min-w-0 flex-1 bg-transparent color-base font-mono text-xs outline-none"
3239
inputmode="url"
33-
placeholder="http://localhost:3000"
40+
:placeholder="isEmbedded ? '/' : 'http://localhost:3000'"
3441
spellcheck="false"
35-
aria-label="Page URL"
42+
:aria-label="isEmbedded ? 'Page path or URL' : 'Page URL'"
3643
:disabled="isStatic"
3744
>
3845
</label>
46+
<ActionIconButton
47+
icon="i-ph-arrow-clockwise-duotone"
48+
:tooltip="isEmbedded ? 'Refresh from the current page' : 'Refresh'"
49+
:disabled="isStatic || loading"
50+
compact
51+
@click="emit('refresh')"
52+
/>
3953
<ActionButton variant="primary" :loading="loading" :disabled="isStatic" icon="i-ph-magnifying-glass-duotone" type="submit">
4054
Inspect
4155
</ActionButton>
4256
</form>
4357
</template>
58+
59+
<template v-if="url" #end>
60+
<div class="max-w-80 flex items-center gap-2 color-muted text-xs">
61+
<span class="i-ph-check-circle-duotone shrink-0 color-status-positive" />
62+
<a :href="url" target="_blank" rel="noreferrer" class="min-w-0 truncate font-mono hover:color-active" :title="url">
63+
{{ url }}
64+
</a>
65+
<span class="shrink-0 font-mono tabular-nums">HTTP {{ status }}</span>
66+
</div>
67+
</template>
4468
</LayoutToolbar>
4569
</template>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { onScopeDispose } from 'vue'
2+
3+
export interface HostPage {
4+
origin: string
5+
href: () => string
6+
onNavigate: (listener: (href: string) => void) => void
7+
}
8+
9+
/**
10+
* The same-origin app page that embeds this frame, or `null`. A hub's floating
11+
* dock embeds the frame in the app page. The hub's own shell is not an app
12+
* page: it lives under the hub base, which is the parent path of this frame.
13+
*/
14+
function readHostWindow(): Window | null {
15+
if (window.parent === window)
16+
return null
17+
try {
18+
const hubBase = new URL('..', location.href).pathname
19+
return window.parent.location.pathname.startsWith(hubBase) ? null : window.parent
20+
}
21+
catch {
22+
// A cross-origin parent throws on `location` access.
23+
return null
24+
}
25+
}
26+
27+
/**
28+
* Track the app page that embeds this frame. `onNavigate` listeners run on
29+
* history navigation and, where the Navigation API exists, also on
30+
* `pushState`, which fires no event of its own. A router that the browser
31+
* cannot report needs a manual read of `href()`.
32+
*/
33+
export function useHostPage(): HostPage | null {
34+
const host = readHostWindow()
35+
if (!host)
36+
return null
37+
38+
const listeners = new Set<(href: string) => void>()
39+
const report = (): void => listeners.forEach(listener => listener(host.location.href))
40+
host.addEventListener('popstate', report)
41+
host.addEventListener('hashchange', report)
42+
host.navigation?.addEventListener('currententrychange', report)
43+
onScopeDispose(() => {
44+
host.removeEventListener('popstate', report)
45+
host.removeEventListener('hashchange', report)
46+
host.navigation?.removeEventListener('currententrychange', report)
47+
})
48+
49+
return {
50+
origin: host.location.origin,
51+
href: () => host.location.href,
52+
onNavigate: listener => listeners.add(listener),
53+
}
54+
}

‎plugins/og/app/app/composables/useOgViewer.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,41 @@ import type { DevframeRpcClient } from 'devframe/client'
22
import type { OgSnapshot } from '../connect'
33
import { connectDevframe } from 'devframe/client'
44
import { computed, readonly, shallowRef } from 'vue'
5+
import { formatAddress, resolveAddress } from '../utils/address'
6+
import { useHostPage } from './useHostPage'
57

68
export function useOgViewer() {
79
const rpc = shallowRef<DevframeRpcClient | null>(null)
8-
const params = new URLSearchParams(location.search)
9-
const target = shallowRef(params.get('url') ?? '')
1010
const snapshot = shallowRef<OgSnapshot | null>(null)
1111
const loading = shallowRef(false)
1212
const error = shallowRef<string | null>(null)
1313
const isStatic = computed(() => rpc.value?.connectionMeta.backend === 'static')
14+
let latestRequest = 0
15+
16+
const host = useHostPage()
17+
const target = shallowRef(host
18+
? formatAddress(host.href(), host.origin)
19+
: new URLSearchParams(location.search).get('url') ?? '')
1420

1521
async function inspect(next = target.value): Promise<void> {
1622
if (isStatic.value && snapshot.value)
1723
return
1824
target.value = next.trim()
25+
const request = ++latestRequest
1926
loading.value = true
2027
error.value = null
2128
try {
2229
rpc.value ??= await connectDevframe()
2330
await rpc.value.ensureTrusted()
24-
const result = await rpc.value.call('devframes:plugin:og:resolve-metadata', { url: target.value })
31+
const result = await rpc.value.call('devframes:plugin:og:resolve-metadata', {
32+
url: resolveAddress(target.value, host?.origin),
33+
})
34+
// A page navigation can start a newer request before this one returns.
35+
if (request !== latestRequest)
36+
return
2537
snapshot.value = result
2638
if ((!target.value || isStatic.value) && result.requestedUrl)
27-
target.value = result.requestedUrl
39+
target.value = formatAddress(result.requestedUrl, host?.origin)
2840

2941
const url = new URL(location.href)
3042
if (target.value)
@@ -34,18 +46,37 @@ export function useOgViewer() {
3446
history.replaceState(null, '', url)
3547
}
3648
catch (cause) {
37-
error.value = cause instanceof Error ? cause.message : String(cause)
49+
if (request === latestRequest) {
50+
// Drop the last result, so the panel does not show it as the current page.
51+
snapshot.value = null
52+
error.value = cause instanceof Error ? cause.message : String(cause)
53+
}
3854
}
3955
finally {
40-
loading.value = false
56+
if (request === latestRequest)
57+
loading.value = false
4158
}
4259
}
4360

61+
host?.onNavigate((href) => {
62+
// Follow the page only while the address is a page path, not a typed remote URL.
63+
const address = formatAddress(href, host.origin)
64+
if (!isStatic.value && target.value.startsWith('/') && address !== target.value)
65+
void inspect(address)
66+
})
67+
68+
/** Read the current page path again, for a router the browser cannot report. */
69+
function refresh(): Promise<void> {
70+
return inspect(host ? formatAddress(host.href(), host.origin) : target.value)
71+
}
72+
4473
return {
4574
error: readonly(error),
4675
inspect,
76+
isEmbedded: host !== null,
4777
isStatic,
4878
loading: readonly(loading),
79+
refresh,
4980
snapshot: readonly(snapshot),
5081
target,
5182
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** Show a URL on `origin` as its path. A URL on another origin stays in full. */
2+
export function formatAddress(url: string, origin: string | undefined): string {
3+
if (!origin)
4+
return url
5+
try {
6+
const parsed = new URL(url)
7+
return parsed.origin === origin ? `${parsed.pathname}${parsed.search}${parsed.hash}` : url
8+
}
9+
catch {
10+
return url
11+
}
12+
}
13+
14+
/** Resolve a typed `/path` against `origin`. Any other input goes to the server as typed. */
15+
export function resolveAddress(input: string, origin: string | undefined): string {
16+
const value = input.trim()
17+
return origin && value.startsWith('/') ? new URL(value, origin).href : value
18+
}

‎plugins/og/test/address.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { formatAddress, resolveAddress } from '../app/app/utils/address'
3+
4+
const origin = 'http://localhost:5175'
5+
6+
describe('og address bar', () => {
7+
it('shows a page on the embedding origin as its path', () => {
8+
expect(formatAddress('http://localhost:5175/', origin)).toBe('/')
9+
expect(formatAddress('http://localhost:5175/posts?page=2#top', origin)).toBe('/posts?page=2#top')
10+
})
11+
12+
it('keeps a URL on another origin, or with no embedding page, in full', () => {
13+
expect(formatAddress('https://devfra.me/guide', origin)).toBe('https://devfra.me/guide')
14+
expect(formatAddress('http://localhost:5175/posts', undefined)).toBe('http://localhost:5175/posts')
15+
})
16+
17+
it('resolves a typed path against the embedding origin', () => {
18+
expect(resolveAddress(' /posts ', origin)).toBe('http://localhost:5175/posts')
19+
})
20+
21+
it('sends full URLs, and paths with no embedding page, as typed', () => {
22+
expect(resolveAddress('https://devfra.me/', origin)).toBe('https://devfra.me/')
23+
expect(resolveAddress('/posts', undefined)).toBe('/posts')
24+
})
25+
})

0 commit comments

Comments
 (0)