create-trokky 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -22,7 +22,8 @@ export function withDefaults(input) {
22
22
  images: pick('images'),
23
23
  parts: pick('parts'),
24
24
  content: pick('content'),
25
- trokkyVersion: input.trokkyVersion ?? '^3.4.1',
25
+ // 3.6: TrokkyCore takes `env`, which the Workers entry passes
26
+ trokkyVersion: input.trokkyVersion ?? '^3.6.0',
26
27
  };
27
28
  }
28
29
  export const hasStudio = (config) => config.parts !== 'api';
package/dist/emit.js CHANGED
@@ -196,6 +196,8 @@ export interface TrokkyEnv {
196
196
  MEDIA: R2Bucket
197
197
  ${config.images === 'cloudflare-images' ? ' IMAGES: ImagesBinding\n' : ''}${hasStudio(config) || config.content !== 'blank' ? ' ASSETS: { fetch(request: Request): Promise<Response> }\n' : ''} TROKKY_JWT_SECRET?: string
198
198
  TROKKY_CLAIM_SECRET?: string
199
+ /** 'development' in .dev.vars for \`wrangler dev\`; unset when deployed */
200
+ TROKKY_ENV?: string
199
201
  }
200
202
 
201
203
  export const API_PATH = '/api'
@@ -226,6 +228,9 @@ ${mediaConfig} },
226
228
  {
227
229
  jwtSecret: env.TROKKY_JWT_SECRET,
228
230
  claimSecret: env.TROKKY_CLAIM_SECRET,
231
+ // A Worker has no NODE_ENV: say production unless .dev.vars says otherwise, so a deploy
232
+ // without a real signing key refuses to start instead of signing with a random one
233
+ env: env.TROKKY_ENV === 'development' ? 'development' : 'production',
229
234
  },
230
235
  )
231
236
  // Creates the image processor; nothing serves images without it.
