Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions plugins/og/app/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ onMounted(() => viewer.inspect())
v-model:target="viewer.target.value"
:loading="viewer.loading.value"
:is-static="viewer.isStatic.value"
:is-embedded="viewer.isEmbedded"
:url="viewer.snapshot.value?.url"
:status="viewer.snapshot.value?.status"
@inspect="viewer.inspect()"
@refresh="viewer.refresh()"
/>

<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">
Expand Down Expand Up @@ -48,13 +52,6 @@ onMounted(() => viewer.inspect())

<div v-else class="grid min-h-full gap4 p4 xl:grid-cols-[minmax(0,1fr)_minmax(28rem,0.85fr)]">
<div class="min-w-0 flex flex-col gap4">
<div class="flex flex-wrap items-center gap-2 color-muted text-xs">
<span class="i-ph-check-circle-duotone color-status-positive" />
<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">
{{ viewer.snapshot.value.url }}
</a>
<span class="ml-auto font-mono tabular-nums">HTTP {{ viewer.snapshot.value.status }}</span>
</div>
<MetadataTable :tags="viewer.snapshot.value.tags" />
<MissingTags :tags="viewer.snapshot.value.tags" />
</div>
Expand Down
17 changes: 14 additions & 3 deletions plugins/og/app/app/components/ViewerToolbar.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ export const Idle: Story = {
const target = ref('https://devfra.me/')
return { target }
},
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" />`,
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" :is-embedded="false" />`,
}),
}

export const Embedded: Story = {
render: () => ({
components: { ViewerToolbar },
setup() {
const target = ref('/guide/')
return { target }
},
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="false" :is-embedded="true" url="http://localhost:5175/guide/" :status="200" />`,
}),
}

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

Expand All @@ -40,6 +51,6 @@ export const Static: Story = {
const target = ref('https://devfra.me/')
return { target }
},
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="true" />`,
template: `<ViewerToolbar v-model:target="target" :loading="false" :is-static="true" :is-embedded="false" url="https://devfra.me/" :status="200" />`,
}),
}
28 changes: 26 additions & 2 deletions plugins/og/app/app/components/ViewerToolbar.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
<script setup lang="ts">
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue'
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'

defineProps<{
loading: boolean
isStatic: boolean
/** The address takes a page path, resolved against the embedding page. */
isEmbedded: boolean
/** The final URL and HTTP status of the last inspected page. */
url?: string
status?: number
}>()

const emit = defineEmits<{
inspect: []
refresh: []
}>()

const target = defineModel<string>('target', { required: true })
Expand All @@ -30,16 +37,33 @@ const target = defineModel<string>('target', { required: true })
v-model="target"
class="min-w-0 flex-1 bg-transparent color-base font-mono text-xs outline-none"
inputmode="url"
placeholder="http://localhost:3000"
:placeholder="isEmbedded ? '/' : 'http://localhost:3000'"
spellcheck="false"
aria-label="Page URL"
:aria-label="isEmbedded ? 'Page path or URL' : 'Page URL'"
:disabled="isStatic"
>
</label>
<ActionIconButton
icon="i-ph-arrow-clockwise-duotone"
:tooltip="isEmbedded ? 'Refresh from the current page' : 'Refresh'"
:disabled="isStatic || loading"
compact
@click="emit('refresh')"
/>
<ActionButton variant="primary" :loading="loading" :disabled="isStatic" icon="i-ph-magnifying-glass-duotone" type="submit">
Inspect
</ActionButton>
</form>
</template>

<template v-if="url" #end>
<div class="max-w-80 flex items-center gap-2 color-muted text-xs">
<span class="i-ph-check-circle-duotone shrink-0 color-status-positive" />
<a :href="url" target="_blank" rel="noreferrer" class="min-w-0 truncate font-mono hover:color-active" :title="url">
{{ url }}
</a>
<span class="shrink-0 font-mono tabular-nums">HTTP {{ status }}</span>
</div>
</template>
</LayoutToolbar>
</template>
54 changes: 54 additions & 0 deletions plugins/og/app/app/composables/useHostPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { onScopeDispose } from 'vue'

export interface HostPage {
origin: string
href: () => string
onNavigate: (listener: (href: string) => void) => void
}

/**
* The same-origin app page that embeds this frame, or `null`. A hub's floating
* dock embeds the frame in the app page. The hub's own shell is not an app
* page: it lives under the hub base, which is the parent path of this frame.
*/
function readHostWindow(): Window | null {
if (window.parent === window)
return null
try {
const hubBase = new URL('..', location.href).pathname
return window.parent.location.pathname.startsWith(hubBase) ? null : window.parent
}
catch {
// A cross-origin parent throws on `location` access.
return null
}
}

