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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 34 additions & 4 deletions backlog/tasks/fl-88 - Embeds-that-stay-current.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,7 +28,35 @@ Let hosted diagrams live inside READMEs, docs and Notion, each a link back to is

<!-- AC:BEGIN -->

- [ ] #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

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

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.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

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.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

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.
<!-- SECTION:FINAL_SUMMARY:END -->
10 changes: 10 additions & 0 deletions e2e/publish.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^<iframe src="https:\/\/isketch\.test\/d\/abc123\/embed" /)

// The token is remembered, so a later visit updates the same link.
await page.keyboard.press('Escape')
await page.reload()
Expand Down
90 changes: 86 additions & 4 deletions server/src/diagrams/diagrams.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import {
HttpCode,
Inject,
NotFoundException,
NotImplementedException,
Param,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common'
import type { Response } from 'express'
import type { Request, Response } from 'express'

import { CONFIG, type Config } from '../config.js'
import { loadDomain } from '../domain.js'
import { DiagramsService } from './diagrams.service.js'
import { renderPage } from './page.js'
import { renderEmbed, renderPage, svgSize } from './page.js'
import { renderOgPng } from './preview.js'

/** A text body arrives as a string; a JSON one may carry `{ text }`. */
Expand Down Expand Up @@ -54,6 +57,76 @@ export class DiagramsController {
return res.type('image/png').send(png)
}

/** The drawing alone, for an iframe; any site may frame it. */
@Get('d/:id/embed')
async embed(@Param('id') id: string, @Res() res: Response) {
const { row, document } = await this.diagrams.load(id)
const domain = await loadDomain()
const sketchFont = document.style === 'sketch' ? await domain.sketchFont() : ''
const { url } = this.diagrams.describe(row.id, row.revision)

res.setHeader('ETag', `"${row.id}-${row.revision}-embed"`)
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Content-Security-Policy', 'frame-ancestors *')
res.setHeader('X-Robots-Tag', 'noindex')
return res.type('text/html; charset=utf-8').send(
renderEmbed({
title: document.title,
svg: domain.renderSvg(document, { sketchFont }),
pageUrl: url,
}),
)
}

/**
* oEmbed (https://oembed.com), so pasting a link into Notion, Medium or a
* docs site gives the live drawing rather than a bare link.
*/
@Get('oembed')
async oembed(
@Query('url') url: string | undefined,
@Query('format') format: string | undefined,
@Query('maxwidth') maxwidth: string | undefined,
@Query('maxheight') maxheight: string | undefined,
@Res() res: Response,
) {
if (format && format !== 'json') throw new NotImplementedException('Only JSON is offered.')
const prefix = `${this.config.publicUrl}/d/`
const id = url?.startsWith(prefix)
? /^([A-Za-z0-9]+)/.exec(url.slice(prefix.length))?.[1]
: undefined
if (!id) throw new NotFoundException(`Give the url of a diagram, starting ${prefix}.`)

const { row, document } = await this.diagrams.load(id)
const domain = await loadDomain()
const { links } = this.diagrams.describe(row.id, row.revision)
// Sized to the drawing, within what the site asks for, with room for the footer.
const drawing = svgSize(domain.renderSvg(document))
const limit = {
width: Math.min(Number(maxwidth) || 800, 1200),
height: Math.min(Number(maxheight) || 800, 1200),
}
const scale = Math.min(1, limit.width / drawing.width, (limit.height - 26) / drawing.height)
const width = Math.round(drawing.width * scale)
const height = Math.round(drawing.height * scale) + 26

res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=60')
return res.json({
version: '1.0',
type: 'rich',
provider_name: 'isketch',
provider_url: this.config.appUrl,
title: document.title,
html: `<iframe src="${links.embed}" width="${width}" height="${height}" style="border:0;max-width:100%" loading="lazy" title="${document.title.replace(/"/g, '&quot;')}"></iframe>`,
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)
Expand All @@ -75,23 +148,32 @@ 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':
return res.type('text/markdown; charset=utf-8').send(domain.toBrief(document))
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 }))
}
Expand Down
12 changes: 11 additions & 1 deletion server/src/diagrams/diagrams.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)}`,
},
}
}
Expand Down
41 changes: 40 additions & 1 deletion server/src/diagrams/page.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -23,6 +25,7 @@ export function renderPage(input: {
<link rel="canonical" href="${escape(links.page)}">
<link rel="alternate" type="text/markdown" href="${escape(links.markdown)}" title="Brief for AI agents">
<link rel="alternate" type="text/plain" href="${escape(links.flow)}" title=".flow source">
<link rel="alternate" type="application/json+oembed" href="${escape(links.oembed)}" title="${escape(title)}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="isketch">
<meta property="og:title" content="${escape(title)} · isketch">
Expand Down Expand Up @@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escape(title)} · isketch</title>
<link rel="canonical" href="${escape(pageUrl)}">
<style>
html, body { margin: 0; height: 100%; background: #fff; font: 12px/1.4 ui-sans-serif, system-ui, sans-serif; }
a.drawing { display: flex; align-items: center; justify-content: center; height: calc(100% - 26px); }
a.drawing svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }
footer { height: 26px; display: flex; align-items: center; justify-content: flex-end; padding: 0 10px; }
footer a { color: #6b7280; text-decoration: none; }
</style>
</head>
<body>
<a class="drawing" href="${escape(pageUrl)}" target="_blank" rel="noopener" aria-label="${escape(title)}, open in isketch">${svg}</a>
<footer><a href="${escape(pageUrl)}" target="_blank" rel="noopener">${escape(title)} · isketch</a></footer>
</body>
</html>
`
}

/** 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(/<svg[^>]*\swidth="([\d.]+)"/.exec(svg)?.[1])
const height = Number(/<svg[^>]*\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, '&amp;')
Expand Down
62 changes: 62 additions & 0 deletions server/test/diagrams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<svg /)
assert.match(html, new RegExp(`href="https://isketch\\.test/d/${id}"`))
})

it('answers oEmbed, and the page says where to ask', async () => {
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, '&amp;')}"`),
)

const response = await fetch(`${base}/oembed?url=${encodeURIComponent(url)}&maxwidth=400`)
assert.equal(response.status, 200)
const oembed = (await response.json()) as Record<string, unknown>
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(`<iframe src="https://isketch\\.test/d/${id}/embed"`),
)
assert.equal(oembed.thumbnail_url, `${url}/og.png`)

assert.equal((await fetch(`${base}/oembed?url=https://elsewhere.test/d/${id}`)).status, 404)
assert.equal(
(await fetch(`${base}/oembed?url=${encodeURIComponent(url)}&format=xml`)).status,
501,
)
})
})

describe('changing a link', () => {
it('updates with the edit token, and refuses without it', async () => {
const { id, editToken } = await publish()
Expand Down
2 changes: 1 addition & 1 deletion src/api/publishApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* AI can read. Off unless the build names a server in VITE_ISKETCH_API; the
* app stays local first either way.
*
* @typedef {{ id: string, url: string, revision: number, links: { page: string, markdown: string, flow: string, svg: string, json: string } }} Published
* @typedef {{ id: string, url: string, revision: number, links: { page: string, markdown: string, flow: string, svg: string, json: string, embed?: string, oembed?: string } }} Published
*/

/** @returns {string} the server's origin, or '' when publishing is off */
Expand Down
Loading
Loading