From 4e54e9316c599b481a4aa50b00aba013ff9e279a Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 22:25:38 +0800 Subject: [PATCH] Embed hosted diagrams: a README image that updates, an iframe and oEmbed The .svg link revalidates on every view, /d/:id/embed can be framed by any site, and /oembed lets Notion, Medium and docs sites embed a link. Share copies the README image and the embed code. --- README.md | 7 ++ .../tasks/fl-88 - Embeds-that-stay-current.md | 38 +++++++- e2e/publish.spec.js | 10 +++ server/src/diagrams/diagrams.controller.ts | 90 ++++++++++++++++++- server/src/diagrams/diagrams.service.ts | 12 ++- server/src/diagrams/page.ts | 41 ++++++++- server/test/diagrams.test.ts | 62 +++++++++++++ src/api/publishApi.js | 2 +- src/components/share/ShareDialog.vue | 36 +++++++- 9 files changed, 286 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e5a7c3b..c8c61cf 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,8 @@ and serves it in every form a reader wants: | `/d/:id.svg` | The drawing, sketch font embedded | | `/d/:id.json` | The document | | `/d/:id/og.png` | The drawing itself, as a 1200x630 PNG link preview, cached per revision | +| `/d/:id/embed` | The drawing alone, for an iframe on any site | +| `/oembed?url=` | oEmbed JSON, so Notion, Medium and docs sites embed a pasted link | `POST /api/diagrams` with `.flow` text (or JSON `{ "text": … }`) publishes it and returns the link and an edit token, shown once and stored only as a hash. `PUT` and `DELETE` on `/api/diagrams/:id` with @@ -331,6 +333,11 @@ can read, only the token can change it. Invalid text is refused with line number It reads, briefs and draws with the app's own `src/domain` code, so a link shows exactly what the editor and the CLI do. +**Embeds stay current.** `/d/:id.svg` is served with `no-cache` and an ETag per revision, so an +image in a README (through GitHub's image proxy) shows each new version as it is published, while an +unchanged one costs a 304. Share copies it ready to paste, `[![Title](…/d/id.svg)](…/d/id)`, and an +iframe for docs sites; Notion and Medium find the embed themselves through oEmbed. + It is also a remote MCP server, at `/mcp` over Streamable HTTP, so an agent that cannot run a local process can still work with diagrams by their links: `read_diagram` (as a brief or `.flow` text), `publish_diagram` (returning the link and an edit token for the person) and `update_diagram` (with diff --git a/backlog/tasks/fl-88 - Embeds-that-stay-current.md b/backlog/tasks/fl-88 - Embeds-that-stay-current.md index a4ed749..ffcb5cc 100644 --- a/backlog/tasks/fl-88 - Embeds-that-stay-current.md +++ b/backlog/tasks/fl-88 - Embeds-that-stay-current.md @@ -1,9 +1,11 @@ --- id: FL-88 title: Embeds that stay current -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 14:25' labels: - server - growth @@ -26,7 +28,35 @@ Let hosted diagrams live inside READMEs, docs and Notion, each a link back to is -- [ ] #1 /d/:id.svg works as an image in a GitHub README and updates when the diagram does -- [ ] #2 An iframe embed and an oEmbed endpoint for Notion, Medium and docs sites +- [x] #1 /d/:id.svg works as an image in a GitHub README and updates when the diagram does +- [x] #2 An iframe embed and an oEmbed endpoint for Notion, Medium and docs sites + +## Implementation Plan + + + +1. /d/:id.svg: Cache-Control no-cache, an ETag per revision and format, and an explicit 304 on If-None-Match, so GitHubs image proxy asks every time and an update shows. +2. /d/:id/embed: the drawing alone, fitted, linking back, framable by any site (frame-ancestors *). +3. /oembed?url=: rich oEmbed JSON with an iframe sized to the drawing within maxwidth/maxheight, a thumbnail, 404 for other links and 501 for XML; the page advertises it with an application/json+oembed link. +4. Share: Copy README image and Copy embed code, built from the link. +5. Server tests against PostgreSQL; the publish e2e covers the two buttons. + + + +## Implementation Notes + + + +Express did not answer the conditional request with 304 by itself through Nest, so the read handler compares If-None-Match itself. ETags now include the format, so a page and its SVG never share one. + +Verified: server tests against PostgreSQL 16 in Docker (16 pass; new: svg no-cache with 304 until an update then 200 with the new text; embed framable with the drawing and a link back; oEmbed JSON, sizing, thumbnail, discovery link, 404 and 501). e2e publish.spec covers both copy buttons. e2e 144, vitest 300, lint and typecheck. + + +## Final Summary + + + +Hosted diagrams embed and stay current: the .svg link revalidates on every view so a README image shows each new version, /d/:id/embed is an iframe any site may frame, and /oembed lets Notion, Medium and docs sites embed a pasted link. Share copies both. Verified with server tests against PostgreSQL and the publish e2e. + diff --git a/e2e/publish.spec.js b/e2e/publish.spec.js index 4985740..62b4a69 100644 --- a/e2e/publish.spec.js +++ b/e2e/publish.spec.js @@ -55,6 +55,16 @@ test('publishes a public link, updates it in place, and unpublishes it', async ( 'https://isketch.test/d/abc123.md', ) + // Placed elsewhere: a README image that shows each new version, and an iframe. + await dialog.getByRole('button', { name: 'Copy README image' }).click() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe('[![Support flow](https://isketch.test/d/abc123.svg)](https://isketch.test/d/abc123)') + await dialog.getByRole('button', { name: 'Copy embed code' }).click() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toMatch(/^`, + width, + height, + thumbnail_url: `${links.page}/og.png`, + thumbnail_width: 1200, + thumbnail_height: 630, + }) + } + @Post('api/diagrams') publish(@Body() body: unknown) { return this.diagrams.publish(textOf(body) as string) @@ -75,16 +148,22 @@ export class DiagramsController { * brief, `.flow` the source, `.svg` the drawing, `.json` the document. */ @Get('d/:file') - async read(@Param('file') file: string, @Res() res: Response) { + async read(@Param('file') file: string, @Req() req: Request, @Res() res: Response) { const [, id, extension = ''] = /^([^.]+)(?:\.(\w+))?$/.exec(file) ?? [] if (!id) throw new NotFoundException() const { row, document } = await this.diagrams.load(id) const domain = await loadDomain() - res.setHeader('ETag', `"${row.id}-${row.revision}"`) + const etag = `"${row.id}-${row.revision}${extension ? `.${extension}` : ''}"` + res.setHeader('ETag', etag) res.setHeader('Cache-Control', 'public, max-age=60') res.setHeader('X-Robots-Tag', 'noindex') res.setHeader('Access-Control-Allow-Origin', '*') + // Unchanged since the asker's copy: say so, and send nothing. + if (req.headers['if-none-match'] === etag) { + if (extension === 'svg') res.setHeader('Cache-Control', 'no-cache, max-age=0') + return res.status(304).end() + } switch (extension) { case 'md': @@ -92,6 +171,9 @@ export class DiagramsController { case 'flow': return res.type('text/plain; charset=utf-8').send(row.text) case 'svg': { + // An image in a README goes through GitHub's proxy, which keeps it as long as it is + // told to: always ask again, so an update shows, and a 304 keeps that cheap. + res.setHeader('Cache-Control', 'no-cache, max-age=0') const sketchFont = document.style === 'sketch' ? await domain.sketchFont() : '' return res.type('image/svg+xml').send(domain.renderSvg(document, { sketchFont })) } diff --git a/server/src/diagrams/diagrams.service.ts b/server/src/diagrams/diagrams.service.ts index df4e42c..93e39a3 100644 --- a/server/src/diagrams/diagrams.service.ts +++ b/server/src/diagrams/diagrams.service.ts @@ -21,7 +21,15 @@ export interface Published { id: string revision: number url: string - links: { page: string; markdown: string; flow: string; svg: string; json: string } + links: { + page: string + markdown: string + flow: string + svg: string + json: string + embed: string + oembed: string + } } /** A diagram as it is served: its row, and the document read from it. */ @@ -84,6 +92,8 @@ export class DiagramsService { flow: `${url}.flow`, svg: `${url}.svg`, json: `${url}.json`, + embed: `${url}/embed`, + oembed: `${this.config.publicUrl}/oembed?url=${encodeURIComponent(url)}`, }, } } diff --git a/server/src/diagrams/page.ts b/server/src/diagrams/page.ts index a821ca4..e0c8967 100644 --- a/server/src/diagrams/page.ts +++ b/server/src/diagrams/page.ts @@ -1,3 +1,5 @@ +import type { Published } from './diagrams.service.js' + /** * The page a link opens. It carries the brief as text, not only a picture, so * an AI that fetches the link reads the whole design; a person sees the @@ -6,7 +8,7 @@ export function renderPage(input: { title: string brief: string - links: { page: string; markdown: string; flow: string; svg: string; json: string } + links: Published['links'] openUrl: string image: string updatedAt: Date @@ -23,6 +25,7 @@ export function renderPage(input: { + @@ -73,6 +76,42 @@ export function renderPage(input: { ` } +/** + * The drawing alone, for an iframe in Notion, Medium or a docs site: it fills + * the frame, keeps its proportions, and links back to the full page. + */ +export function renderEmbed(input: { title: string; svg: string; pageUrl: string }): string { + const { title, svg, pageUrl } = input + return ` + + + + +${escape(title)} · isketch + + + + +${svg} + + + +` +} + +/** A drawing's own size, from its root element, or a sensible frame. */ +export function svgSize(svg: string): { width: number; height: number } { + const width = Number(/]*\swidth="([\d.]+)"/.exec(svg)?.[1]) + const height = Number(/]*\sheight="([\d.]+)"/.exec(svg)?.[1]) + return width > 0 && height > 0 ? { width, height } : { width: 800, height: 500 } +} + function escape(text: string): string { return String(text) .replace(/&/g, '&') diff --git a/server/test/diagrams.test.ts b/server/test/diagrams.test.ts index ba6e25e..b071c69 100644 --- a/server/test/diagrams.test.ts +++ b/server/test/diagrams.test.ts @@ -164,6 +164,68 @@ describe('reading a link', () => { }) }) +describe('embedding a link', () => { + it('serves the drawing so a README image always shows the latest version', async () => { + const { id, editToken } = await publish() + const first = await fetch(`${base}/d/${id}.svg`) + assert.equal(first.headers.get('cache-control'), 'no-cache, max-age=0') + const etag = first.headers.get('etag') ?? '' + assert.ok(etag) + + // Asked again with what it has: nothing changed, nothing sent. + const again = await fetch(`${base}/d/${id}.svg`, { headers: { 'if-none-match': etag } }) + assert.equal(again.status, 304) + + await fetch(`${base}/api/diagrams/${id}`, { + method: 'PUT', + headers: { 'content-type': 'text/plain', authorization: `Bearer ${editToken}` }, + body: FLOW.replace('"Orders"', '"Invoices"'), + }) + const updated = await fetch(`${base}/d/${id}.svg`, { headers: { 'if-none-match': etag } }) + assert.equal(updated.status, 200) + assert.match(await updated.text(), />Invoices<\/text>/) + }) + + it('offers the drawing alone for an iframe, which any site may frame', async () => { + const { id } = await publish() + const embed = await fetch(`${base}/d/${id}/embed`) + const html = await embed.text() + assert.match(embed.headers.get('content-type') ?? '', /^text\/html/) + assert.equal(embed.headers.get('content-security-policy'), 'frame-ancestors *') + assert.equal(embed.headers.get('x-frame-options'), null) + assert.match(html, / { + const { id, url } = await publish() + const page = await (await fetch(`${base}/d/${id}`)).text() + const discovery = `https://isketch.test/oembed?url=${encodeURIComponent(url)}` + assert.ok( + page.includes(`type="application/json+oembed" href="${discovery.replace(/&/g, '&')}"`), + ) + + const response = await fetch(`${base}/oembed?url=${encodeURIComponent(url)}&maxwidth=400`) + assert.equal(response.status, 200) + const oembed = (await response.json()) as Record + assert.equal(oembed.version, '1.0') + assert.equal(oembed.type, 'rich') + assert.equal(oembed.title, 'Shop') + assert.ok((oembed.width as number) <= 400) + assert.match( + oembed.html as string, + new RegExp(`