diff --git a/app/src/lib/shared.ts b/app/src/lib/shared.ts index 23acd46..87a0ac0 100644 --- a/app/src/lib/shared.ts +++ b/app/src/lib/shared.ts @@ -1,9 +1,9 @@ -/** Fixed social-card canvas dimensions and cache version. */ +/** Standard social-card canvas dimensions and cache version. */ export const card = { height: 420, padding: 80, scale: 1.5, - version: 3, + version: 4, width: 800, } as const diff --git a/app/worker/index.ts b/app/worker/index.ts index 55d1a0d..466384b 100644 --- a/app/worker/index.ts +++ b/app/worker/index.ts @@ -112,7 +112,7 @@ const app = new Hono<{ Bindings: Cloudflare.Env }>() if (!kept) return c.redirect('/og.jpg', 302) // Named rather than `caches.default`, which shares its keyspace with the // asset cache in front of this Worker. - const cache = await caches.open('og') + const cache = await caches.open(`og:${Links.card.version}`) const hit = await cache.match(c.req.raw) if (hit) return hit @@ -273,7 +273,7 @@ function varied(response: Response): Response { /** * Renders a shared-link card from encoded editor state. * - * Uses the image route to share validation and rendering behavior. Returns + * Uses the document route to share validation and rendering behavior. Returns * `undefined` when rendering fails so callers can serve the default card. */ async function card( @@ -289,19 +289,19 @@ async function card( Wallpapers.at(settings.background) ?? (settings.background === 'default' ? Wallpapers.byId(settings.theme) : undefined) const picture = named ? await inlined(env, origin, named.id) : undefined - // Limit source to a fixed viewport so dense snippets remain readable. - const shown = Links.excerpt(Links.withoutTypes(settings.code)) + // Truncate source to the largest canvas, then size the canvas to what remains. + const shape = Links.layout(Links.withoutTypes(settings.code)) const drawn = await api.request( '/document', { body: JSON.stringify({ // Pass wallpaper content through `picture`, not the application identifier. background: settings.background.startsWith('wallpaper:') ? 'default' : settings.background, - code: shown.code, - height: Links.card.height, + code: shape.code, + height: shape.height, // Resolve automatic language detection before invoking the renderer. lang: settings.lang === 'auto' ? (detect(settings.code) ?? 'typescript') : settings.lang, - padding: Links.card.padding, + padding: shape.padding, ...(picture ? { picture } : {}), // Apply the selected theme's frame radius override. radius: Themes.frame(settings.theme).radius ?? settings.radius, @@ -309,7 +309,7 @@ async function card( titleBar: false, // Preview crawlers cannot wait for package acquisition during Twoslash. twoslash: false, - width: Links.card.width, + width: shape.width, }), headers: { 'content-type': 'application/json' }, method: 'POST', @@ -320,8 +320,8 @@ async function card( if (!drawn.ok) return undefined try { const png = await Browser.screenshot(env.BROWSER, { - html: Links.fade(await drawn.text(), shown.overflow), - scale: Links.card.scale, + html: Links.fade(await drawn.text(), shape.overflow), + scale: shape.scale, }) return png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength) as ArrayBuffer } catch { diff --git a/app/worker/links.test.ts b/app/worker/links.test.ts index cc40a18..0d015c8 100644 --- a/app/worker/links.test.ts +++ b/app/worker/links.test.ts @@ -36,7 +36,7 @@ describe('card', () => { "height": 420, "padding": 80, "scale": 1.5, - "version": 3, + "version": 4, "width": 800, } `) @@ -45,27 +45,6 @@ describe('card', () => { }) }) -describe('excerpt', () => { - test('limits long lines to 72 display columns', () => { - const shown = Links.excerpt('x'.repeat(100)) - expect(shown.code).toHaveLength(72) - expect(shown.overflow).toEqual({ horizontal: true, vertical: false }) - }) - - test('limits tall snippets to ten lines', () => { - const shown = Links.excerpt(Array.from({ length: 12 }, (_, at) => `line ${at}`).join('\n')) - expect(shown.code.split('\n')).toHaveLength(10) - expect(shown.overflow).toEqual({ horizontal: false, vertical: true }) - }) - - test('preserves code that fits the viewport', () => { - expect(Links.excerpt('const a = 1')).toEqual({ - code: 'const a = 1', - overflow: { horizontal: false, vertical: false }, - }) - }) -}) - describe('withoutTypes', () => { test('removes query rows without changing surrounding source', () => { expect(Links.withoutTypes('const value = run()\n// ^?\nvalue\n // ^? ')).toBe( @@ -95,6 +74,89 @@ describe('fade', () => { }) }) +describe('layout', () => { + test('uses standard dimensions for a short snippet', () => { + const { code, overflow, ...shape } = Links.layout('const a = 1\n') + expect(code).toBe('const a = 1\n') + expect(overflow).toEqual({ horizontal: false, vertical: false }) + expect(shape).toMatchInlineSnapshot(` + { + "height": 420, + "padding": 80, + "scale": 1.5, + "width": 800, + } + `) + }) + + test('preserves output dimensions as the canvas grows', () => { + const fifteen = Array.from({ length: 15 }, (_, at) => `const v${at} = ${at}`).join('\n') + const { code, overflow, ...grown } = Links.layout(fifteen) + expect(code).toBe(fifteen) + expect(overflow).toEqual({ horizontal: false, vertical: false }) + expect(grown).toMatchInlineSnapshot(` + { + "height": 546, + "padding": 80, + "scale": 1.1538461538461537, + "width": 1040, + } + `) + // Verify that scaling produces the fixed social-card dimensions. + expect(Math.round(grown.width * grown.scale)).toBe(1200) + expect(Math.round(grown.height * grown.scale)).toBe(630) + }) + + test('draws the tallest snippet the card holds without dropping a line', () => { + const most = Array.from({ length: 29 }, () => 'const a = 1').join('\n') + const grown = Links.layout(most) + expect(grown.width).toBe(1600) + expect(grown.code).toBe(most) + expect(grown.overflow.vertical).toBe(false) + }) + + test('drops the line past the last one the card holds', () => { + const over = Array.from({ length: 30 }, (_, at) => `const v${at} = ${at}`).join('\n') + const grown = Links.layout(over) + expect(grown.width).toBe(1600) + expect(grown.code).toBe(`${over.split('\n').slice(0, 29).join('\n')}\n`) + expect(grown.overflow.vertical).toBe(true) + }) + + test('grows the canvas when a long line wraps', () => { + expect(Links.layout('x'.repeat(721)).width).toBeGreaterThan(800) + }) + + test('cuts wrapped code to the rows available at the widest canvas', () => { + const grown = Links.layout('x'.repeat(10_000)) + expect(grown.code.length).toBeLessThan(10_000) + expect(grown.width).toBe(1600) + expect(grown.overflow.vertical).toBe(true) + }) + + test('wraps a wide-character line at half the columns of a Latin one', () => { + // East Asian wide characters occupy two display columns, so the same count wraps sooner. + const wide = Links.layout('あ'.repeat(400)) + const latin = Links.layout('a'.repeat(400)) + expect(latin.width).toBe(800) + expect(wide.width).toBeGreaterThan(latin.width) + }) + + test('keeps single-column Unicode without truncating it as wide text', () => { + const code = Array.from({ length: 29 }, () => 'éλЖ'.repeat(30)).join('\n') + const grown = Links.layout(code) + expect(grown.width).toBe(1600) + expect(grown.code).toBe(code) + }) + + test('includes annotation-row margins when choosing the canvas', () => { + const plain = Array.from({ length: 10 }, () => 'const a = 1').join('\n') + const tagged = Array.from({ length: 10 }, () => '// @log: looked at').join('\n') + expect(Links.layout(plain).width).toBe(800) + expect(Links.layout(tagged).width).toBeGreaterThan(800) + }) +}) + describe('describe', () => { /** Creates a deterministic implementation of the model interface. */ function answering(answer: unknown): Links.describe.Model { diff --git a/app/worker/links.ts b/app/worker/links.ts index 8ccdb24..8c4dd68 100644 --- a/app/worker/links.ts +++ b/app/worker/links.ts @@ -1,16 +1,179 @@ +import * as DocumentLayout from '../../src/internal/DocumentLayout.js' +import * as Tags from '../../src/internal/Tags.js' import * as Shared from '../src/lib/shared.js' -/** Size, retention, and preview limits for shared links. */ -export const limits = { - columns: 72, - rows: 10, - size: 20_000, - ttl: 60 * 60 * 24 * 90, -} as const +/** Size and retention limits for shared links. */ +export const limits = { size: 20_000, ttl: 60 * 60 * 24 * 90 } as const -/** Fixed social-card canvas dimensions and cache version. */ +/** Standard social-card canvas dimensions and cache version. */ export const card = Shared.card +/** Fixed social-card output dimensions in pixels. */ +const output = { height: card.height * card.scale, width: card.width * card.scale } as const + +/** Card-specific canvas bounds and increments in pixels. */ +const canvas = { + /** Narrowest canvas width. */ + narrowest: card.width, + /** Canvas space around the code window. */ + padding: card.padding, + /** Canvas width increment. */ + step: 40, + /** Widest canvas width. */ + widest: 1600, +} as const + +/** + * Truncates code to what the widest canvas shows, then sizes a canvas to fit it. + * + * Width grows in fixed increments at the output ratio before scaling to the + * output size, leaving a short snippet larger on the card than a long one. + */ +export function layout(code: string): layout.Result { + const shown = excerpt(code) + const measured = measure(codeLines(shown.code)) + let width = canvas.narrowest + for (; width < canvas.widest; width += canvas.step) + if (contentHeight(measured, width) <= room(width)) break + return { + code: shown.code, + height: canvasHeight(width), + overflow: { horizontal: false, vertical: shown.truncated }, + padding: canvas.padding, + scale: output.width / width, + width, + } +} + +export declare namespace layout { + type Result = { + /** Source code, truncated to the rows the widest canvas shows. */ + code: string + /** Canvas height in pixels, derived from `width` at the output ratio. */ + height: number + /** Clipped axes used to mark truncated source at the canvas edge. */ + overflow: fade.Overflow + /** Canvas space around the code window in pixels. */ + padding: number + /** Device scale factor that maps the canvas to the output size. */ + scale: number + /** Canvas width in pixels. */ + width: number + } +} + +/** Truncates code to the rows available at the widest canvas. */ +function excerpt(code: string): { code: string; truncated: boolean } { + const source = codeLines(code) + const measured = measure(source) + if (contentHeight(measured, canvas.widest) <= room(canvas.widest)) + return { code, truncated: false } + const available = columns(canvas.widest) + const shown: string[] = [] + let remaining = room(canvas.widest) + for (const [at, line] of source.entries()) { + const measuredLine = measured[at]! + const needed = lineHeight(measuredLine, available) + if (needed > remaining) { + const gap = measuredLine.gap ? DocumentLayout.metrics.annotation.gap : 0 + const rows = Math.floor((remaining - gap) / DocumentLayout.metrics.code.line) + if (rows > 0) shown.push(takeColumns(line, rows * available)) + break + } + shown.push(line) + remaining -= needed + } + return { code: `${shown.join('\n')}\n`, truncated: true } +} + +/** Splits code into lines, ignoring a single trailing newline. */ +function codeLines(code: string): string[] { + return code.replace(/\n$/, '').split('\n') +} + +/** A source line's width and preceding annotation gap. */ +type Line = { columns: number; gap: boolean } + +/** Measures each line once before searching canvas widths. */ +function measure(source: readonly string[]): Line[] { + let previousTagged = false + return source.map((line) => { + const tagged = Tags.tagged(line) + const measured = { columns: columnCount(line), gap: tagged && !previousTagged } + previousTagged = tagged + return measured + }) +} + +/** Canvas height at a canvas width, held to the output ratio. */ +function canvasHeight(width: number): number { + return (width * output.height) / output.width +} + +/** Code columns available at a canvas width. */ +function columns(width: number): number { + const { advance, size } = DocumentLayout.metrics.code + const { inset } = DocumentLayout.metrics.body + return Math.max(1, Math.floor((width - canvas.padding * 2 - inset * 2) / (size * advance))) +} + +/** Vertical pixels available to source and annotation rows. */ +function room(width: number): number { + const { padding } = DocumentLayout.metrics.body + const window = padding.plain * 2 + DocumentLayout.metrics.source.padding * 2 + return canvasHeight(width) - canvas.padding * 2 - window +} + +/** Vertical pixels that measured lines occupy at a canvas width. */ +function contentHeight(measured: readonly Line[], width: number): number { + const available = columns(width) + return measured.reduce((height, line) => height + lineHeight(line, available), 0) +} + +/** Vertical pixels that one measured line occupies. */ +function lineHeight(line: Line, available: number): number { + const annotation = line.gap ? DocumentLayout.metrics.annotation.gap : 0 + return span(line.columns, available) * DocumentLayout.metrics.code.line + annotation +} + +/** Rows a line of the given display columns occupies at a column capacity. */ +function span(count: number, available: number): number { + return Math.max(1, Math.ceil(count / available)) +} + +const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' }) +const zeroWidth = /^(?:\p{Control}|\p{Format}|\p{Mark})+$/u +const wide = + /[\u1100-\u115f\u2329-\u232a\u2e80-\u303e\u3040-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{1b000}-\u{1b2ff}\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}|\p{Regional_Indicator}/u + +/** Display columns a grapheme occupies at a column offset. */ +function advance(character: string, at: number): number { + const { tab } = DocumentLayout.metrics.code + if (character === '\t') return tab - (at % tab) + if (zeroWidth.test(character)) return 0 + return wide.test(character) || character.includes('\u20e3') ? 2 : 1 +} + +/** Counts display columns, including tabs and East Asian wide graphemes. */ +function columnCount(value: string): number { + let count = 0 + for (const { segment } of graphemes.segment(value)) count += advance(segment, count) + return count +} + +/** Returns the prefix that fits within the requested display columns. */ +function takeColumns(value: string, limit: number): string { + let count = 0 + let result = '' + for (const { segment } of graphemes.segment(value)) { + const next = count + advance(segment, count) + if (next > limit) break + count = next + result += segment + } + return result +} + /** * Generates a 12-character identifier with 60 bits of entropy. * @@ -30,28 +193,6 @@ export function id(): string { return name } -/** Limits code to the readable social-card viewport and reports clipped axes. */ -export function excerpt(code: string): excerpt.Result { - const lines = code.replace(/\n$/, '').split('\n') - let horizontal = false - const shown = lines.slice(0, limits.rows).map((line) => { - if (columnCount(line) <= limits.columns) return line - horizontal = true - return takeColumns(line, limits.columns) - }) - return { - code: shown.join('\n'), - overflow: { horizontal, vertical: lines.length > limits.rows }, - } -} - -export declare namespace excerpt { - type Result = { - code: string - overflow: fade.Overflow - } -} - /** Removes Twoslash query rows from a preview that does not resolve types. */ export function withoutTypes(code: string): string { return code @@ -104,28 +245,6 @@ export declare namespace fade { } } -/** Counts display columns, including two-column tabs and non-ASCII characters. */ -function columnCount(value: string): number { - let count = 0 - for (const character of value) - count += character === '\t' ? 2 - (count % 2) : character.codePointAt(0)! > 127 ? 2 : 1 - return count -} - -/** Returns the prefix that fits within the requested display columns. */ -function takeColumns(value: string, limit: number): string { - let count = 0 - let result = '' - for (const character of value) { - const next = - count + (character === '\t' ? 2 - (count % 2) : character.codePointAt(0)! > 127 ? 2 : 1) - if (next > limit) break - count = next - result += character - } - return result -} - /** Returns the first non-empty line, truncated for use as a preview title. */ export const summarize = Shared.summarize diff --git a/src/internal/Document.ts b/src/internal/Document.ts index bba1012..bbdefb8 100644 --- a/src/internal/Document.ts +++ b/src/internal/Document.ts @@ -1,4 +1,5 @@ import * as Theme from '../Theme.js' +import * as DocumentLayout from './DocumentLayout.js' /** Font data embedded in the standalone document. */ export type Font = { @@ -88,12 +89,12 @@ export function build(options: Options): string { ${fontFaces(fonts)} :root { --code-font-family: ${fontStack(fonts)}; - --code-font-size: 14px; - --code-line-height: 22px; - --code-tab-size: 2; - --code-annotation-size: 12px; + --code-font-size: ${DocumentLayout.metrics.code.size}px; + --code-line-height: ${DocumentLayout.metrics.code.line}px; + --code-tab-size: ${DocumentLayout.metrics.code.tab}; + --code-annotation-size: ${DocumentLayout.metrics.annotation.size}px; ${annotationWidth} /* What the window insets its code by, so a marked line can reach past it. */ - --body-inset: 16px; + --body-inset: ${DocumentLayout.metrics.body.inset}px; --window-background: ${palette.window.background}; --window-border: ${palette.window.border}; --window-shadow: ${shadow[palette.type]}; @@ -138,7 +139,7 @@ ${canvasWidth} } .body { color: ${palette.window.foreground}; - padding: ${titleBar ? '4px' : '8px'} var(--body-inset); + padding: ${titleBar ? DocumentLayout.metrics.body.padding.titled : DocumentLayout.metrics.body.padding.plain}px var(--body-inset); } .shiki, .shiki code { background: transparent !important; @@ -152,7 +153,7 @@ ${canvasWidth} tab-size: var(--code-tab-size); white-space: pre-wrap; } -.shiki { padding-block: 12px; } +.shiki { padding-block: ${DocumentLayout.metrics.source.padding}px; } ${marked(html) ? marks(palette) : ''} ${styles} .twoslash-block { @@ -368,7 +369,7 @@ ${mark(Theme.marks.remove)} .twoslash-tag-line { align-items: center; /* Separate annotation prose from the source line it describes. */ - margin-top: 6px; + margin-top: ${DocumentLayout.metrics.annotation.gap}px; display: flex; font-size: var(--code-annotation-size); gap: 6px; diff --git a/src/internal/DocumentLayout.ts b/src/internal/DocumentLayout.ts new file mode 100644 index 0000000..cc5bbaa --- /dev/null +++ b/src/internal/DocumentLayout.ts @@ -0,0 +1,7 @@ +/** Renderer metrics shared by document CSS and fixed-canvas layout calculations. */ +export const metrics = { + annotation: { gap: 6, size: 12 }, + body: { inset: 16, padding: { plain: 8, titled: 4 } }, + code: { advance: 0.6, line: 22, size: 14, tab: 2 }, + source: { padding: 12 }, +} as const diff --git a/src/internal/Tags.ts b/src/internal/Tags.ts index 214edee..0871470 100644 --- a/src/internal/Tags.ts +++ b/src/internal/Tags.ts @@ -19,6 +19,11 @@ const written = new RegExp(`^[ \\t]*(?://|#|--|;|%|/\\*|)[ \t]*$/ +/** Whether a source line becomes a prose annotation row. */ +export function tagged(line: string): boolean { + return written.test(line) +} + /** * Draws the tags a snippet carries as rows of prose, the way a resolved run * would draw them.