create-trokky 0.1.1
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/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +95 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +29 -0
- package/dist/emit.d.ts +21 -0
- package/dist/emit.js +489 -0
- package/dist/files-node.d.ts +8 -0
- package/dist/files-node.js +37 -0
- package/dist/files.d.ts +25 -0
- package/dist/files.js +48 -0
- package/dist/generate.d.ts +9 -0
- package/dist/generate.js +131 -0
- package/dist/generated/files-binary.d.ts +7 -0
- package/dist/generated/files-binary.js +7 -0
- package/dist/generated/files-text.d.ts +6 -0
- package/dist/generated/files-text.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest.d.ts +89 -0
- package/dist/manifest.js +24 -0
- package/dist/node.d.ts +6 -0
- package/dist/node.js +6 -0
- package/dist/validate.d.ts +51 -0
- package/dist/validate.js +97 -0
- package/dist/write.d.ts +5 -0
- package/dist/write.js +15 -0
- package/files/base/gitignore +7 -0
- package/files/content/magazine/public/seed/README.txt +4 -0
- package/files/content/magazine/public/seed/annual-report-2026.pdf +0 -0
- package/files/content/magazine/public/seed/avatar-1.jpg +0 -0
- package/files/content/magazine/public/seed/avatar-2.jpg +0 -0
- package/files/content/magazine/public/seed/avatar-3.jpg +0 -0
- package/files/content/magazine/public/seed/cover-1.jpg +0 -0
- package/files/content/magazine/public/seed/cover-2.jpg +0 -0
- package/files/content/magazine/public/seed/cover-3.jpg +0 -0
- package/files/content/magazine/public/seed/cover-4.jpg +0 -0
- package/files/content/magazine/public/seed/cover-5.jpg +0 -0
- package/files/content/magazine/public/seed/cover-6.jpg +0 -0
- package/files/content/magazine/public/seed/editorial-guidelines.pdf +0 -0
- package/files/content/magazine/src/trokky/schemas.ts +116 -0
- package/files/content/magazine/src/trokky/seed.ts +143 -0
- package/files/content/magazine/src/trokky/structure.ts +19 -0
- package/files/site/magazine/src/layouts/Base.astro +55 -0
- package/files/site/magazine/src/pages/[slug].astro +11 -0
- package/files/site/magazine/src/pages/articles/[slug].astro +30 -0
- package/files/site/magazine/src/pages/articles/index.astro +21 -0
- package/files/site/magazine/src/pages/authors/[slug].astro +19 -0
- package/files/site/magazine/src/pages/categories/[slug].astro +19 -0
- package/files/site/magazine/src/pages/documents.astro +19 -0
- package/files/site/magazine/src/pages/index.astro +35 -0
- package/files/site/magazine/src/trokky/site.ts +81 -0
- package/files/workers/scripts/copy-studio.mjs +28 -0
- package/manifest.json +330 -0
- package/package.json +62 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by scripts/inline-files.mjs — do not edit.
|
|
3
|
+
*
|
|
4
|
+
* Every text file under files/, so the generator can run without a filesystem.
|
|
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" };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { secretsFor, manifest, axis, type Manifest, type Axis, type Option, type Constraint, type Template, type Secret, type SecretGroup, type AxisId } from './manifest.js';
|
|
2
|
+
export { withDefaults, hasStudio, hasFrontend, type ProjectConfig, type PartialConfig } from './config.js';
|
|
3
|
+
export { validate, optionsFor, reconcile, templateFor, describe, type ValidationResult, type Problem } from './validate.js';
|
|
4
|
+
export { generate, type FileTree } from './generate.js';
|
|
5
|
+
export { inlineFileSource, isBinaryPath, type FileSource, type FileSet, type InlinedFiles } from './files.js';
|
|
6
|
+
export * as emit from './emit.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { secretsFor, manifest, axis } from './manifest.js';
|
|
2
|
+
export { withDefaults, hasStudio, hasFrontend } from './config.js';
|
|
3
|
+
export { validate, optionsFor, reconcile, templateFor, describe } from './validate.js';
|
|
4
|
+
export { generate } from './generate.js';
|
|
5
|
+
export { inlineFileSource, isBinaryPath } from './files.js';
|
|
6
|
+
export * as emit from './emit.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manifest is the single source of truth for what can be composed and what cannot.
|
|
3
|
+
*
|
|
4
|
+
* It is plain data, published as `manifest.json` beside the code, so every surface reads the
|
|
5
|
+
* same rules: `npm create trokky@latest`, the trokky.build configurator, and `trokky new` in the
|
|
6
|
+
* Go CLI. Nothing reimplements them — a second implementation is a second set of bugs, and these
|
|
7
|
+
* particular rules were each learned the expensive way.
|
|
8
|
+
*/
|
|
9
|
+
export type AxisId = 'target' | 'data' | 'media' | 'images' | 'parts' | 'content';
|
|
10
|
+
export interface Option {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A heading a surface can gather axes under. Presentation only: nothing in `validate` or
|
|
17
|
+
* `generate` reads it. It lives here because a configurator that hardcodes its own grouping
|
|
18
|
+
* silently drops any axis added later into nowhere.
|
|
19
|
+
*/
|
|
20
|
+
export interface Group {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Axis {
|
|
26
|
+
id: AxisId;
|
|
27
|
+
group?: string;
|
|
28
|
+
title: string;
|
|
29
|
+
options: Option[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* "When the config looks like `when`, only these values are allowed."
|
|
33
|
+
*
|
|
34
|
+
* `because` is not decoration: a builder that refuses a combination without saying why teaches
|
|
35
|
+
* nobody. Every surface shows it.
|
|
36
|
+
*/
|
|
37
|
+
export interface Constraint {
|
|
38
|
+
id: string;
|
|
39
|
+
when: Partial<Record<AxisId, string>>;
|
|
40
|
+
allow: Partial<Record<AxisId, string[]>>;
|
|
41
|
+
because: string;
|
|
42
|
+
}
|
|
43
|
+
export interface Secret {
|
|
44
|
+
name: string;
|
|
45
|
+
/** Absent means the value is yours to supply — an endpoint, a bucket name, a key. */
|
|
46
|
+
generate?: 'hex32' | 'hex24';
|
|
47
|
+
/** The emitted config already has a working default; leaving it blank is fine. */
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
why: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* "A composition that looks like `when` also needs these."
|
|
53
|
+
*
|
|
54
|
+
* Keyed by shape rather than by target because that is the truth: the JWT secret depends on the
|
|
55
|
+
* target, S3 credentials depend on the media choice, and the next one will depend on something
|
|
56
|
+
* else again. A `Record<target, Secret[]>` could only express the first of those.
|
|
57
|
+
*/
|
|
58
|
+
export interface SecretGroup {
|
|
59
|
+
when: Partial<Record<AxisId, string>>;
|
|
60
|
+
secrets: Secret[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A composition that also exists as a published repository.
|
|
64
|
+
*
|
|
65
|
+
* This is what makes one-click deployment possible at all: Cloudflare's Deploy button takes a
|
|
66
|
+
* public repo URL, and a bundle composed in a browser does not have one. Where a composition
|
|
67
|
+
* matches a template exactly, the button can serve it; everywhere else it cannot, and saying so
|
|
68
|
+
* is better than pretending.
|
|
69
|
+
*/
|
|
70
|
+
export interface Template {
|
|
71
|
+
id: string;
|
|
72
|
+
title: string;
|
|
73
|
+
repo: string;
|
|
74
|
+
deployButton: string;
|
|
75
|
+
composition: Partial<Record<AxisId, string>>;
|
|
76
|
+
}
|
|
77
|
+
export interface Manifest {
|
|
78
|
+
manifestVersion: number;
|
|
79
|
+
groups: Group[];
|
|
80
|
+
axes: Axis[];
|
|
81
|
+
constraints: Constraint[];
|
|
82
|
+
defaults: Record<string, Partial<Record<AxisId, string>>>;
|
|
83
|
+
secrets: SecretGroup[];
|
|
84
|
+
templates: Template[];
|
|
85
|
+
}
|
|
86
|
+
export declare const manifest: Manifest;
|
|
87
|
+
export declare const axis: (id: AxisId) => Axis;
|
|
88
|
+
/** Every secret a composition needs, in manifest order, with no duplicates. */
|
|
89
|
+
export declare const secretsFor: (config: Partial<Record<AxisId, string>>) => Secret[];
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manifest is the single source of truth for what can be composed and what cannot.
|
|
3
|
+
*
|
|
4
|
+
* It is plain data, published as `manifest.json` beside the code, so every surface reads the
|
|
5
|
+
* same rules: `npm create trokky@latest`, the trokky.build configurator, and `trokky new` in the
|
|
6
|
+
* Go CLI. Nothing reimplements them — a second implementation is a second set of bugs, and these
|
|
7
|
+
* particular rules were each learned the expensive way.
|
|
8
|
+
*/
|
|
9
|
+
import manifestJson from '../manifest.json' with { type: 'json' };
|
|
10
|
+
export const manifest = manifestJson;
|
|
11
|
+
export const axis = (id) => {
|
|
12
|
+
const found = manifest.axes.find(a => a.id === id);
|
|
13
|
+
if (!found)
|
|
14
|
+
throw new Error(`Unknown axis: ${id}`);
|
|
15
|
+
return found;
|
|
16
|
+
};
|
|
17
|
+
/** Every secret a composition needs, in manifest order, with no duplicates. */
|
|
18
|
+
export const secretsFor = (config) => {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
return manifest.secrets
|
|
21
|
+
.filter(group => Object.entries(group.when).every(([axis, value]) => config[axis] === value))
|
|
22
|
+
.flatMap(group => group.secrets)
|
|
23
|
+
.filter(secret => (seen.has(secret.name) ? false : seen.add(secret.name)));
|
|
24
|
+
};
|
package/dist/node.d.ts
ADDED
package/dist/node.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse impossible compositions, and say why.
|
|
3
|
+
*
|
|
4
|
+
* The rules live in the manifest, so this function is only the engine: it never knows that
|
|
5
|
+
* sharp cannot run on Workers, it knows how to apply a constraint that says so.
|
|
6
|
+
*/
|
|
7
|
+
import { type AxisId, type Template } from './manifest.js';
|
|
8
|
+
import type { ProjectConfig } from './config.js';
|
|
9
|
+
export interface Problem {
|
|
10
|
+
/** The axis the caller has to change. */
|
|
11
|
+
axis: AxisId;
|
|
12
|
+
/** What they chose. */
|
|
13
|
+
chosen: string;
|
|
14
|
+
/** What they could choose instead, given everything else. */
|
|
15
|
+
allowed: string[];
|
|
16
|
+
/** The constraint's own explanation. */
|
|
17
|
+
because: string;
|
|
18
|
+
constraint: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ValidationResult {
|
|
21
|
+
valid: boolean;
|
|
22
|
+
problems: Problem[];
|
|
23
|
+
}
|
|
24
|
+
/** Every problem with a composition, not just the first. */
|
|
25
|
+
export declare function validate(config: Partial<ProjectConfig>): ValidationResult;
|
|
26
|
+
/**
|
|
27
|
+
* What is still choosable on one axis, given the choices already made.
|
|
28
|
+
*
|
|
29
|
+
* Judged per axis, not on the whole config. A caller mid-change holds a config that is briefly
|
|
30
|
+
* inconsistent — someone switches target to Node while the Cloudflare adapters are still
|
|
31
|
+
* selected — and asking "is everything valid?" would answer *nothing* is available on any axis,
|
|
32
|
+
* which reads as a broken page rather than a stale one. So an option counts as available when it
|
|
33
|
+
* causes no problem *on this axis*; problems the caller has yet to resolve elsewhere are theirs
|
|
34
|
+
* to resolve, and each axis reports its own.
|
|
35
|
+
*/
|
|
36
|
+
export declare function optionsFor(axisId: AxisId, config: Partial<ProjectConfig>): string[];
|
|
37
|
+
/**
|
|
38
|
+
* The nearest valid composition to what is chosen, preferring the target's defaults.
|
|
39
|
+
*
|
|
40
|
+
* Changing one axis can invalidate several others at once; this settles them in one go so a
|
|
41
|
+
* caller never has to walk the constraints itself.
|
|
42
|
+
*/
|
|
43
|
+
export declare function reconcile(config: Partial<ProjectConfig>): Partial<ProjectConfig>;
|
|
44
|
+
/**
|
|
45
|
+
* The published repository that is exactly this composition, if there is one.
|
|
46
|
+
*
|
|
47
|
+
* Only an exact match counts. A template that differs in any axis would deploy something other
|
|
48
|
+
* than what the caller chose, which is worse than offering no button at all.
|
|
49
|
+
*/
|
|
50
|
+
export declare function templateFor(config: Partial<ProjectConfig>): Template | null;
|
|
51
|
+
export declare function describe(problems: Problem[]): string;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse impossible compositions, and say why.
|
|
3
|
+
*
|
|
4
|
+
* The rules live in the manifest, so this function is only the engine: it never knows that
|
|
5
|
+
* sharp cannot run on Workers, it knows how to apply a constraint that says so.
|
|
6
|
+
*/
|
|
7
|
+
import { manifest } from './manifest.js';
|
|
8
|
+
const matches = (constraint, config) => Object.entries(constraint.when).every(([key, value]) => config[key] === value);
|
|
9
|
+
/** Every problem with a composition, not just the first. */
|
|
10
|
+
export function validate(config) {
|
|
11
|
+
const problems = [];
|
|
12
|
+
for (const constraint of manifest.constraints) {
|
|
13
|
+
if (!matches(constraint, config))
|
|
14
|
+
continue;
|
|
15
|
+
for (const [key, allowed] of Object.entries(constraint.allow)) {
|
|
16
|
+
const chosen = config[key];
|
|
17
|
+
if (typeof chosen !== 'string' || allowed.includes(chosen))
|
|
18
|
+
continue;
|
|
19
|
+
problems.push({
|
|
20
|
+
axis: key,
|
|
21
|
+
chosen,
|
|
22
|
+
allowed,
|
|
23
|
+
because: constraint.because,
|
|
24
|
+
constraint: constraint.id,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// An unknown value is a different failure from a disallowed one, and worth naming separately.
|
|
29
|
+
for (const axis of manifest.axes) {
|
|
30
|
+
const chosen = config[axis.id];
|
|
31
|
+
if (typeof chosen !== 'string')
|
|
32
|
+
continue;
|
|
33
|
+
if (axis.options.some(option => option.id === chosen))
|
|
34
|
+
continue;
|
|
35
|
+
problems.push({
|
|
36
|
+
axis: axis.id,
|
|
37
|
+
chosen,
|
|
38
|
+
allowed: axis.options.map(option => option.id),
|
|
39
|
+
because: `"${chosen}" is not one of the ${axis.title.toLowerCase()} options.`,
|
|
40
|
+
constraint: 'known-option',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return { valid: problems.length === 0, problems };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* What is still choosable on one axis, given the choices already made.
|
|
47
|
+
*
|
|
48
|
+
* Judged per axis, not on the whole config. A caller mid-change holds a config that is briefly
|
|
49
|
+
* inconsistent — someone switches target to Node while the Cloudflare adapters are still
|
|
50
|
+
* selected — and asking "is everything valid?" would answer *nothing* is available on any axis,
|
|
51
|
+
* which reads as a broken page rather than a stale one. So an option counts as available when it
|
|
52
|
+
* causes no problem *on this axis*; problems the caller has yet to resolve elsewhere are theirs
|
|
53
|
+
* to resolve, and each axis reports its own.
|
|
54
|
+
*/
|
|
55
|
+
export function optionsFor(axisId, config) {
|
|
56
|
+
const all = manifest.axes.find(a => a.id === axisId)?.options.map(o => o.id) ?? [];
|
|
57
|
+
return all.filter(option => validate({ ...config, [axisId]: option }).problems.every(problem => problem.axis !== axisId));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The nearest valid composition to what is chosen, preferring the target's defaults.
|
|
61
|
+
*
|
|
62
|
+
* Changing one axis can invalidate several others at once; this settles them in one go so a
|
|
63
|
+
* caller never has to walk the constraints itself.
|
|
64
|
+
*/
|
|
65
|
+
export function reconcile(config) {
|
|
66
|
+
const settled = { ...config };
|
|
67
|
+
const defaults = manifest.defaults[String(settled.target)] ?? {};
|
|
68
|
+
for (const axis of manifest.axes) {
|
|
69
|
+
if (axis.id === 'target')
|
|
70
|
+
continue;
|
|
71
|
+
const available = optionsFor(axis.id, settled);
|
|
72
|
+
if (available.length === 0)
|
|
73
|
+
continue;
|
|
74
|
+
const current = settled[axis.id];
|
|
75
|
+
if (typeof current === 'string' && available.includes(current))
|
|
76
|
+
continue;
|
|
77
|
+
// The target's own default where it fits, so switching target lands somewhere sensible
|
|
78
|
+
// rather than merely somewhere legal.
|
|
79
|
+
const preferred = defaults[axis.id];
|
|
80
|
+
settled[axis.id] = preferred && available.includes(preferred) ? preferred : available[0];
|
|
81
|
+
}
|
|
82
|
+
return settled;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The published repository that is exactly this composition, if there is one.
|
|
86
|
+
*
|
|
87
|
+
* Only an exact match counts. A template that differs in any axis would deploy something other
|
|
88
|
+
* than what the caller chose, which is worse than offering no button at all.
|
|
89
|
+
*/
|
|
90
|
+
export function templateFor(config) {
|
|
91
|
+
return (manifest.templates.find(template => Object.entries(template.composition).every(([axis, value]) => config[axis] === value)) ?? null);
|
|
92
|
+
}
|
|
93
|
+
export function describe(problems) {
|
|
94
|
+
return problems
|
|
95
|
+
.map(p => ` ${p.axis}: "${p.chosen}" is not available here.\n ${p.because}\n Try: ${p.allowed.join(', ')}`)
|
|
96
|
+
.join('\n');
|
|
97
|
+
}
|
package/dist/write.d.ts
ADDED
package/dist/write.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put a generated tree on disk. The only part of the generator that touches the filesystem.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
export function writeTree(tree, target) {
|
|
7
|
+
if (existsSync(target) && readdirSync(target).length > 0) {
|
|
8
|
+
throw new Error(`${target} is not empty. Choose an empty directory, or a name that does not exist yet.`);
|
|
9
|
+
}
|
|
10
|
+
for (const [path, contents] of tree) {
|
|
11
|
+
const full = join(target, path);
|
|
12
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
13
|
+
writeFileSync(full, contents);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Sample images and documents used only by the first-boot seed.
|
|
2
|
+
Photos: Lorem Picsum (picsum.photos), which serves Unsplash photos under the Unsplash license.
|
|
3
|
+
PDFs: generated with Typst from a few paragraphs of placeholder text.
|
|
4
|
+
Delete this folder once the sample content is gone; nothing else references it.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content model: a magazine.
|
|
3
|
+
*
|
|
4
|
+
* Chosen because it exercises what a newcomer evaluates a CMS on — references both ways,
|
|
5
|
+
* rich text, media in two kinds (images and PDFs), drafts, slugs, a singleton — in a shape
|
|
6
|
+
* everyone already understands. Rename, add and remove freely; the Studio follows the schema.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const settingsSchema = {
|
|
10
|
+
name: 'settings',
|
|
11
|
+
title: 'Site settings',
|
|
12
|
+
type: 'document',
|
|
13
|
+
singleton: true,
|
|
14
|
+
description: 'Name, navigation and footer for the whole site',
|
|
15
|
+
fields: {
|
|
16
|
+
siteName: { type: 'string', title: 'Site name', required: true, default: 'The Magazine' },
|
|
17
|
+
tagline: { type: 'string', title: 'Tagline', description: 'One line under the name' },
|
|
18
|
+
tagline_fr: { type: 'string', title: 'Tagline (français)' },
|
|
19
|
+
logo: { type: 'media', title: 'Logo', options: { accept: 'image/*' } },
|
|
20
|
+
navigation: {
|
|
21
|
+
type: 'array',
|
|
22
|
+
title: 'Navigation',
|
|
23
|
+
description: 'Links in the header, in order',
|
|
24
|
+
of: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
fields: {
|
|
27
|
+
label: { type: 'string', title: 'Label', required: true },
|
|
28
|
+
href: { type: 'string', title: 'Path or URL', required: true }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
footer: { type: 'text', title: 'Footer text', options: { rows: 2 } },
|
|
33
|
+
footer_fr: { type: 'text', title: 'Footer text (français)', options: { rows: 2 } }
|
|
34
|
+
}
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
export const authorSchema = {
|
|
38
|
+
name: 'author',
|
|
39
|
+
title: 'Author',
|
|
40
|
+
type: 'document',
|
|
41
|
+
fields: {
|
|
42
|
+
name: { type: 'string', title: 'Name', required: true },
|
|
43
|
+
slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },
|
|
44
|
+
role: { type: 'string', title: 'Role', description: 'Editor, contributor, photographer…' },
|
|
45
|
+
bio: { type: 'text', title: 'Bio', options: { rows: 4 } },
|
|
46
|
+
avatar: { type: 'media', title: 'Photo', options: { accept: 'image/*' } }
|
|
47
|
+
}
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
export const categorySchema = {
|
|
51
|
+
name: 'category',
|
|
52
|
+
title: 'Category',
|
|
53
|
+
type: 'document',
|
|
54
|
+
fields: {
|
|
55
|
+
name: { type: 'string', title: 'Name', required: true },
|
|
56
|
+
slug: { type: 'slug', title: 'URL', source: 'name', autoGenerate: true, required: true },
|
|
57
|
+
description: { type: 'text', title: 'Description', options: { rows: 2 } }
|
|
58
|
+
}
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
export const articleSchema = {
|
|
62
|
+
name: 'article',
|
|
63
|
+
title: 'Article',
|
|
64
|
+
type: 'document',
|
|
65
|
+
description: 'A story. Save as draft, publish when ready.',
|
|
66
|
+
fields: {
|
|
67
|
+
title: { type: 'string', title: 'Title', required: true, validation: { maxLength: 150 } },
|
|
68
|
+
title_fr: { type: 'string', title: 'Title (français)', validation: { maxLength: 150 } },
|
|
69
|
+
slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },
|
|
70
|
+
excerpt: { type: 'text', title: 'Excerpt', description: 'Shown in lists', options: { rows: 3 }, validation: { maxLength: 300 } },
|
|
71
|
+
cover: { type: 'media', title: 'Cover image', options: { accept: 'image/*' } },
|
|
72
|
+
author: { type: 'reference', title: 'Author', to: 'author' },
|
|
73
|
+
category: { type: 'reference', title: 'Category', to: 'category' },
|
|
74
|
+
featured: { type: 'boolean', title: 'Featured on the home page', default: false },
|
|
75
|
+
publishedAt: { type: 'date', title: 'Published on', options: { includeTime: true }, default: 'now' },
|
|
76
|
+
body: {
|
|
77
|
+
type: 'richtext',
|
|
78
|
+
title: 'Body',
|
|
79
|
+
options: { headingLevels: [2, 3], enableFullscreen: true, minHeight: '320px', showStats: true }
|
|
80
|
+
},
|
|
81
|
+
related: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
title: 'Related articles',
|
|
84
|
+
of: { type: 'reference', to: 'article' },
|
|
85
|
+
validation: { maxItems: 3 }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} as const
|
|
89
|
+
|
|
90
|
+
export const documentSchema = {
|
|
91
|
+
name: 'document',
|
|
92
|
+
title: 'Document',
|
|
93
|
+
type: 'document',
|
|
94
|
+
description: 'A downloadable file — a report, a form, a PDF',
|
|
95
|
+
fields: {
|
|
96
|
+
title: { type: 'string', title: 'Title', required: true },
|
|
97
|
+
slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },
|
|
98
|
+
summary: { type: 'text', title: 'Summary', options: { rows: 2 } },
|
|
99
|
+
file: { type: 'media', title: 'File', required: true, options: { accept: 'application/pdf' } },
|
|
100
|
+
publishedAt: { type: 'date', title: 'Published on', default: 'now' }
|
|
101
|
+
}
|
|
102
|
+
} as const
|
|
103
|
+
|
|
104
|
+
export const pageSchema = {
|
|
105
|
+
name: 'page',
|
|
106
|
+
title: 'Page',
|
|
107
|
+
type: 'document',
|
|
108
|
+
description: 'A standalone page such as About or Contact',
|
|
109
|
+
fields: {
|
|
110
|
+
title: { type: 'string', title: 'Title', required: true },
|
|
111
|
+
slug: { type: 'slug', title: 'URL', source: 'title', autoGenerate: true, required: true },
|
|
112
|
+
body: { type: 'richtext', title: 'Body', options: { headingLevels: [2, 3] } }
|
|
113
|
+
}
|
|
114
|
+
} as const
|
|
115
|
+
|
|
116
|
+
export const schemas = [settingsSchema, authorSchema, categorySchema, articleSchema, documentSchema, pageSchema]
|