diff --git a/alias.ts b/alias.ts index da4f75d..90ba979 100644 --- a/alias.ts +++ b/alias.ts @@ -79,6 +79,12 @@ export const alias = { '@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'), '@devframes/plugin-inspect/vite': p('inspect/src/vite.ts'), '@devframes/plugin-inspect': p('inspect/src/index.ts'), + '@devframes/plugin-og/client': p('og/src/client/index.ts'), + '@devframes/plugin-og/node': p('og/src/node/index.ts'), + '@devframes/plugin-og/rpc': p('og/src/rpc/index.ts'), + '@devframes/plugin-og/cli': p('og/src/cli.ts'), + '@devframes/plugin-og/vite': p('og/src/vite.ts'), + '@devframes/plugin-og': p('og/src/index.ts'), '@devframes/plugin-a11y/client': p('a11y/src/client/index.ts'), '@devframes/plugin-a11y/node': p('a11y/src/node/index.ts'), '@devframes/plugin-a11y/cli': p('a11y/src/cli.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2321348..8d2c716 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -64,6 +64,7 @@ function pluginsItems(prefix: string) { { text: 'Overview', link: `${prefix}/plugins/` }, { text: 'Data Inspector', link: `${prefix}/plugins/data-inspector` }, { text: 'Devframe Inspector', link: `${prefix}/plugins/inspect` }, + { text: 'Open Graph Viewer', link: `${prefix}/plugins/og` }, { text: 'Accessibility Inspector', link: `${prefix}/plugins/a11y` }, { text: 'Git', link: `${prefix}/plugins/git` }, { text: 'Terminals', link: `${prefix}/plugins/terminals` }, diff --git a/docs/plugins/index.md b/docs/plugins/index.md index fb9be4f..b303e5d 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -12,6 +12,7 @@ Each plugin is built with a **different UI framework**. That is deliberate: devf |--------|--------------|--------------| | [Data Inspector](./data-inspector) | Vue | Query live server-side objects with jora — sources contributed by plugins, hosts, data files, or attached processes. | | [Devframe Inspector](./inspect) | Vue | Browse the RPC registry, invoke read-only queries, watch shared state update live, and explore the agent surface. | +| [Open Graph Viewer](./og) | Vue | Inspect Open Graph and Twitter metadata and compare social-card previews. | | [Accessibility Inspector](./a11y) | Solid | Run axe-core against a host app, list WCAG violations, and highlight the offending element in the page on hover. | | [Git](./git) | React (Next.js) | A repository dashboard — status, a commit graph, branches, and diffs, with optional staging and committing. | | [Terminals](./terminals) | Svelte | Stream read-only command output and run fully interactive PTY shells in the browser. | @@ -29,6 +30,7 @@ Most plugins publish a `bin`, so the quickest path is `npx`: ```sh npx @devframes/plugin-inspect # the Devframe Inspector, standalone +npx @devframes/plugin-og # inspect Open Graph metadata and social cards npx @devframes/plugin-git # the Git dashboard against the current repo ``` diff --git a/docs/plugins/og.md b/docs/plugins/og.md new file mode 100644 index 0000000..63f4205 --- /dev/null +++ b/docs/plugins/og.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- + +# Open Graph Viewer + +Inspect a page's resolved Open Graph and Twitter metadata and compare its social cards across common platforms. + +## Run Standalone + +```sh +npx @devframes/plugin-og +``` + +Enter any HTTP or HTTPS page reachable from the devframe process. The viewer resolves relative image and icon URLs against the fetched document and identifies missing metadata with a ready-to-use Nuxt `useSeoMeta()` snippet. + +## Create A Definition + +```ts +import { createOgDevframe } from '@devframes/plugin-og' + +export default createOgDevframe({ + defaultUrl: 'http://localhost:3000/about', +}) +``` + +`defaultUrl` supplies the initial target and makes `devframe build` bake that page into the static RPC dump. The resulting report keeps its assets relative and runs from any deployment path. + +The standalone server requires devframe's trust handshake by default because it can request developer-supplied URLs. Set `auth: false` only for an isolated local environment. diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index d9825e2..6c4f111 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -19,6 +19,7 @@ "@devframes/plugin-git": "workspace:*", "@devframes/plugin-inspect": "workspace:*", "@devframes/plugin-messages": "workspace:*", + "@devframes/plugin-og": "workspace:*", "@devframes/plugin-terminals": "workspace:*", "colorjs.io": "catalog:frontend", "devframe": "workspace:*" diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index c8e1a73..61e3903 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -5,6 +5,7 @@ import { registerDataSource } from '@devframes/plugin-data-inspector/registry' import gitDevframe from '@devframes/plugin-git' import inspectDevframe from '@devframes/plugin-inspect' import messagesDevframe from '@devframes/plugin-messages' +import ogDevframe from '@devframes/plugin-og' import terminalsDevframe from '@devframes/plugin-terminals' import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' @@ -61,6 +62,7 @@ export default defineConfig({ dataInspectorDevframe, a11yDevframe, messagesDevframe, + ogDevframe, ], // Attach the a11y inspector's in-page agent as its dock's client script. // The hub client runtime (booted in src/client/main.ts) imports it into diff --git a/plugins/og/.gitignore b/plugins/og/.gitignore new file mode 100644 index 0000000..c392c97 --- /dev/null +++ b/plugins/og/.gitignore @@ -0,0 +1,2 @@ +.nuxt +src/spa/.nuxt diff --git a/plugins/og/README.md b/plugins/og/README.md new file mode 100644 index 0000000..ccee16f --- /dev/null +++ b/plugins/og/README.md @@ -0,0 +1,9 @@ +# `@devframes/plugin-og` + +Inspect Open Graph and Twitter metadata for any reachable page, then compare its social preview across Twitter, Facebook, LinkedIn, and Telegram. + +```sh +npx @devframes/plugin-og +``` + +The package exports `createOgDevframe()` for custom definitions and `ogVitePlugin()` from `@devframes/plugin-og/vite` for Vite hosts. Pass `defaultUrl` to bake a shareable report with the devframe build adapter. diff --git a/plugins/og/bin.mjs b/plugins/og/bin.mjs new file mode 100755 index 0000000..04bf447 --- /dev/null +++ b/plugins/og/bin.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createOgCli } from './dist/cli.mjs' + +async function main() { + await createOgCli().parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/og/package.json b/plugins/og/package.json new file mode 100644 index 0000000..78f9253 --- /dev/null +++ b/plugins/og/package.json @@ -0,0 +1,79 @@ +{ + "name": "@devframes/plugin-og", + "type": "module", + "version": "0.7.5", + "description": "Devframe plugin for inspecting Open Graph metadata and previewing social cards for any URL.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "plugins/og", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-plugin", + "devtools", + "open-graph", + "social-preview" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./client": "./dist/client/index.mjs", + "./cli": "./dist/cli.mjs", + "./node": "./dist/node/index.mjs", + "./rpc": "./dist/rpc/index.mjs", + "./vite": "./dist/vite.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "devframe-og": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "dist" + ], + "scripts": { + "build": "tsdown && vite build --config src/spa/vite.config.ts", + "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", + "watch": "tsdown --watch", + "cli:build": "node bin.mjs build --out-dir dist/static", + "prepack": "pnpm build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*", + "vite": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "dependencies": { + "cac": "catalog:deps", + "nostics": "catalog:deps", + "parse5": "catalog:deps", + "valibot": "catalog:deps" + }, + "devDependencies": { + "@antfu/design": "catalog:frontend", + "@iconify-json/ph": "catalog:frontend", + "@types/node": "catalog:types", + "@vitejs/plugin-vue": "catalog:build", + "devframe": "workspace:*", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "tsdown": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build", + "vitest": "catalog:testing", + "vue": "catalog:frontend", + "ws": "catalog:deps" + } +} diff --git a/plugins/og/src/cli.ts b/plugins/og/src/cli.ts new file mode 100644 index 0000000..33262c1 --- /dev/null +++ b/plugins/og/src/cli.ts @@ -0,0 +1,7 @@ +import type { CacHandle } from 'devframe/adapters/cac' +import { createCac } from 'devframe/adapters/cac' +import ogDevframe from './index' + +export function createOgCli(): CacHandle { + return createCac(ogDevframe) +} diff --git a/plugins/og/src/client/index.ts b/plugins/og/src/client/index.ts new file mode 100644 index 0000000..3b066d8 --- /dev/null +++ b/plugins/og/src/client/index.ts @@ -0,0 +1,9 @@ +import type { DevframeRpcClientOptions } from 'devframe/client' +import { connectDevframe } from 'devframe/client' + +export type { OgHeadTag, OgHeadTagName, OgResolveInput, OgSnapshot } from '../types' +export type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client' + +export function connectOg(options?: DevframeRpcClientOptions) { + return connectDevframe(options) +} diff --git a/plugins/og/src/diagnostics.ts b/plugins/og/src/diagnostics.ts new file mode 100644 index 0000000..b36ad4b --- /dev/null +++ b/plugins/og/src/diagnostics.ts @@ -0,0 +1,27 @@ +import { defineDiagnostics } from 'nostics' + +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DP_OG_0001: { + why: (p: { url: string }) => `Cannot inspect "${p.url}" because it is not a valid HTTP or HTTPS URL.`, + fix: 'Enter an absolute URL, such as `http://localhost:3000/about`.', + }, + DP_OG_0002: { + why: (p: { url: string, reason: string }) => `Could not fetch Open Graph metadata from "${p.url}": ${p.reason}`, + fix: 'Check that the target server is running and reachable from the devframe process.', + }, + DP_OG_0003: { + why: (p: { url: string, status: number }) => `The target "${p.url}" returned HTTP ${p.status}.`, + fix: 'Inspect a route that returns a successful HTML response.', + }, + DP_OG_0004: { + why: (p: { url: string, size: number }) => `The HTML response from "${p.url}" is ${p.size} bytes, which exceeds the 2 MB inspection limit.`, + fix: 'Inspect a smaller HTML document or reduce the response payload.', + }, + DP_OG_0005: { + why: 'A static Open Graph report requires a default URL to inspect.', + fix: 'Pass `defaultUrl` to `createOgDevframe()` before running the build adapter.', + }, + }, +}) diff --git a/plugins/og/src/index.ts b/plugins/og/src/index.ts new file mode 100644 index 0000000..4202b56 --- /dev/null +++ b/plugins/og/src/index.ts @@ -0,0 +1,60 @@ +import type { DevframeDefinition } from 'devframe/types' +import type { OgFetch } from './types' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } +import { setupOg } from './node/index' + +const DEFAULT_ID = 'devframes_plugin_og' +const distDir = fileURLToPath(new URL('../dist/spa', import.meta.url)) + +export interface OgDevframeOptions { + id?: string + name?: string + icon?: string + basePath?: string + port?: number + /** Require the trust handshake. Enabled by default for the network-capable backend. */ + auth?: boolean + /** URL inspected when the UI does not supply one and baked by `build`. */ + defaultUrl?: string + /** Override the request implementation, primarily for custom transports and tests. */ + fetch?: OgFetch +} + +/** + * Create the standalone Open Graph viewer definition. + * + * @experimental This plugin is experimental and may change without a major + * version bump until it stabilizes. + */ +export function createOgDevframe(options: OgDevframeOptions = {}): DevframeDefinition { + const id = options.id ?? DEFAULT_ID + return defineDevframe({ + id, + name: options.name ?? 'Open Graph', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: pkg.description, + icon: options.icon ?? 'ph:image-square-duotone', + basePath: options.basePath, + cli: { + command: id, + port: options.port ?? 9016, + distDir: existsSync(distDir) ? distDir : undefined, + auth: options.auth ?? true, + }, + spa: { loader: 'query' }, + dock: { category: '~builtin' }, + setup(ctx) { + setupOg(ctx, { defaultUrl: options.defaultUrl, fetch: options.fetch }) + }, + }) +} + +const ogDevframe: DevframeDefinition = createOgDevframe() + +export default ogDevframe +export type { OgFetch, OgHeadTag, OgHeadTagName, OgResolveInput, OgSnapshot } from './types' diff --git a/plugins/og/src/node/index.ts b/plugins/og/src/node/index.ts new file mode 100644 index 0000000..74256ce --- /dev/null +++ b/plugins/og/src/node/index.ts @@ -0,0 +1,10 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { ResolveMetadataOptions } from '../rpc/functions/resolve-metadata' +import { createResolveMetadataRpc, resolveMetadata } from '../rpc/functions/resolve-metadata' + +export function setupOg(ctx: DevframeNodeContext, options: ResolveMetadataOptions = {}): void { + const hasOptions = options.defaultUrl !== undefined || options.fetch !== undefined + ctx.rpc.register(hasOptions ? createResolveMetadataRpc(options) : resolveMetadata) +} + +export { fetchOgMetadata, parseOgMetadata } from './metadata' diff --git a/plugins/og/src/node/metadata.ts b/plugins/og/src/node/metadata.ts new file mode 100644 index 0000000..43a0dd0 --- /dev/null +++ b/plugins/og/src/node/metadata.ts @@ -0,0 +1,215 @@ +import type { OgFetch, OgHeadTag, OgSnapshot } from '../types' +import { parse } from 'parse5' +import { diagnostics } from '../diagnostics' + +const MAX_HTML_BYTES = 2 * 1024 * 1024 +const URL_TAGS = new Set([ + 'canonical', + 'icon', + 'og:image', + 'og:image:secure_url', + 'og:url', + 'twitter:image', +]) + +interface HtmlNode { + nodeName: string + tagName?: string + attrs?: { name: string, value: string }[] + childNodes?: HtmlNode[] + value?: string +} + +function getAttribute(node: HtmlNode, name: string): string { + return node.attrs?.find(attribute => attribute.name === name)?.value ?? '' +} + +function findElement(node: HtmlNode, name: string): HtmlNode | undefined { + if (node.tagName === name) + return node + for (const child of node.childNodes ?? []) { + const match = findElement(child, name) + if (match) + return match + } +} + +function textContent(node: HtmlNode): string { + if (node.nodeName === '#text') + return node.value ?? '' + return (node.childNodes ?? []).map(textContent).join('') +} + +function resolveTagValue(name: string, value: string, baseUrl: string): string { + if (!value || (!URL_TAGS.has(name) && !name.endsWith(':image'))) + return value + try { + const resolved = new URL(value, baseUrl) + return resolved.protocol === 'http:' || resolved.protocol === 'https:' ? resolved.href : '' + } + catch { + return '' + } +} + +export function parseOgMetadata(html: string, url: string): OgHeadTag[] { + const document = parse(html) as unknown as HtmlNode + const htmlElement = findElement(document, 'html') + const head = findElement(document, 'head') + if (!head) + return [] + + let baseUrl = url + const baseHref = findElement(head, 'base') + const baseValue = baseHref ? getAttribute(baseHref, 'href') : '' + if (baseValue) { + try { + baseUrl = new URL(baseValue, url).href + } + catch {} + } + + const tags: OgHeadTag[] = [] + if (htmlElement) { + const lang = getAttribute(htmlElement, 'lang') + if (lang) + tags.push({ tag: 'html', name: 'lang', value: lang }) + } + + function visit(node: HtmlNode): void { + if (node.tagName === 'title') { + const value = textContent(node).trim() + if (value) + tags.push({ tag: 'title', name: 'title', value }) + } + else if (node.tagName === 'meta') { + const property = getAttribute(node, 'property') + const metaName = getAttribute(node, 'name') + const charset = getAttribute(node, 'charset') + const name = (property || metaName || (charset ? 'charset' : '')).toLowerCase() + const rawValue = getAttribute(node, 'content') || charset + const value = resolveTagValue(name, rawValue, baseUrl) + if (name && value) + tags.push({ tag: 'meta', name, value }) + } + else if (node.tagName === 'link') { + const name = getAttribute(node, 'rel').toLowerCase() + const value = resolveTagValue(name, getAttribute(node, 'href'), baseUrl) + if (name && value) + tags.push({ tag: 'link', name, value }) + } + + for (const child of node.childNodes ?? []) + visit(child) + } + visit(head) + + return tags +} + +function normalizeUrl(input: string): string { + const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `http://${input}` + try { + const url = new URL(candidate) + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new TypeError('Unsupported protocol') + return url.href + } + catch { + throw diagnostics.DP_OG_0001({ url: input }) + } +} + +async function readHtml(response: Response, url: string): Promise { + const declaredSize = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredSize) && declaredSize > MAX_HTML_BYTES) { + await response.body?.cancel().catch(() => {}) + throw diagnostics.DP_OG_0004({ url, size: declaredSize }) + } + + const chunks: Uint8Array[] = [] + let size = 0 + const reader = response.body?.getReader() + if (reader) { + while (true) { + const { done, value } = await reader.read() + if (done) + break + size += value.byteLength + if (size > MAX_HTML_BYTES) { + await reader.cancel().catch(() => {}) + throw diagnostics.DP_OG_0004({ url, size }) + } + chunks.push(value) + } + } + + const buffer = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + buffer.set(chunk, offset) + offset += chunk.byteLength + } + const contentType = response.headers.get('content-type') ?? '' + const headerCharset = contentType.match(/charset\s*=\s*["']?([^\s;"']+)/i)?.[1] + const asciiHead = new TextDecoder('windows-1252').decode(buffer.subarray(0, 2048)) + const documentCharset = asciiHead.match(/]*charset\s*=\s*["']?([^\s;"'/>]+)/i)?.[1] + try { + return new TextDecoder(headerCharset || documentCharset || 'utf-8').decode(buffer) + } + catch { + return new TextDecoder().decode(buffer) + } +} + +export async function fetchOgMetadata( + input: string, + fetcher: OgFetch = globalThis.fetch, +): Promise { + const requestedUrl = normalizeUrl(input.trim()) + let response: Response + try { + response = await fetcher(requestedUrl, { + headers: { + 'accept': 'text/html,application/xhtml+xml', + 'user-agent': 'devframe-og/0.7', + }, + redirect: 'follow', + signal: AbortSignal.timeout(10_000), + }) + } + catch (error) { + throw diagnostics.DP_OG_0002({ + url: requestedUrl, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw diagnostics.DP_OG_0003({ url: requestedUrl, status: response.status }) + } + + const resolvedUrl = response.url || requestedUrl + let html: string + try { + html = await readHtml(response, requestedUrl) + } + catch (error) { + if (error instanceof Error && error.name === 'DP_OG_0004') + throw error + throw diagnostics.DP_OG_0002({ + url: requestedUrl, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + return { + requestedUrl, + url: resolvedUrl, + status: response.status, + fetchedAt: Date.now(), + tags: parseOgMetadata(html, resolvedUrl), + } +} diff --git a/plugins/og/src/rpc/functions/resolve-metadata.ts b/plugins/og/src/rpc/functions/resolve-metadata.ts new file mode 100644 index 0000000..f12f344 --- /dev/null +++ b/plugins/og/src/rpc/functions/resolve-metadata.ts @@ -0,0 +1,70 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { OgFetch, OgSnapshot } from '../../types' +import { createDefineWrapperWithContext } from 'devframe/rpc' +import * as v from 'valibot' +import { diagnostics } from '../../diagnostics' +import { fetchOgMetadata } from '../../node/metadata' + +export interface ResolveMetadataOptions { + defaultUrl?: string + fetch?: OgFetch +} + +const EMPTY_SNAPSHOT: OgSnapshot = { + requestedUrl: '', + url: '', + status: 0, + fetchedAt: 0, + tags: [], +} + +const tagSchema = v.object({ + tag: v.picklist(['html', 'link', 'meta', 'title']), + name: v.string(), + value: v.string(), +}) + +const snapshotSchema = v.object({ + requestedUrl: v.string(), + url: v.string(), + status: v.number(), + fetchedAt: v.number(), + tags: v.array(tagSchema), +}) + +const defineOgRpc = createDefineWrapperWithContext() + +export function createResolveMetadataRpc(options: ResolveMetadataOptions = {}) { + return defineOgRpc({ + name: 'devframes:plugin:og:resolve-metadata', + type: 'query', + jsonSerializable: true, + args: [v.object({ url: v.optional(v.string()) })], + returns: snapshotSchema, + agent: { + title: 'Inspect Open Graph metadata', + description: 'Fetch an HTTP or HTTPS page and return its normalized title, language, Open Graph, Twitter, and link metadata.', + safety: 'read', + tags: ['open-graph', 'seo'], + }, + dump: async (_ctx, handler) => { + if (!options.defaultUrl) + throw diagnostics.DP_OG_0005() + const input = { url: options.defaultUrl } + const snapshot = await handler(input) + return { records: [{ inputs: [input], output: snapshot }], fallback: snapshot } + }, + setup: () => ({ + // The RPC runtime awaits handlers before validating `returns`; its public + // setup type currently models schema-backed returns as synchronous. + handler: (async ({ url = '' }): Promise => { + const target = url.trim() || options.defaultUrl?.trim() + if (!target) + return EMPTY_SNAPSHOT + return fetchOgMetadata(target, options.fetch) + }) as any, + }), + }) +} + +export const resolveMetadata = createResolveMetadataRpc() diff --git a/plugins/og/src/rpc/index.ts b/plugins/og/src/rpc/index.ts new file mode 100644 index 0000000..7d024b8 --- /dev/null +++ b/plugins/og/src/rpc/index.ts @@ -0,0 +1,11 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { resolveMetadata } from './functions/resolve-metadata' + +export const serverFunctions = [resolveMetadata] as const + +declare module 'devframe' { + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} +} + +export { createResolveMetadataRpc, resolveMetadata } from './functions/resolve-metadata' +export type { ResolveMetadataOptions } from './functions/resolve-metadata' diff --git a/plugins/og/src/spa/app/app.vue b/plugins/og/src/spa/app/app.vue new file mode 100644 index 0000000..0f10f99 --- /dev/null +++ b/plugins/og/src/spa/app/app.vue @@ -0,0 +1,67 @@ + + + diff --git a/plugins/og/src/spa/app/assets/main.css b/plugins/og/src/spa/app/assets/main.css new file mode 100644 index 0000000..2cb9621 --- /dev/null +++ b/plugins/og/src/spa/app/assets/main.css @@ -0,0 +1,29 @@ +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + background: var(--c-bg-base); + color: var(--c-text-base); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} + +button, +input { + font: inherit; +} + +.preview-grid { + background-image: + linear-gradient(to right, color-mix(in srgb, currentColor 4%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in srgb, currentColor 4%, transparent) 1px, transparent 1px); + background-size: 16px 16px; +} diff --git a/plugins/og/src/spa/app/components/MetadataTable.vue b/plugins/og/src/spa/app/components/MetadataTable.vue new file mode 100644 index 0000000..09c9faa --- /dev/null +++ b/plugins/og/src/spa/app/components/MetadataTable.vue @@ -0,0 +1,53 @@ + + + diff --git a/plugins/og/src/spa/app/components/MissingTags.vue b/plugins/og/src/spa/app/components/MissingTags.vue new file mode 100644 index 0000000..afb0e80 --- /dev/null +++ b/plugins/og/src/spa/app/components/MissingTags.vue @@ -0,0 +1,59 @@ + + + diff --git a/plugins/og/src/spa/app/components/SocialPreview.vue b/plugins/og/src/spa/app/components/SocialPreview.vue new file mode 100644 index 0000000..234d5a6 --- /dev/null +++ b/plugins/og/src/spa/app/components/SocialPreview.vue @@ -0,0 +1,43 @@ + + + diff --git a/plugins/og/src/spa/app/components/ViewerToolbar.vue b/plugins/og/src/spa/app/components/ViewerToolbar.vue new file mode 100644 index 0000000..41c2fe7 --- /dev/null +++ b/plugins/og/src/spa/app/components/ViewerToolbar.vue @@ -0,0 +1,45 @@ + + + diff --git a/plugins/og/src/spa/app/components/previews/FacebookPreview.vue b/plugins/og/src/spa/app/components/previews/FacebookPreview.vue new file mode 100644 index 0000000..3895c35 --- /dev/null +++ b/plugins/og/src/spa/app/components/previews/FacebookPreview.vue @@ -0,0 +1,25 @@ + + + diff --git a/plugins/og/src/spa/app/components/previews/LinkedinPreview.vue b/plugins/og/src/spa/app/components/previews/LinkedinPreview.vue new file mode 100644 index 0000000..2f9e7cc --- /dev/null +++ b/plugins/og/src/spa/app/components/previews/LinkedinPreview.vue @@ -0,0 +1,22 @@ + + + diff --git a/plugins/og/src/spa/app/components/previews/PreviewImage.vue b/plugins/og/src/spa/app/components/previews/PreviewImage.vue new file mode 100644 index 0000000..3213c9e --- /dev/null +++ b/plugins/og/src/spa/app/components/previews/PreviewImage.vue @@ -0,0 +1,19 @@ + + + diff --git a/plugins/og/src/spa/app/components/previews/TelegramPreview.vue b/plugins/og/src/spa/app/components/previews/TelegramPreview.vue new file mode 100644 index 0000000..107b4ac --- /dev/null +++ b/plugins/og/src/spa/app/components/previews/TelegramPreview.vue @@ -0,0 +1,34 @@ + + + diff --git a/plugins/og/src/spa/app/components/previews/TwitterPreview.vue b/plugins/og/src/spa/app/components/previews/TwitterPreview.vue new file mode 100644 index 0000000..2682b10 --- /dev/null +++ b/plugins/og/src/spa/app/components/previews/TwitterPreview.vue @@ -0,0 +1,39 @@ + + + diff --git a/plugins/og/src/spa/app/composables/useOgViewer.ts b/plugins/og/src/spa/app/composables/useOgViewer.ts new file mode 100644 index 0000000..76dbcde --- /dev/null +++ b/plugins/og/src/spa/app/composables/useOgViewer.ts @@ -0,0 +1,52 @@ +import type { DevframeRpcClient } from 'devframe/client' +import type { OgSnapshot } from '../../../types' +import { computed, readonly, shallowRef } from 'vue' +import { connectOg } from '../../../client' + +export function useOgViewer() { + const rpc = shallowRef(null) + const params = new URLSearchParams(location.search) + const target = shallowRef(params.get('url') ?? '') + const snapshot = shallowRef(null) + const loading = shallowRef(false) + const error = shallowRef(null) + const isStatic = computed(() => rpc.value?.connectionMeta.backend === 'static') + + async function inspect(next = target.value): Promise { + if (isStatic.value && snapshot.value) + return + target.value = next.trim() + loading.value = true + error.value = null + try { + rpc.value ??= await connectOg() + await rpc.value.ensureTrusted() + const result = await rpc.value.call('devframes:plugin:og:resolve-metadata', { url: target.value }) + snapshot.value = result + if ((!target.value || isStatic.value) && result.requestedUrl) + target.value = result.requestedUrl + + const url = new URL(location.href) + if (target.value) + url.searchParams.set('url', target.value) + else + url.searchParams.delete('url') + history.replaceState(null, '', url) + } + catch (cause) { + error.value = cause instanceof Error ? cause.message : String(cause) + } + finally { + loading.value = false + } + } + + return { + error: readonly(error), + inspect, + isStatic, + loading: readonly(loading), + snapshot: readonly(snapshot), + target, + } +} diff --git a/plugins/og/src/spa/app/utils/metadata.ts b/plugins/og/src/spa/app/utils/metadata.ts new file mode 100644 index 0000000..087df71 --- /dev/null +++ b/plugins/og/src/spa/app/utils/metadata.ts @@ -0,0 +1,155 @@ +import type { OgHeadTag, OgSnapshot } from '../../../types' + +export type Suggestion = 'optional' | 'recommended' | 'required' + +export interface OgTagDefinition { + name: string + suggestion: Suggestion + description: string + docs?: string +} + +export interface SocialCardData { + title: string + description: string + image: string + imageAlt: string + url: string + hostname: string + favicon: string + twitterCard: string +} + +export const tagDefinitions: OgTagDefinition[] = [ + { + name: 'title', + suggestion: 'required', + description: 'A concise browser title that identifies the page.', + docs: 'https://developer.mozilla.org/docs/Web/HTML/Element/title', + }, + { + name: 'description', + suggestion: 'required', + description: 'A one or two sentence summary for search engines and link previews.', + }, + { + name: 'icon', + suggestion: 'recommended', + description: 'A small image that identifies the site in browser and social surfaces.', + }, + { + name: 'lang', + suggestion: 'recommended', + description: 'The primary language used by the page.', + }, + { + name: 'og:title', + suggestion: 'recommended', + description: 'The title shown by Open Graph consumers.', + docs: 'https://ogp.me/#metadata', + }, + { + name: 'og:description', + suggestion: 'recommended', + description: 'The description shown by Open Graph consumers.', + docs: 'https://ogp.me/#metadata', + }, + { + name: 'og:image', + suggestion: 'recommended', + description: 'The image used for rich social previews.', + docs: 'https://ogp.me/#metadata', + }, + { + name: 'og:url', + suggestion: 'recommended', + description: 'The canonical URL represented by the social card.', + docs: 'https://ogp.me/#metadata', + }, + { + name: 'twitter:card', + suggestion: 'optional', + description: 'The Twitter card layout, such as summary_large_image.', + docs: 'https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards', + }, + { + name: 'twitter:title', + suggestion: 'optional', + description: 'A Twitter-specific title override.', + }, + { + name: 'twitter:description', + suggestion: 'optional', + description: 'A Twitter-specific description override.', + }, + { + name: 'twitter:image', + suggestion: 'optional', + description: 'A Twitter-specific preview image override.', + }, +] + +export function findTag(tags: OgHeadTag[], ...names: string[]): string { + for (const name of names) { + const value = tags.find(tag => tag.name === name)?.value + if (value) + return value + } + return '' +} + +export function toSocialCard(snapshot: OgSnapshot, useTwitterOverrides = false): SocialCardData { + const tags = snapshot.tags + const url = findTag(tags, 'og:url') || snapshot.url + let hostname = url + try { + hostname = new URL(url).hostname + } + catch {} + return { + title: useTwitterOverrides + ? findTag(tags, 'twitter:title', 'og:title', 'title') + : findTag(tags, 'og:title', 'title'), + description: useTwitterOverrides + ? findTag(tags, 'twitter:description', 'og:description', 'description') + : findTag(tags, 'og:description', 'description'), + image: useTwitterOverrides + ? findTag(tags, 'twitter:image', 'og:image') + : findTag(tags, 'og:image'), + imageAlt: useTwitterOverrides + ? findTag(tags, 'twitter:image:alt', 'og:image:alt') + : findTag(tags, 'og:image:alt'), + url, + hostname, + favicon: findTag(tags, 'icon'), + twitterCard: findTag(tags, 'twitter:card') || 'summary_large_image', + } +} + +export function createMissingTagSnippet(definitions: OgTagDefinition[]): string { + const names = new Set(definitions.map(item => item.name)) + const values: Record = {} + if (names.has('title')) + values.title = '[title]' + if (names.has('description')) + values.description = '[description]' + if (names.has('og:title')) + values.ogTitle = '[og:title]' + if (names.has('og:description')) + values.ogDescription = '[og:description]' + if (names.has('og:image')) + values.ogImage = '[og:image]' + if (names.has('og:url')) + values.ogUrl = '[og:url]' + if (names.has('twitter:card')) + values.twitterCard = 'summary_large_image' + if (names.has('twitter:title')) + values.twitterTitle = '[twitter:title]' + if (names.has('twitter:description')) + values.twitterDescription = '[twitter:description]' + if (names.has('twitter:image')) + values.twitterImage = '[twitter:image]' + + const lines = Object.entries(values).map(([key, value]) => ` ${key}: '${value}',`) + return `useSeoMeta({\n${lines.join('\n')}\n})` +} diff --git a/plugins/og/src/spa/index.html b/plugins/og/src/spa/index.html new file mode 100644 index 0000000..c3e842f --- /dev/null +++ b/plugins/og/src/spa/index.html @@ -0,0 +1,13 @@ + + + + + + + Open Graph Viewer + + +
+ + + diff --git a/plugins/og/src/spa/main.ts b/plugins/og/src/spa/main.ts new file mode 100644 index 0000000..41492cd --- /dev/null +++ b/plugins/og/src/spa/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import App from './app/app.vue' + +const media = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) + document.documentElement.classList.toggle('light', !dark) +} +applyScheme(media.matches) +media.addEventListener('change', event => applyScheme(event.matches)) + +createApp(App).mount('#app') diff --git a/plugins/og/src/spa/shims.d.ts b/plugins/og/src/spa/shims.d.ts new file mode 100644 index 0000000..a68bd32 --- /dev/null +++ b/plugins/og/src/spa/shims.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, any> + export default component +} diff --git a/plugins/og/src/spa/vite.config.ts b/plugins/og/src/spa/vite.config.ts new file mode 100644 index 0000000..ba285d2 --- /dev/null +++ b/plugins/og/src/spa/vite.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' +import { ogVitePlugin } from '../vite' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [ + vue(), + UnoCSS(), + ogVitePlugin({ devMiddleware: true, base: '/', auth: false }), + ], + optimizeDeps: { exclude: ['@antfu/design'] }, + build: { + outDir: fileURLToPath(new URL('../../dist/spa', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/plugins/og/src/types.ts b/plugins/og/src/types.ts new file mode 100644 index 0000000..e77c6b2 --- /dev/null +++ b/plugins/og/src/types.ts @@ -0,0 +1,21 @@ +export type OgHeadTagName = 'html' | 'link' | 'meta' | 'title' + +export interface OgHeadTag { + tag: OgHeadTagName + name: string + value: string +} + +export interface OgSnapshot { + requestedUrl: string + url: string + status: number + fetchedAt: number + tags: OgHeadTag[] +} + +export interface OgResolveInput { + url?: string +} + +export type OgFetch = (input: string, init: RequestInit) => Promise diff --git a/plugins/og/src/vite.ts b/plugins/og/src/vite.ts new file mode 100644 index 0000000..cf17e5f --- /dev/null +++ b/plugins/og/src/vite.ts @@ -0,0 +1,16 @@ +import type { DevframeVitePlugin, ViteDevBridgeOptions } from 'devframe/helpers/vite' +import { viteDevBridge } from 'devframe/helpers/vite' +import ogDevframe, { createOgDevframe } from './index' + +export type { ViteDevBridgeOptions } + +export interface OgVitePluginOptions extends ViteDevBridgeOptions { + /** Override standalone trust for the bridge; useful for local SPA development. */ + auth?: boolean +} + +export function ogVitePlugin(options: OgVitePluginOptions = {}): DevframeVitePlugin { + const { auth, ...bridgeOptions } = options + const definition = auth === undefined ? ogDevframe : createOgDevframe({ auth }) + return viteDevBridge(definition, bridgeOptions) +} diff --git a/plugins/og/test/_utils.ts b/plugins/og/test/_utils.ts new file mode 100644 index 0000000..80c32c5 --- /dev/null +++ b/plugins/og/test/_utils.ts @@ -0,0 +1,55 @@ +import type { StartedServer } from 'devframe/node' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { createOgDevframe } from '@devframes/plugin-og' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { createH3DevframeHost, createHostContext, startHttpAndWs } from 'devframe/node' +import { resolveBasePath } from 'devframe/node/hub-internals' +import { mountStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { H3 } from 'h3' + +export async function testFetch(_url: string): Promise { + return new Response(` + + OG test + + + `, { + status: 200, + headers: { 'content-type': 'text/html' }, + }) +} + +export const testDevframe = createOgDevframe({ fetch: testFetch }) + +export function assertSpaBuilt(): void { + const distDir = testDevframe.cli!.distDir + if (!distDir || !existsSync(path.join(distDir, 'index.html'))) + throw new Error('Open Graph SPA missing. Run the plugin build first.') +} + +export interface OgServer extends StartedServer { + basePath: string +} + +export async function startOgServer(): Promise { + const distDir = testDevframe.cli!.distDir! + const basePath = resolveBasePath(testDevframe, 'standalone') + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + const app = new H3() + const origin = `http://${host}:${port}` + const h3Host = createH3DevframeHost({ + origin, + appName: testDevframe.id, + mount: (base, dir) => mountStaticHandler(app, base, dir), + }) + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) + await testDevframe.setup(ctx) + app.use(`${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`, () => ({ backend: 'websocket', websocket: port })) + mountStaticHandler(app, basePath, path.resolve(distDir)) + const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + return Object.assign(server, { basePath }) +} diff --git a/plugins/og/test/dev-server.test.ts b/plugins/og/test/dev-server.test.ts new file mode 100644 index 0000000..eb00fd2 --- /dev/null +++ b/plugins/og/test/dev-server.test.ts @@ -0,0 +1,45 @@ +import type { OgSnapshot } from '@devframes/plugin-og' +import type { OgServer } from './_utils' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { assertSpaBuilt, startOgServer } from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +describe('open Graph dev server', () => { + let server: OgServer + + beforeAll(async () => { + assertSpaBuilt() + server = await startOgServer() + }) + + afterAll(async () => { + await server?.close() + }) + + it('serves the Vue SPA with relative assets', async () => { + const response = await fetch(`${server.origin}${server.basePath}`) + const html = await response.text() + expect(response.status).toBe(200) + expect(html).toContain('') + expect(html).toMatch(/src="\.\/assets\/[^"?]+\.js"/) + }) + + it('resolves metadata over the plugin RPC', async () => { + const channel = createWsRpcChannel({ + url: `ws://127.0.0.1:${server.port}`, + authToken: 'test', + }) + const rpc = createRpcClient({}, { channel }) + const snapshot = await rpc.$call('devframes:plugin:og:resolve-metadata', { url: 'https://example.com/post' }) as OgSnapshot + + expect(snapshot).toMatchObject({ + requestedUrl: 'https://example.com/post', + status: 200, + }) + expect(snapshot.tags).toContainEqual({ tag: 'meta', name: 'og:image', value: 'https://example.com/card.png' }) + }) +}) diff --git a/plugins/og/test/metadata.test.ts b/plugins/og/test/metadata.test.ts new file mode 100644 index 0000000..57b5cd6 --- /dev/null +++ b/plugins/og/test/metadata.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { fetchOgMetadata, parseOgMetadata } from '../src/node/metadata' + +describe('open Graph metadata', () => { + it('normalizes relevant head tags and resolves relative URLs', () => { + const tags = parseOgMetadata(` + + + + Devframe & Friends + + + + + + + `, 'https://example.com/guide') + + expect(tags).toEqual([ + { tag: 'html', name: 'lang', value: 'en-GB' }, + { tag: 'title', name: 'title', value: 'Devframe & Friends' }, + { tag: 'meta', name: 'description', value: 'Portable devtools' }, + { tag: 'meta', name: 'og:title', value: 'A social title' }, + { tag: 'meta', name: 'og:image', value: 'https://example.com/docs/card.png' }, + { tag: 'meta', name: 'twitter:card', value: 'summary_large_image' }, + { tag: 'link', name: 'icon', value: 'https://example.com/favicon.png' }, + ]) + }) + + it('accepts a localhost shorthand and fetches HTML', async () => { + const calls: string[] = [] + const snapshot = await fetchOgMetadata('localhost:4321/about', async (url) => { + calls.push(url) + return new Response('Local page', { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + }) + + expect(calls).toEqual(['http://localhost:4321/about']) + expect(snapshot).toMatchObject({ + requestedUrl: 'http://localhost:4321/about', + url: 'http://localhost:4321/about', + status: 200, + tags: [{ tag: 'title', name: 'title', value: 'Local page' }], + }) + }) + + it('rejects unsupported URL protocols', async () => { + await expect(fetchOgMetadata('file:///tmp/index.html')).rejects.toThrow(/valid HTTP or HTTPS URL/) + }) + + it('uses HTML parsing semantics and excludes unsafe metadata URLs', () => { + const tags = parseOgMetadata(` + + + + + `, 'https://example.com') + + expect(tags).toEqual([ + { tag: 'meta', name: 'og:title', value: 'A > B' }, + ]) + }) + + it('rejects declared and streamed responses over 2 MB', async () => { + await expect(fetchOgMetadata('https://example.com', async () => new Response('', { + headers: { 'content-length': String(2 * 1024 * 1024 + 1) }, + }))).rejects.toThrow(/exceeds the 2 MB inspection limit/) + + const chunk = new Uint8Array(1024 * 1024 + 1) + await expect(fetchOgMetadata('https://example.com', async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(chunk) + controller.enqueue(chunk) + controller.close() + }, + })))).rejects.toThrow(/exceeds the 2 MB inspection limit/) + }) + + it('decodes metadata with the declared response charset', async () => { + const bytes = new Uint8Array([ + ...new TextEncoder().encode('Caf'), + 0xE9, + ...new TextEncoder().encode(''), + ]) + const snapshot = await fetchOgMetadata('https://example.com', async () => new Response(bytes, { + headers: { 'content-type': 'text/html; charset=windows-1252' }, + })) + expect(snapshot.tags).toContainEqual({ tag: 'title', name: 'title', value: 'Café' }) + }) +}) diff --git a/plugins/og/test/social-card.test.ts b/plugins/og/test/social-card.test.ts new file mode 100644 index 0000000..f18ee9a --- /dev/null +++ b/plugins/og/test/social-card.test.ts @@ -0,0 +1,36 @@ +import type { OgSnapshot } from '../src/types' +import { describe, expect, it } from 'vitest' +import { toSocialCard } from '../src/spa/app/utils/metadata' + +const snapshot: OgSnapshot = { + requestedUrl: 'https://example.com/article', + url: 'https://example.com/article', + status: 200, + fetchedAt: 1, + tags: [ + { tag: 'meta', name: 'og:title', value: 'Open Graph title' }, + { tag: 'meta', name: 'og:description', value: 'Open Graph description' }, + { tag: 'meta', name: 'og:image', value: 'https://example.com/og.png' }, + { tag: 'meta', name: 'twitter:title', value: 'Twitter title' }, + { tag: 'meta', name: 'twitter:description', value: 'Twitter description' }, + { tag: 'meta', name: 'twitter:image', value: 'https://example.com/twitter.png' }, + ], +} + +describe('social card metadata', () => { + it('uses Open Graph values for general previews', () => { + expect(toSocialCard(snapshot)).toMatchObject({ + title: 'Open Graph title', + description: 'Open Graph description', + image: 'https://example.com/og.png', + }) + }) + + it('prefers Twitter overrides only for the Twitter preview', () => { + expect(toSocialCard(snapshot, true)).toMatchObject({ + title: 'Twitter title', + description: 'Twitter description', + image: 'https://example.com/twitter.png', + }) + }) +}) diff --git a/plugins/og/test/static-build.test.ts b/plugins/og/test/static-build.test.ts new file mode 100644 index 0000000..5182a42 --- /dev/null +++ b/plugins/og/test/static-build.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createOgDevframe } from '@devframes/plugin-og' +import { createBuild } from 'devframe/adapters/build' +import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME } from 'devframe/constants' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { assertSpaBuilt, testFetch } from './_utils' + +it('requires standalone trust by default', () => { + expect(createOgDevframe().cli?.auth).toBe(true) +}) + +it('requires a default URL for static reports', async () => { + const emptyOutDir = await mkdtemp(path.join(os.tmpdir(), 'devframes_plugin_og-empty-build-')) + await expect(createBuild(createOgDevframe(), { outDir: emptyOutDir })).rejects.toThrow(/requires a default URL/) + await rm(emptyOutDir, { recursive: true, force: true }) +}) + +describe('open Graph static build', () => { + let outDir: string + + beforeAll(async () => { + assertSpaBuilt() + outDir = await mkdtemp(path.join(os.tmpdir(), 'devframes_plugin_og-build-')) + const definition = createOgDevframe({ defaultUrl: 'https://example.com/report', fetch: testFetch }) + await createBuild(definition, { outDir }) + }) + + afterAll(async () => { + if (outDir) + await rm(outDir, { recursive: true, force: true }) + }) + + it('copies the Vue SPA and query loader metadata', async () => { + const html = await readFile(path.join(outDir, 'index.html'), 'utf8') + const loader = JSON.parse(await readFile(path.join(outDir, 'spa-loader.json'), 'utf8')) + expect(html).toContain('') + expect(html).toMatch(/src="\.\/assets\/[^"?]+\.js"/) + expect(loader).toMatchObject({ mode: 'query' }) + }) + + it('bakes the default target into the RPC dump', async () => { + const manifest = JSON.parse( + await readFile(path.join(outDir, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME), 'utf8'), + ) as Record + expect(manifest['devframes:plugin:og:resolve-metadata']).toBeTruthy() + }) +}) diff --git a/plugins/og/tsconfig.json b/plugins/og/tsconfig.json new file mode 100644 index 0000000..9c7effe --- /dev/null +++ b/plugins/og/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "Bundler", + "types": ["node"], + "noEmit": true, + "isolatedDeclarations": false + }, + "include": ["src", "test", "bin.mjs"] +} diff --git a/plugins/og/tsdown.config.ts b/plugins/og/tsdown.config.ts new file mode 100644 index 0000000..877704c --- /dev/null +++ b/plugins/og/tsdown.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'tsdown' + +const tsconfig = '../../tsconfig.base.json' + +const clientEntries = { + 'client/index': 'src/client/index.ts', +} + +const serverEntries = { + 'index': 'src/index.ts', + 'cli': 'src/cli.ts', + 'vite': 'src/vite.ts', + 'node/index': 'src/node/index.ts', + 'rpc/index': 'src/rpc/index.ts', +} + +export default defineConfig([ + { + clean: true, + platform: 'browser', + tsconfig, + dts: false, + outExtensions: () => ({ js: '.mjs' }), + entry: clientEntries, + }, + { + clean: false, + platform: 'node', + tsconfig, + dts: false, + entry: serverEntries, + }, + { + clean: false, + platform: 'neutral', + tsconfig, + dts: { emitDtsOnly: true }, + outExtensions: () => ({ dts: '.d.mts' }), + entry: { ...clientEntries, ...serverEntries }, + }, +]) diff --git a/plugins/og/uno.config.ts b/plugins/og/uno.config.ts new file mode 100644 index 0000000..bf0b101 --- /dev/null +++ b/plugins/og/uno.config.ts @@ -0,0 +1,35 @@ +import { presetAnthonyDesign } from '@antfu/design/unocss' +import { + defineConfig, + presetIcons, + presetWebFonts, + presetWind4, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetAnthonyDesign({ primary: '#3a6a45' }), + presetWind4(), + presetIcons({ scale: 1.1 }), + presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + shortcuts: { + 'z-nav': 'z-[30]', + 'z-dropdown': 'z-[40]', + 'z-tooltip': 'z-[45]', + 'z-toast': 'z-[50]', + 'z-modal-backdrop': 'z-[60]', + 'z-modal-content': 'z-[70]', + 'z-drawer-backdrop': 'z-[80]', + 'z-drawer-content': 'z-[90]', + }, + content: { + pipeline: { + include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb1adb..043e149 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ catalogs: p-limit: specifier: ^7.3.0 version: 7.3.0 + parse5: + specifier: ^7.3.0 + version: 7.3.0 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -541,6 +544,9 @@ importers: '@devframes/plugin-messages': specifier: workspace:* version: link:../../plugins/messages + '@devframes/plugin-og': + specifier: workspace:* + version: link:../../plugins/og '@devframes/plugin-terminals': specifier: workspace:* version: link:../../plugins/terminals @@ -1244,6 +1250,61 @@ importers: specifier: catalog:deps version: 8.21.1 + plugins/og: + dependencies: + cac: + specifier: catalog:deps + version: 7.0.0 + nostics: + specifier: catalog:deps + version: 1.2.0 + parse5: + specifier: catalog:deps + version: 7.3.0 + valibot: + specifier: catalog:deps + version: 1.4.2(typescript@6.0.3) + devDependencies: + '@antfu/design': + specifier: catalog:frontend + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@iconify-json/ph': + specifier: catalog:frontend + version: 1.2.2 + '@types/node': + specifier: catalog:types + version: 26.1.1 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) + devframe: + specifier: workspace:* + version: link:../../packages/devframe + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) + tsdown: + specifier: catalog:build + version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + unocss: + specifier: catalog:frontend + version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vite: + specifier: catalog:build + version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vue: + specifier: catalog:frontend + version: 3.5.40(typescript@6.0.3) + ws: + specifier: catalog:deps + version: 8.21.1 + plugins/terminals: dependencies: '@xterm/addon-fit': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f54d81..6736ba5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,6 +66,7 @@ catalogs: ohash: ^2.0.11 open: ^11.0.0 p-limit: ^7.3.0 + parse5: ^7.3.0 pathe: ^2.0.3 perfect-debounce: ^2.1.0 structured-clone-es: ^2.0.0 diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.d.ts new file mode 100644 index 0000000..0d05f6a --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/cli` + */ +// #region Functions +export declare function createOgCli(): CacHandle; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.js new file mode 100644 index 0000000..a00adaf --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/cli.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/cli` + */ +// #region Functions +export function createOgCli() {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.d.ts new file mode 100644 index 0000000..88bb258 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.d.ts @@ -0,0 +1,15 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/client` + */ +// #region Functions +export declare function connectOg(_?: DevframeRpcClientOptions): Promise; +// #endregion + +// #region Other +export { DevframeConnectionStatus } +export { DevframeRpcClient } +export { OgHeadTag } +export { OgHeadTagName } +export { OgResolveInput } +export { OgSnapshot } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.js new file mode 100644 index 0000000..b3e97da --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/client.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/client` + */ +// #region Functions +export function connectOg(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.d.ts new file mode 100644 index 0000000..c136dbf --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.d.ts @@ -0,0 +1,32 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og` + */ +// #region Interfaces +export interface OgDevframeOptions { + id?: string; + name?: string; + icon?: string; + basePath?: string; + port?: number; + auth?: boolean; + defaultUrl?: string; + fetch?: OgFetch; +} +// #endregion + +// #region Functions +export declare function createOgDevframe(_?: OgDevframeOptions): DevframeDefinition; +// #endregion + +// #region Default Export +declare const _default: DevframeDefinition; +export default _default +// #endregion + +// #region Other +export { OgFetch } +export { OgHeadTag } +export { OgHeadTagName } +export { OgResolveInput } +export { OgSnapshot } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.js new file mode 100644 index 0000000..b652bb1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/index.snapshot.js @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og` + */ +// #region Default Export +export default ogDevframe +// #endregion + +// #region Other +export { createOgDevframe } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.d.ts new file mode 100644 index 0000000..8749dba --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/node` + */ +// #region Functions +export declare function fetchOgMetadata(_: string, _?: OgFetch): Promise; +export declare function parseOgMetadata(_: string, _: string): OgHeadTag[]; +export declare function setupOg(_: DevframeNodeContext, _?: ResolveMetadataOptions): void; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.js new file mode 100644 index 0000000..2e137d2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/node.snapshot.js @@ -0,0 +1,11 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/node` + */ +// #region Functions +export function setupOg(_, _) {} +// #endregion + +// #region Other +export { fetchOgMetadata } +export { parseOgMetadata } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts new file mode 100644 index 0000000..25339d5 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.d.ts @@ -0,0 +1,98 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/rpc` + */ +// #region Variables +export declare const serverFunctions: readonly [{ + name: "devframes:plugin:og:resolve-metadata"; + type?: "query" | undefined; + cacheable?: boolean; + args: readonly [import("valibot").ObjectSchema<{ + readonly url: import("valibot").OptionalSchema, undefined>; + }, undefined>]; + returns: import("valibot").ObjectSchema<{ + readonly requestedUrl: import("valibot").StringSchema; + readonly url: import("valibot").StringSchema; + readonly status: import("valibot").NumberSchema; + readonly fetchedAt: import("valibot").NumberSchema; + readonly tags: import("valibot").ArraySchema; + readonly name: import("valibot").StringSchema; + readonly value: import("valibot").StringSchema; + }, undefined>, undefined>; + }, undefined>; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + handler?: ((args_0: { + url?: string | undefined; + }) => { + requestedUrl: string; + url: string; + status: number; + fetchedAt: number; + tags: { + tag: "html" | "link" | "meta" | "title"; + name: string; + value: string; + }[]; + }) | undefined; + dump?: import("devframe/rpc").RpcDump<[{ + url?: string | undefined; + }], { + requestedUrl: string; + url: string; + status: number; + fetchedAt: number; + tags: { + tag: "html" | "link" | "meta" | "title"; + name: string; + value: string; + }[]; + }, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; +}]; +// #endregion + +// #region Other +export { createResolveMetadataRpc } +export { resolveMetadata } +export { ResolveMetadataOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.js new file mode 100644 index 0000000..a39000c --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/rpc.snapshot.js @@ -0,0 +1,11 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/rpc` + */ +// #region Variables +export var serverFunctions /* const */ +// #endregion + +// #region Other +export { createResolveMetadataRpc } +export { resolveMetadata } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts new file mode 100644 index 0000000..883cfcd --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/vite` + */ +// #region Interfaces +export interface OgVitePluginOptions extends ViteDevBridgeOptions { + auth?: boolean; +} +// #endregion + +// #region Functions +export declare function ogVitePlugin(_?: OgVitePluginOptions): DevframeVitePlugin; +// #endregion + +// #region Other +export { ViteDevBridgeOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.js new file mode 100644 index 0000000..c8ade01 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/vite` + */ +// #region Functions +export function ogVitePlugin(_) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index da13968..b17cf3b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -223,6 +223,24 @@ "@devframes/plugin-inspect": [ "./plugins/inspect/src/index.ts" ], + "@devframes/plugin-og/client": [ + "./plugins/og/src/client/index.ts" + ], + "@devframes/plugin-og/node": [ + "./plugins/og/src/node/index.ts" + ], + "@devframes/plugin-og/rpc": [ + "./plugins/og/src/rpc/index.ts" + ], + "@devframes/plugin-og/cli": [ + "./plugins/og/src/cli.ts" + ], + "@devframes/plugin-og/vite": [ + "./plugins/og/src/vite.ts" + ], + "@devframes/plugin-og": [ + "./plugins/og/src/index.ts" + ], "@devframes/plugin-a11y/client": [ "./plugins/a11y/src/client/index.ts" ], diff --git a/turbo.json b/turbo.json index 8cfd0b7..b07adc6 100644 --- a/turbo.json +++ b/turbo.json @@ -33,6 +33,11 @@ "dependsOn": ["devframe#build"], "outputs": ["dist/**"] }, + "@devframes/plugin-og#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "@devframes/plugin-data-inspector#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], @@ -52,6 +57,7 @@ "@devframes/plugin-terminals#build", "@devframes/plugin-code-server#build", "@devframes/plugin-inspect#build", + "@devframes/plugin-og#build", "@devframes/plugin-data-inspector#build", "@devframes/plugin-a11y#build", "@devframes/plugin-messages#build" diff --git a/vitest.config.ts b/vitest.config.ts index 7d9e4e6..93763f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ 'plugins/data-inspector', 'plugins/terminals', 'plugins/inspect', + 'plugins/og', 'examples/files-inspector', 'examples/streaming-chat', 'examples/next-runtime-snapshot',