Skip to content
Draft
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
3 changes: 3 additions & 0 deletions datasette_libfec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
)
from .router import router, LIBFEC_ACCESS_NAME, LIBFEC_WRITE_NAME

# datasette-paper third-party embed provider (no-op unless paper is installed).
from .paper import paper_embed_provider # noqa: F401

_ = (
routes_rss,
routes_export,
Expand Down
82 changes: 82 additions & 0 deletions datasette_libfec/paper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""datasette-paper integration: a ``paper_embed_provider`` so an FEC candidate
can be referenced/embedded inside a paper document.

Paper resolves and renders embeds **entirely client-side** (see
``frontend/src/paper_embed.ts``): the bundle claims
``/-/libfec/candidate/{db}/{candidate_id}`` refs, fetches its own data from
Datasette's native table JSON API (``/{db}/libfec_candidates.json``) with the
viewer's cookie, and owns leak discipline. The backend's only job is to
*describe* this provider — its stable ``kind``, the ref namespace it owns, the
``/``-menu source, and where its JS/CSS bundle lives — so paper can lazy-load
that bundle on demand. There is no server-side resolve/render/search.

Why the ref is reordered (``/-/libfec/candidate/{db}/{id}``) rather than the
literal page path (``/{db}/-/libfec/candidate/{id}``): every libfec URL leads
with a *variable* ``{database}`` segment, and paper claims a stored ref for
lazy-loading by ``ref.startswith(prefix)`` (server-side ``provider_for_ref`` +
the frontend manifest). A literal-path ref could therefore never sit under a
static ``ref_prefix``, so existing embeds would not re-inject this bundle on
page reload. Reordering puts the libfec namespace first, giving a stable static
prefix; the bundle's ``matchUrl`` transforms the real pasted page URL into this
ref, and resolve/mount rebuild the real page URL for the pill/card link.

The hook is a no-op unless datasette-paper is installed (it owns the
``paper_embed_provider`` spec); when absent, nothing calls this.
"""

from __future__ import annotations

from datasette import hookimpl
from datasette_vite import vite_css_urls, vite_js_urls

# Vite entry that defines the read-only card + default-exports the paper provider.
_PAPER_EMBED_ENTRY = "src/paper_embed.ts"


class LibfecCandidateEmbedProvider:
"""Describes the FEC-candidate embed for datasette-paper's editor.

``kind`` must equal the bundle's default-exported provider ``kind``. The
actual resolve/render/search all live in that bundle.
"""

kind = "libfec-candidate"
label = "FEC candidate"
# Stored refs are ``/-/libfec/candidate/{db}/{candidate_id}`` — all under
# this namespace. Lets paper inject our bundle for a doc's embeds before
# running our matchRef (see the module docstring for why it's reordered).
ref_prefixes = ["/-/libfec/candidate/"]
# Mirrors the bundle's picker() source so the `/` menu can list it before
# the bundle loads; picking it injects the bundle, then runs its search().
sources = [
{
"id": "libfec-candidate",
"label": "FEC candidate",
"icon": "person-vcard",
"mode": "block",
},
]

def frontend_assets(self, datasette):
# Paper's embed loader does `import(url)` over a list of *string* URLs
# (see embedProviders.ts); vite_js_urls returns `{"url", "module"}`
# dicts (shaped for a script-tag consumer), so unwrap to the bare URL.
# vite_css_urls already returns plain href strings.
js = vite_js_urls(
datasette=datasette,
entrypoint=_PAPER_EMBED_ENTRY,
plugin_package="datasette_libfec",
)
return {
"js": [u["url"] if isinstance(u, dict) else u for u in js],
"css": vite_css_urls(
datasette=datasette,
entrypoint=_PAPER_EMBED_ENTRY,
plugin_package="datasette_libfec",
),
}


@hookimpl
def paper_embed_provider(datasette):
return LibfecCandidateEmbedProvider()
Binary file added docs/screenshots/paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 13 additions & 5 deletions frontend/scripts/screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@
// datasette-paper / datasette-places convention):
// * shots/config.mjs — constants + out(name)
// * shots/server.mjs — boot/teardown of the throwaway datasette
// * shots/cookie.mjs — signed ds_actor cookie (itsdangerous via uv)
// * shots/seed.mjs — seed() the paper-embed doc (paper shot only)
// * shots/helpers.mjs — STABILITY_CSS / freezeVolatile / makeContext
// * shots/defineShot.mjs — per-shot context → goto → wait → freeze → capture
// * shots/defs/<name>.mjs — ONE FILE PER SHOT, auto-discovered below.
//
// libfec's pages are anonymous, read-only views over the fixture db — no cookies
// or seeding, just open the access gate and browse.
// libfec specifics: its own pages are anonymous, read-only views over the
// fixture db (no cookies/seed). The one exception is the `paper` shot, which
// boots the server with datasette-paper and seeds a document embedding FEC
// candidates as cards, viewed as a signed-in owner — see shots/seed.mjs.
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { mkdir, readdir } from 'node:fs/promises';
import { BASE, OUT, out } from './shots/config.mjs';
import { startServer, stopServer } from './shots/server.mjs';
import { seed } from './shots/seed.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -60,10 +65,12 @@ async function main() {
throw new Error(`unknown shot(s): ${unknown.join(', ')} (have: ${names.join(', ')})`);
}
const todo = requested.size ? shots.filter((s) => requested.has(s.name)) : shots;
// Only the paper shot needs datasette-paper booted + a seeded doc.
const needsPaper = todo.some((s) => s.name === 'paper');

await mkdir(OUT, { recursive: true });
console.log(`booting datasette on ${BASE} …`);
const server = await startServer();
console.log(`booting datasette on ${BASE}${needsPaper ? ' (with paper)' : ''} …`);
const server = await startServer(needsPaper);
const onSignal = () => {
stopServer(server);
process.exit(130);
Expand All @@ -73,8 +80,9 @@ async function main() {

const browser = await chromium.launch();
try {
const ids = await seed(needsPaper);
for (const { name, run } of todo) {
await run(browser, {});
await run(browser, ids);
console.log(`✓ ${name} → ${out(name)}`);
}
} finally {
Expand Down
27 changes: 27 additions & 0 deletions frontend/scripts/shot-plugins/profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Throwaway actor display names for the screenshot script.

Loaded via ``datasette --plugins-dir`` from shots/server.mjs (only for the
``paper`` shot) so the seeded paper document shows a friendly author name
("Alice Ada") instead of the raw ``alice`` id in its header. NOT shipped —
dev/screenshot use only.
"""

from datasette import hookimpl

DISPLAY_NAMES = {
"alice": "Alice Ada",
"bob": "Bob Babbage",
"carol": "Carol Shaw",
}


@hookimpl
def actors_from_ids(actor_ids):
out = {}
for aid in actor_ids:
sid = str(aid)
actor = {"id": sid}
if sid in DISPLAY_NAMES:
actor["name"] = DISPLAY_NAMES[sid]
out[sid] = actor
return out
19 changes: 18 additions & 1 deletion frontend/scripts/shots/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ export const BASE = `http://localhost:${PORT}`;
export const DB_NAME = 'data';
// The plugin's base UI path; each shot builds its per-page URL off this.
export const APP = `${BASE}/${DB_NAME}/-/libfec`;
// datasette-paper's base path (only the `paper` shot touches it).
export const PAPER = `${BASE}/-/paper`;

// Fixed --secret for the throwaway server; stable so runs stay reproducible.
// Fixed signing secret — lets us mint a signed actor cookie for the paper doc
// owner. Must match datasette's --secret. NOT a real secret.
export const SECRET = 'screenshots-secret-not-for-prod';

const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots
Expand All @@ -22,14 +25,28 @@ const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots
export const FIXTURE_DB = resolve(HERE, '../shot-data/libfec.db');
export const DATA_DIR = '/tmp/datasette-libfec-shots-data'; // stem == DB_NAME
export const DATA_DB = `${DATA_DIR}/${DB_NAME}.db`;
// datasette-paper stores docs in the internal DB; a throwaway one, wiped each
// run, keeps the seeded doc's id deterministic. Only used for the `paper` shot.
export const INTERNAL_DB = `${DATA_DIR}/internal.db`;
// Throwaway plugin dir: friendly display name for the paper doc author.
export const PLUGINS_DIR = resolve(HERE, '../shot-plugins');
export const OUT = resolve(HERE, '../../../docs/screenshots');

export const VIEWPORT = { width: 1200, height: 900 };
// Cropped to the paper editor chrome + heading + the two stacked embed cards.
export const VIEWPORT_TALL = { width: 1200, height: 800 };

// The paper doc owner — create seeds an owner acl grant to this actor, and
// shot-plugins/profiles.py maps it to a friendly display name.
export const PAPER_ACTOR = 'alice';

// --- Concrete demo entities (from the fixture: 2024 Ohio Senate + big PACs) ---
export const SHERROD_BROWN = 'S6OH00163'; // candidate id
export const BERNIE_MORENO = 'S4OH00192'; // his 2024 Senate opponent
export const FAIRSHAKE = 'C00835959'; // crypto super PAC committee id
export const BROWN_YE_FILING = '1870297'; // Friends of Sherrod Brown, F3 YE 2024
// Candidates embedded as cards in the seeded paper doc.
export const PAPER_EMBED_CANDIDATES = [SHERROD_BROWN, BERNIE_MORENO];

// Absolute path of a shot's output PNG.
export const out = (name) => resolve(OUT, `${name}.png`);
29 changes: 29 additions & 0 deletions frontend/scripts/shots/cookie.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Signed ds_actor cookie for an actor id. itsdangerous has no maintained Node
// port, so shell out to the same one-liner datasette signs with. Cached per id.
import { execFileSync } from 'node:child_process';
import { SECRET } from './config.mjs';

const _signed = new Map();

export function signActorCookie(actorId) {
let v = _signed.get(actorId);
if (!v) {
const out = execFileSync(
'uv',
[
'run',
'--prerelease=allow',
'python',
'-c',
'import sys, json; from itsdangerous import URLSafeSerializer; ' +
'print(URLSafeSerializer(sys.argv[1]).dumps(json.loads(sys.argv[2]), salt="actor"))',
SECRET,
JSON.stringify({ a: { id: actorId } }),
],
{ encoding: 'utf-8' }
);
v = out.trim();
_signed.set(actorId, v);
}
return v;
}
35 changes: 21 additions & 14 deletions frontend/scripts/shots/defineShot.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
// defineShot turns a declarative descriptor into the `async (browser, ids) =>
// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context →
// navigate → wait for readiness → interact → freeze → capture → close.
// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context
// (with the right actor + viewport) → navigate → wait for readiness → interact →
// freeze → capture → close.
//
// Descriptor fields:
// name (required) — output PNG base name; MUST equal the shot's file name
// (asserted by the runner).
// order — run sequence (ascending, then by name). libfec shots are
// independent, so this just fixes a stable order.
// url (required) — (ids) => string, the page to screenshot.
// viewport — context viewport (default VIEWPORT).
// ready — a selector waited on after navigation (the common case).
// prepare — async (page, {ids}) for custom waits / interaction.
// capture — async (page, file, {ids}); default = full-page screenshot.
// freeze — default true; runs freezeVolatile before capture.
// name (required) — output PNG base name; MUST equal the shot's file name
// (asserted by the runner).
// order — run sequence (ascending, then by name). libfec shots are
// independent, so this just fixes a stable order.
// url (required) — (ids) => string, the page to screenshot.
// actor — actor id to sign a ds_actor cookie for (default null = anon).
// viewport — context viewport (default VIEWPORT).
// waitUntil — page.goto wait condition (default "networkidle"; the paper shot
// uses "domcontentloaded", as its editor holds a live SSE
// connection that never goes idle).
// ready — a selector waited on after navigation (the common case).
// prepare — async (page, {ids}) for custom waits / interaction.
// capture — async (page, file, {ids}); default = full-page screenshot.
// freeze — default true; runs freezeVolatile before capture.
import { makeContext, freezeVolatile } from './helpers.mjs';
import { VIEWPORT, out } from './config.mjs';

Expand All @@ -21,7 +26,9 @@ export function defineShot(desc) {
name,
order = 0,
url,
actor = null,
viewport = VIEWPORT,
waitUntil = 'networkidle',
ready,
prepare,
capture,
Expand All @@ -33,10 +40,10 @@ export function defineShot(desc) {
}

const run = async (browser, ids) => {
const ctx = await makeContext(browser, { viewport });
const ctx = await makeContext(browser, { actor, viewport });
try {
const page = await ctx.newPage();
await page.goto(url(ids), { waitUntil: 'networkidle' });
await page.goto(url(ids), { waitUntil });
if (ready) await page.locator(ready).first().waitFor({ timeout: 15_000 });
if (prepare) await prepare(page, { ids });
if (freeze) await freezeVolatile(page);
Expand Down
31 changes: 31 additions & 0 deletions frontend/scripts/shots/defs/paper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineShot } from '../defineShot.mjs';
import { PAPER, PAPER_ACTOR, PAPER_EMBED_CANDIDATES, VIEWPORT_TALL } from '../config.mjs';

// An FEC candidate embedded as a live card inside a datasette-paper document
// (the paper embed provider — datasette_libfec/paper.py + src/paper_embed.ts).
// Needs `--extra paper` + the doc seeded by shots/seed.mjs.
//
// The editor holds a live SSE connection that never goes network-idle, so wait
// on domcontentloaded + explicit readiness: the editor surface, then both cards
// resolved (each card's mount() swaps its "Loading candidate…" placeholder for
// a card carrying a "Candidate ID" row).
export default defineShot({
name: 'paper',
order: 10,
actor: PAPER_ACTOR,
viewport: VIEWPORT_TALL,
waitUntil: 'domcontentloaded',
url: (ids) => `${PAPER}/doc/${ids.paperDocId}`,
prepare: async (page) => {
await page.locator('.ProseMirror').first().waitFor({ timeout: 15_000 });
await page.waitForFunction(
(n) =>
[...document.querySelectorAll('span')].filter((s) => s.textContent === 'Candidate ID')
.length >= n,
PAPER_EMBED_CANDIDATES.length,
{ timeout: 15_000 }
);
// Let the cards' final layout settle before capture.
await new Promise((r) => setTimeout(r, 300));
},
});
19 changes: 13 additions & 6 deletions frontend/scripts/shots/helpers.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Page-stabilization + capture helpers shared by every shot.
import { VIEWPORT } from './config.mjs';
import { BASE, VIEWPORT } from './config.mjs';
import { signActorCookie } from './cookie.mjs';

// Injected on the context (survives navigation) so re-runs produce no binary
// diff: kill the caret, transitions and animations, and hide the debug bar.
Expand All @@ -10,17 +11,20 @@ export const STABILITY_CSS = `*, *::before, *::after {
}
#datasette-debug-bar { display: none !important; }`;

// Rewrite any moving text to fixed strings just before each capture. libfec's
// pages carry none, so this only removes the dev debug bar if present.
// Rewrite the moving text to fixed strings just before each capture. libfec's
// own pages carry none, but the paper editor header shows a relative "edited …"
// time — pin it so the paper shot doesn't diff on the clock.
export async function freezeVolatile(page) {
await page.evaluate(() => {
document.getElementById('datasette-debug-bar')?.remove();
document.querySelectorAll('.updated-at').forEach((el) => (el.textContent = 'edited just now'));
});
}

// New context: viewport + retina, with the stability stylesheet injected on
// every navigation.
export async function makeContext(browser, { viewport = VIEWPORT } = {}) {
// New context: viewport + retina, the stability stylesheet on every navigation,
// and (for the paper shot) a signed owner cookie. libfec's own pages browse
// anonymously — only `actor` shots get a cookie.
export async function makeContext(browser, { actor = null, viewport = VIEWPORT } = {}) {
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 2 });
await ctx.addInitScript((css) => {
const inject = () => {
Expand All @@ -33,5 +37,8 @@ export async function makeContext(browser, { viewport = VIEWPORT } = {}) {
inject();
document.addEventListener('DOMContentLoaded', inject);
}, STABILITY_CSS);
if (actor) {
await ctx.addCookies([{ name: 'ds_actor', value: signActorCookie(actor), url: BASE }]);
}
return ctx;
}
Loading
Loading