/**
* Track the app page that embeds this frame. `onNavigate` listeners run on
* history navigation and, where the Navigation API exists, also on
* `pushState`, which fires no event of its own. A router that the browser
* cannot report needs a manual read of `href()`.
*/
export function useHostPage(): HostPage | null {
const host = readHostWindow()
if (!host)
return null

const listeners = new Set<(href: string) => void>()
const report = (): void => listeners.forEach(listener => listener(host.location.href))
host.addEventListener('popstate', report)
host.addEventListener('hashchange', report)
host.navigation?.addEventListener('currententrychange', report)
onScopeDispose(() => {
host.removeEventListener('popstate', report)
host.removeEventListener('hashchange', report)
host.navigation?.removeEventListener('currententrychange', report)
})

return {
origin: host.location.origin,
href: () => host.location.href,
onNavigate: listener => listeners.add(listener),
}
}
43 changes: 37 additions & 6 deletions plugins/og/app/app/composables/useOgViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,41 @@ import type { DevframeRpcClient } from 'devframe/client'
import type { OgSnapshot } from '../connect'
import { connectDevframe } from 'devframe/client'
import { computed, readonly, shallowRef } from 'vue'
import { formatAddress, resolveAddress } from '../utils/address'
import { useHostPage } from './useHostPage'

export function useOgViewer() {
const rpc = shallowRef<DevframeRpcClient | null>(null)
const params = new URLSearchParams(location.search)
const target = shallowRef(params.get('url') ?? '')
const snapshot = shallowRef<OgSnapshot | null>(null)
const loading = shallowRef(false)
const error = shallowRef<string | null>(null)
const isStatic = computed(() => rpc.value?.connectionMeta.backend === 'static')
let latestRequest = 0

const host = useHostPage()
const target = shallowRef(host
? formatAddress(host.href(), host.origin)
: new URLSearchParams(location.search).get('url') ?? '')

async function inspect(next = target.value): Promise<void> {
if (isStatic.value && snapshot.value)
return
target.value = next.trim()
const request = ++latestRequest
loading.value = true
error.value = null
try {
rpc.value ??= await connectDevframe()
await rpc.value.ensureTrusted()
const result = await rpc.value.call('devframes:plugin:og:resolve-metadata', { url: target.value })
const result = await rpc.value.call('devframes:plugin:og:resolve-metadata', {
url: resolveAddress(target.value, host?.origin),
})
// A page navigation can start a newer request before this one returns.
if (request !== latestRequest)
return
snapshot.value = result
if ((!target.value || isStatic.value) && result.requestedUrl)
target.value = result.requestedUrl
target.value = formatAddress(result.requestedUrl, host?.origin)

const url = new URL(location.href)
if (target.value)
Expand All @@ -34,18 +46,37 @@ export function useOgViewer() {
history.replaceState(null, '', url)
}
catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
if (request === latestRequest) {
// Drop the last result, so the panel does not show it as the current page.
snapshot.value = null
error.value = cause instanceof Error ? cause.message : String(cause)
}
}
finally {
loading.value = false
if (request === latestRequest)
loading.value = false
}
}

host?.onNavigate((href) => {
// Follow the page only while the address is a page path, not a typed remote URL.
const address = formatAddress(href, host.origin)
if (!isStatic.value && target.value.startsWith('/') && address !== target.value)
void inspect(address)
})

/** Read the current page path again, for a router the browser cannot report. */
function refresh(): Promise<void> {
return inspect(host ? formatAddress(host.href(), host.origin) : target.value)
}

return {
error: readonly(error),
inspect,
isEmbedded: host !== null,
isStatic,
loading: readonly(loading),
refresh,
snapshot: readonly(snapshot),
target,
}
Expand Down
18 changes: 18 additions & 0 deletions plugins/og/app/app/utils/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Show a URL on `origin` as its path. A URL on another origin stays in full. */
export function formatAddress(url: string, origin: string | undefined): string {
if (!origin)
return url
try {
const parsed = new URL(url)
return parsed.origin === origin ? `${parsed.pathname}${parsed.search}${parsed.hash}` : url
}
catch {
return url
}
}

/** Resolve a typed `/path` against `origin`. Any other input goes to the server as typed. */
export function resolveAddress(input: string, origin: string | undefined): string {
const value = input.trim()
return origin && value.startsWith('/') ? new URL(value, origin).href : value
}
25 changes: 25 additions & 0 deletions plugins/og/test/address.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { formatAddress, resolveAddress } from '../app/app/utils/address'

const origin = 'http://localhost:5175'

describe('og address bar', () => {
it('shows a page on the embedding origin as its path', () => {
expect(formatAddress('http://localhost:5175/', origin)).toBe('/')
expect(formatAddress('http://localhost:5175/posts?page=2#top', origin)).toBe('/posts?page=2#top')
})

it('keeps a URL on another origin, or with no embedding page, in full', () => {
expect(formatAddress('https://devfra.me/guide', origin)).toBe('https://devfra.me/guide')
expect(formatAddress('http://localhost:5175/posts', undefined)).toBe('http://localhost:5175/posts')
})

it('resolves a typed path against the embedding origin', () => {
expect(resolveAddress(' /posts ', origin)).toBe('http://localhost:5175/posts')
})

it('sends full URLs, and paths with no embedding page, as typed', () => {
expect(resolveAddress('https://devfra.me/', origin)).toBe('https://devfra.me/')
expect(resolveAddress('/posts', undefined)).toBe('/posts')
})
})
Loading