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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample content, created the moment the instance is claimed.
|
|
3
|
+
*
|
|
4
|
+
* An empty CMS shows an empty site, and the first minute decides whether a newcomer keeps
|
|
5
|
+
* going. So the new owner's first sight of both the site and the Studio is a working magazine —
|
|
6
|
+
* illustrated, with authors, categories, downloadable documents and pages — that they can edit
|
|
7
|
+
* or delete. Every document id is fixed, so seeding twice is harmless; media is only uploaded
|
|
8
|
+
* when there are no articles yet.
|
|
9
|
+
*
|
|
10
|
+
* The images and PDFs ship with the project and go through the normal upload pipeline, so
|
|
11
|
+
* thumbnails are made exactly as they would be for a real upload.
|
|
12
|
+
*
|
|
13
|
+
* How those files are read differs by runtime — a Worker has an assets binding, a Node process
|
|
14
|
+
* has a disk — so the caller supplies a reader rather than this file guessing.
|
|
15
|
+
*/
|
|
16
|
+
import type { TrokkyCore } from '@trokky/trokky'
|
|
17
|
+
|
|
18
|
+
/** Read one file from `public/seed/`, however this runtime reaches it. */
|
|
19
|
+
export type SeedAssetReader = (path: string) => Promise<ArrayBuffer>
|
|
20
|
+
|
|
21
|
+
const P = { _status: 'published' } as const
|
|
22
|
+
|
|
23
|
+
/** The value the Studio's media field reads and writes. */
|
|
24
|
+
const media = (id: string, alt?: string) => ({ _type: 'media', asset: { _type: 'mediaAsset', _ref: id }, ...(alt ? { alt } : {}) })
|
|
25
|
+
|
|
26
|
+
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString()
|
|
27
|
+
const paras = (...ps: string[]) => ps.map(p => `<p>${p}</p>`).join('')
|
|
28
|
+
|
|
29
|
+
export async function seedSampleContent(core: TrokkyCore, readAsset: SeedAssetReader): Promise<void> {
|
|
30
|
+
if ((await core.listDocuments('article', { limit: 1 })).length > 0) return
|
|
31
|
+
|
|
32
|
+
const upload = async (path: string, filename: string, type: string): Promise<string> => {
|
|
33
|
+
const file = new File([await readAsset(path)], filename, { type })
|
|
34
|
+
return (await core.uploadMedia(file)).id
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [c1, c2, c3, c4, c5, c6] = await Promise.all(
|
|
38
|
+
[1, 2, 3, 4, 5, 6].map(n => upload(`cover-${n}.jpg`, `cover-${n}.jpg`, 'image/jpeg'))
|
|
39
|
+
)
|
|
40
|
+
const [a1, a2, a3] = await Promise.all(
|
|
41
|
+
[1, 2, 3].map(n => upload(`avatar-${n}.jpg`, `portrait-${n}.jpg`, 'image/jpeg'))
|
|
42
|
+
)
|
|
43
|
+
const [pdfReport, pdfGuide] = await Promise.all([
|
|
44
|
+
upload('annual-report-2026.pdf', 'annual-report-2026.pdf', 'application/pdf'),
|
|
45
|
+
upload('editorial-guidelines.pdf', 'editorial-guidelines.pdf', 'application/pdf')
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
await core.saveDocument('settings', {
|
|
49
|
+
id: 'settings', ...P,
|
|
50
|
+
siteName: 'The Magazine',
|
|
51
|
+
tagline: 'Stories worth the read',
|
|
52
|
+
tagline_fr: 'Des histoires qui valent le détour',
|
|
53
|
+
navigation: [
|
|
54
|
+
{ label: 'Articles', href: '/articles' },
|
|
55
|
+
{ label: 'Documents', href: '/documents' },
|
|
56
|
+
{ label: 'About', href: '/about' },
|
|
57
|
+
{ label: 'Contact', href: '/contact' }
|
|
58
|
+
],
|
|
59
|
+
footer: 'Published with Trokky. Everything you see here is editable in Studio.',
|
|
60
|
+
footer_fr: 'Publié avec Trokky. Tout ce que vous voyez se modifie dans Studio.'
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
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') })
|
|
64
|
+
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') })
|
|
65
|
+
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') })
|
|
66
|
+
|
|
67
|
+
await core.saveDocument('category', { id: 'category-culture', ...P, name: 'Culture', slug: 'culture', description: 'Books, music, and the people behind them.' })
|
|
68
|
+
await core.saveDocument('category', { id: 'category-tech', ...P, name: 'Technology', slug: 'technology', description: 'What is changing, and what it changes.' })
|
|
69
|
+
await core.saveDocument('category', { id: 'category-city', ...P, name: 'City', slug: 'city', description: 'Streets, markets, and the life between buildings.' })
|
|
70
|
+
|
|
71
|
+
await core.saveDocument('article', {
|
|
72
|
+
id: 'article-welcome', ...P,
|
|
73
|
+
title: 'Welcome to your magazine', title_fr: 'Bienvenue dans votre magazine',
|
|
74
|
+
slug: 'welcome-to-your-magazine',
|
|
75
|
+
excerpt: 'This site is yours. Here is how it fits together, and what to change first.',
|
|
76
|
+
cover: media(c1, 'A desk with a notebook and coffee'),
|
|
77
|
+
author: 'author-ama', category: 'category-tech', featured: true, publishedAt: daysAgo(0),
|
|
78
|
+
body: paras(
|
|
79
|
+
'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.',
|
|
80
|
+
'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.',
|
|
81
|
+
'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.'
|
|
82
|
+
),
|
|
83
|
+
related: ['article-references', 'article-media']
|
|
84
|
+
})
|
|
85
|
+
await core.saveDocument('article', {
|
|
86
|
+
id: 'article-references', ...P,
|
|
87
|
+
title: 'Authors, categories, and how references work', slug: 'authors-categories-and-references',
|
|
88
|
+
excerpt: 'An article points at an author and a category. Change the author once and every article follows.',
|
|
89
|
+
cover: media(c2), author: 'author-kossi', category: 'category-tech', featured: false, publishedAt: daysAgo(1),
|
|
90
|
+
body: paras(
|
|
91
|
+
'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.',
|
|
92
|
+
'Categories work the same way, and so do the related articles at the bottom of this page.'
|
|
93
|
+
),
|
|
94
|
+
related: ['article-welcome']
|
|
95
|
+
})
|
|
96
|
+
await core.saveDocument('article', {
|
|
97
|
+
id: 'article-media', ...P,
|
|
98
|
+
title: 'Images and documents', slug: 'images-and-documents',
|
|
99
|
+
excerpt: 'Every cover on this site was uploaded once; the thumbnails were made automatically.',
|
|
100
|
+
cover: media(c3), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(2),
|
|
101
|
+
body: paras(
|
|
102
|
+
'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.',
|
|
103
|
+
'The <a href="/documents">Documents</a> section holds files rather than stories: reports, forms, anything people download.'
|
|
104
|
+
),
|
|
105
|
+
related: ['article-welcome']
|
|
106
|
+
})
|
|
107
|
+
await core.saveDocument('article', {
|
|
108
|
+
id: 'article-market', ...P,
|
|
109
|
+
title: 'Saturday at the market, before the heat', slug: 'saturday-at-the-market',
|
|
110
|
+
excerpt: 'Six in the morning is when the market belongs to the people who run it.',
|
|
111
|
+
cover: media(c4), author: 'author-kossi', category: 'category-city', featured: false, publishedAt: daysAgo(4),
|
|
112
|
+
body: paras(
|
|
113
|
+
'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.',
|
|
114
|
+
'This piece is placeholder text — replace it with your own reporting. The structure (a title, an excerpt, a cover, a body) is what stays.'
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
await core.saveDocument('article', {
|
|
118
|
+
id: 'article-drafts', ...P,
|
|
119
|
+
title: 'Drafts, publishing, and what readers see', slug: 'drafts-and-publishing',
|
|
120
|
+
excerpt: 'Save without publishing, publish when ready, unpublish without deleting.',
|
|
121
|
+
cover: media(c5), author: 'author-ama', category: 'category-tech', featured: false, publishedAt: daysAgo(6),
|
|
122
|
+
body: paras(
|
|
123
|
+
'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.',
|
|
124
|
+
'The site only ever reads published content. Drafts are yours alone.'
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
await core.saveDocument('article', {
|
|
128
|
+
id: 'article-listening', ...P,
|
|
129
|
+
title: 'What the city sounds like at night', slug: 'what-the-city-sounds-like-at-night',
|
|
130
|
+
excerpt: 'A photographer on the difference between looking and listening.',
|
|
131
|
+
cover: media(c6), author: 'author-efua', category: 'category-culture', featured: false, publishedAt: daysAgo(9),
|
|
132
|
+
body: paras(
|
|
133
|
+
'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.',
|
|
134
|
+
'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.'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
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) })
|
|
139
|
+
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) })
|
|
140
|
+
|
|
141
|
+
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.') })
|
|
142
|
+
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.') })
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How content is arranged in the Studio sidebar.
|
|
3
|
+
*
|
|
4
|
+
* The schema decides what a singleton is; this only decides where things appear and which
|
|
5
|
+
* document id the singleton uses.
|
|
6
|
+
*/
|
|
7
|
+
export const structure = {
|
|
8
|
+
title: 'Magazine',
|
|
9
|
+
items: [
|
|
10
|
+
{ type: 'documentList', title: 'Articles', schemaType: 'article', icon: 'newspaper' },
|
|
11
|
+
{ type: 'documentList', title: 'Documents', schemaType: 'document', icon: 'document-text' },
|
|
12
|
+
{ type: 'divider' },
|
|
13
|
+
{ type: 'documentList', title: 'Authors', schemaType: 'author', icon: 'user-group' },
|
|
14
|
+
{ type: 'documentList', title: 'Categories', schemaType: 'category', icon: 'tag' },
|
|
15
|
+
{ type: 'documentList', title: 'Pages', schemaType: 'page', icon: 'document' },
|
|
16
|
+
{ type: 'divider' },
|
|
17
|
+
{ type: 'singleton', title: 'Site settings', schemaType: 'settings', documentId: 'settings', icon: 'cog-6-tooth' }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { mediaUrl } from '../trokky/site'
|
|
3
|
+
interface Props { title?: string; settings: Record<string, any> | null; description?: string }
|
|
4
|
+
const { title, settings, description } = Astro.props
|
|
5
|
+
const siteName = settings?.siteName ?? 'The Magazine'
|
|
6
|
+
const nav: { label: string; href: string }[] = settings?.navigation ?? []
|
|
7
|
+
const logo = mediaUrl(settings?.logo, 'thumbnail')
|
|
8
|
+
---
|
|
9
|
+
<!doctype html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8" />
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
14
|
+
<title>{title ? `${title} — ${siteName}` : siteName}</title>
|
|
15
|
+
{description && <meta name="description" content={description} />}
|
|
16
|
+
<style is:global>
|
|
17
|
+
:root { --ink: #1a1a1a; --muted: #666; --line: #e5e5e5; --accent: #2b6cb0; --bg: #fff; }
|
|
18
|
+
@media (prefers-color-scheme: dark) { :root { --ink: #ececec; --muted: #9a9a9a; --line: #2a2a2a; --accent: #7ab0f5; --bg: #111; } }
|
|
19
|
+
* { box-sizing: border-box }
|
|
20
|
+
body { margin: 0; font: 17px/1.6 Georgia, "Times New Roman", serif; color: var(--ink); background: var(--bg) }
|
|
21
|
+
a { color: var(--accent); text-decoration: none } a:hover { text-decoration: underline }
|
|
22
|
+
.wrap { max-width: 860px; margin: 0 auto; padding: 0 20px }
|
|
23
|
+
header { border-bottom: 1px solid var(--line); padding: 20px 0; font-family: system-ui, sans-serif }
|
|
24
|
+
header .wrap { display: flex; align-items: center; gap: 24px; flex-wrap: wrap }
|
|
25
|
+
header .brand { font-weight: 700; font-size: 20px; color: var(--ink); display: flex; align-items: center; gap: 10px }
|
|
26
|
+
header .brand img { height: 32px; width: auto }
|
|
27
|
+
header nav { display: flex; gap: 18px; margin-left: auto } header nav a { color: var(--ink) }
|
|
28
|
+
header .tag { width: 100%; color: var(--muted); font-size: 14px; margin: 0 }
|
|
29
|
+
main { padding: 36px 0 } h1 { font-size: 2.2rem; line-height: 1.15; margin: 0 0 8px }
|
|
30
|
+
.meta { color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; margin-bottom: 24px }
|
|
31
|
+
.lead { font-size: 1.15rem; color: var(--muted) }
|
|
32
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 28px; list-style: none; padding: 0; margin: 0 }
|
|
33
|
+
.card img { width: 100%; aspect-ratio: 3/2; object-fit: cover; border-radius: 6px; background: var(--line) }
|
|
34
|
+
.card a { color: var(--ink) } .card a:hover { text-decoration: none } .card a:hover h3 { color: var(--accent) }
|
|
35
|
+
.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 }
|
|
36
|
+
h1 a { color: var(--ink) } h1 a:hover { color: var(--accent); text-decoration: none }
|
|
37
|
+
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 }
|
|
38
|
+
.cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: 8px; margin: 0 0 24px }
|
|
39
|
+
.body :is(h2, h3) { font-family: system-ui, sans-serif; margin-top: 1.8em } .body img { max-width: 100% }
|
|
40
|
+
.related { border-top: 1px solid var(--line); margin-top: 48px; padding-top: 24px }
|
|
41
|
+
footer { border-top: 1px solid var(--line); color: var(--muted); font-family: system-ui, sans-serif; font-size: 14px; padding: 24px 0 40px }
|
|
42
|
+
.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 }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<header><div class="wrap">
|
|
47
|
+
<a class="brand" href="/">{logo && <img src={logo} alt="" />}{siteName}</a>
|
|
48
|
+
<nav>{nav.map(item => <a href={item.href}>{item.label}</a>)}</nav>
|
|
49
|
+
{settings?.tagline && <p class="tag">{settings.tagline}</p>}
|
|
50
|
+
</div></header>
|
|
51
|
+
<main><div class="wrap"><slot /></div></main>
|
|
52
|
+
<footer><div class="wrap">{settings?.footer ?? 'Published with Trokky.'}</div></footer>
|
|
53
|
+
<a class="studio" href="/studio">Edit in Studio →</a>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../layouts/Base.astro'
|
|
3
|
+
import { load } from '../trokky/page'
|
|
4
|
+
const s = await load(Astro)
|
|
5
|
+
const page = await s.page(Astro.params.slug!)
|
|
6
|
+
if (!page) return new Response(null, { status: 404 })
|
|
7
|
+
---
|
|
8
|
+
<Base title={page.title} settings={s.settings}>
|
|
9
|
+
<h1>{page.title}</h1>
|
|
10
|
+
<div class="body" set:html={page.body ?? ''} />
|
|
11
|
+
</Base>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../../layouts/Base.astro'
|
|
3
|
+
import { load } from '../../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const article = await s.article(Astro.params.slug!)
|
|
7
|
+
if (!article) return new Response(null, { status: 404 })
|
|
8
|
+
const author = article.author ? await s.author(article.author) : null
|
|
9
|
+
const category = article.category ? await s.category(article.category) : null
|
|
10
|
+
const related = Array.isArray(article.related) ? await s.articlesIn(article.related) : []
|
|
11
|
+
---
|
|
12
|
+
<Base title={article.title} settings={s.settings} description={article.excerpt}>
|
|
13
|
+
<article>
|
|
14
|
+
{mediaUrl(article.cover, 'large') && <img class="cover" src={mediaUrl(article.cover, 'large')} alt="" />}
|
|
15
|
+
<h1>{article.title}</h1>
|
|
16
|
+
<p class="meta">
|
|
17
|
+
{author && <a href={`/authors/${author.slug}`}>{author.name}</a>}{author && ' · '}
|
|
18
|
+
{category && <a href={`/categories/${category.slug}`}>{category.name}</a>}{category && ' · '}
|
|
19
|
+
{formatDate(article.publishedAt)}
|
|
20
|
+
</p>
|
|
21
|
+
{article.excerpt && <p class="lead">{article.excerpt}</p>}
|
|
22
|
+
<div class="body" set:html={article.body ?? ''} />
|
|
23
|
+
</article>
|
|
24
|
+
{related.length > 0 && (
|
|
25
|
+
<section class="related">
|
|
26
|
+
<h2 class="section">Related</h2>
|
|
27
|
+
<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>
|
|
28
|
+
</section>
|
|
29
|
+
)}
|
|
30
|
+
</Base>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../../layouts/Base.astro'
|
|
3
|
+
import { load } from '../../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const articles = await s.articles()
|
|
7
|
+
---
|
|
8
|
+
<Base title="Articles" settings={s.settings}>
|
|
9
|
+
<h1>Articles</h1>
|
|
10
|
+
<ul class="grid">
|
|
11
|
+
{articles.map(a => (
|
|
12
|
+
<li class="card">
|
|
13
|
+
<a href={`/articles/${a.slug}`}>
|
|
14
|
+
{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt="" />}
|
|
15
|
+
<h3>{a.title}</h3>
|
|
16
|
+
</a>
|
|
17
|
+
<p>{formatDate(a.publishedAt)} — {a.excerpt}</p>
|
|
18
|
+
</li>
|
|
19
|
+
))}
|
|
20
|
+
</ul>
|
|
21
|
+
</Base>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../../layouts/Base.astro'
|
|
3
|
+
import { load } from '../../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const item = await s.authorBySlug(Astro.params.slug!)
|
|
7
|
+
if (!item) return new Response(null, { status: 404 })
|
|
8
|
+
const articles = await s.articlesBy('author', item.id)
|
|
9
|
+
---
|
|
10
|
+
<Base title={item.name} settings={s.settings}>
|
|
11
|
+
<h1>{item.name}</h1>
|
|
12
|
+
{item.role && <p class="meta">{item.role}</p>}
|
|
13
|
+
{(item.bio ?? item.description) && <p class="lead">{item.bio ?? item.description}</p>}
|
|
14
|
+
<ul class="grid">
|
|
15
|
+
{articles.map(a => (
|
|
16
|
+
<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>
|
|
17
|
+
))}
|
|
18
|
+
</ul>
|
|
19
|
+
</Base>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../../layouts/Base.astro'
|
|
3
|
+
import { load } from '../../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const item = await s.categoryBySlug(Astro.params.slug!)
|
|
7
|
+
if (!item) return new Response(null, { status: 404 })
|
|
8
|
+
const articles = await s.articlesBy('category', item.id)
|
|
9
|
+
---
|
|
10
|
+
<Base title={item.name} settings={s.settings}>
|
|
11
|
+
<h1>{item.name}</h1>
|
|
12
|
+
{item.role && <p class="meta">{item.role}</p>}
|
|
13
|
+
{(item.bio ?? item.description) && <p class="lead">{item.bio ?? item.description}</p>}
|
|
14
|
+
<ul class="grid">
|
|
15
|
+
{articles.map(a => (
|
|
16
|
+
<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>
|
|
17
|
+
))}
|
|
18
|
+
</ul>
|
|
19
|
+
</Base>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../layouts/Base.astro'
|
|
3
|
+
import { load } from '../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const docs = await s.documents()
|
|
7
|
+
---
|
|
8
|
+
<Base title="Documents" settings={s.settings}>
|
|
9
|
+
<h1>Documents</h1>
|
|
10
|
+
{docs.length === 0 && <p class="lead">No documents yet. Upload a PDF under <a href="/studio">Documents</a> in Studio.</p>}
|
|
11
|
+
<ul style="list-style:none;padding:0">
|
|
12
|
+
{docs.map(d => (
|
|
13
|
+
<li style="padding:16px 0;border-bottom:1px solid var(--line)">
|
|
14
|
+
<h3 style="margin:0 0 4px"><a href={mediaUrl(d.file) ?? '#'}>{d.title}</a></h3>
|
|
15
|
+
<p class="meta" style="margin:0">{formatDate(d.publishedAt)}{d.summary && ` — ${d.summary}`}</p>
|
|
16
|
+
</li>
|
|
17
|
+
))}
|
|
18
|
+
</ul>
|
|
19
|
+
</Base>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../layouts/Base.astro'
|
|
3
|
+
import { load } from '../trokky/page'
|
|
4
|
+
import { mediaUrl, formatDate } from '../trokky/site'
|
|
5
|
+
const s = await load(Astro)
|
|
6
|
+
const featured = await s.featured()
|
|
7
|
+
const latest = (await s.articles(9)).filter(a => a.id !== featured?.id)
|
|
8
|
+
const authorOf = async (a: any) => a.author ? await s.author(a.author) : null
|
|
9
|
+
const featuredAuthor = featured ? await authorOf(featured) : null
|
|
10
|
+
---
|
|
11
|
+
<Base settings={s.settings} description={s.settings?.tagline}>
|
|
12
|
+
{featured && (
|
|
13
|
+
<article>
|
|
14
|
+
{mediaUrl(featured.cover, 'large') && <img class="cover" src={mediaUrl(featured.cover, 'large')} alt="" />}
|
|
15
|
+
<h1><a href={`/articles/${featured.slug}`}>{featured.title}</a></h1>
|
|
16
|
+
<p class="meta">{featuredAuthor?.name}{featuredAuthor && ' · '}{formatDate(featured.publishedAt)}</p>
|
|
17
|
+
<p class="lead">{featured.excerpt}</p>
|
|
18
|
+
</article>
|
|
19
|
+
)}
|
|
20
|
+
{latest.length > 0 && <h2 class="section">Latest</h2>}
|
|
21
|
+
<ul class="grid">
|
|
22
|
+
{latest.map(a => (
|
|
23
|
+
<li class="card">
|
|
24
|
+
<a href={`/articles/${a.slug}`}>
|
|
25
|
+
{mediaUrl(a.cover, 'thumbnail') && <img src={mediaUrl(a.cover, 'thumbnail')} alt="" />}
|
|
26
|
+
<h3>{a.title}</h3>
|
|
27
|
+
</a>
|
|
28
|
+
<p>{a.excerpt}</p>
|
|
29
|
+
</li>
|
|
30
|
+
))}
|
|
31
|
+
</ul>
|
|
32
|
+
{!featured && latest.length === 0 && (
|
|
33
|
+
<p class="lead">Nothing published yet. Open <a href="/studio">Studio</a> to write the first article.</p>
|
|
34
|
+
)}
|
|
35
|
+
</Base>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the pages read.
|
|
3
|
+
*
|
|
4
|
+
* They call the core directly rather than the HTTP API: the site is server-side code in the same
|
|
5
|
+
* process as Trokky, so it has the same trust and needs no token, and a render never leaves the
|
|
6
|
+
* process. Only published content is ever returned from here.
|
|
7
|
+
*
|
|
8
|
+
* Nothing in this file is runtime-specific — that is the point. It is byte-identical on Workers
|
|
9
|
+
* and on Node; only `page.ts` differs, because only the way a page gets hold of the core differs.
|
|
10
|
+
*/
|
|
11
|
+
import type { TrokkyCore } from '@trokky/trokky'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the API is mounted, for the media URLs the browser will fetch. Declared here rather than
|
|
15
|
+
* imported, because the Workers entry that also declares it does not exist on Node.
|
|
16
|
+
*/
|
|
17
|
+
const API_PATH = '/api'
|
|
18
|
+
|
|
19
|
+
type Doc = Record<string, any> & { id: string; _status?: string }
|
|
20
|
+
|
|
21
|
+
const published = { filter: { _status: 'published' } }
|
|
22
|
+
|
|
23
|
+
export interface Site {
|
|
24
|
+
settings: Doc | null
|
|
25
|
+
articles(limit?: number): Promise<Doc[]>
|
|
26
|
+
article(slug: string): Promise<Doc | null>
|
|
27
|
+
featured(): Promise<Doc | null>
|
|
28
|
+
author(id: string): Promise<Doc | null>
|
|
29
|
+
authorBySlug(slug: string): Promise<Doc | null>
|
|
30
|
+
category(id: string): Promise<Doc | null>
|
|
31
|
+
categoryBySlug(slug: string): Promise<Doc | null>
|
|
32
|
+
categories(): Promise<Doc[]>
|
|
33
|
+
articlesBy(field: 'author' | 'category', id: string): Promise<Doc[]>
|
|
34
|
+
articlesIn(ids: string[]): Promise<Doc[]>
|
|
35
|
+
documents(): Promise<Doc[]>
|
|
36
|
+
page(slug: string): Promise<Doc | null>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byDateDesc = (a: Doc, b: Doc) => String(b.publishedAt ?? '').localeCompare(String(a.publishedAt ?? ''))
|
|
40
|
+
|
|
41
|
+
export async function site(core: TrokkyCore): Promise<Site> {
|
|
42
|
+
const settings = (await core.getDocument('settings', 'settings')) as Doc | null
|
|
43
|
+
const list = async (collection: string, filter: Record<string, unknown> = {}) =>
|
|
44
|
+
(await core.listDocuments(collection, { filter: { ...published.filter, ...filter } })) as Doc[]
|
|
45
|
+
const one = async (collection: string, filter: Record<string, unknown>) => (await list(collection, filter))[0] ?? null
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
settings,
|
|
49
|
+
articles: async (limit) => (await list('article')).sort(byDateDesc).slice(0, limit),
|
|
50
|
+
article: (slug) => one('article', { slug }),
|
|
51
|
+
featured: async () => (await list('article', { featured: true })).sort(byDateDesc)[0] ?? null,
|
|
52
|
+
author: async (id) => (await core.getDocument('author', id)) as Doc | null,
|
|
53
|
+
authorBySlug: (slug) => one('author', { slug }),
|
|
54
|
+
category: async (id) => (await core.getDocument('category', id)) as Doc | null,
|
|
55
|
+
categoryBySlug: (slug) => one('category', { slug }),
|
|
56
|
+
categories: () => list('category'),
|
|
57
|
+
articlesBy: async (field, id) => (await list('article', { [field]: id })).sort(byDateDesc),
|
|
58
|
+
articlesIn: async (ids) => (await Promise.all(ids.map(id => core.getDocument('article', id)))).filter((d): d is Doc => !!d && d._status === 'published'),
|
|
59
|
+
documents: async () => (await list('document')).sort(byDateDesc),
|
|
60
|
+
page: (slug) => one('page', { slug }),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A media field holds `{ asset: { _ref: <media id> }, alt?, caption? }` — the shape the Studio
|
|
66
|
+
* reads and writes. A bare id is accepted too, for content written by hand.
|
|
67
|
+
*/
|
|
68
|
+
export function mediaId(value: unknown): string | null {
|
|
69
|
+
if (typeof value === 'string') return value
|
|
70
|
+
const ref = (value as { asset?: { _ref?: unknown } } | null)?.asset?._ref
|
|
71
|
+
return typeof ref === 'string' ? ref : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const mediaUrl = (value: unknown, variant?: 'thumbnail' | 'large'): string | null => {
|
|
75
|
+
const id = mediaId(value)
|
|
76
|
+
if (!id) return null
|
|
77
|
+
return variant ? `${API_PATH}/media/${id}/variants/${variant}` : `${API_PATH}/media/${id}/file`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const formatDate = (value: unknown, locale = 'en'): string =>
|
|
81
|
+
value ? new Date(String(value)).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }) : ''
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put the built Studio where the Worker serves it from.
|
|
3
|
+
*
|
|
4
|
+
* `@trokky/studio` ships its SPA in `dist/`. Astro copies `public/` into the build output, and
|
|
5
|
+
* the Worker serves `/studio/*` from the assets binding — so Studio has to sit at
|
|
6
|
+
* `public/studio/` before `astro build` runs. It is copied, never committed: `.gitignore`
|
|
7
|
+
* excludes it so an upgrade of the package is the whole upgrade.
|
|
8
|
+
*/
|
|
9
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
|
+
// ESM-only package: resolve its entry (dist/index.js) the ESM way and take the directory.
|
|
15
|
+
const studioDist = dirname(fileURLToPath(import.meta.resolve('@trokky/studio')))
|
|
16
|
+
const target = join(root, 'public', 'studio')
|
|
17
|
+
|
|
18
|
+
if (!existsSync(join(studioDist, 'index.html'))) {
|
|
19
|
+
console.error(`@trokky/studio has no built dist at ${studioDist}`)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
rmSync(target, { recursive: true, force: true })
|
|
24
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
25
|
+
// Only what the browser needs: the document and the hashed assets. Not the server helpers.
|
|
26
|
+
cpSync(join(studioDist, 'index.html'), join(target, 'index.html'))
|
|
27
|
+
cpSync(join(studioDist, 'assets'), join(target, 'assets'), { recursive: true })
|
|
28
|
+
console.log(`Studio copied to public/studio from ${studioDist}`)
|