@@ -451,6 +456,9 @@ export function envExample(config) {
451
456
  for (const secret of secrets) {
452
457
  lines.push(...comment(secret.why), `${secret.name}=`, '');
453
458
  }
459
+ if (edge) {
460
+ lines.push(...comment('Local development only: keep this line in .dev.vars and never set it on the deployed'), ...comment('Worker, which must run with the production checks.'), 'TROKKY_ENV=development', '');
461
+ }
454
462
  if (config.data === 'postgres-data')
455
463
  lines.push('# Postgres connection string.', 'DATABASE_URL=', '');
456
464
  // Only where something is actually written to disk. A Worker has none, and with s3-media the
@@ -3,4 +3,4 @@
3
3
  *
4
4
  * Every text file under files/, so the generator can run without a filesystem.
5
5
  */
6
- export const text = { "base/gitignore": "node_modules/\ndist/\n.astro/\n.wrangler/\n.dev.vars\n# Copied from @trokky/studio at build time; never hand-edited.\npublic/studio/\n", "content/magazine/public/seed/README.txt": "Sample images and documents used only by the first-boot seed.\nPhotos: Lorem Picsum (picsum.photos), which serves Unsplash photos under the Unsplash license.\nPDFs: generated with Typst from a few paragraphs of placeholder text.\nDelete this folder once the sample content is gone; nothing else references it.\n", "content/magazine/src/trokky/schemas.ts": "/**\n * The content model: a magazine.\n *\n * Chosen because it exercises what a newcomer evaluates a CMS on — references both ways,\n * rich text, media in two kinds (images and PDFs), drafts, slugs, a singleton — in a shape\n * everyone already understands. Rename, add and remove freely; the Studio follows the schema.\n */\n\nexport const settingsSchema = {\n name: 'settings',\n title: 'Site settings',\n type: 'document',\n singleton: true,\n description: 'Name, navigation and footer for the whole site',\n fields: {\n siteName: { type: 'string', title: 'Site name', required: true, default: 'The Magazine' },\n tagline: { type: 'string', title: 'Tagline', description: 'One line under the name' },\n tagline_fr: { type: 'string', title: 'Tagline (français)' },\n logo: { type: 'media', title: 'Logo', options: { accept: 'image/*' } },\n navigation: {\n type: 'array',\n title: 'Navigation',\n description: 'Links in the header, in order',\n of: {\n type: 'object',\n fields: {\n label: { type: 'string', title: 'Label', required: true },\n href: { type: 'string', title: 'Path or URL', required: true }\n }\n }\n },\n footer: { type: 'text', title: 'Footer text', options: { rows: 2 } },\n footer_fr: { type: 'text', title: 'Footer text (français)', options: { rows: 2 } }\n }\n} as const\n\nexport const authorSchema = {\n name: 'author',\n title: 'Author',\n type: 'document',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },\n role: { type: 'string', title: 'Role', description: 'Editor, contributor, photographer…' },\n bio: { type: 'text', title: 'Bio', options: { rows: 4 } },\n avatar: { type: 'media', title: 'Photo', options: { accept: 'image/*' } }\n }\n} as const\n\nexport const categorySchema = {\n name: 'category',\n title: 'Category',\n type: 'document',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },\n description: { type: 'text', title: 'Description', options: { rows: 2 } }\n }\n} as const\n\nexport const articleSchema = {\n name: 'article',\n title: 'Article',\n type: 'document',\n description: 'A story. Save as draft, publish when ready.',\n fields: {\n title: { type: 'string', title: 'Title', required: true, validation: { maxLength: 150 } },\n title_fr: { type: 'string', title: 'Title (français)', validation: { maxLength: 150 } },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n excerpt: { type: 'text', title: 'Excerpt', description: 'Shown in lists', options: { rows: 3 }, validation: { maxLength: 300 } },\n cover: { type: 'media', title: 'Cover image', options: { accept: 'image/*' } },\n author: { type: 'reference', title: 'Author', to: 'author' },\n category: { type: 'reference', title: 'Category', to: 'category' },\n featured: { type: 'boolean', title: 'Featured on the home page', default: false },\n publishedAt: { type: 'date', title: 'Published on', options: { includeTime: true }, default: 'now' },\n body: {\n type: 'richtext',\n title: 'Body',\n options: { headingLevels: [2, 3], enableFullscreen: true, minHeight: '320px', showStats: true }\n },\n related: {\n type: 'array',\n title: 'Related articles',\n of: { type: 'reference', to: 'article' },\n validation: { maxItems: 3 }\n }\n }\n} as const\n\nexport const documentSchema = {\n name: 'document',\n title: 'Document',\n type: 'document',\n description: 'A downloadable file — a report, a form, a PDF',\n fields: {\n title: { type: 'string', title: 'Title', required: true },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n summary: { type: 'text', title: 'Summary', options: { rows: 2 } },\n file: { type: 'media', title: 'File', required: true, options: { accept: 'application/pdf' } },\n publishedAt: { type: 'date', title: 'Published on', default: 'now' }\n }\n} as const\n\nexport const pageSchema = {\n name: 'page',\n title: 'Page',\n type: 'document',\n description: 'A standalone page such as About or Contact',\n fields: {\n title: { type: 'string', title: 'Title', required: true },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n body: { type: 'richtext', title: 'Body', options: { headingLevels: [2, 3] } }\n }\n} as const\n\nexport const schemas = [settingsSchema, authorSchema, categorySchema, articleSchema, documentSchema, pageSchema]\n", "content/magazine/src/trokky/seed.ts": "/**\n * Sample content, created the moment the instance is claimed.\n *\n * An empty CMS shows an empty site, and the first minute decides whether a newcomer keeps\n * going. So the new owner's first sight of both the site and the Studio is a working magazine —\n * illustrated, with authors, categories, downloadable documents and pages — that they can edit\n * or delete. Every document id is fixed, so seeding twice is harmless; media is only uploaded\n * when there are no articles yet.\n *\n * The images and PDFs ship with the project and go through the normal upload pipeline, so\n * thumbnails are made exactly as they would be for a real upload.\n *\n * How those files are read differs by runtime — a Worker has an assets binding, a Node process\n * has a disk — so the caller supplies a reader rather than this file guessing.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/** Read one file from `public/seed/`, however this runtime reaches it. */\nexport type SeedAssetReader = (path: string) => Promise<ArrayBuffer>\n\nconst P = { _status: 'published' } as const\n\n/** The value the Studio's media field reads and writes. */\nconst media = (id: string, alt?: string) => ({ _type: 'media', asset: { _type: 'mediaAsset', _ref: id }, ...(alt ? { alt } : {}) })\n\nconst daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString()\nconst paras = (...ps: string[]) => ps.map(p => `<p>${p}</p>`).join('')\n\nexport async function seedSampleContent(core: TrokkyCore, readAsset: SeedAssetReader): Promise<void> {\n if ((await core.listDocuments('article', { limit: 1 })).length > 0) return\n\n const upload = async (path: string, filename: string, type: string): Promise<string> => {\n const file = new File([await readAsset(path)], filename, { type })\n return (await core.uploadMedia(file)).id\n }\n\n const [c1, c2, c3, c4, c5, c6] = await Promise.all(\n [1, 2, 3, 4, 5, 6].map(n => upload(`cover-${n}.jpg`, `cover-${n}.jpg`, 'image/jpeg'))\n )\n const [a1, a2, a3] = await Promise.all(\n [1, 2, 3].map(n => upload(`avatar-${n}.jpg`, `portrait-${n}.jpg`, 'image/jpeg'))\n )\n const [pdfReport, pdfGuide] = await Promise.all([\n upload('annual-report-2026.pdf', 'annual-report-2026.pdf', 'application/pdf'),\n upload('editorial-guidelines.pdf', 'editorial-guidelines.pdf', 'application/pdf')\n ])\n\n await core.saveDocument('settings', {\n id: 'settings', ...P,\n siteName: 'The Magazine',\n tagline: 'Stories worth the read',\n tagline_fr: 'Des histoires qui valent le détour',\n navigation: [\n { label: 'Articles', href: '/articles' },\n { label: 'Documents', href: '/documents' },\n { label: 'About', href: '/about' },\n { label: 'Contact', href: '/contact' }\n ],\n footer: 'Published with Trokky. Everything you see here is editable in Studio.',\n footer_fr: 'Publié avec Trokky. Tout ce que vous voyez se modifie dans Studio.'\n })\n\n await core.saveDocument('author', { id: 'author-ama', ...P, name: 'Ama Kodjo', slug: 'ama-kodjo', role: 'Editor', bio: 'Runs the magazine and writes the long pieces. Previously ten years in print.', avatar: media(a1, 'Ama Kodjo') })\n await core.saveDocument('author', { id: 'author-kossi', ...P, name: 'Kossi Mensah', slug: 'kossi-mensah', role: 'Contributor', bio: 'Reports from the field, mostly on foot.', avatar: media(a2, 'Kossi Mensah') })\n await core.saveDocument('author', { id: 'author-efua', ...P, name: 'Efua Asante', slug: 'efua-asante', role: 'Photographer', bio: 'Shoots the covers and occasionally writes about how.', avatar: media(a3, 'Efua Asante') })\n\n await core.saveDocument('category', { id: 'category-culture', ...P, name: 'Culture', slug: 'culture', description: 'Books, music, and the people behind them.' })\n await core.saveDocument('category', { id: 'category-tech', ...P, name: 'Technology', slug: 'technology', description: 'What is changing, and what it changes.' })\n await core.saveDocument('category', { id: 'category-city', ...P, name: 'City', slug: 'city', description: 'Streets, markets, and the life between buildings.' })\n\n await core.saveDocument('article', {\n id: 'article-welcome', ...P,\n title: 'Welcome to your magazine', title_fr: 'Bienvenue dans votre magazine',\n slug: 'welcome-to-your-magazine',\n excerpt: 'This site is yours. Here is how it fits together, and what to change first.',\n cover: media(c1, 'A desk with a notebook and coffee'),\n author: 'author-ama', category: 'category-tech', featured: true, publishedAt: daysAgo(0),\n body: paras(\n 'Everything on this site comes from the Studio at <a href=\"/studio\">/studio</a>. This article, the authors, the categories, the navigation in the header — all of it is content you can edit.',\n 'Start with <strong>Site settings</strong>: give the magazine its real name. Then open this article and change the title. Refresh the home page. That is the whole loop.',\n 'Articles can be drafts. A draft is saved but not shown here; publish it when it is ready. The <em>featured</em> switch decides what leads the home page.'\n ),\n related: ['article-references', 'article-media']\n })\n await core.saveDocument('article', {\n id: 'article-references', ...P,\n title: 'Authors, categories, and how references work', slug: 'authors-categories-and-references',\n excerpt: 'An article points at an author and a category. Change the author once and every article follows.',\n cover: media(c2), author: 'author-kossi', category: 'category-tech', featured: false, publishedAt: daysAgo(1),\n body: paras(\n 'This article references <strong>Kossi Mensah</strong> as its author. Open the Authors list, rename him, and come back: the byline changed, because the article stores a reference, not a copy.',\n 'Categories work the same way, and so do the related articles at the bottom of this page.'\n ),\n related: ['article-welcome']\n })\n await core.saveDocument('article', {\n id: 'article-media', ...P,\n title: 'Images and documents', slug: 'images-and-documents',\n excerpt: 'Every cover on this site was uploaded once; the thumbnails were made automatically.',\n cover: media(c3), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(2),\n body: paras(\n 'The cover above is an original upload. The smaller version in the article list is a thumbnail generated at upload time — you never resize anything by hand.',\n 'The <a href=\"/documents\">Documents</a> section holds files rather than stories: reports, forms, anything people download.'\n ),\n related: ['article-welcome']\n })\n await core.saveDocument('article', {\n id: 'article-market', ...P,\n title: 'Saturday at the market, before the heat', slug: 'saturday-at-the-market',\n excerpt: 'Six in the morning is when the market belongs to the people who run it.',\n cover: media(c4), author: 'author-kossi', category: 'category-city', featured: false, publishedAt: daysAgo(4),\n body: paras(\n 'By nine the aisles are full and the prices are set. Two hours earlier they are still being argued about, crate by crate, between people who have known each other for years.',\n 'This piece is placeholder text — replace it with your own reporting. The structure (a title, an excerpt, a cover, a body) is what stays.'\n )\n })\n await core.saveDocument('article', {\n id: 'article-drafts', ...P,\n title: 'Drafts, publishing, and what readers see', slug: 'drafts-and-publishing',\n excerpt: 'Save without publishing, publish when ready, unpublish without deleting.',\n cover: media(c5), author: 'author-ama', category: 'category-tech', featured: false, publishedAt: daysAgo(6),\n body: paras(\n 'Open this article in Studio and set it back to draft. Refresh this page: it is gone from the site, but nothing was deleted. Publish it again and it returns.',\n 'The site only ever reads published content. Drafts are yours alone.'\n )\n })\n await core.saveDocument('article', {\n id: 'article-listening', ...P,\n title: 'What the city sounds like at night', slug: 'what-the-city-sounds-like-at-night',\n excerpt: 'A photographer on the difference between looking and listening.',\n cover: media(c6), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(9),\n body: paras(\n 'Placeholder text for a longer feature. The photographer who shot the covers on this site talks about working after dark, when the light is bad and the sound is better.',\n 'Use it as a shape for your own longer pieces: a strong first line, a cover that earns its place, and section headings when it runs past a screen.'\n )\n })\n\n await core.saveDocument('document', { id: 'document-report', ...P, title: 'Annual Report 2026', slug: 'annual-report-2026', summary: 'The year in numbers, and what they mean.', file: media(pdfReport), publishedAt: daysAgo(3) })\n await core.saveDocument('document', { id: 'document-guidelines', ...P, title: 'Editorial guidelines', slug: 'editorial-guidelines', summary: 'How we write, and what we will not publish.', file: media(pdfGuide), publishedAt: daysAgo(12) })\n\n await core.saveDocument('page', { id: 'page-about', ...P, title: 'About', slug: 'about', body: paras('This magazine runs on <a href=\"https://trokky.dev\">Trokky</a>, a self-hosted CMS, on Cloudflare Workers. The content is in a D1 database and the files in an R2 bucket — both on your own account.', 'Replace this page with who you are.') })\n await core.saveDocument('page', { id: 'page-contact', ...P, title: 'Contact', slug: 'contact', body: paras('Put an email address, a form, or a map here. This is an ordinary page: edit it under <strong>Pages</strong> in Studio.') })\n}\n", "content/magazine/src/trokky/structure.ts": "/**\n * How content is arranged in the Studio sidebar.\n *\n * The schema decides what a singleton is; this only decides where things appear and which\n * document id the singleton uses.\n */\nexport const structure = {\n title: 'Magazine',\n items: [\n { type: 'documentList', title: 'Articles', schemaType: 'article', icon: 'newspaper' },\n { type: 'documentList', title: 'Documents', schemaType: 'document', icon: 'document-text' },\n { type: 'divider' },\n { type: 'documentList', title: 'Authors', schemaType: 'author', icon: 'user-group' },\n { type: 'documentList', title: 'Categories', schemaType: 'category', icon: 'tag' },\n { type: 'documentList', title: 'Pages', schemaType: 'page', icon: 'document' },\n { type: 'divider' },\n { type: 'singleton', title: 'Site settings', schemaType: 'settings', documentId: 'settings', icon: 'cog-6-tooth' }\n ]\n}\n", "site/magazine/src/layouts/Base.astro": "---\nimport { mediaUrl } from '../trokky/site'\ninterface Props { title?: string; settings: Record<string, any> | null; description?: string }\nconst { title, settings, description } = Astro.props\nconst siteName = settings?.siteName ?? 'The Magazine'\nconst nav: { label: string; href: string }[] = settings?.navigation ?? []\nconst logo = mediaUrl(settings?.logo, 'thumbnail')\n---\n<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{title ? `${title} — ${siteName}` : siteName}</title>\n {description && <meta name=\"description\" content={description} />}\n <style is:global>\n :root { --ink: #1a1a1a; --muted: #666; --line: #e5e5e5; --accent: #2b6cb0; --bg: #fff; }\n @media (prefers-color-scheme: dark) { :root { --ink: #ececec; --muted: #9a9a9a; --line: #2a2a2a; --accent: #7ab0f5; --bg: #111; } }\n * { box-sizing: border-box }\n body { margin: 0; font: 17px/1.6 Georgia, \"Times New Roman\", serif; color: var(--ink); background: var(--bg) }\n a { color: var(--accent); text-decoration: none } a:hover { text-decoration: underline }\n .wrap { max-width: 860px; margin: 0 auto; padding: 0 20px }\n header { border-bottom: 1px solid var(--line); padding: 20px 0; font-family: system-ui, sans-serif }\n header .wrap { display: flex; align-items: center; gap: 24px; flex-wrap: wrap }\n header .brand { font-weight: 700; font-size: 20px; color: var(--ink); display: flex; align-items: center; gap: 10px }\n header .brand img { height: 32px; width: auto }\n header nav { display: flex; gap: 18px; margin-left: auto } header nav a { color: var(--ink) }\n header .tag { width: 100%; color: var(--muted); font-size: 14px; margin: 0 }\n main { padding: 36px 0 } h1 { font-size: 2.2rem; line-height: 1.15; margin: 0 0 8px }\n .meta { color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; margin-bottom: 24px }\n .lead { font-size: 1.15rem; color: var(--muted) }\n .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 28px; list-style: none; padding: 0; margin: 0 }\n .card img { width: 100%; aspect-ratio: 3/2; object-fit: cover; border-radius: 6px; background: var(--line) }\n .card a { color: var(--ink) } .card a:hover { text-decoration: none } .card a:hover h3 { color: var(--accent) }\n .card h3 { margin: 12px 0 6px; font-size: 1.2rem; line-height: 1.3; font-family: system-ui, sans-serif } .card p { margin: 0; color: var(--muted); font-size: .95rem }\n h1 a { color: var(--ink) } h1 a:hover { color: var(--accent); text-decoration: none }\n h2.section { font-family: system-ui, sans-serif; font-size: 1.1rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin: 48px 0 20px }\n .cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: 8px; margin: 0 0 24px }\n .body :is(h2, h3) { font-family: system-ui, sans-serif; margin-top: 1.8em } .body img { max-width: 100% }\n .related { border-top: 1px solid var(--line); margin-top: 48px; padding-top: 24px }\n footer { border-top: 1px solid var(--line); color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; padding: 24px 0 40px }\n .studio { position: fixed; right: 16px; bottom: 16px; font: 13px system-ui, sans-serif; background: var(--accent); color: #fff; padding: 8px 12px; border-radius: 999px; opacity: .9 }\n </style>\n </head>\n <body>\n <header><div class=\"wrap\">\n <a class=\"brand\" href=\"/\">{logo && <img src={logo} alt=\"\" />}{siteName}</a>\n <nav>{nav.map(item => <a href={item.href}>{item.label}</a>)}</nav>\n {settings?.tagline && <p class=\"tag\">{settings.tagline}</p>}\n </div></header>\n <main><div class=\"wrap\"><slot /></div></main>\n <footer><div class=\"wrap\">{settings?.footer ?? 'Published with Trokky.'}</div></footer>\n <a class=\"studio\" href=\"/studio\">Edit in Studio →</a>\n </body>\n</html>\n", "site/magazine/src/pages/[slug].astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nconst s = await load(Astro)\nconst page = await s.page(Astro.params.slug!)\nif (!page) return new Response(null, { status: 404 })\n---\n<Base title={page.title} settings={s.settings}>\n <h1>{page.title}</h1>\n <div class=\"body\" set:html={page.body ?? ''} />\n</Base>\n", "site/magazine/src/pages/articles/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst article = await s.article(Astro.params.slug!)\nif (!article) return new Response(null, { status: 404 })\nconst author = article.author ? await s.author(article.author) : null\nconst category = article.category ? await s.category(article.category) : null\nconst related = Array.isArray(article.related) ? await s.articlesIn(article.related) : []\n---\n<Base title={article.title} settings={s.settings} description={article.excerpt}>\n <article>\n {mediaUrl(article.cover, 'large') && <img class=\"cover\" src={mediaUrl(article.cover, 'large')} alt=\"\" />}\n <h1>{article.title}</h1>\n <p class=\"meta\">\n {author && <a href={`/authors/${author.slug}`}>{author.name}</a>}{author && ' · '}\n {category && <a href={`/categories/${category.slug}`}>{category.name}</a>}{category && ' · '}\n {formatDate(article.publishedAt)}\n </p>\n {article.excerpt && <p class=\"lead\">{article.excerpt}</p>}\n <div class=\"body\" set:html={article.body ?? ''} />\n </article>\n {related.length > 0 && (\n <section class=\"related\">\n <h2 class=\"section\">Related</h2>\n <ul class=\"grid\">{related.map(r => <li class=\"card\"><a href={`/articles/${r.slug}`}><h3>{r.title}</h3></a><p>{r.excerpt}</p></li>)}</ul>\n </section>\n )}\n</Base>\n", "site/magazine/src/pages/articles/index.astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst articles = await s.articles()\n---\n<Base title=\"Articles\" settings={s.settings}>\n <h1>Articles</h1>\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\">\n <a href={`/articles/${a.slug}`}>\n {mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}\n <h3>{a.title}</h3>\n </a>\n <p>{formatDate(a.publishedAt)} — {a.excerpt}</p>\n </li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/authors/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst item = await s.authorBySlug(Astro.params.slug!)\nif (!item) return new Response(null, { status: 404 })\nconst articles = await s.articlesBy('author', item.id)\n---\n<Base title={item.name} settings={s.settings}>\n <h1>{item.name}</h1>\n {item.role && <p class=\"meta\">{item.role}</p>}\n {(item.bio ?? item.description) && <p class=\"lead\">{item.bio ?? item.description}</p>}\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\"><a href={`/articles/${a.slug}`}>{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}<h3>{a.title}</h3></a><p>{formatDate(a.publishedAt)}</p></li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/categories/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst item = await s.categoryBySlug(Astro.params.slug!)\nif (!item) return new Response(null, { status: 404 })\nconst articles = await s.articlesBy('category', item.id)\n---\n<Base title={item.name} settings={s.settings}>\n <h1>{item.name}</h1>\n {item.role && <p class=\"meta\">{item.role}</p>}\n {(item.bio ?? item.description) && <p class=\"lead\">{item.bio ?? item.description}</p>}\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\"><a href={`/articles/${a.slug}`}>{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}<h3>{a.title}</h3></a><p>{formatDate(a.publishedAt)}</p></li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/documents.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { mediaUrl, formatDate } from '../trokky/site'\nconst s = await load(Astro)\nconst docs = await s.documents()\n---\n<Base title=\"Documents\" settings={s.settings}>\n <h1>Documents</h1>\n {docs.length === 0 && <p class=\"lead\">No documents yet. Upload a PDF under <a href=\"/studio\">Documents</a> in Studio.</p>}\n <ul style=\"list-style:none;padding:0\">\n {docs.map(d => (\n <li style=\"padding:16px 0;border-bottom:1px solid var(--line)\">\n <h3 style=\"margin:0 0 4px\"><a href={mediaUrl(d.file) ?? '#'}>{d.title}</a></h3>\n <p class=\"meta\" style=\"margin:0\">{formatDate(d.publishedAt)}{d.summary && ` — ${d.summary}`}</p>\n </li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/index.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { mediaUrl, formatDate } from '../trokky/site'\nconst s = await load(Astro)\nconst featured = await s.featured()\nconst latest = (await s.articles(9)).filter(a => a.id !== featured?.id)\nconst authorOf = async (a: any) => a.author ? await s.author(a.author) : null\nconst featuredAuthor = featured ? await authorOf(featured) : null\n---\n<Base settings={s.settings} description={s.settings?.tagline}>\n {featured && (\n <article>\n {mediaUrl(featured.cover, 'large') && <img class=\"cover\" src={mediaUrl(featured.cover, 'large')} alt=\"\" />}\n <h1><a href={`/articles/${featured.slug}`}>{featured.title}</a></h1>\n <p class=\"meta\">{featuredAuthor?.name}{featuredAuthor && ' · '}{formatDate(featured.publishedAt)}</p>\n <p class=\"lead\">{featured.excerpt}</p>\n </article>\n )}\n {latest.length > 0 && <h2 class=\"section\">Latest</h2>}\n <ul class=\"grid\">\n {latest.map(a => (\n <li class=\"card\">\n <a href={`/articles/${a.slug}`}>\n {mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}\n <h3>{a.title}</h3>\n </a>\n <p>{a.excerpt}</p>\n </li>\n ))}\n </ul>\n {!featured && latest.length === 0 && (\n <p class=\"lead\">Nothing published yet. Open <a href=\"/studio\">Studio</a> to write the first article.</p>\n )}\n</Base>\n", "site/magazine/src/trokky/site.ts": "/**\n * What the pages read.\n *\n * They call the core directly rather than the HTTP API: the site is server-side code in the same\n * process as Trokky, so it has the same trust and needs no token, and a render never leaves the\n * process. Only published content is ever returned from here.\n *\n * Nothing in this file is runtime-specific — that is the point. It is byte-identical on Workers\n * and on Node; only `page.ts` differs, because only the way a page gets hold of the core differs.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/**\n * Where the API is mounted, for the media URLs the browser will fetch. Declared here rather than\n * imported, because the Workers entry that also declares it does not exist on Node.\n */\nconst API_PATH = '/api'\n\ntype Doc = Record<string, any> & { id: string; _status?: string }\n\nconst published = { filter: { _status: 'published' } }\n\nexport interface Site {\n settings: Doc | null\n articles(limit?: number): Promise<Doc[]>\n article(slug: string): Promise<Doc | null>\n featured(): Promise<Doc | null>\n author(id: string): Promise<Doc | null>\n authorBySlug(slug: string): Promise<Doc | null>\n category(id: string): Promise<Doc | null>\n categoryBySlug(slug: string): Promise<Doc | null>\n categories(): Promise<Doc[]>\n articlesBy(field: 'author' | 'category', id: string): Promise<Doc[]>\n articlesIn(ids: string[]): Promise<Doc[]>\n documents(): Promise<Doc[]>\n page(slug: string): Promise<Doc | null>\n}\n\nconst byDateDesc = (a: Doc, b: Doc) => String(b.publishedAt ?? '').localeCompare(String(a.publishedAt ?? ''))\n\nexport async function site(core: TrokkyCore): Promise<Site> {\n const settings = (await core.getDocument('settings', 'settings')) as Doc | null\n const list = async (collection: string, filter: Record<string, unknown> = {}) =>\n (await core.listDocuments(collection, { filter: { ...published.filter, ...filter } })) as Doc[]\n const one = async (collection: string, filter: Record<string, unknown>) => (await list(collection, filter))[0] ?? null\n\n return {\n settings,\n articles: async (limit) => (await list('article')).sort(byDateDesc).slice(0, limit),\n article: (slug) => one('article', { slug }),\n featured: async () => (await list('article', { featured: true })).sort(byDateDesc)[0] ?? null,\n author: async (id) => (await core.getDocument('author', id)) as Doc | null,\n authorBySlug: (slug) => one('author', { slug }),\n category: async (id) => (await core.getDocument('category', id)) as Doc | null,\n categoryBySlug: (slug) => one('category', { slug }),\n categories: () => list('category'),\n articlesBy: async (field, id) => (await list('article', { [field]: id })).sort(byDateDesc),\n articlesIn: async (ids) => (await Promise.all(ids.map(id => core.getDocument('article', id)))).filter((d): d is Doc => !!d && d._status === 'published'),\n documents: async () => (await list('document')).sort(byDateDesc),\n page: (slug) => one('page', { slug }),\n }\n}\n\n/**\n * A media field holds `{ asset: { _ref: <media id> }, alt?, caption? }` — the shape the Studio\n * reads and writes. A bare id is accepted too, for content written by hand.\n */\nexport function mediaId(value: unknown): string | null {\n if (typeof value === 'string') return value\n const ref = (value as { asset?: { _ref?: unknown } } | null)?.asset?._ref\n return typeof ref === 'string' ? ref : null\n}\n\nexport const mediaUrl = (value: unknown, variant?: 'thumbnail' | 'large'): string | null => {\n const id = mediaId(value)\n if (!id) return null\n return variant ? `${API_PATH}/media/${id}/variants/${variant}` : `${API_PATH}/media/${id}/file`\n}\n\nexport const formatDate = (value: unknown, locale = 'en'): string =>\n value ? new Date(String(value)).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }) : ''\n", "workers/scripts/copy-studio.mjs": "/**\n * Put the built Studio where the Worker serves it from.\n *\n * `@trokky/studio` ships its SPA in `dist/`. Astro copies `public/` into the build output, and\n * the Worker serves `/studio/*` from the assets binding — so Studio has to sit at\n * `public/studio/` before `astro build` runs. It is copied, never committed: `.gitignore`\n * excludes it so an upgrade of the package is the whole upgrade.\n */\nimport { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst root = join(dirname(fileURLToPath(import.meta.url)), '..')\n// ESM-only package: resolve its entry (dist/index.js) the ESM way and take the directory.\nconst studioDist = dirname(fileURLToPath(import.meta.resolve('@trokky/studio')))\nconst target = join(root, 'public', 'studio')\n\nif (!existsSync(join(studioDist, 'index.html'))) {\n console.error(`@trokky/studio has no built dist at ${studioDist}`)\n process.exit(1)\n}\n\nrmSync(target, { recursive: true, force: true })\nmkdirSync(dirname(target), { recursive: true })\n// Only what the browser needs: the document and the hashed assets. Not the server helpers.\ncpSync(join(studioDist, 'index.html'), join(target, 'index.html'))\ncpSync(join(studioDist, 'assets'), join(target, 'assets'), { recursive: true })\nconsole.log(`Studio copied to public/studio from ${studioDist}`)\n" };
6
+ export const text = { "base/gitignore": "node_modules/\ndist/\n.astro/\n.wrangler/\n.dev.vars\n# Copied from @trokky/studio at build time; never hand-edited.\npublic/studio/\n", "content/conference/src/trokky/schemas.ts": "/**\n * The content model: a conference.\n *\n * Two singletons (event, venue) setting the site's copy, four listed collections, and the\n * two many-to-many links a program is made of — sessions point at their track and their\n * speakers. Type, level and tier are plain string fields with dropdown lists, so editors\n * pick from a fixed set and the site can group on the value. The session document id is its\n * URL identity; there are no slug fields.\n */\n\nexport const eventSchema = {\n name: 'event',\n title: 'Event',\n type: 'document',\n singleton: true,\n description: 'What the whole site leads with: name, tagline, dates and navigation',\n fields: {\n name: { type: 'string', title: 'Event name', required: true },\n edition: { type: 'string', title: 'Edition', description: 'The year or edition, shown next to the name' },\n tagline: { type: 'string', title: 'Tagline', description: 'One line under the name' },\n description: { type: 'text', title: 'Description', options: { rows: 3 } },\n startDate: { type: 'date', title: 'First day' },\n endDate: { type: 'date', title: 'Last day' },\n city: { type: 'string', title: 'City' },\n venue: { type: 'string', title: 'Venue name', description: 'Shown in the header meta; the Venue document holds the detail' },\n stats: {\n type: 'array',\n title: 'Stat strip',\n description: 'The numbers under the hero, in order',\n of: {\n type: 'object',\n fields: {\n label: { type: 'string', title: 'Label', required: true },\n value: { type: 'string', title: 'Value', required: true }\n }\n }\n },\n ctaLabel: { type: 'string', title: 'Header button label' },\n ctaHref: { type: 'string', title: 'Header button path' },\n navigation: {\n type: 'array',\n title: 'Navigation',\n description: 'Links in the header, in order',\n of: {\n type: 'object',\n fields: {\n label: { type: 'string', title: 'Label', required: true },\n href: { type: 'string', title: 'Path or URL', required: true }\n }\n }\n }\n }\n} as const\n\nexport const venueSchema = {\n name: 'venue',\n title: 'Venue',\n type: 'document',\n singleton: true,\n description: 'Where the event is, how to get there, and what tickets cost',\n fields: {\n name: { type: 'string', title: 'Venue name', required: true },\n address: { type: 'string', title: 'Address' },\n about: { type: 'text', title: 'About the hall', options: { rows: 3 } },\n travel: {\n type: 'array',\n title: 'Getting there',\n of: {\n type: 'object',\n fields: {\n mode: { type: 'string', title: 'Mode', required: true, description: 'Plane, metro, bike…' },\n detail: { type: 'string', title: 'Detail' }\n }\n }\n },\n tickets: {\n type: 'array',\n title: 'Tickets',\n of: {\n type: 'object',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n price: { type: 'string', title: 'Price', required: true, description: 'Shown as written, e.g. €420' },\n note: { type: 'string', title: 'Note' },\n features: {\n type: 'array',\n title: 'Features',\n of: { type: 'string' }\n },\n featured: { type: 'boolean', title: 'Highlight', default: false }\n }\n }\n }\n }\n} as const\n\nexport const trackSchema = {\n name: 'track',\n title: 'Track',\n type: 'document',\n description: 'A room and a theme; the schedule grid uses one column per track',\n fields: {\n name: { type: 'string', title: 'Track name', required: true },\n room: { type: 'string', title: 'Room', required: true },\n color: { type: 'color', title: 'Track color', description: 'Hex color, e.g. #C8442B' },\n order: { type: 'number', title: 'Order', required: true, description: 'Column position in the schedule grid, starting at 1' }\n }\n} as const\n\nexport const speakerSchema = {\n name: 'speaker',\n title: 'Speaker',\n type: 'document',\n description: 'A person; their sessions are resolved from the session documents',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n role: { type: 'string', title: 'Role' },\n org: { type: 'string', title: 'Organization' },\n pronouns: { type: 'string', title: 'Pronouns' },\n bio: { type: 'text', title: 'Bio', options: { rows: 3 } },\n photo: { type: 'media', title: 'Photo', description: 'Optional; without one the site draws a monogram', options: { accept: 'image/*' } },\n city: { type: 'string', title: 'City' }\n }\n} as const\n\nexport const sessionSchema = {\n name: 'session',\n title: 'Session',\n type: 'document',\n description: 'One slot in the program; its id is its URL',\n fields: {\n title: { type: 'string', title: 'Title', required: true },\n type: {\n type: 'string',\n title: 'Type',\n required: true,\n options: { list: ['Keynote', 'Talk', 'Workshop', 'Panel'] }\n },\n day: { type: 'date', title: 'Day', required: true },\n start: { type: 'string', title: 'Starts at', required: true, description: 'HH:MM', validation: { maxLength: 5 } },\n end: { type: 'string', title: 'Ends at', required: true, description: 'HH:MM', validation: { maxLength: 5 } },\n track: { type: 'reference', title: 'Track', to: 'track', required: true },\n speakers: {\n type: 'array',\n title: 'Speakers',\n description: 'Who is in this session',\n of: { type: 'reference', to: 'speaker' }\n },\n abstract: { type: 'text', title: 'Abstract', options: { rows: 3 } },\n tags: { type: 'array', title: 'Tags', of: { type: 'string' } },\n level: {\n type: 'string',\n title: 'Level',\n options: { list: ['All', 'Intro', 'Intermediate', 'Advanced'] }\n }\n }\n} as const\n\nexport const sponsorSchema = {\n name: 'sponsor',\n title: 'Sponsor',\n type: 'document',\n description: 'A sponsor; the site groups them by tier, in that order',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n tier: {\n type: 'string',\n title: 'Tier',\n required: true,\n options: { list: ['platinum', 'gold', 'silver', 'partner'] },\n description: 'Lowercase; tiers are grouped in platinum, gold, silver, partner order'\n },\n blurb: { type: 'text', title: 'Blurb', options: { rows: 2 } },\n url: { type: 'url', title: 'Website' },\n logo: { type: 'media', title: 'Logo', description: 'Optional; without one the site draws a wordmark plate', options: { accept: 'image/*' } }\n }\n} as const\n\nexport const schemas = [eventSchema, venueSchema, trackSchema, speakerSchema, sessionSchema, sponsorSchema]", "content/conference/src/trokky/seed.ts": "/**\n * Sample content, created the moment the instance is claimed.\n *\n * An empty CMS shows an empty site, and the first minute decides whether a newcomer keeps\n * going. So the new owner’s first sight of both the site and the Studio is a whole working\n * conference — six tracks, eight speakers, a fourteen-slot program, sponsors, a venue with\n * tickets — that they can edit or delete. Every document id is fixed, so seeding twice is\n * harmless; sessions are only created when there are none. This template ships no media:\n * speakers and sponsors render generated monograms until real uploads replace them.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/** Read one file from `public/seed/`. The emitted hook always passes a reader; here it goes\n * unused, because this template’s seeds are text documents only. */\nexport type SeedAssetReader = (path: string) => Promise<ArrayBuffer>\n\nconst P = { _status: 'published' } as const\n\nexport async function seedSampleContent(core: TrokkyCore, readAsset: SeedAssetReader): Promise<void> {\n if ((await core.listDocuments('session', { limit: 1 })).length > 0) return\n\n await core.saveDocument('event', {\n id: 'event', ...P,\n name: 'Frameworks Live',\n edition: '2026',\n tagline: 'A field guide to building for the next decade of the web.',\n description: 'Three days, six tracks, fourteen sessions and eight speakers. Frameworks Live is the annual gathering for the people who build the platforms other developers build on — runtimes, editors, headless CMSs, and the frameworks that tie them together.',\n startDate: '2026-05-13',\n endDate: '2026-05-15',\n city: 'Lisbon',\n venue: 'Cordoaria Nacional',\n stats: [\n { label: 'Days', value: '3' },\n { label: 'Tracks', value: '6' },\n { label: 'Sessions', value: '14' },\n { label: 'Speakers', value: '8' }\n ],\n ctaLabel: 'Get your pass',\n ctaHref: '/venue#tickets',\n navigation: [\n { label: 'Schedule', href: '/schedule' },\n { label: 'Speakers', href: '/speakers' },\n { label: 'Sessions', href: '/sessions' },\n { label: 'Sponsors', href: '/sponsors' },\n { label: 'Venue', href: '/venue' }\n ]\n })\n\n await core.saveDocument('venue', {\n id: 'venue', ...P,\n name: 'Cordoaria Nacional',\n address: 'Av. da Índia, 2050-014 Lisboa, Portugal',\n about: 'A former 18th-century rope factory on the Lisbon waterfront, now a landmark hall for the city’s biggest gatherings. Forty-foot ceilings, exposed brick, and the Tagus river at the door.',\n travel: [\n { mode: 'Plane', detail: 'Lisbon Airport (LIS) is 7km / 20 min by metro. Direct shuttles run every 20 minutes during conference days.' },\n { mode: 'Metro', detail: 'Take the Linha Verde (green) to Cais do Sodré, then a 10-minute riverside walk west along Av. da Índia.' },\n { mode: 'Bike', detail: 'Secure bike parking on the riverside promenade. There are 40 racks under the western canopy.' },\n { mode: 'Car', detail: 'Limited paid parking on-site (€6/day). We strongly recommend transit — the riverside approach is walk-only during event hours.' }\n ],\n tickets: [\n {\n name: 'Conference',\n price: '€420',\n note: 'All three days, all tracks, recordings included.',\n features: ['3-day full access', '14 sessions, 6 tracks', 'Recordings within 48h', 'Welcome reception'],\n featured: false\n },\n {\n name: 'Workshop',\n price: '€180',\n note: 'Single workshop seat, limited capacity.',\n features: ['One 90-min workshop', 'Same-day track access', 'Lunch'],\n featured: false\n },\n {\n name: 'Community',\n price: '€0',\n note: 'For maintainers of qualifying open projects.',\n features: ['Full access, sponsored seats', 'Apply with your project', 'Travel fund available'],\n featured: true\n }\n ]\n })\n\n await core.saveDocument('track', { id: 'track-01', ...P, name: 'Runtimes', room: 'Hall A', color: '#C8442B', order: 1 })\n await core.saveDocument('track', { id: 'track-02', ...P, name: 'Editors & DX', room: 'Hall B', color: '#2C6E6A', order: 2 })\n await core.saveDocument('track', { id: 'track-03', ...P, name: 'Headless & Content', room: 'Hall C', color: '#B8860B', order: 3 })\n await core.saveDocument('track', { id: 'track-04', ...P, name: 'Frameworks', room: 'Hall D', color: '#5B4B8A', order: 4 })\n await core.saveDocument('track', { id: 'track-05', ...P, name: 'Edge & Infra', room: 'Hall E', color: '#3D6B9E', order: 5 })\n await core.saveDocument('track', { id: 'track-06', ...P, name: 'Community', room: 'Hall F', color: '#8A3324', order: 6 })\n\n await core.saveDocument('speaker', {\n id: 'speaker-01', ...P,\n name: 'Nadia Eke', role: 'Runtime engineer', org: 'Meridian Runtime', pronouns: 'she/her',\n bio: 'Nadia works on the engine’s optimizer pipeline and has shipped the fast-calls path used by every modern serverless runtime. Before that she built JIT profilers at a trading firm where milliseconds were measured in dollars.',\n city: 'Munich'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-02', ...P,\n name: 'Tomás Ribeiro', role: 'Editor architect', org: 'Quillworks', pronouns: 'he/him',\n bio: 'Tomás leads the rendering layer of a popular collaborative editor. He thinks a lot about CRDTs, latency budgets, and why your editor’s cursor jump is the most intimate thing in software.',\n city: 'Lisbon'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-03', ...P,\n name: 'Priya Nair', role: 'Founder', org: 'Blockhead CMS', pronouns: 'she/they',\n bio: 'Priya built and open-sourced a headless CMS now used by two thousand studios. She writes about structured content, schema-first design, and why your blog should not be a database table.',\n city: 'Bangalore'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-04', ...P,\n name: 'Marcus Feld', role: 'Framework maintainer', org: 'Lumen UI', pronouns: 'he/him',\n bio: 'Marcus is a core maintainer on a fine-grained reactive framework and has been writing about signals long before they were cool. He will not let you call them \"reactive state\".',\n city: 'Berlin'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-05', ...P,\n name: 'Lucia Moreau', role: 'Edge platform lead', org: 'Kestrel Compute', pronouns: 'she/her',\n bio: 'Lucia runs the runtime-at-the-edge team at a global compute platform. She has opinions about cold starts that she will share whether or not you ask.',\n city: 'Paris'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-06', ...P,\n name: 'Devon Park', role: 'DX advocate', org: 'Northship', pronouns: 'they/them',\n bio: 'Devon writes the docs that get screenshot-ed into conference talks. They care about error messages, onboarding flows, and the shape of the first ten minutes of a tool.',\n city: 'New York'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-07', ...P,\n name: 'Hana Sato', role: 'Tooling engineer', org: 'Furlong', pronouns: 'she/her',\n bio: 'Hana works on bundler internals and has been quietly deleting milliseconds from the dev-server startup path for four years.',\n city: 'Tokyo'\n })\n await core.saveDocument('speaker', {\n id: 'speaker-08', ...P,\n name: 'Idris Bello', role: 'Community lead', org: 'Commons Fund', pronouns: 'he/him',\n bio: 'Idris helps maintainers get paid. He has read more maintainer burnout post-mortems than anyone and will tell you what they have in common.',\n city: 'Lagos'\n })\n\n await core.saveDocument('session', {\n id: 'session-01', ...P,\n title: 'Fast-calls: stealing 40µs from every function call',\n type: 'Talk', day: '2026-05-13', start: '09:30', end: '10:15',\n track: 'track-01', speakers: ['speaker-01'],\n abstract: 'A deep dive into the inline-cache path that lets modern runtimes skip argument count checks. We’ll look at the bytecode, the assembly the engine actually emits, and the one flag you can flip in your own runtime to inherit the speedup.',\n tags: ['runtime', 'performance', 'jit'],\n level: 'Advanced'\n })\n await core.saveDocument('session', {\n id: 'session-02', ...P,\n title: 'The cursor is the interface',\n type: 'Talk', day: '2026-05-13', start: '10:30', end: '11:15',\n track: 'track-02', speakers: ['speaker-02'],\n abstract: 'Why does a remote cursor jump the wrong way? A tour of CRDT selection-state, the difference between presence and intent, and how to make a collaborative editor feel local at 300ms round-trip.',\n tags: ['crdt', 'editors', 'realtime'],\n level: 'Intermediate'\n })\n await core.saveDocument('session', {\n id: 'session-03', ...P,\n title: 'Your content model is your API',\n type: 'Keynote', day: '2026-05-13', start: '11:30', end: '12:30',\n track: 'track-03', speakers: ['speaker-03'],\n abstract: 'A keynote on why a headless CMS is really a typed query language, what a good content schema gets you for free, and the design decisions behind an open one.',\n tags: ['cms', 'structured-content', 'open-source'],\n level: 'All'\n })\n await core.saveDocument('session', {\n id: 'session-04', ...P,\n title: 'Signals without the magic',\n type: 'Talk', day: '2026-05-13', start: '14:00', end: '14:45',\n track: 'track-04', speakers: ['speaker-04'],\n abstract: 'Fine-grained reactivity from first principles: a 60-line reimplementation of a signal store, and where the real frameworks spend the rest of their lines.',\n tags: ['frameworks', 'reactivity', 'ui'],\n level: 'Intermediate'\n })\n await core.saveDocument('session', {\n id: 'session-05', ...P,\n title: 'Cold starts are a design problem',\n type: 'Talk', day: '2026-05-13', start: '15:00', end: '15:45',\n track: 'track-05', speakers: ['speaker-05'],\n abstract: 'Snapshot restore, lazy module init, and the trade-off between image size and first-request latency. Numbers from a platform that serves a billion functions a day.',\n tags: ['edge', 'serverless', 'performance'],\n level: 'Advanced'\n })\n await core.saveDocument('session', {\n id: 'session-06', ...P,\n title: 'The first ten minutes',\n type: 'Talk', day: '2026-05-13', start: '16:00', end: '16:45',\n track: 'track-02', speakers: ['speaker-06'],\n abstract: 'A practical framework for auditing a developer’s first ten minutes with your tool. Bring your own README.',\n tags: ['dx', 'docs', 'onboarding'],\n level: 'All'\n })\n await core.saveDocument('session', {\n id: 'session-07', ...P,\n title: 'Workshop: build a multiplayer editor in 90 minutes',\n type: 'Workshop', day: '2026-05-14', start: '09:30', end: '11:00',\n track: 'track-02', speakers: ['speaker-02'],\n abstract: 'Hands-on: from a textarea to a CRDT-backed collaborative editor with presence, cursors, and undo. Laptop required; we’ll provide the starter repo.',\n tags: ['crdt', 'workshop', 'editors'],\n level: 'Intermediate'\n })\n await core.saveDocument('session', {\n id: 'session-08', ...P,\n title: 'Deleting the dev-server restart',\n type: 'Talk', day: '2026-05-14', start: '11:15', end: '12:00',\n track: 'track-04', speakers: ['speaker-07'],\n abstract: 'How a dev server can keep module graphs hot across file moves and renames, and the dirty module-cache tricks that make it feel instant.',\n tags: ['tooling', 'dx', 'bundler'],\n level: 'Advanced'\n })\n await core.saveDocument('session', {\n id: 'session-09', ...P,\n title: 'Panel: what is a runtime, now?',\n type: 'Panel', day: '2026-05-14', start: '14:00', end: '15:00',\n track: 'track-01', speakers: ['speaker-01', 'speaker-05'],\n abstract: 'The line between a runtime, a framework, and a platform has dissolved. Where do the people who build them draw it now?',\n tags: ['runtime', 'panel', 'platforms'],\n level: 'All'\n })\n await core.saveDocument('session', {\n id: 'session-10', ...P,\n title: 'Paying maintainers without burning them out',\n type: 'Talk', day: '2026-05-14', start: '15:15', end: '16:00',\n track: 'track-06', speakers: ['speaker-08'],\n abstract: 'What three years of maintainer-funding data says about which models actually work, and the one variable that predicts burnout better than hours.',\n tags: ['community', 'open-source', 'funding'],\n level: 'All'\n })\n await core.saveDocument('session', {\n id: 'session-11', ...P,\n title: 'Workshop: ship a function to the edge',\n type: 'Workshop', day: '2026-05-14', start: '16:15', end: '17:45',\n track: 'track-05', speakers: ['speaker-05'],\n abstract: 'From local handler to a globally-distributed function with snapshot cold-starts. We’ll measure latency from five regions.',\n tags: ['edge', 'workshop', 'serverless'],\n level: 'Intermediate'\n })\n await core.saveDocument('session', {\n id: 'session-12', ...P,\n title: 'Keynote: the framework is the compiler',\n type: 'Keynote', day: '2026-05-15', start: '09:30', end: '10:30',\n track: 'track-04', speakers: ['speaker-04'],\n abstract: 'UI frameworks are quietly becoming compilers. A look at what we gain when the framework can see your whole component tree at build time.',\n tags: ['frameworks', 'compilers', 'ui'],\n level: 'All'\n })\n await core.saveDocument('session', {\n id: 'session-13', ...P,\n title: 'Bundling for the next decade',\n type: 'Talk', day: '2026-05-15', start: '11:00', end: '11:45',\n track: 'track-04', speakers: ['speaker-07'],\n abstract: 'Native ESM, import attributes, and what a bundler even needs to do when browsers ship most of it natively. Some surprising answers.',\n tags: ['bundler', 'modules', 'tooling'],\n level: 'Intermediate'\n })\n await core.saveDocument('session', {\n id: 'session-14', ...P,\n title: 'Closing: the people who stay',\n type: 'Keynote', day: '2026-05-15', start: '16:00', end: '17:00',\n track: 'track-06', speakers: ['speaker-08'],\n abstract: 'A closing reflection on the people who maintain the invisible platforms the rest of us build on, and what the next decade asks of them.',\n tags: ['community', 'open-source', 'keynote'],\n level: 'All'\n })\n\n await core.saveDocument('sponsor', { id: 'sponsor-01', ...P, name: 'Blockhead', tier: 'platinum', blurb: 'The open headless CMS. Hosted or self, structured content for everyone.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-02', ...P, name: 'Kestrel Compute', tier: 'platinum', blurb: 'Run full apps and databases close to your users, anywhere.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-03', ...P, name: 'Northship', tier: 'gold', blurb: 'The platform for frontend developers.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-04', ...P, name: 'Quillworks', tier: 'gold', blurb: 'The editor built for collaboration at the speed of thought.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-05', ...P, name: 'Furlong', tier: 'gold', blurb: 'Next generation frontend tooling.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-06', ...P, name: 'Lumen UI', tier: 'silver', blurb: 'Simple performant reactive UI.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-07', ...P, name: 'Commons Fund', tier: 'silver', blurb: 'Funding for open source communities.', url: '#' })\n await core.saveDocument('sponsor', { id: 'sponsor-08', ...P, name: 'Cordoaria Press', tier: 'partner', blurb: 'Independent publisher of the proceedings.', url: '#' })\n}", "content/conference/src/trokky/structure.ts": "/**\n * How content is arranged in the Studio sidebar.\n *\n * The schema decides what a singleton is; this only decides where things appear and which\n * document id the singleton uses.\n */\nexport const structure = {\n title: 'Conference',\n items: [\n { type: 'documentList', title: 'Sessions', schemaType: 'session', icon: 'presentation-chart-line' },\n { type: 'documentList', title: 'Speakers', schemaType: 'speaker', icon: 'user-group' },\n { type: 'documentList', title: 'Tracks', schemaType: 'track', icon: 'squares-2x2' },\n { type: 'documentList', title: 'Sponsors', schemaType: 'sponsor', icon: 'star' },\n { type: 'divider' },\n { type: 'singleton', title: 'Event', schemaType: 'event', documentId: 'event', icon: 'sparkle' },\n { type: 'singleton', title: 'Venue', schemaType: 'venue', documentId: 'venue', icon: 'map-pin' }\n ]\n}", "content/magazine/public/seed/README.txt": "Sample images and documents used only by the first-boot seed.\nPhotos: Lorem Picsum (picsum.photos), which serves Unsplash photos under the Unsplash license.\nPDFs: generated with Typst from a few paragraphs of placeholder text.\nDelete this folder once the sample content is gone; nothing else references it.\n", "content/magazine/src/trokky/schemas.ts": "/**\n * The content model: a magazine.\n *\n * Chosen because it exercises what a newcomer evaluates a CMS on — references both ways,\n * rich text, media in two kinds (images and PDFs), drafts, slugs, a singleton — in a shape\n * everyone already understands. Rename, add and remove freely; the Studio follows the schema.\n */\n\nexport const settingsSchema = {\n name: 'settings',\n title: 'Site settings',\n type: 'document',\n singleton: true,\n description: 'Name, navigation and footer for the whole site',\n fields: {\n siteName: { type: 'string', title: 'Site name', required: true, default: 'The Magazine' },\n tagline: { type: 'string', title: 'Tagline', description: 'One line under the name' },\n tagline_fr: { type: 'string', title: 'Tagline (français)' },\n logo: { type: 'media', title: 'Logo', options: { accept: 'image/*' } },\n navigation: {\n type: 'array',\n title: 'Navigation',\n description: 'Links in the header, in order',\n of: {\n type: 'object',\n fields: {\n label: { type: 'string', title: 'Label', required: true },\n href: { type: 'string', title: 'Path or URL', required: true }\n }\n }\n },\n footer: { type: 'text', title: 'Footer text', options: { rows: 2 } },\n footer_fr: { type: 'text', title: 'Footer text (français)', options: { rows: 2 } }\n }\n} as const\n\nexport const authorSchema = {\n name: 'author',\n title: 'Author',\n type: 'document',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },\n role: { type: 'string', title: 'Role', description: 'Editor, contributor, photographer…' },\n bio: { type: 'text', title: 'Bio', options: { rows: 4 } },\n avatar: { type: 'media', title: 'Photo', options: { accept: 'image/*' } }\n }\n} as const\n\nexport const categorySchema = {\n name: 'category',\n title: 'Category',\n type: 'document',\n fields: {\n name: { type: 'string', title: 'Name', required: true },\n slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },\n description: { type: 'text', title: 'Description', options: { rows: 2 } }\n }\n} as const\n\nexport const articleSchema = {\n name: 'article',\n title: 'Article',\n type: 'document',\n description: 'A story. Save as draft, publish when ready.',\n fields: {\n title: { type: 'string', title: 'Title', required: true, validation: { maxLength: 150 } },\n title_fr: { type: 'string', title: 'Title (français)', validation: { maxLength: 150 } },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n excerpt: { type: 'text', title: 'Excerpt', description: 'Shown in lists', options: { rows: 3 }, validation: { maxLength: 300 } },\n cover: { type: 'media', title: 'Cover image', options: { accept: 'image/*' } },\n author: { type: 'reference', title: 'Author', to: 'author' },\n category: { type: 'reference', title: 'Category', to: 'category' },\n featured: { type: 'boolean', title: 'Featured on the home page', default: false },\n publishedAt: { type: 'date', title: 'Published on', options: { includeTime: true }, default: 'now' },\n body: {\n type: 'richtext',\n title: 'Body',\n options: { headingLevels: [2, 3], enableFullscreen: true, minHeight: '320px', showStats: true }\n },\n related: {\n type: 'array',\n title: 'Related articles',\n of: { type: 'reference', to: 'article' },\n validation: { maxItems: 3 }\n }\n }\n} as const\n\nexport const documentSchema = {\n name: 'document',\n title: 'Document',\n type: 'document',\n description: 'A downloadable file — a report, a form, a PDF',\n fields: {\n title: { type: 'string', title: 'Title', required: true },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n summary: { type: 'text', title: 'Summary', options: { rows: 2 } },\n file: { type: 'media', title: 'File', required: true, options: { accept: 'application/pdf' } },\n publishedAt: { type: 'date', title: 'Published on', default: 'now' }\n }\n} as const\n\nexport const pageSchema = {\n name: 'page',\n title: 'Page',\n type: 'document',\n description: 'A standalone page such as About or Contact',\n fields: {\n title: { type: 'string', title: 'Title', required: true },\n slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },\n body: { type: 'richtext', title: 'Body', options: { headingLevels: [2, 3] } }\n }\n} as const\n\nexport const schemas = [settingsSchema, authorSchema, categorySchema, articleSchema, documentSchema, pageSchema]\n", "content/magazine/src/trokky/seed.ts": "/**\n * Sample content, created the moment the instance is claimed.\n *\n * An empty CMS shows an empty site, and the first minute decides whether a newcomer keeps\n * going. So the new owner's first sight of both the site and the Studio is a working magazine —\n * illustrated, with authors, categories, downloadable documents and pages — that they can edit\n * or delete. Every document id is fixed, so seeding twice is harmless; media is only uploaded\n * when there are no articles yet.\n *\n * The images and PDFs ship with the project and go through the normal upload pipeline, so\n * thumbnails are made exactly as they would be for a real upload.\n *\n * How those files are read differs by runtime — a Worker has an assets binding, a Node process\n * has a disk — so the caller supplies a reader rather than this file guessing.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/** Read one file from `public/seed/`, however this runtime reaches it. */\nexport type SeedAssetReader = (path: string) => Promise<ArrayBuffer>\n\nconst P = { _status: 'published' } as const\n\n/** The value the Studio's media field reads and writes. */\nconst media = (id: string, alt?: string) => ({ _type: 'media', asset: { _type: 'mediaAsset', _ref: id }, ...(alt ? { alt } : {}) })\n\nconst daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString()\nconst paras = (...ps: string[]) => ps.map(p => `<p>${p}</p>`).join('')\n\nexport async function seedSampleContent(core: TrokkyCore, readAsset: SeedAssetReader): Promise<void> {\n if ((await core.listDocuments('article', { limit: 1 })).length > 0) return\n\n const upload = async (path: string, filename: string, type: string): Promise<string> => {\n const file = new File([await readAsset(path)], filename, { type })\n return (await core.uploadMedia(file)).id\n }\n\n const [c1, c2, c3, c4, c5, c6] = await Promise.all(\n [1, 2, 3, 4, 5, 6].map(n => upload(`cover-${n}.jpg`, `cover-${n}.jpg`, 'image/jpeg'))\n )\n const [a1, a2, a3] = await Promise.all(\n [1, 2, 3].map(n => upload(`avatar-${n}.jpg`, `portrait-${n}.jpg`, 'image/jpeg'))\n )\n const [pdfReport, pdfGuide] = await Promise.all([\n upload('annual-report-2026.pdf', 'annual-report-2026.pdf', 'application/pdf'),\n upload('editorial-guidelines.pdf', 'editorial-guidelines.pdf', 'application/pdf')\n ])\n\n await core.saveDocument('settings', {\n id: 'settings', ...P,\n siteName: 'The Magazine',\n tagline: 'Stories worth the read',\n tagline_fr: 'Des histoires qui valent le détour',\n navigation: [\n { label: 'Articles', href: '/articles' },\n { label: 'Documents', href: '/documents' },\n { label: 'About', href: '/about' },\n { label: 'Contact', href: '/contact' }\n ],\n footer: 'Published with Trokky. Everything you see here is editable in Studio.',\n footer_fr: 'Publié avec Trokky. Tout ce que vous voyez se modifie dans Studio.'\n })\n\n await core.saveDocument('author', { id: 'author-ama', ...P, name: 'Ama Kodjo', slug: 'ama-kodjo', role: 'Editor', bio: 'Runs the magazine and writes the long pieces. Previously ten years in print.', avatar: media(a1, 'Ama Kodjo') })\n await core.saveDocument('author', { id: 'author-kossi', ...P, name: 'Kossi Mensah', slug: 'kossi-mensah', role: 'Contributor', bio: 'Reports from the field, mostly on foot.', avatar: media(a2, 'Kossi Mensah') })\n await core.saveDocument('author', { id: 'author-efua', ...P, name: 'Efua Asante', slug: 'efua-asante', role: 'Photographer', bio: 'Shoots the covers and occasionally writes about how.', avatar: media(a3, 'Efua Asante') })\n\n await core.saveDocument('category', { id: 'category-culture', ...P, name: 'Culture', slug: 'culture', description: 'Books, music, and the people behind them.' })\n await core.saveDocument('category', { id: 'category-tech', ...P, name: 'Technology', slug: 'technology', description: 'What is changing, and what it changes.' })\n await core.saveDocument('category', { id: 'category-city', ...P, name: 'City', slug: 'city', description: 'Streets, markets, and the life between buildings.' })\n\n await core.saveDocument('article', {\n id: 'article-welcome', ...P,\n title: 'Welcome to your magazine', title_fr: 'Bienvenue dans votre magazine',\n slug: 'welcome-to-your-magazine',\n excerpt: 'This site is yours. Here is how it fits together, and what to change first.',\n cover: media(c1, 'A desk with a notebook and coffee'),\n author: 'author-ama', category: 'category-tech', featured: true, publishedAt: daysAgo(0),\n body: paras(\n 'Everything on this site comes from the Studio at <a href=\"/studio\">/studio</a>. This article, the authors, the categories, the navigation in the header — all of it is content you can edit.',\n 'Start with <strong>Site settings</strong>: give the magazine its real name. Then open this article and change the title. Refresh the home page. That is the whole loop.',\n 'Articles can be drafts. A draft is saved but not shown here; publish it when it is ready. The <em>featured</em> switch decides what leads the home page.'\n ),\n related: ['article-references', 'article-media']\n })\n await core.saveDocument('article', {\n id: 'article-references', ...P,\n title: 'Authors, categories, and how references work', slug: 'authors-categories-and-references',\n excerpt: 'An article points at an author and a category. Change the author once and every article follows.',\n cover: media(c2), author: 'author-kossi', category: 'category-tech', featured: false, publishedAt: daysAgo(1),\n body: paras(\n 'This article references <strong>Kossi Mensah</strong> as its author. Open the Authors list, rename him, and come back: the byline changed, because the article stores a reference, not a copy.',\n 'Categories work the same way, and so do the related articles at the bottom of this page.'\n ),\n related: ['article-welcome']\n })\n await core.saveDocument('article', {\n id: 'article-media', ...P,\n title: 'Images and documents', slug: 'images-and-documents',\n excerpt: 'Every cover on this site was uploaded once; the thumbnails were made automatically.',\n cover: media(c3), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(2),\n body: paras(\n 'The cover above is an original upload. The smaller version in the article list is a thumbnail generated at upload time — you never resize anything by hand.',\n 'The <a href=\"/documents\">Documents</a> section holds files rather than stories: reports, forms, anything people download.'\n ),\n related: ['article-welcome']\n })\n await core.saveDocument('article', {\n id: 'article-market', ...P,\n title: 'Saturday at the market, before the heat', slug: 'saturday-at-the-market',\n excerpt: 'Six in the morning is when the market belongs to the people who run it.',\n cover: media(c4), author: 'author-kossi', category: 'category-city', featured: false, publishedAt: daysAgo(4),\n body: paras(\n 'By nine the aisles are full and the prices are set. Two hours earlier they are still being argued about, crate by crate, between people who have known each other for years.',\n 'This piece is placeholder text — replace it with your own reporting. The structure (a title, an excerpt, a cover, a body) is what stays.'\n )\n })\n await core.saveDocument('article', {\n id: 'article-drafts', ...P,\n title: 'Drafts, publishing, and what readers see', slug: 'drafts-and-publishing',\n excerpt: 'Save without publishing, publish when ready, unpublish without deleting.',\n cover: media(c5), author: 'author-ama', category: 'category-tech', featured: false, publishedAt: daysAgo(6),\n body: paras(\n 'Open this article in Studio and set it back to draft. Refresh this page: it is gone from the site, but nothing was deleted. Publish it again and it returns.',\n 'The site only ever reads published content. Drafts are yours alone.'\n )\n })\n await core.saveDocument('article', {\n id: 'article-listening', ...P,\n title: 'What the city sounds like at night', slug: 'what-the-city-sounds-like-at-night',\n excerpt: 'A photographer on the difference between looking and listening.',\n cover: media(c6), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(9),\n body: paras(\n 'Placeholder text for a longer feature. The photographer who shot the covers on this site talks about working after dark, when the light is bad and the sound is better.',\n 'Use it as a shape for your own longer pieces: a strong first line, a cover that earns its place, and section headings when it runs past a screen.'\n )\n })\n\n await core.saveDocument('document', { id: 'document-report', ...P, title: 'Annual Report 2026', slug: 'annual-report-2026', summary: 'The year in numbers, and what they mean.', file: media(pdfReport), publishedAt: daysAgo(3) })\n await core.saveDocument('document', { id: 'document-guidelines', ...P, title: 'Editorial guidelines', slug: 'editorial-guidelines', summary: 'How we write, and what we will not publish.', file: media(pdfGuide), publishedAt: daysAgo(12) })\n\n await core.saveDocument('page', { id: 'page-about', ...P, title: 'About', slug: 'about', body: paras('This magazine runs on <a href=\"https://trokky.dev\">Trokky</a>, a self-hosted CMS, on Cloudflare Workers. The content is in a D1 database and the files in an R2 bucket — both on your own account.', 'Replace this page with who you are.') })\n await core.saveDocument('page', { id: 'page-contact', ...P, title: 'Contact', slug: 'contact', body: paras('Put an email address, a form, or a map here. This is an ordinary page: edit it under <strong>Pages</strong> in Studio.') })\n}\n", "content/magazine/src/trokky/structure.ts": "/**\n * How content is arranged in the Studio sidebar.\n *\n * The schema decides what a singleton is; this only decides where things appear and which\n * document id the singleton uses.\n */\nexport const structure = {\n title: 'Magazine',\n items: [\n { type: 'documentList', title: 'Articles', schemaType: 'article', icon: 'newspaper' },\n { type: 'documentList', title: 'Documents', schemaType: 'document', icon: 'document-text' },\n { type: 'divider' },\n { type: 'documentList', title: 'Authors', schemaType: 'author', icon: 'user-group' },\n { type: 'documentList', title: 'Categories', schemaType: 'category', icon: 'tag' },\n { type: 'documentList', title: 'Pages', schemaType: 'page', icon: 'document' },\n { type: 'divider' },\n { type: 'singleton', title: 'Site settings', schemaType: 'settings', documentId: 'settings', icon: 'cog-6-tooth' }\n ]\n}\n", "site/conference/src/components/Avatar.astro": "---\n/**\n * A speaker's portrait block. With an uploaded photo it renders that; without one it draws the\n * deterministic monogram — a duotone block in a color seeded from the speaker's id — so the\n * template ships no image assets at all.\n */\nimport { mediaUrl, colorFor, initials } from '../trokky/site'\ninterface Props { speaker: Record<string, any>; size?: number }\nconst { speaker, size = 220 } = Astro.props\n\nconst photo = mediaUrl(speaker.photo, 'thumbnail')\nconst bg = colorFor(String(speaker.id))\nconst txt = '#FAF7F2'\nconst init = initials(String(speaker.name))\nconst svgSize = Math.round(size)\n---\n{photo ? (\n <img class=\"avatar\" src={photo} alt={speaker.name} style={`width:${size}px;height:${size}px`} />\n) : (\n <span class=\"avatar\" role=\"img\" aria-label={speaker.name} style={`width:${size}px;height:${size}px;background:${bg};color:${txt}`}>\n <svg viewBox={`0 0 ${svgSize} ${svgSize}`} width={svgSize} height={svgSize} xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n <rect width={svgSize} height={svgSize} fill={bg} />\n <rect x=\"0\" y={svgSize * 0.72} width={svgSize} height={svgSize * 0.28} fill=\"#000\" opacity=\"0.18\" />\n <text x=\"50%\" y=\"50%\" dy=\"0.06em\" text-anchor=\"middle\" font-family=\"Fraunces, Georgia, serif\" font-weight=\"500\" font-size={Math.round(svgSize * 0.34)} fill={txt}>{init}</text>\n </svg>\n </span>\n)}", "site/conference/src/components/SessionCard.astro": "---\n/**\n * A session row. In list form the meta shows the speakers, then the track; with `showDay` it\n * shows the day and the track instead, for speaker pages where the speakers are already known.\n */\nimport { formatDate } from '../trokky/site'\ninterface Props { session: Record<string, any>; showDay?: boolean }\nconst { session, showDay = false } = Astro.props\nconst s = session\n---\n<a class=\"session-row\" href={`/sessions/${s.id}`}>\n <span class=\"session-time\">\n <span class=\"t-start\">{s.start}</span>\n <span class=\"t-end\">{s.end}</span>\n </span>\n <span class=\"session-pill\" style={`--c: ${s.track?.color || '#333'}`}>{s.type}</span>\n <span class=\"session-main\">\n <span class=\"session-title\">{s.title}</span>\n <span class=\"session-meta\">\n {showDay\n ? <>{formatDate(s.day)}{s.track ? <> · <span class=\"session-track\">{s.track.name}</span></> : null}</>\n : s.speakers.map((sp: any) => <span class=\"session-speaker\">{sp.name}</span>)}\n {!showDay && s.track ? <> · <span class=\"session-track\">{s.track.name}</span></> : null}\n </span>\n </span>\n <span class=\"session-arrow\" aria-hidden=\"true\">→</span>\n</a>", "site/conference/src/components/SpeakerCard.astro": "---\nimport Avatar from './Avatar.astro'\ninterface Props { speaker: Record<string, any>; size?: number }\nconst { speaker, size = 220 } = Astro.props\n---\n<a class=\"card speaker-card\" href={`/speakers/${speaker.id}`}>\n <Avatar speaker={speaker} size={size} />\n <div class=\"card-body\">\n <h3 class=\"card-name\">{speaker.name}</h3>\n <p class=\"card-role\">{[speaker.role, speaker.org].filter(Boolean).join(' · ')}</p>\n </div>\n</a>", "site/conference/src/components/SponsorMark.astro": "---\n/**\n * A sponsor's mark. With an uploaded logo it renders that; without one it draws the wordmark\n * plate in a color seeded from the sponsor's name.\n */\nimport { mediaUrl, colorFor } from '../trokky/site'\ninterface Props { sponsor: Record<string, any> }\nconst { sponsor } = Astro.props\n\nconst logo = mediaUrl(sponsor.logo, 'thumbnail')\n---\n{logo ? (\n <img class=\"sponsor-mark-img\" src={logo} alt={sponsor.name} />\n) : (\n <span class=\"sponsor-mark\" style={`--mark: ${colorFor(String(sponsor.name))}`}>{sponsor.name}</span>\n)}", "site/conference/src/layouts/Base.astro": "---\n/**\n * All the chrome: fonts, the whole stylesheet, the header from the event's navigation and the\n * footer from the event — nothing else knows this much of the design. The pages pass the event\n * down and stay focused on their own body.\n *\n * One deliberate extension of the demo CSS: the filter bar also styles `a` alongside `button`,\n * because the session filters are links that carry URL params, not forms.\n */\nimport { formatDateRange } from '../trokky/site'\ninterface Props { title?: string; description?: string; event: Record<string, any> | null }\nconst { title, description, event } = Astro.props\nconst ev = event\nconst siteName = ev?.name ?? 'Conference'\nconst nav: { label: string; href: string }[] = ev?.navigation ?? []\n// Active link: the current path under the nav href (/speakers/speaker-01 sits under /speakers).\nconst current = Astro.url.pathname\n---\n<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{title ? `${title} — ${siteName}` : siteName}</title>\n {description && <meta name=\"description\" content={description} />}\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n <link href=\"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\" />\n <style is:global>\n /* ==========================================================================\n * The conference design: serif display, grotesk body, off-white ground,\n * ink type, one editorial-red accent. Ported from the framework-live demo\n * template; the only change is Söhne's removal (a commercial font) —\n * Inter leads the sans stack instead — plus the four rules marked below.\n * ========================================================================== */\n\n :root {\n --bg: #FAF7F2;\n --bg-tint: #F2EDE4;\n --ink: #14110F;\n --ink-soft: #4A4540;\n --ink-mute: #8A827A;\n --rule: #D9D2C7;\n --accent: #C8442B;\n --accent-deep:#9E3320;\n\n --serif: \"Fraunces\", \"Source Serif 4\", Georgia, \"Times New Roman\", serif;\n --sans: \"Inter\", \"Helvetica Neue\", Arial, sans-serif;\n --mono: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n --maxw: 1240px;\n --gutter: clamp(20px, 4vw, 56px);\n --rhythm: clamp(1.6rem, 0.4rem + 3vw, 2.4rem);\n }\n\n /* ---- reset ------------------------------------------------------------- */\n *, *::before, *::after { box-sizing: border-box; }\n html { -webkit-text-size-adjust: 100%; }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--ink);\n font-family: var(--sans);\n font-size: 16.5px;\n line-height: 1.55;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n }\n img, svg { display: block; max-width: 100%; }\n a { color: inherit; text-decoration: none; }\n button { font: inherit; }\n\n .wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--gutter); }\n\n /* ---- type -------------------------------------------------------------- */\n .display, .serif { font-family: var(--serif); font-weight: 400; letter-spacing: -0.01em; }\n .eyebrow {\n font-family: var(--sans);\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.18em;\n color: var(--accent);\n font-weight: 600;\n }\n h1, h2, h3, h4 { font-family: var(--serif); font-weight: 400; letter-spacing: -0.015em; line-height: 1.04; }\n h1 { font-size: clamp(2.6rem, 6vw, 4.6rem); }\n h2 { font-size: clamp(1.9rem, 3.6vw, 2.9rem); }\n h3 { font-size: clamp(1.25rem, 1.6vw, 1.55rem); }\n p { margin: 0 0 1em; color: var(--ink-soft); }\n .lede { font-family: var(--serif); font-size: clamp(1.15rem, 1.8vw, 1.45rem); line-height: 1.4; color: var(--ink); }\n\n /* ---- header ------------------------------------------------------------ */\n .site-header { position: sticky; top: 0; z-index: 50; background: rgba(250,247,242,0.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--rule); }\n .header-row { display: flex; align-items: baseline; gap: 1.5rem; padding: 1rem 0; }\n .brand { display: flex; align-items: baseline; gap: 0.55rem; flex: 0 0 auto; }\n .brand-mark { font-family: var(--serif); font-size: 1.3rem; font-weight: 500; letter-spacing: -0.02em; }\n .brand-edition { font-family: var(--mono); font-size: 0.68rem; color: var(--ink-mute); letter-spacing: 0.05em; }\n .site-nav { display: flex; gap: 1.6rem; margin-left: auto; }\n .site-nav a { font-size: 0.92rem; color: var(--ink-soft); position: relative; padding-bottom: 2px; }\n .site-nav a:hover { color: var(--ink); }\n .site-nav a.is-active { color: var(--ink); }\n .site-nav a.is-active::after { content: \"\"; position: absolute; left: 0; right: 0; bottom: -4px; height: 2px; background: var(--accent); }\n .header-cta { font-size: 0.85rem; font-weight: 600; color: var(--bg); background: var(--ink); padding: 0.5rem 0.95rem; border-radius: 999px; }\n .header-cta:hover { background: var(--accent); }\n\n /* ---- footer ------------------------------------------------------------ */\n .site-footer { margin-top: 6rem; border-top: 1px solid var(--rule); background: var(--bg-tint); }\n .footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1.4fr; gap: 2.5rem; padding-top: 3rem; padding-bottom: 2rem; }\n .footer-brand .brand-mark { font-family: var(--serif); font-size: 1.5rem; }\n .footer-brand p { font-family: var(--serif); font-style: italic; max-width: 26ch; }\n .footer-col h4 { font-family: var(--sans); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--ink-mute); margin: 0 0 0.9rem; font-weight: 600; }\n .footer-col a { display: block; font-size: 0.95rem; color: var(--ink-soft); padding: 0.18rem 0; }\n .footer-col a:hover { color: var(--accent); }\n .footer-meta p { font-size: 0.9rem; }\n /* The bottom padding clears the fixed Studio button, which would otherwise sit on the credit. */\n .footer-base { display: flex; justify-content: space-between; padding: 1.2rem 0 3.5rem; border-top: 1px solid var(--rule); font-size: 0.8rem; color: var(--ink-mute); }\n\n /* ---- hero -------------------------------------------------------------- */\n .hero { padding: clamp(3rem, 7vw, 6rem) 0 clamp(2.5rem, 5vw, 4rem); border-bottom: 1px solid var(--rule); }\n .hero-eyebrow { margin-bottom: 1.2rem; }\n .hero h1 { max-width: 16ch; margin: 0 0 1.4rem; }\n .hero .lede { max-width: 52ch; margin-bottom: 2rem; }\n .hero-meta { display: flex; flex-wrap: wrap; gap: 1.5rem 3rem; align-items: center; font-family: var(--mono); font-size: 0.82rem; color: var(--ink-soft); }\n .hero-meta span { display: inline-flex; align-items: center; gap: 0.5rem; }\n .hero-meta .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); display: inline-block; }\n\n /* ---- stat strip -------------------------------------------------------- */\n .stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--rule); }\n .stat { padding: 2rem 0; border-right: 1px solid var(--rule); }\n .stat:last-child { border-right: none; }\n .stat .v { font-family: var(--serif); font-size: clamp(2rem, 3.5vw, 3rem); }\n .stat .l { font-family: var(--mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-mute); margin-top: 0.3rem; }\n\n /* ---- section frame ----------------------------------------------------- */\n .section { padding: clamp(3rem, 6vw, 5rem) 0; border-bottom: 1px solid var(--rule); }\n .section-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 2.5rem; gap: 2rem; flex-wrap: wrap; }\n .section-head .eyebrow { display: block; margin-bottom: 0.6rem; }\n .section-head a.more { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); border-bottom: 1px solid var(--accent); padding-bottom: 1px; }\n .section-head a.more:hover { color: var(--accent-deep); }\n\n /* ---- speaker grid ------------------------------------------------------ */\n .card-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(1.2rem, 2.5vw, 2.2rem); }\n .card { display: block; border: 0; }\n .avatar { display: block; border-radius: 2px; overflow: hidden; aspect-ratio: 1; line-height: 0; box-shadow: 0 1px 0 var(--rule); transition: transform 0.25s ease; }\n .card:hover .avatar { transform: translateY(-4px); }\n .card-body { padding-top: 0.9rem; }\n .card-name { font-size: 1.15rem; margin: 0 0 0.2rem; }\n .card-role { font-size: 0.86rem; color: var(--ink-mute); margin: 0; }\n\n /* ---- session rows ------------------------------------------------------ */\n .session-row { display: grid; grid-template-columns: 92px 84px 1fr 24px; gap: 1.2rem; align-items: baseline; padding: 1.15rem 0; border-top: 1px solid var(--rule); transition: background 0.18s ease; }\n .session-row:hover { background: var(--bg-tint); }\n .session-row:last-child { border-bottom: 1px solid var(--rule); }\n .session-time { display: flex; flex-direction: column; font-family: var(--mono); font-size: 0.8rem; color: var(--ink); }\n .session-time .t-end { color: var(--ink-mute); }\n .session-pill { justify-self: start; font-family: var(--mono); font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.1em; color: #fff; background: var(--c); padding: 0.18rem 0.5rem; border-radius: 2px; align-self: center; }\n .session-title { display: block; font-family: var(--serif); font-size: 1.2rem; color: var(--ink); margin-bottom: 0.25rem; }\n .session-meta { font-size: 0.85rem; color: var(--ink-mute); }\n .session-meta .session-speaker { color: var(--ink-soft); }\n .session-meta .session-speaker + .session-speaker::before { content: \" & \"; }\n .session-track { color: var(--ink-mute); }\n .session-arrow { color: var(--ink-mute); transition: transform 0.18s ease, color 0.18s ease; }\n .session-row:hover .session-arrow { color: var(--accent); transform: translateX(4px); }\n\n /* ---- schedule grid ----------------------------------------------------- */\n .schedule { display: grid; grid-template-columns: 140px 1fr; gap: 0; border-top: 1px solid var(--rule); }\n .schedule-time { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-mute); padding: 1.1rem 1rem 1.1rem 0; border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); text-align: right; }\n .schedule-slots { display: grid; grid-template-columns: repeat(var(--tracks, 6), 1fr); border-bottom: 1px solid var(--rule); }\n .schedule-slot { border-right: 1px solid var(--rule); padding: 0.9rem; min-height: 84px; }\n .schedule-slot:last-child { border-right: none; }\n .schedule-slot.is-empty { background: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--bg-tint) 6px, var(--bg-tint) 7px); }\n .schedule-slot .slot-type { font-family: var(--mono); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--c); margin-bottom: 0.3rem; }\n .schedule-slot .slot-title { font-family: var(--serif); font-size: 0.98rem; line-height: 1.2; margin-bottom: 0.35rem; }\n .schedule-slot .slot-who { font-size: 0.76rem; color: var(--ink-mute); }\n .schedule-slot a { color: inherit; }\n .schedule-slot a:hover .slot-title { color: var(--accent); }\n .schedule-slot .slot-bar { height: 3px; background: var(--c); width: 28px; margin-bottom: 0.5rem; }\n\n .schedule-day-head { display: flex; justify-content: space-between; align-items: baseline; margin: 2.5rem 0 1rem; }\n .schedule-day-head h2 { margin: 0; }\n .schedule-day-head .date { font-family: var(--mono); font-size: 0.85rem; color: var(--ink-mute); }\n .track-legend { display: flex; flex-wrap: wrap; gap: 0.9rem 1.6rem; margin: 0.4rem 0 1rem; }\n .track-legend span { display: inline-flex; align-items: center; gap: 0.45rem; font-size: 0.82rem; color: var(--ink-soft); }\n .track-legend i { width: 10px; height: 10px; border-radius: 2px; }\n .schedule-cols { display: grid; grid-template-columns: 140px repeat(var(--tracks, 6), 1fr); }\n .schedule-cols .col-head { border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); padding: 0.7rem 0.9rem; }\n .schedule-cols .col-head h4 { font-family: var(--sans); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; margin: 0 0 0.15rem; font-weight: 600; }\n .schedule-cols .col-head .room { font-size: 0.74rem; color: var(--ink-mute); }\n .schedule-cols .col-spacer { border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); }\n\n /* ---- sponsors ---------------------------------------------------------- */\n .sponsor-tier { margin-bottom: 2.5rem; }\n .sponsor-tier-head { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1.2rem; }\n .sponsor-tier-head h3 { margin: 0; text-transform: capitalize; }\n .sponsor-tier-head .tier-line { flex: 1; height: 1px; background: var(--rule); }\n .sponsor-grid { display: grid; gap: 1px; background: var(--rule); border: 1px solid var(--rule); }\n .sponsor-grid.tier-platinum { grid-template-columns: repeat(2, 1fr); }\n .sponsor-grid.tier-gold { grid-template-columns: repeat(3, 1fr); }\n .sponsor-grid.tier-silver { grid-template-columns: repeat(4, 1fr); }\n .sponsor-grid.tier-partner { grid-template-columns: repeat(4, 1fr); }\n .sponsor-card { background: var(--bg); padding: 1.8rem; display: flex; flex-direction: column; gap: 0.8rem; transition: background 0.18s ease; }\n .sponsor-card:hover { background: var(--bg-tint); }\n .sponsor-mark { font-family: var(--serif); font-size: 1.4rem; font-weight: 500; letter-spacing: -0.02em; padding: 0.3rem 0.7rem; color: #fff; background: var(--mark); align-self: flex-start; border-radius: 2px; }\n .sponsor-blurb { font-size: 0.86rem; color: var(--ink-mute); margin: 0; }\n .sponsor-card .sponsor-link { font-family: var(--mono); font-size: 0.74rem; color: var(--accent); margin-top: auto; }\n\n /* ---- detail pages ------------------------------------------------------ */\n .detail-head { padding: clamp(2.5rem, 5vw, 4rem) 0 2rem; border-bottom: 1px solid var(--rule); }\n .back-link { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-mute); display: inline-flex; gap: 0.4rem; margin-bottom: 1.5rem; }\n .back-link:hover { color: var(--accent); }\n .detail-grid { display: grid; grid-template-columns: 1fr 320px; gap: 3rem; align-items: start; }\n .detail-main h1 { margin: 0 0 1.2rem; }\n .detail-meta { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 2rem; }\n .chip { font-family: var(--mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.3rem 0.7rem; border: 1px solid var(--rule); border-radius: 2px; color: var(--ink-soft); }\n .chip.accent { color: #fff; background: var(--accent); border-color: var(--accent); }\n .detail-prose { font-size: 1.12rem; line-height: 1.6; max-width: 62ch; }\n .detail-prose p { color: var(--ink); margin-bottom: 1.1em; }\n .detail-aside { border-left: 1px solid var(--rule); padding-left: 2rem; }\n .detail-aside h4 { font-family: var(--sans); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-mute); margin: 0 0 1rem; font-weight: 600; }\n .kv { display: flex; justify-content: space-between; padding: 0.55rem 0; border-bottom: 1px solid var(--rule); font-size: 0.9rem; }\n .kv .k { color: var(--ink-mute); font-family: var(--mono); font-size: 0.78rem; }\n .kv .v { color: var(--ink); text-align: right; }\n\n /* speaker detail */\n .speaker-detail-grid { display: grid; grid-template-columns: 280px 1fr; gap: 3rem; align-items: start; }\n .speaker-aside .avatar { width: 280px !important; height: 280px !important; }\n .pronoun { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-mute); margin: 0.4rem 0 1.2rem; }\n\n /* ---- venue ------------------------------------------------------------- */\n .venue-hero { padding: clamp(2.5rem,5vw,4rem) 0; border-bottom: 1px solid var(--rule); }\n .venue-map { aspect-ratio: 16/7; background: var(--bg-tint); border: 1px solid var(--rule); position: relative; overflow: hidden; margin-top: 2rem; }\n .venue-map svg { width: 100%; height: 100%; }\n .travel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; border-top: 1px solid var(--rule); }\n .travel-item { padding: 1.8rem 0; border-bottom: 1px solid var(--rule); border-right: 1px solid var(--rule); padding-right: 2rem; }\n .travel-item:nth-child(2n) { border-right: none; }\n .travel-item h3 { font-size: 1.3rem; margin: 0 0 0.5rem; }\n .travel-item .mode { font-family: var(--mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent); margin-bottom: 0.5rem; }\n\n /* tickets */\n .tickets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }\n .ticket { border: 1px solid var(--rule); padding: 2rem; display: flex; flex-direction: column; background: var(--bg); }\n .ticket.is-featured { border-color: var(--ink); background: var(--ink); color: var(--bg); }\n .ticket .t-name { font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.14em; }\n .ticket .t-price { font-family: var(--serif); font-size: 2.6rem; margin: 0.5rem 0 0.3rem; }\n .ticket .t-note { font-size: 0.86rem; color: var(--ink-mute); margin: 0 0 1.4rem; }\n .ticket.is-featured .t-note { color: rgba(250,247,242,0.7); }\n .ticket ul { list-style: none; margin: 0 0 1.6rem; padding: 0; }\n .ticket li { font-size: 0.92rem; padding: 0.35rem 0; display: flex; gap: 0.6rem; }\n .ticket li::before { content: \"+\"; color: var(--accent); font-family: var(--mono); }\n .ticket.is-featured li::before { color: var(--bg); }\n .ticket .t-cta { margin-top: auto; font-family: var(--mono); font-size: 0.82rem; padding: 0.7rem; text-align: center; border: 1px solid currentColor; }\n .ticket.is-featured .t-cta { background: var(--accent); border-color: var(--accent); color: #fff; }\n\n /* ---- generic helpers --------------------------------------------------- */\n .tag-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }\n .tag { font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); background: var(--bg-tint); padding: 0.25rem 0.6rem; border-radius: 2px; }\n .filter-bar { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 1.5rem; }\n .filter-bar button, .filter-bar a { font-family: var(--mono); font-size: 0.8rem; padding: 0.4rem 0.9rem; border: 1px solid var(--rule); background: var(--bg); color: var(--ink-soft); border-radius: 999px; cursor: pointer; }\n .filter-bar button:hover, .filter-bar a:hover { border-color: var(--ink); color: var(--ink); }\n .filter-bar button.is-active, .filter-bar a.is-active { background: var(--ink); color: var(--bg); border-color: var(--ink); }\n .empty-note { padding: 2rem 0; color: var(--ink-mute); font-style: italic; }\n /* The empty notes link to Studio; muted italic alone gave an invisible link nothing to click on. */\n .empty-note a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }\n .section-noborder { border-bottom: none; }\n\n /* ---- port additions -------------------------------------------------- */\n img.avatar { object-fit: cover; }\n .sponsor-mark-img { align-self: flex-start; max-height: 84px; width: auto; }\n .session-day-title { font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-mute); margin: 2.2rem 0 0.6rem; }\n .studio { position: fixed; right: 16px; bottom: 16px; font: 13px \"Inter\", system-ui, sans-serif; background: var(--accent); color: #fff; padding: 8px 12px; border-radius: 999px; opacity: .9 }\n\n /* ---- responsive -------------------------------------------------------- */\n @media (max-width: 1000px) {\n .card-grid { grid-template-columns: repeat(3, 1fr); }\n .stat-strip { grid-template-columns: repeat(2, 1fr); }\n .stat:nth-child(2) { border-right: none; }\n .stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--rule); }\n .footer-grid { grid-template-columns: 1fr 1fr; }\n .detail-grid, .speaker-detail-grid { grid-template-columns: 1fr; gap: 2rem; }\n .detail-aside, .speaker-aside { border-left: none; border-top: 1px solid var(--rule); padding-left: 0; padding-top: 1.5rem; }\n .schedule, .schedule-cols { grid-template-columns: 70px repeat(var(--tracks,6), 1fr); font-size: 0.8rem; }\n .schedule-slot { padding: 0.5rem 0.4rem; min-height: 70px; }\n .schedule-slot .slot-title { font-size: 0.82rem; }\n }\n @media (max-width: 700px) {\n .site-nav { display: none; }\n .header-cta { margin-left: auto; }\n .card-grid { grid-template-columns: repeat(2, 1fr); }\n .footer-grid { grid-template-columns: 1fr; }\n .tickets { grid-template-columns: 1fr; }\n .travel-grid { grid-template-columns: 1fr; }\n .travel-item { border-right: none; padding-right: 0; }\n .session-row { grid-template-columns: 70px 1fr; row-gap: 0.4rem; }\n .session-pill, .session-arrow { grid-column: 2; }\n .schedule, .schedule-cols { grid-template-columns: 50px repeat(var(--tracks,6), minmax(44px, 1fr)); }\n .schedule-slot .slot-who { display: none; }\n .schedule-slot .slot-title { font-size: 0.74rem; }\n .col-head .room, .schedule-time { font-size: 0.62rem; }\n .sponsor-grid.tier-platinum, .sponsor-grid.tier-gold,\n .sponsor-grid.tier-silver, .sponsor-grid.tier-partner { grid-template-columns: 1fr 1fr; }\n }\n\n /* ---- print niceties ---------------------------------------------------- */\n @media (prefers-reduced-motion: reduce) {\n * { transition: none !important; animation: none !important; }\n }\n </style>\n </head>\n <body>\n <header class=\"site-header\"><div class=\"wrap header-row\">\n <a class=\"brand\" href=\"/\">\n <span class=\"brand-mark\">{siteName}</span>\n {ev?.edition && <span class=\"brand-edition\">{ev.edition}</span>}\n </a>\n <nav class=\"site-nav\">\n {nav.map(item => <a href={item.href} class={current === item.href || (item.href !== '/' && current.startsWith(`${item.href}/`)) ? 'is-active' : ''}>{item.label}</a>)}\n </nav>\n {ev?.ctaLabel && ev?.ctaHref && <a class=\"header-cta\" href={ev.ctaHref}>{ev.ctaLabel}</a>}\n </div></header>\n\n <main><slot /></main>\n\n {ev && (\n <footer class=\"site-footer\">\n <div class=\"wrap footer-grid\">\n <div class=\"footer-brand\">\n <span class=\"brand-mark\">{ev.name}</span>\n {ev.tagline && <p>{ev.tagline}</p>}\n </div>\n <div class=\"footer-col\">\n <h4>Programme</h4>\n <a href=\"/schedule\">Schedule</a>\n <a href=\"/speakers\">Speakers</a>\n <a href=\"/sessions\">Sessions</a>\n </div>\n <div class=\"footer-col\">\n <h4>Visit</h4>\n <a href=\"/venue\">Venue &amp; travel</a>\n <a href=\"/sponsors\">Sponsors</a>\n {ev.ctaHref && <a href={ev.ctaHref}>Get a pass</a>}\n </div>\n <div class=\"footer-col footer-meta\">\n <h4>{ev.city}</h4>\n {ev.venue && <p>{ev.venue}</p>}\n {(ev.startDate || ev.endDate) && <p>{formatDateRange(ev.startDate, ev.endDate)}</p>}\n </div>\n </div>\n <div class=\"wrap footer-base\">\n <span>© {[ev.edition, ev.name].filter(Boolean).join(' ')}. Every word here is editable in Studio.</span>\n <span class=\"footer-credit\">A Trokky template</span>\n </div>\n </footer>\n )}\n <a class=\"studio\" href=\"/studio\">Edit in Studio →</a>\n </body>\n</html>", "site/conference/src/pages/index.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { formatDateRange, formatDate } from '../trokky/site'\nimport SpeakerCard from '../components/SpeakerCard.astro'\nimport SessionCard from '../components/SessionCard.astro'\nimport SponsorMark from '../components/SponsorMark.astro'\nconst s = await load(Astro)\nconst ev = s.event\nconst featured = (await s.speakers()).slice(0, 4)\nconst days = await s.days()\nconst dayOne = (await (s.sessions({ day: days[0] }))).slice(0, 5)\nconst firstDay = dayOne[0]?.day ?? days[0]\nconst platinum = (await s.sponsorsByTier())[0]?.sponsors ?? []\nconst stats = ev?.stats ?? []\nconst speakerCount = ev?.stats?.[3]?.value ?? String(featured.length)\n---\n<Base event={ev} description={ev?.tagline}>\n {!ev && (\n <section class=\"hero\"><div class=\"wrap\">\n <p class=\"empty-note lede\">Nothing here yet — open <a href=\"/studio\">Studio</a>, claim the instance, and the conference seeds itself.</p>\n </div></section>\n )}\n {ev && (\n <section class=\"hero\"><div class=\"wrap\">\n <div class=\"hero-eyebrow eyebrow\">{ev.edition} edition · {ev.city}</div>\n <h1 class=\"display\">{ev.name}</h1>\n <p class=\"lede\">{ev.tagline}</p>\n {ev.description && <p style=\"max-width:54ch;margin-bottom:2rem\">{ev.description}</p>}\n <div class=\"hero-meta\">\n <span><i class=\"dot\"></i>{formatDateRange(ev.startDate, ev.endDate)}</span>\n {ev.venue && <span>{ev.venue}, {ev.city}</span>}\n {stats[2] && <span>{stats[2].value} sessions</span>}\n </div>\n </div></section>\n )}\n\n {stats.length > 0 && (\n <section><div class=\"wrap\"><div class=\"stat-strip\">\n {stats.map((st: any) => <div class=\"stat\"><div class=\"v\">{st.value}</div><div class=\"l\">{st.label}</div></div>)}\n </div></div></section>\n )}\n\n {featured.length > 0 && (\n <section class=\"section\"><div class=\"wrap\">\n <div class=\"section-head\">\n <div>\n <span class=\"eyebrow\">Featured speakers</span>\n <h2 class=\"display\">The people on the main stage</h2>\n </div>\n <a class=\"more\" href=\"/speakers\">All {speakerCount} →</a>\n </div>\n <div class=\"card-grid\">{featured.map(sp => <SpeakerCard speaker={sp} />)}</div>\n </div></section>\n )}\n\n {dayOne.length > 0 && (\n <section class=\"section section-noborder\"><div class=\"wrap\">\n <div class=\"section-head\">\n <div>\n <span class=\"eyebrow\">Day one · {formatDate(firstDay)}</span>\n <h2 class=\"display\">The opening line-up</h2>\n </div>\n <a class=\"more\" href=\"/schedule\">Full schedule →</a>\n </div>\n {dayOne.map(x => <SessionCard session={x} />)}\n </div></section>\n )}\n\n {platinum.length > 0 && (\n <section class=\"section\"><div class=\"wrap\">\n <div class=\"section-head\">\n <div>\n <span class=\"eyebrow\">With thanks to</span>\n <h2 class=\"display\">Our platinum partners</h2>\n </div>\n <a class=\"more\" href=\"/sponsors\">All sponsors →</a>\n </div>\n <div class=\"sponsor-grid tier-platinum\">{platinum.map(sp => <SponsorMark sponsor={sp} />)}</div>\n </div></section>\n )}\n</Base>", "site/conference/src/pages/schedule.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { formatDate } from '../trokky/site'\nconst s = await load(Astro)\nconst tracks = await s.tracks()\nconst days = await s.days()\nconst dayBlocks = await Promise.all(days.map(async day => ({ day, sessions: await s.sessions({ day }) })))\n---\n<Base title=\"Schedule\" event={s.event}>\n <section class=\"detail-head\"><div class=\"wrap\">\n <span class=\"eyebrow\">{days.length} days · {tracks.length} tracks</span>\n <h1 class=\"display\">The full programme</h1>\n <p class=\"lede\" style=\"max-width:54ch;color:var(--ink-soft)\">Every session, every room. Click a block for the talk detail.</p>\n </div></section>\n\n <section class=\"section section-noborder\"><div class=\"wrap\">\n {dayBlocks.map(({ day, sessions }, i) => {\n const rows = [...new Set(sessions.map(x => String(x.start)))].sort((a, b) => (a < b ? -1 : 1))\n return (\n <>\n <div class=\"schedule-day-head\">\n <h2 class=\"display\">{formatDate(day)}</h2>\n <span class=\"date\">Day {i + 1} · {sessions.length} sessions</span>\n </div>\n <div class=\"track-legend\">\n {tracks.map(t => <span><i style={`background:${t.color || '#333'}`}></i>{t.name}</span>)}\n </div>\n <div class=\"schedule-cols\" style={`--tracks: ${tracks.length}`}>\n <div class=\"col-spacer\"></div>\n {tracks.map(t => <div class=\"col-head\"><h4>{t.name}</h4><div class=\"room\">{t.room}</div></div>)}\n </div>\n <div class=\"schedule\" style={`--tracks: ${tracks.length}`}>\n {rows.map(time => {\n const slot = sessions.filter(x => String(x.start) === time)\n const endTime = String(slot[0]?.end ?? '')\n return (\n <>\n <div class=\"schedule-time\">{time}<br /><span style=\"color:var(--ink-mute)\">{endTime}</span></div>\n <div class=\"schedule-slots\" style={`--tracks: ${tracks.length}`}>\n {tracks.map(t => {\n const x = slot.find(y => y.track?.id === t.id)\n if (!x) return <div class=\"schedule-slot is-empty\"></div>\n return (\n <div class=\"schedule-slot\" style={`--c:${t.color || '#333'}`}>\n <div class=\"slot-bar\"></div>\n <a href={`/sessions/${x.id}`}>\n <div class=\"slot-type\">{x.type}</div>\n <div class=\"slot-title\">{x.title}</div>\n <div class=\"slot-who\">{x.speakers.map((sp: any) => sp.name).join(' · ')}</div>\n </a>\n </div>\n )\n })}\n </div>\n </>\n )\n })}\n </div>\n </>\n )\n })}\n {!dayBlocks.length && <p class=\"empty-note\">No sessions yet — open <a href=\"/studio\">Studio</a> and add the first one.</p>}\n </div></section>\n</Base>", "site/conference/src/pages/sessions/[id].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { formatDate } from '../../trokky/site'\nimport Avatar from '../../components/Avatar.astro'\nconst s = await load(Astro)\nconst x = await s.session(Astro.params.id!)\nif (!x) return new Response(null, { status: 404 })\n---\n<Base title={x.title} event={s.event}>\n <section><div class=\"wrap\">\n <a class=\"back-link\" href=\"/sessions\">← All sessions</a>\n <div class=\"detail-grid\">\n <div class=\"detail-main\">\n <div class=\"detail-meta\">\n <span class=\"chip accent\">{x.type}</span>\n {x.track && <span class=\"chip\">{x.track.name}</span>}\n <span class=\"chip\">{formatDate(x.day)}</span>\n <span class=\"chip\">{x.start}–{x.end}</span>\n {x.level && <span class=\"chip\">{x.level}</span>}\n </div>\n <h1 class=\"display\">{x.title}</h1>\n {x.abstract && <p class=\"detail-prose\">{x.abstract}</p>}\n {(x.tags ?? []).length > 0 && (\n <div class=\"tag-row\" style=\"margin-top:1.5rem\">\n {x.tags.map((t: string) => <span class=\"tag\">#{t}</span>)}\n </div>\n )}\n {x.speakers.length > 0 && (\n <>\n <h3 class=\"serif\" style=\"margin:2.5rem 0 1rem\">Featuring</h3>\n <div class=\"card-grid\" style=\"grid-template-columns:repeat(2,1fr);max-width:480px\">\n {x.speakers.map(sp => (\n <a class=\"card speaker-card\" href={`/speakers/${sp.id}`}>\n <Avatar speaker={sp} size={160} />\n <div class=\"card-body\">\n <h3 class=\"card-name\">{sp.name}</h3>\n <p class=\"card-role\">{sp.role}</p>\n </div>\n </a>\n ))}\n </div>\n </>\n )}\n </div>\n <aside class=\"detail-aside\">\n <h4>Where &amp; when</h4>\n <div class=\"kv\"><span class=\"k\">Room</span><span class=\"v\">{x.track?.room || '—'}</span></div>\n <div class=\"kv\"><span class=\"k\">Track</span><span class=\"v\">{x.track?.name || '—'}</span></div>\n <div class=\"kv\"><span class=\"k\">Day</span><span class=\"v\">{formatDate(x.day)}</span></div>\n <div class=\"kv\"><span class=\"k\">Time</span><span class=\"v\">{x.start}–{x.end}</span></div>\n <div class=\"kv\"><span class=\"k\">Level</span><span class=\"v\">{x.level}</span></div>\n <div class=\"kv\" style=\"border:none\"><span class=\"k\">Type</span><span class=\"v\">{x.type}</span></div>\n </aside>\n </div>\n </div></section>\n</Base>", "site/conference/src/pages/sessions/index.astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { formatDate } from '../../trokky/site'\nimport SessionCard from '../../components/SessionCard.astro'\nconst s = await load(Astro)\nconst tracks = await s.tracks()\nconst all = await s.sessions()\nconst types = [...new Set(all.map(x => String(x.type)))]\nconst type = Astro.url.searchParams.get('type') ?? ''\nconst track = Astro.url.searchParams.get('track') ?? ''\nconst list = await s.sessions({ type: type || undefined, track: track || undefined })\nconst groups: { day: string; items: Record<string, any>[] }[] = []\n// A session with no parseable day has no group header to sit under; it renders on its own detail\n// page and nowhere else.\nfor (const x of list.filter((x: { day?: string }) => x.day)) {\n const g = groups[groups.length - 1]\n if (g && g.day === String(x.day)) g.items.push(x)\n else groups.push({ day: String(x.day), items: [x] })\n}\n/**\n * Filter-bar link: carries the current search params except the one being changed (empty string clears).\n */\nconst link = (next: { type?: string; track?: string }) => {\n const t = next.type !== undefined ? next.type : type\n const k = next.track !== undefined ? next.track : track\n const q = new URLSearchParams()\n if (t) q.set('type', t)\n if (k) q.set('track', k)\n return q.toString() ? `/sessions?${q.toString()}` : '/sessions'\n}\n---\n<Base title=\"Sessions\" event={s.event}>\n <section class=\"detail-head\"><div class=\"wrap\">\n <span class=\"eyebrow\">{list.length} sessions</span>\n <h1 class=\"display\">Every talk, workshop &amp; panel</h1>\n <p class=\"lede\" style=\"max-width:54ch;color:var(--ink-soft)\">Filter by type or track — every session on the programme, in day order.</p>\n </div></section>\n\n <section class=\"section section-noborder\"><div class=\"wrap\">\n {all.length > 0 && (\n <>\n <div class=\"filter-bar\">\n <a href={link({ type: '' })} class={!type ? 'is-active' : ''}>All</a>\n {types.map(t => <a href={link({ type: t })} class={type === t ? 'is-active' : ''}>{t}</a>)}\n </div>\n <div class=\"filter-bar\">\n <a href={link({ track: '' })} class={!track ? 'is-active' : ''}>Every track</a>\n {tracks.map(t => <a href={link({ track: String(t.id) })} class={track === String(t.id) ? 'is-active' : ''}>{t.name}</a>)}\n </div>\n {groups.map(g => (\n <>\n <h3 class=\"session-day-title\">{formatDate(g.day)}</h3>\n {g.items.map(x => <SessionCard session={x} />)}\n </>\n ))}\n {!list.length && <p class=\"empty-note\">No sessions match those filters.</p>}\n </>\n )}\n {!all.length && <p class=\"empty-note\">No sessions yet — open <a href=\"/studio\">Studio</a> and add the first one.</p>}\n </div></section>\n</Base>", "site/conference/src/pages/speakers/[id].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport Avatar from '../../components/Avatar.astro'\nimport SessionCard from '../../components/SessionCard.astro'\nconst s = await load(Astro)\nconst sp = await s.speaker(Astro.params.id!)\nif (!sp) return new Response(null, { status: 404 })\n---\n<Base title={sp.name} event={s.event}>\n <section><div class=\"wrap\">\n <a class=\"back-link\" href=\"/speakers\">← All speakers</a>\n <div class=\"speaker-detail-grid\">\n <div class=\"speaker-aside\">\n <Avatar speaker={sp} size={280} />\n {(sp.pronouns || sp.city) && <p class=\"pronoun\">{[sp.pronouns, sp.city].filter(Boolean).join(\" · \")}</p>}\n </div>\n <div class=\"detail-main\">\n {sp.role && <div class=\"eyebrow\">{sp.role}</div>}\n <h1 class=\"display\">{sp.name}</h1>\n {sp.org && <p class=\"lede\" style=\"color:var(--ink-soft)\">{sp.org}</p>}\n {sp.bio && <p class=\"detail-prose\">{sp.bio}</p>}\n {sp.sessions.length > 0 && (\n <>\n <h3 class=\"serif\" style=\"margin:2.5rem 0 1rem\">Sessions at {s.event?.name}</h3>\n {sp.sessions.map(x => <SessionCard session={x} showDay />)}\n </>\n )}\n </div>\n </div>\n </div></section>\n</Base>", "site/conference/src/pages/speakers/index.astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport SpeakerCard from '../../components/SpeakerCard.astro'\nconst s = await load(Astro)\nconst speakers = await s.speakers()\n---\n<Base title=\"Speakers\" event={s.event}>\n <section class=\"detail-head\"><div class=\"wrap\">\n <span class=\"eyebrow\">{speakers.length} speakers</span>\n <h1 class=\"display\">The line-up</h1>\n <p class=\"lede\" style=\"max-width:54ch;color:var(--ink-soft)\">The people on the stages and in the rooms — every card links to the speaker's own page, with their sessions.</p>\n </div></section>\n\n <section class=\"section section-noborder\"><div class=\"wrap\">\n {speakers.length > 0\n ? <div class=\"card-grid\">{speakers.map(sp => <SpeakerCard speaker={sp} />)}</div>\n : <p class=\"empty-note\">No speakers yet — open <a href=\"/studio\">Studio</a> and add the first one.</p>}\n </div></section>\n</Base>", "site/conference/src/pages/sponsors.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport SponsorMark from '../components/SponsorMark.astro'\nconst s = await load(Astro)\nconst tiers = await s.sponsorsByTier()\n---\n<Base title=\"Sponsors\" event={s.event}>\n <section class=\"detail-head\"><div class=\"wrap\">\n <span class=\"eyebrow\">Grouped by tier</span>\n <h1 class=\"display\">Sponsors</h1>\n <p class=\"lede\" style=\"max-width:54ch;color:var(--ink-soft)\">The partners who make Frameworks Live possible, grouped by tier.</p>\n </div></section>\n\n <section class=\"section section-noborder\"><div class=\"wrap\">\n {tiers.length > 0 ? tiers.map(t => (\n <div class=\"sponsor-tier\">\n <div class=\"sponsor-tier-head\">\n <h3 class=\"serif\">{t.tier}</h3>\n <span class=\"tier-line\"></span>\n <span style=\"font-family:var(--mono);font-size:0.78rem;color:var(--ink-mute)\">{t.sponsors.length} sponsor{t.sponsors.length > 1 ? 's' : ''}</span>\n </div>\n <div class={`sponsor-grid tier-${t.tier}`}>\n {t.sponsors.map(sp => (\n <a class=\"sponsor-card\" href={sp.url || '#'}>\n <SponsorMark sponsor={sp} />\n <p class=\"sponsor-blurb\">{sp.blurb}</p>\n <span class=\"sponsor-link\">visit {String(sp.name).toLowerCase()} →</span>\n </a>\n ))}\n </div>\n </div>\n )) : (\n <p class=\"empty-note\">No sponsors yet — open <a href=\"/studio\">Studio</a> and add the first one.</p>\n )}\n </div></section>\n</Base>", "site/conference/src/pages/venue.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nconst s = await load(Astro)\nconst v = s.venue\nconst ev = s.event\nconst firstWord = v?.name ? String(v.name).split(' ')[0] : 'Hall'\nconst verticals = Array.from({ length: 9 }, (_, i) => i * 100)\nconst horizontals = Array.from({ length: 4 }, (_, i) => i * 90)\n---\n<Base title=\"Venue & tickets\" event={ev}>\n <section class=\"venue-hero\"><div class=\"wrap\">\n {v ? (\n <>\n <div class=\"eyebrow\">The hall</div>\n <h1 class=\"display\" style=\"margin:0.4rem 0 1.2rem\">{v.name}</h1>\n <p class=\"lede\">{v.address}</p>\n {v.about && <p style=\"max-width:62ch\">{v.about}</p>}\n </>\n ) : (\n <p class=\"empty-note lede\">No venue yet — open <a href=\"/studio\">Studio</a> and describe the hall.</p>\n )}\n <div class=\"venue-map\">\n <svg viewBox=\"0 0 800 360\" preserveAspectRatio=\"xMidYMid slice\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"800\" height=\"360\" fill=\"#F2EDE4\" />\n {verticals.map(x => <line x1={x} y1={0} x2={x} y2={360} stroke=\"#E4DCCD\" stroke-width={1} />)}\n {horizontals.map(y => <line x1={0} y1={y} x2={800} y2={y} stroke=\"#E4DCCD\" stroke-width={1} />)}\n <rect x={60} y={120} width={160} height={120} fill=\"none\" stroke=\"#14110F\" stroke-width={2} />\n <text x={140} y={185} text-anchor=\"middle\" font-family=\"Fraunces, serif\" font-size={22} fill=\"#14110F\">{firstWord}</text>\n <path d=\"M250 220 Q 400 230 540 250 L 800 320\" stroke=\"#3D6B9E\" stroke-width={14} fill=\"none\" opacity={0.5} />\n <circle cx={140} cy={180} r={14} fill=\"#C8442B\" />\n <circle cx={140} cy={180} r={28} fill=\"#C8442B\" opacity={0.2} />\n <text x={170} y={186} font-family=\"JetBrains Mono, monospace\" font-size={13} fill=\"#4A4540\">{v?.name ?? ''}</text>\n <text x={470} y={300} font-family=\"JetBrains Mono, monospace\" font-size={12} fill=\"#3D6B9E\">Rio Tejo</text>\n </svg>\n </div>\n </div></section>\n\n <section class=\"section\"><div class=\"wrap\">\n <div class=\"section-head\">\n <div><span class=\"eyebrow\">Getting there</span><h2 class=\"display\">Travel</h2></div>\n </div>\n <div class=\"travel-grid\">\n {(v?.travel ?? []).length > 0\n ? v.travel.map((t: any) => (\n <div class=\"travel-item\">\n <div class=\"mode\">{t.mode}</div>\n <p>{t.detail}</p>\n </div>\n ))\n : <p class=\"empty-note\">No travel details yet — add them to the Venue document in Studio.</p>}\n </div>\n </div></section>\n\n <section class=\"section section-noborder\" id=\"tickets\"><div class=\"wrap\">\n <div class=\"section-head\">\n <div><span class=\"eyebrow\">Tickets</span><h2 class=\"display\">Get a pass</h2></div>\n </div>\n <div class=\"tickets\">\n {(v?.tickets ?? []).length > 0\n ? v.tickets.map((t: any) => (\n <div class={`ticket ${t.featured ? 'is-featured' : ''}`}>\n <div class=\"t-name\">{t.name}</div>\n <div class=\"t-price\">{t.price}</div>\n <p class=\"t-note\">{t.note}</p>\n <ul>{(t.features ?? []).map((f: string) => <li>{f}</li>)}</ul>\n <a class=\"t-cta\" href=\"#\">Reserve a {String(t.name).toLowerCase()} pass</a>\n </div>\n ))\n : <p class=\"empty-note\">No tickets yet — add them to the Venue document in Studio.</p>}\n </div>\n </div></section>\n</Base>", "site/conference/src/trokky/site.ts": "/**\n * What the pages read.\n *\n * They call the core directly rather than the HTTP API: the site is server-side code in the same\n * process as Trokky, so it has the same trust and needs no token, and a render never leaves the\n * process. Only published content is ever returned from here.\n *\n * Nothing in this file is runtime-specific — that is the point. It is byte-identical on Workers\n * and on Node; only `page.ts` differs, because only the way a page gets hold of the core differs.\n *\n * A conference is joined data — a session points at its track and its speakers — so the whole\n * program is loaded once and resolved here. The Studio writes references as `{_ref, _type}`\n * objects and seeds write plain ids; both must join, which is what `refId` is for.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/**\n * Where the API is mounted, for the media URLs the browser will fetch. Declared here rather than\n * imported, because the Workers entry that also declares it does not exist on Node.\n */\nconst API_PATH = '/api'\n\ntype Doc = Record<string, any> & { id: string; _status?: string }\n\nconst published = { filter: { _status: 'published' } }\n\n/** A Studio reference field stores `{_ref, _type}`; seeds and hand-written content store the\n * plain id. The adapter matches on the stored shape, so joins read through this — never `===`. */\nexport const refId = (value: unknown): string | null => {\n if (typeof value === 'string') return value\n const ref = (value as { _ref?: unknown } | null)?._ref\n return typeof ref === 'string' ? ref : null\n}\n\n/** A date field is stored exactly as written (`2026-05-13`); the Studio may write a full ISO\n * datetime. Both are the same day, which is what the schedule groups on. */\nconst dayOf = (value: unknown): string => (/^\\d{4}-\\d{2}-\\d{2}/.exec(String(value ?? '')) ?? [''])[0]\n\nexport interface Site {\n event: Doc | null\n venue: Doc | null\n /** Tracks in their scheduled column order. */\n tracks(): Promise<Doc[]>\n track(id: string): Promise<Doc | null>\n /** Speakers in name order. */\n speakers(): Promise<Doc[]>\n /** One speaker, with sessions reverse-resolved from the session documents. */\n speaker(id: string): Promise<Doc | null>\n sessions(opts?: { day?: string; track?: string; speaker?: string; type?: string }): Promise<Doc[]>\n session(id: string): Promise<Doc | null>\n /** Tiers in platinum, gold, silver, partner order; empty tiers are skipped. */\n sponsorsByTier(): Promise<{ tier: string; sponsors: Doc[] }[]>\n /** The distinct session days, in order. */\n days(): Promise<string[]>\n}\n\nconst TIERS = ['platinum', 'gold', 'silver', 'partner']\n\nexport async function site(core: TrokkyCore): Promise<Site> {\n const [event, venue, trackRows, speakerRows, sessionRows, sponsorRows] = await Promise.all([\n core.getDocument('event', 'event') as Promise<Doc | null>,\n core.getDocument('venue', 'venue') as Promise<Doc | null>,\n core.listDocuments('track', published) as Promise<Doc[]>,\n core.listDocuments('speaker', published) as Promise<Doc[]>,\n core.listDocuments('session', published) as Promise<Doc[]>,\n core.listDocuments('sponsor', published) as Promise<Doc[]>,\n ])\n\n // Name guards: a hand-written document without a name would otherwise throw once it reached a\n // sort key, and one bad doc would 500 every route on the site.\n const tracks = [...trackRows].sort((a, b) =>\n Number(a.order ?? 0) - Number(b.order ?? 0) || String(a.name ?? '').localeCompare(String(b.name ?? '')))\n const tracksById = new Map(tracks.map(t => [t.id, t]))\n\n const speakers = [...speakerRows].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')))\n const speakersById = new Map(speakers.map(sp => [sp.id, sp]))\n\n const resolveSession = (s: Doc): Doc => ({\n ...s,\n day: dayOf(s.day),\n track: tracksById.get(refId(s.track) ?? '') ?? null,\n speakers: (s.speakers ?? []).map((v: unknown) => speakersById.get(refId(v) ?? '')).filter((sp): sp is Doc => Boolean(sp)),\n })\n\n const sessions = sessionRows\n .map(resolveSession)\n .sort((a, b) => a.day.localeCompare(b.day) || String(a.start ?? '').localeCompare(String(b.start ?? '')))\n\n /** A speaker's sessions, reverse-resolved: the speaker document stores none. */\n const sessionsBySpeaker = new Map<string, Doc[]>()\n for (const s of sessions) for (const sp of s.speakers) {\n if (!sessionsBySpeaker.has(sp.id)) sessionsBySpeaker.set(sp.id, [])\n sessionsBySpeaker.get(sp.id)!.push(s)\n }\n\n const sessionById = new Map(sessions.map(s => [s.id, s]))\n\n return {\n event,\n venue,\n\n tracks: async () => tracks,\n track: async id => tracksById.get(id) ?? null,\n\n speakers: async () => speakers,\n speaker: async id => {\n const sp = speakersById.get(id)\n return sp ? { ...sp, sessions: sessionsBySpeaker.get(id) ?? [] } : null\n },\n\n sessions: async ({ day, track, speaker, type } = {}) => sessions.filter(s =>\n (!day || s.day === day) &&\n (!track || s.track?.id === track) &&\n (!type || s.type === type) &&\n (!speaker || s.speakers.some((sp: Doc) => sp.id === speaker))\n ),\n session: async id => sessionById.get(id) ?? null,\n\n sponsorsByTier: async () => {\n const tiers = new Map(TIERS.map(t => [t, [] as Doc[]]))\n for (const sp of sponsorRows) if (tiers.has(sp.tier)) tiers.get(sp.tier)!.push(sp)\n return [...tiers.entries()].filter(([, sponsors]) => sponsors.length > 0).map(([tier, sponsors]) => ({ tier, sponsors }))\n },\n\n // A session with no parseable day gets '' from dayOf; it must not become a phantom column.\n days: async () => [...new Set(sessions.map(s => s.day).filter(Boolean))].sort(),\n }\n}\n\n/**\n * A media field holds `{ asset: { _ref: <media id> }, alt?, caption? }` — the shape the Studio\n * reads and writes. A bare id is accepted too, for content written by hand.\n */\nexport function mediaId(value: unknown): string | null {\n if (typeof value === 'string') return value\n const ref = (value as { asset?: { _ref?: unknown } } | null)?.asset?._ref\n return typeof ref === 'string' ? ref : null\n}\n\nexport const mediaUrl = (value: unknown, variant?: 'thumbnail' | 'large'): string | null => {\n const id = mediaId(value)\n if (!id) return null\n return variant ? `${API_PATH}/media/${id}/variants/${variant}` : `${API_PATH}/media/${id}/file`\n}\n\n/** Formatting and the generated marks, shared by the pages and the components. */\n\nconst DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n\n/** `2026-05-13` — or the Studio's datetime form of it — as `Wed 13 May`. */\nexport const formatDate = (value: unknown): string => {\n const day = dayOf(value)\n if (!day) return ''\n const d = new Date(`${day}T00:00:00`)\n return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`\n}\n\nexport const formatDateRange = (start: unknown, end: unknown): string => {\n const s = dayOf(start).slice(0, 10)\n const e = dayOf(end).slice(0, 10)\n if (!s || !e) return ''\n const a = new Date(`${s}T00:00:00`)\n const b = new Date(`${e}T00:00:00`)\n if (a.getMonth() === b.getMonth()) {\n return `${MONTHS[a.getMonth()]} ${a.getDate()}–${b.getDate()}, ${b.getFullYear()}`\n }\n return `${formatDate(s)} – ${formatDate(e)}`\n}\n\n/**\n * The generated marks: speakers and sponsors without an uploaded image render a deterministic\n * monogram instead, seeded from a string so the same name always gets the same color — the\n * site ships no image assets at all.\n */\nexport const hashStr = (s: string): number => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 0)\n\nconst MARK_COLORS = ['#C8442B', '#2C6E6A', '#B8860B', '#5B4B8A', '#3D6B9E', '#8A3324', '#4A6741', '#7A4B6E']\nexport const colorFor = (s: string): string => MARK_COLORS[hashStr(s) % MARK_COLORS.length]\n\nexport const initials = (name: string): string =>\n name.split(/\\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase()", "site/magazine/src/layouts/Base.astro": "---\nimport { mediaUrl } from '../trokky/site'\ninterface Props { title?: string; settings: Record<string, any> | null; description?: string }\nconst { title, settings, description } = Astro.props\nconst siteName = settings?.siteName ?? 'The Magazine'\nconst nav: { label: string; href: string }[] = settings?.navigation ?? []\nconst logo = mediaUrl(settings?.logo, 'thumbnail')\n---\n<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{title ? `${title} — ${siteName}` : siteName}</title>\n {description && <meta name=\"description\" content={description} />}\n <style is:global>\n :root { --ink: #1a1a1a; --muted: #666; --line: #e5e5e5; --accent: #2b6cb0; --bg: #fff; }\n @media (prefers-color-scheme: dark) { :root { --ink: #ececec; --muted: #9a9a9a; --line: #2a2a2a; --accent: #7ab0f5; --bg: #111; } }\n * { box-sizing: border-box }\n body { margin: 0; font: 17px/1.6 Georgia, \"Times New Roman\", serif; color: var(--ink); background: var(--bg) }\n a { color: var(--accent); text-decoration: none } a:hover { text-decoration: underline }\n .wrap { max-width: 860px; margin: 0 auto; padding: 0 20px }\n header { border-bottom: 1px solid var(--line); padding: 20px 0; font-family: system-ui, sans-serif }\n header .wrap { display: flex; align-items: center; gap: 24px; flex-wrap: wrap }\n header .brand { font-weight: 700; font-size: 20px; color: var(--ink); display: flex; align-items: center; gap: 10px }\n header .brand img { height: 32px; width: auto }\n header nav { display: flex; gap: 18px; margin-left: auto } header nav a { color: var(--ink) }\n header .tag { width: 100%; color: var(--muted); font-size: 14px; margin: 0 }\n main { padding: 36px 0 } h1 { font-size: 2.2rem; line-height: 1.15; margin: 0 0 8px }\n .meta { color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; margin-bottom: 24px }\n .lead { font-size: 1.15rem; color: var(--muted) }\n .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 28px; list-style: none; padding: 0; margin: 0 }\n .card img { width: 100%; aspect-ratio: 3/2; object-fit: cover; border-radius: 6px; background: var(--line) }\n .card a { color: var(--ink) } .card a:hover { text-decoration: none } .card a:hover h3 { color: var(--accent) }\n .card h3 { margin: 12px 0 6px; font-size: 1.2rem; line-height: 1.3; font-family: system-ui, sans-serif } .card p { margin: 0; color: var(--muted); font-size: .95rem }\n h1 a { color: var(--ink) } h1 a:hover { color: var(--accent); text-decoration: none }\n h2.section { font-family: system-ui, sans-serif; font-size: 1.1rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin: 48px 0 20px }\n .cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: 8px; margin: 0 0 24px }\n .body :is(h2, h3) { font-family: system-ui, sans-serif; margin-top: 1.8em } .body img { max-width: 100% }\n .related { border-top: 1px solid var(--line); margin-top: 48px; padding-top: 24px }\n footer { border-top: 1px solid var(--line); color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; padding: 24px 0 40px }\n .studio { position: fixed; right: 16px; bottom: 16px; font: 13px system-ui, sans-serif; background: var(--accent); color: #fff; padding: 8px 12px; border-radius: 999px; opacity: .9 }\n </style>\n </head>\n <body>\n <header><div class=\"wrap\">\n <a class=\"brand\" href=\"/\">{logo && <img src={logo} alt=\"\" />}{siteName}</a>\n <nav>{nav.map(item => <a href={item.href}>{item.label}</a>)}</nav>\n {settings?.tagline && <p class=\"tag\">{settings.tagline}</p>}\n </div></header>\n <main><div class=\"wrap\"><slot /></div></main>\n <footer><div class=\"wrap\">{settings?.footer ?? 'Published with Trokky.'}</div></footer>\n <a class=\"studio\" href=\"/studio\">Edit in Studio →</a>\n </body>\n</html>\n", "site/magazine/src/pages/[slug].astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nconst s = await load(Astro)\nconst page = await s.page(Astro.params.slug!)\nif (!page) return new Response(null, { status: 404 })\n---\n<Base title={page.title} settings={s.settings}>\n <h1>{page.title}</h1>\n <div class=\"body\" set:html={page.body ?? ''} />\n</Base>\n", "site/magazine/src/pages/articles/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst article = await s.article(Astro.params.slug!)\nif (!article) return new Response(null, { status: 404 })\nconst author = article.author ? await s.author(article.author) : null\nconst category = article.category ? await s.category(article.category) : null\nconst related = Array.isArray(article.related) ? await s.articlesIn(article.related) : []\n---\n<Base title={article.title} settings={s.settings} description={article.excerpt}>\n <article>\n {mediaUrl(article.cover, 'large') && <img class=\"cover\" src={mediaUrl(article.cover, 'large')} alt=\"\" />}\n <h1>{article.title}</h1>\n <p class=\"meta\">\n {author && <a href={`/authors/${author.slug}`}>{author.name}</a>}{author && ' · '}\n {category && <a href={`/categories/${category.slug}`}>{category.name}</a>}{category && ' · '}\n {formatDate(article.publishedAt)}\n </p>\n {article.excerpt && <p class=\"lead\">{article.excerpt}</p>}\n <div class=\"body\" set:html={article.body ?? ''} />\n </article>\n {related.length > 0 && (\n <section class=\"related\">\n <h2 class=\"section\">Related</h2>\n <ul class=\"grid\">{related.map(r => <li class=\"card\"><a href={`/articles/${r.slug}`}><h3>{r.title}</h3></a><p>{r.excerpt}</p></li>)}</ul>\n </section>\n )}\n</Base>\n", "site/magazine/src/pages/articles/index.astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst articles = await s.articles()\n---\n<Base title=\"Articles\" settings={s.settings}>\n <h1>Articles</h1>\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\">\n <a href={`/articles/${a.slug}`}>\n {mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}\n <h3>{a.title}</h3>\n </a>\n <p>{formatDate(a.publishedAt)} — {a.excerpt}</p>\n </li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/authors/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst item = await s.authorBySlug(Astro.params.slug!)\nif (!item) return new Response(null, { status: 404 })\nconst articles = await s.articlesBy('author', item.id)\n---\n<Base title={item.name} settings={s.settings}>\n <h1>{item.name}</h1>\n {item.role && <p class=\"meta\">{item.role}</p>}\n {(item.bio ?? item.description) && <p class=\"lead\">{item.bio ?? item.description}</p>}\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\"><a href={`/articles/${a.slug}`}>{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}<h3>{a.title}</h3></a><p>{formatDate(a.publishedAt)}</p></li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/categories/[slug].astro": "---\nimport Base from '../../layouts/Base.astro'\nimport { load } from '../../trokky/page'\nimport { mediaUrl, formatDate } from '../../trokky/site'\nconst s = await load(Astro)\nconst item = await s.categoryBySlug(Astro.params.slug!)\nif (!item) return new Response(null, { status: 404 })\nconst articles = await s.articlesBy('category', item.id)\n---\n<Base title={item.name} settings={s.settings}>\n <h1>{item.name}</h1>\n {item.role && <p class=\"meta\">{item.role}</p>}\n {(item.bio ?? item.description) && <p class=\"lead\">{item.bio ?? item.description}</p>}\n <ul class=\"grid\">\n {articles.map(a => (\n <li class=\"card\"><a href={`/articles/${a.slug}`}>{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}<h3>{a.title}</h3></a><p>{formatDate(a.publishedAt)}</p></li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/documents.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { mediaUrl, formatDate } from '../trokky/site'\nconst s = await load(Astro)\nconst docs = await s.documents()\n---\n<Base title=\"Documents\" settings={s.settings}>\n <h1>Documents</h1>\n {docs.length === 0 && <p class=\"lead\">No documents yet. Upload a PDF under <a href=\"/studio\">Documents</a> in Studio.</p>}\n <ul style=\"list-style:none;padding:0\">\n {docs.map(d => (\n <li style=\"padding:16px 0;border-bottom:1px solid var(--line)\">\n <h3 style=\"margin:0 0 4px\"><a href={mediaUrl(d.file) ?? '#'}>{d.title}</a></h3>\n <p class=\"meta\" style=\"margin:0\">{formatDate(d.publishedAt)}{d.summary && ` — ${d.summary}`}</p>\n </li>\n ))}\n </ul>\n</Base>\n", "site/magazine/src/pages/index.astro": "---\nimport Base from '../layouts/Base.astro'\nimport { load } from '../trokky/page'\nimport { mediaUrl, formatDate } from '../trokky/site'\nconst s = await load(Astro)\nconst featured = await s.featured()\nconst latest = (await s.articles(9)).filter(a => a.id !== featured?.id)\nconst authorOf = async (a: any) => a.author ? await s.author(a.author) : null\nconst featuredAuthor = featured ? await authorOf(featured) : null\n---\n<Base settings={s.settings} description={s.settings?.tagline}>\n {featured && (\n <article>\n {mediaUrl(featured.cover, 'large') && <img class=\"cover\" src={mediaUrl(featured.cover, 'large')} alt=\"\" />}\n <h1><a href={`/articles/${featured.slug}`}>{featured.title}</a></h1>\n <p class=\"meta\">{featuredAuthor?.name}{featuredAuthor && ' · '}{formatDate(featured.publishedAt)}</p>\n <p class=\"lead\">{featured.excerpt}</p>\n </article>\n )}\n {latest.length > 0 && <h2 class=\"section\">Latest</h2>}\n <ul class=\"grid\">\n {latest.map(a => (\n <li class=\"card\">\n <a href={`/articles/${a.slug}`}>\n {mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt=\"\" />}\n <h3>{a.title}</h3>\n </a>\n <p>{a.excerpt}</p>\n </li>\n ))}\n </ul>\n {!featured && latest.length === 0 && (\n <p class=\"lead\">Nothing published yet. Open <a href=\"/studio\">Studio</a> to write the first article.</p>\n )}\n</Base>\n", "site/magazine/src/trokky/site.ts": "/**\n * What the pages read.\n *\n * They call the core directly rather than the HTTP API: the site is server-side code in the same\n * process as Trokky, so it has the same trust and needs no token, and a render never leaves the\n * process. Only published content is ever returned from here.\n *\n * Nothing in this file is runtime-specific — that is the point. It is byte-identical on Workers\n * and on Node; only `page.ts` differs, because only the way a page gets hold of the core differs.\n */\nimport type { TrokkyCore } from '@trokky/trokky'\n\n/**\n * Where the API is mounted, for the media URLs the browser will fetch. Declared here rather than\n * imported, because the Workers entry that also declares it does not exist on Node.\n */\nconst API_PATH = '/api'\n\ntype Doc = Record<string, any> & { id: string; _status?: string }\n\nconst published = { filter: { _status: 'published' } }\n\nexport interface Site {\n settings: Doc | null\n articles(limit?: number): Promise<Doc[]>\n article(slug: string): Promise<Doc | null>\n featured(): Promise<Doc | null>\n author(id: string): Promise<Doc | null>\n authorBySlug(slug: string): Promise<Doc | null>\n category(id: string): Promise<Doc | null>\n categoryBySlug(slug: string): Promise<Doc | null>\n categories(): Promise<Doc[]>\n articlesBy(field: 'author' | 'category', id: string): Promise<Doc[]>\n articlesIn(ids: string[]): Promise<Doc[]>\n documents(): Promise<Doc[]>\n page(slug: string): Promise<Doc | null>\n}\n\nconst byDateDesc = (a: Doc, b: Doc) => String(b.publishedAt ?? '').localeCompare(String(a.publishedAt ?? ''))\n\nexport async function site(core: TrokkyCore): Promise<Site> {\n const settings = (await core.getDocument('settings', 'settings')) as Doc | null\n const list = async (collection: string, filter: Record<string, unknown> = {}) =>\n (await core.listDocuments(collection, { filter: { ...published.filter, ...filter } })) as Doc[]\n const one = async (collection: string, filter: Record<string, unknown>) => (await list(collection, filter))[0] ?? null\n\n return {\n settings,\n articles: async (limit) => (await list('article')).sort(byDateDesc).slice(0, limit),\n article: (slug) => one('article', { slug }),\n featured: async () => (await list('article', { featured: true })).sort(byDateDesc)[0] ?? null,\n author: async (id) => (await core.getDocument('author', id)) as Doc | null,\n authorBySlug: (slug) => one('author', { slug }),\n category: async (id) => (await core.getDocument('category', id)) as Doc | null,\n categoryBySlug: (slug) => one('category', { slug }),\n categories: () => list('category'),\n articlesBy: async (field, id) => (await list('article', { [field]: id })).sort(byDateDesc),\n articlesIn: async (ids) => (await Promise.all(ids.map(id => core.getDocument('article', id)))).filter((d): d is Doc => !!d && d._status === 'published'),\n documents: async () => (await list('document')).sort(byDateDesc),\n page: (slug) => one('page', { slug }),\n }\n}\n\n/**\n * A media field holds `{ asset: { _ref: <media id> }, alt?, caption? }` — the shape the Studio\n * reads and writes. A bare id is accepted too, for content written by hand.\n */\nexport function mediaId(value: unknown): string | null {\n if (typeof value === 'string') return value\n const ref = (value as { asset?: { _ref?: unknown } } | null)?.asset?._ref\n return typeof ref === 'string' ? ref : null\n}\n\nexport const mediaUrl = (value: unknown, variant?: 'thumbnail' | 'large'): string | null => {\n const id = mediaId(value)\n if (!id) return null\n return variant ? `${API_PATH}/media/${id}/variants/${variant}` : `${API_PATH}/media/${id}/file`\n}\n\nexport const formatDate = (value: unknown, locale = 'en'): string =>\n value ? new Date(String(value)).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }) : ''\n", "workers/scripts/copy-studio.mjs": "/**\n * Put the built Studio where the Worker serves it from.\n *\n * `@trokky/studio` ships its SPA in `dist/`. Astro copies `public/` into the build output, and\n * the Worker serves `/studio/*` from the assets binding — so Studio has to sit at\n * `public/studio/` before `astro build` runs. It is copied, never committed: `.gitignore`\n * excludes it so an upgrade of the package is the whole upgrade.\n */\nimport { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst root = join(dirname(fileURLToPath(import.meta.url)), '..')\n// ESM-only package: resolve its entry (dist/index.js) the ESM way and take the directory.\nconst studioDist = dirname(fileURLToPath(import.meta.resolve('@trokky/studio')))\nconst target = join(root, 'public', 'studio')\n\nif (!existsSync(join(studioDist, 'index.html'))) {\n console.error(`@trokky/studio has no built dist at ${studioDist}`)\n process.exit(1)\n}\n\nrmSync(target, { recursive: true, force: true })\nmkdirSync(dirname(target), { recursive: true })\n// Only what the browser needs: the document and the hashed assets. Not the server helpers.\ncpSync(join(studioDist, 'index.html'), join(target, 'index.html'))\ncpSync(join(studioDist, 'assets'), join(target, 'assets'), { recursive: true })\nconsole.log(`Studio copied to public/studio from ${studioDist}`)\n" };