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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/cli.d.ts +8 -0
  4. package/dist/cli.js +95 -0
  5. package/dist/config.d.ts +24 -0
  6. package/dist/config.js +29 -0
  7. package/dist/emit.d.ts +21 -0
  8. package/dist/emit.js +489 -0
  9. package/dist/files-node.d.ts +8 -0
  10. package/dist/files-node.js +37 -0
  11. package/dist/files.d.ts +25 -0
  12. package/dist/files.js +48 -0
  13. package/dist/generate.d.ts +9 -0
  14. package/dist/generate.js +131 -0
  15. package/dist/generated/files-binary.d.ts +7 -0
  16. package/dist/generated/files-binary.js +7 -0
  17. package/dist/generated/files-text.d.ts +6 -0
  18. package/dist/generated/files-text.js +6 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/manifest.d.ts +89 -0
  22. package/dist/manifest.js +24 -0
  23. package/dist/node.d.ts +6 -0
  24. package/dist/node.js +6 -0
  25. package/dist/validate.d.ts +51 -0
  26. package/dist/validate.js +97 -0
  27. package/dist/write.d.ts +5 -0
  28. package/dist/write.js +15 -0
  29. package/files/base/gitignore +7 -0
  30. package/files/content/magazine/public/seed/README.txt +4 -0
  31. package/files/content/magazine/public/seed/annual-report-2026.pdf +0 -0
  32. package/files/content/magazine/public/seed/avatar-1.jpg +0 -0
  33. package/files/content/magazine/public/seed/avatar-2.jpg +0 -0
  34. package/files/content/magazine/public/seed/avatar-3.jpg +0 -0
  35. package/files/content/magazine/public/seed/cover-1.jpg +0 -0
  36. package/files/content/magazine/public/seed/cover-2.jpg +0 -0
  37. package/files/content/magazine/public/seed/cover-3.jpg +0 -0
  38. package/files/content/magazine/public/seed/cover-4.jpg +0 -0
  39. package/files/content/magazine/public/seed/cover-5.jpg +0 -0
  40. package/files/content/magazine/public/seed/cover-6.jpg +0 -0
  41. package/files/content/magazine/public/seed/editorial-guidelines.pdf +0 -0
  42. package/files/content/magazine/src/trokky/schemas.ts +116 -0
  43. package/files/content/magazine/src/trokky/seed.ts +143 -0
  44. package/files/content/magazine/src/trokky/structure.ts +19 -0
  45. package/files/site/magazine/src/layouts/Base.astro +55 -0
  46. package/files/site/magazine/src/pages/[slug].astro +11 -0
  47. package/files/site/magazine/src/pages/articles/[slug].astro +30 -0
  48. package/files/site/magazine/src/pages/articles/index.astro +21 -0
  49. package/files/site/magazine/src/pages/authors/[slug].astro +19 -0
  50. package/files/site/magazine/src/pages/categories/[slug].astro +19 -0
  51. package/files/site/magazine/src/pages/documents.astro +19 -0
  52. package/files/site/magazine/src/pages/index.astro +35 -0
  53. package/files/site/magazine/src/trokky/site.ts +81 -0
  54. package/files/workers/scripts/copy-studio.mjs +28 -0
  55. package/manifest.json +330 -0
  56. package/package.json +62 -0
package/dist/emit.js ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * The files a composition produces that are not simply copied: anything whose *contents* depend
3
+ * on the choices. Copied files live under `files/`; computed ones are built here.
4
+ */
5
+ import { hasFrontend, hasStudio } from './config.js';
6
+ import { secretsFor } from './manifest.js';
7
+ const TROKKY = '@trokky/trokky';
8
+ const STUDIO = '@trokky/studio';
9
+ export function packageJson(config) {
10
+ const dependencies = { [TROKKY]: config.trokkyVersion };
11
+ const devDependencies = { typescript: '^5.6.0' };
12
+ const scripts = {};
13
+ if (hasStudio(config))
14
+ dependencies[STUDIO] = config.trokkyVersion;
15
+ if (config.target === 'workers') {
16
+ devDependencies['@cloudflare/workers-types'] = '^5.20260914.1';
17
+ devDependencies.wrangler = '^4.131.2';
18
+ // Workers Builds runs the build command and then the deploy command; a deploy that also
19
+ // builds doubles every deploy.
20
+ scripts.deploy = 'wrangler deploy';
21
+ scripts.preview = 'wrangler dev';
22
+ scripts.types = 'wrangler types';
23
+ if (hasFrontend(config)) {
24
+ dependencies.astro = '^7.3.2';
25
+ dependencies['@astrojs/cloudflare'] = '^14.3.1';
26
+ dependencies['@trokky/client'] = config.trokkyVersion;
27
+ scripts.dev = hasStudio(config) ? 'node scripts/copy-studio.mjs && astro dev' : 'astro dev';
28
+ scripts.build = hasStudio(config) ? 'node scripts/copy-studio.mjs && astro build' : 'astro build';
29
+ scripts.check = 'astro check';
30
+ }
31
+ else {
32
+ scripts.dev = 'wrangler dev';
33
+ // Without Astro there is no bundler of our own: wrangler builds the Worker at deploy time.
34
+ scripts.build = hasStudio(config) ? 'node scripts/copy-studio.mjs' : 'echo "nothing to build"';
35
+ }
36
+ }
37
+ else {
38
+ dependencies.express = '^4.21.2';
39
+ dependencies.dotenv = '^16.4.7';
40
+ devDependencies['@types/express'] = '^4.17.21';
41
+ devDependencies['@types/node'] = '^22.0.0';
42
+ devDependencies.tsx = '^4.19.2';
43
+ if (config.data === 'postgres-data')
44
+ dependencies.pg = '^8.13.1';
45
+ if (config.images === 'sharp')
46
+ dependencies.sharp = '^0.33.5';
47
+ if (hasFrontend(config)) {
48
+ dependencies.astro = '^7.3.2';
49
+ dependencies['@astrojs/node'] = '^11.1.5';
50
+ // The server imports the built Astro handler, so the site has to be built before it runs.
51
+ scripts.build = 'astro build';
52
+ scripts.dev = 'astro build && tsx watch src/server.ts';
53
+ scripts.start = 'tsx src/server.ts';
54
+ scripts.check = 'astro check';
55
+ }
56
+ else {
57
+ scripts.dev = 'tsx watch src/server.ts';
58
+ scripts.build = 'tsc -p tsconfig.json';
59
+ scripts.start = 'node dist/server.js';
60
+ }
61
+ }
62
+ return JSON.stringify({
63
+ name: config.name,
64
+ private: true,
65
+ type: 'module',
66
+ version: '0.1.0',
67
+ engines: { node: config.target === 'workers' ? '>=22.12.0' : '>=20' },
68
+ scripts,
69
+ dependencies,
70
+ devDependencies,
71
+ }, null, 2) + '\n';
72
+ }
73
+ /** Astro renders on request on both targets; only the adapter differs. */
74
+ export function astroConfig(config) {
75
+ return config.target === 'workers'
76
+ ? `import { defineConfig } from 'astro/config'
77
+ import cloudflare from '@astrojs/cloudflare'
78
+
79
+ // Pages render on request, reading Trokky in the same Worker: edit in Studio, refresh, it's live.
80
+ export default defineConfig({
81
+ output: 'server',
82
+ adapter: cloudflare(),
83
+ })
84
+ `
85
+ : `import { defineConfig } from 'astro/config'
86
+ import node from '@astrojs/node'
87
+
88
+ // Middleware mode: the build produces a handler that the Express app mounts, so the site and the
89
+ // API share one process — and a page can read Trokky directly instead of calling its own HTTP API.
90
+ export default defineConfig({
91
+ output: 'server',
92
+ adapter: node({ mode: 'middleware' }),
93
+ })
94
+ `;
95
+ }
96
+ /**
97
+ * How a page gets hold of Trokky. The one file that cannot be shared between targets: a Worker
98
+ * takes its bindings from `cloudflare:workers`, a server is handed the core through Astro locals.
99
+ */
100
+ export function pageHelper(config) {
101
+ return config.target === 'workers'
102
+ ? `/**
103
+ * The one line every page starts with: Trokky, from the Worker's bindings.
104
+ *
105
+ * Astro 6+ dropped \`Astro.locals.runtime.env\`; bindings come from the \`cloudflare:workers\`
106
+ * module, which is importable anywhere in the Worker. The \`astro\` argument is unused here and
107
+ * present so pages read the same on both runtimes.
108
+ */
109
+ import type { AstroGlobal } from 'astro'
110
+ import { env } from 'cloudflare:workers'
111
+ import { getTrokky, type TrokkyEnv } from './core'
112
+ import { site, type Site } from './site'
113
+
114
+ export async function load(_astro: AstroGlobal): Promise<Site> {
115
+ const { core } = await getTrokky(env as unknown as TrokkyEnv)
116
+ return site(core)
117
+ }
118
+ `
119
+ : `/**
120
+ * The one line every page starts with: Trokky, from the server that mounted this handler.
121
+ *
122
+ * The Express app passes the core in as Astro locals, so a page reads content in-process — the
123
+ * same way it does on Workers, and without an API token or a round trip.
124
+ */
125
+ import type { AstroGlobal } from 'astro'
126
+ import type { TrokkyCore } from '@trokky/trokky'
127
+ import { site, type Site } from './site'
128
+
129
+ export async function load(astro: AstroGlobal): Promise<Site> {
130
+ const core = (astro.locals as { trokky?: TrokkyCore }).trokky
131
+ if (!core) throw new Error('Trokky was not passed into Astro locals. Check src/server.ts.')
132
+ return site(core)
133
+ }
134
+ `;
135
+ }
136
+ export function wranglerConfig(config) {
137
+ const assets = hasFrontend(config)
138
+ ? `"./dist/client"`
139
+ : hasStudio(config)
140
+ ? `"./public"`
141
+ : null;
142
+ const lines = [
143
+ '{',
144
+ ' "$schema": "node_modules/wrangler/config-schema.json",',
145
+ ` "name": ${JSON.stringify(config.name)},`,
146
+ ` "main": "./src/worker.ts",`,
147
+ ' "compatibility_date": "2026-09-01",',
148
+ ' // Trokky\'s core imports crypto, events and module directly.',
149
+ ' "compatibility_flags": ["nodejs_compat"],',
150
+ ];
151
+ if (assets) {
152
+ lines.push(' "assets": {', ` "directory": ${assets},`, ' "binding": "ASSETS",', ' // Every request goes through the Worker: /studio needs its config injected, and /api', ' // is not a file. Static files are fetched through the binding by the handlers.', ' "run_worker_first": true,', ' "html_handling": "none",', ' "not_found_handling": "none"', ' },');
153
+ }
154
+ lines.push(' "d1_databases": [', ` { "binding": "DB", "database_name": ${JSON.stringify(config.name)}, "database_id": "" }`, ' ],', ' "r2_buckets": [', ` { "binding": "MEDIA", "bucket_name": ${JSON.stringify(config.name + '-media')} }`, ' ],');
155
+ if (config.images === 'cloudflare-images') {
156
+ lines.push(' // Thumbnails are made through the Images binding at upload time.', ' "images": { "binding": "IMAGES" },');
157
+ }
158
+ lines.push(' "observability": { "enabled": true }', '}', '');
159
+ return lines.join('\n');
160
+ }
161
+ /** The Worker's Trokky wiring: which adapters are imported is the composition, literally. */
162
+ export function workersCore(config) {
163
+ const imports = [
164
+ `import { TrokkyCore } from '${TROKKY}'`,
165
+ `import { createFetchHandler, type FetchHandler } from '${TROKKY}/workers'`,
166
+ `import { CloudflareD1Adapter } from '${TROKKY}/adapters/cloudflare-d1'`,
167
+ `import { CloudflareR2Adapter } from '${TROKKY}/adapters/cloudflare-r2'`,
168
+ ];
169
+ if (hasStudio(config))
170
+ imports.push(`import { createStudioFetchHandler } from '${STUDIO}/workers'`);
171
+ if (config.content !== 'blank') {
172
+ imports.push(`import { schemas } from './schemas'`);
173
+ imports.push(`import { structure } from './structure'`);
174
+ }
175
+ const mediaConfig = config.images === 'cloudflare-images'
176
+ ? ` media: {
177
+ imageProcessor: 'cloudflare-images',
178
+ imageProcessorOptions: { images: env.IMAGES },
179
+ imageVariants: [
180
+ { name: 'thumbnail', width: 480, height: 320, format: 'webp', quality: 80, fit: 'cover' },
181
+ { name: 'large', width: 1600, height: 1000, format: 'webp', quality: 85, fit: 'inside' },
182
+ ],
183
+ },
184
+ `
185
+ : '';
186
+ return `/**
187
+ * One Trokky per isolate.
188
+ *
189
+ * Bindings only exist inside \`fetch\`, so the core is built on the first request and kept for
190
+ * the life of the isolate. Building it per request would re-check the D1 schema every time.
191
+ */
192
+ ${imports.join('\n')}
193
+
194
+ export interface TrokkyEnv {
195
+ DB: D1Database
196
+ MEDIA: R2Bucket
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
+ TROKKY_CLAIM_SECRET?: string
199
+ }
200
+
201
+ export const API_PATH = '/api'
202
+ ${hasStudio(config) ? "export const STUDIO_PATH = '/studio'\n" : ''}
203
+ export interface Trokky {
204
+ core: TrokkyCore
205
+ api: FetchHandler
206
+ ${hasStudio(config) ? ' studio: (request: Request) => Promise<Response>\n' : ''}}
207
+
208
+ let instance: Promise<Trokky> | undefined
209
+
210
+ export function getTrokky(env: TrokkyEnv): Promise<Trokky> {
211
+ instance ??= build(env)
212
+ return instance
213
+ }
214
+
215
+ async function build(env: TrokkyEnv): Promise<Trokky> {
216
+ const core = new TrokkyCore(
217
+ {
218
+ // Required by the type even though the adapters below are passed directly.
219
+ storage: { adapter: 'cloudflare-d1', options: {} },
220
+ schemas: ${config.content === 'blank' ? '[]' : 'schemas as never'},
221
+ ${mediaConfig} },
222
+ {
223
+ data: new CloudflareD1Adapter({ database: env.DB }),
224
+ media: new CloudflareR2Adapter({ bucket: env.MEDIA }),
225
+ },
226
+ {
227
+ jwtSecret: env.TROKKY_JWT_SECRET,
228
+ claimSecret: env.TROKKY_CLAIM_SECRET,
229
+ },
230
+ )
231
+ // Creates the image processor; nothing serves images without it.
232
+ await core.init()
233
+
234
+ const api = createFetchHandler({ core, basePath: API_PATH${config.content === 'blank' ? '' : ', structureConfig: structure'} })
235
+ ${hasStudio(config) ? ` const studio = createStudioFetchHandler({ basePath: STUDIO_PATH, apiPath: API_PATH, assets: env.ASSETS })
236
+ return { core, api, studio }` : ' return { core, api }'}
237
+ }
238
+ `;
239
+ }
240
+ export function workersEntry(config) {
241
+ const seeds = config.content !== 'blank';
242
+ const parts = [
243
+ `/**
244
+ * The Worker. ${hasFrontend(config) ? 'Three things share one deployment:' : hasStudio(config) ? 'Two things share one deployment:' : 'One thing, one deployment:'}
245
+ *
246
+ ${hasFrontend(config) ? ' * /api/* the Trokky API\n * /studio/* the Studio, served from static assets\n * /* the Astro site, rendered on request\n *\n * Astro\'s handler is wrapped rather than replaced: wrangler\'s `main` points here, and Astro\'s\n * own handler is imported for everything that is not Trokky\'s.\n' : hasStudio(config) ? ' * /api/* the Trokky API\n * /studio/* the Studio, served from static assets\n' : ' * /api/* the Trokky API\n'} */`,
247
+ ];
248
+ if (hasFrontend(config))
249
+ parts.push(`import { handle } from '@astrojs/cloudflare/handler'`);
250
+ parts.push(`import { getTrokky, API_PATH${hasStudio(config) ? ', STUDIO_PATH' : ''}, type TrokkyEnv } from './trokky/core'`);
251
+ if (seeds)
252
+ parts.push(`import { seedSampleContent } from './trokky/seed'`);
253
+ const seedBlock = seeds
254
+ ? `
255
+ // The first administrator has just claimed the instance: give them something to look at.
256
+ // Runs after the response so the claim itself is never slowed or failed by seeding.
257
+ if (request.method === 'POST' && pathname === \`\${API_PATH}/auth/claim\` && response.ok) {
258
+ const origin = new URL(request.url).origin
259
+ const readAsset = async (path: string): Promise<ArrayBuffer> => {
260
+ const asset = await env.ASSETS.fetch(new Request(\`\${origin}/seed/\${path}\`))
261
+ if (!asset.ok) throw new Error(\`seed asset missing: /seed/\${path} (\${asset.status})\`)
262
+ return asset.arrayBuffer()
263
+ }
264
+ ctx.waitUntil(seedSampleContent(core, readAsset).catch(error => console.error('seed failed', error)))
265
+ }
266
+ `
267
+ : '';
268
+ parts.push(`
269
+ export default {
270
+ async fetch(request: Request, env: TrokkyEnv, ctx: ExecutionContext): Promise<Response> {
271
+ const { pathname } = new URL(request.url)
272
+
273
+ if (pathname === API_PATH || pathname.startsWith(\`\${API_PATH}/\`)) {
274
+ const { core, api } = await getTrokky(env)
275
+ const response = await api(request)
276
+ ${seedBlock} return response
277
+ }
278
+ ${hasStudio(config) ? `
279
+ if (pathname === STUDIO_PATH || pathname.startsWith(\`\${STUDIO_PATH}/\`)) {
280
+ const { studio } = await getTrokky(env)
281
+ return studio(request)
282
+ }
283
+ ` : ''}${hasFrontend(config) ? `
284
+ return handle(request, env as unknown as Env, ctx)` : `
285
+ return new Response('Not Found', { status: 404 })`}
286
+ },
287
+ } satisfies ExportedHandler<TrokkyEnv>
288
+ `);
289
+ return parts.join('\n');
290
+ }
291
+ export function nodeEntry(config) {
292
+ const seeds = config.content !== 'blank';
293
+ const site = hasFrontend(config);
294
+ return `/**
295
+ * The server.
296
+ *
297
+ * \`startServer\` builds the Express app, mounts the API and installs shutdown handlers.
298
+ ${site ? ` * The Studio and the site are mounted onto that same app, in order: the API already owns
299
+ * /api, then /studio, then Astro takes everything else.` : hasStudio(config) ? ' * The Studio is mounted separately, before any catch-all of your own.' : ' * No Studio here: this is a headless API.'}
300
+ */
301
+ import { startServer } from '${TROKKY}/express'
302
+ // Adapters register themselves as a side effect of being imported. Without these two lines the
303
+ // config below names an adapter that nothing registered, and startup fails.
304
+ import '${TROKKY}/adapters/${config.data}'
305
+ import '${TROKKY}/adapters/${config.media}'
306
+ ${hasStudio(config) ? `import { studioRouter } from '${STUDIO}/express'\n` : ''}${site ? `import express from 'express'
307
+ // Built by \`astro build\`; run the build before the server.
308
+ import { handler as ssrHandler } from '../dist/server/entry.mjs'
309
+ ` : ''}${seeds ? `import { readFile } from 'node:fs/promises'
310
+ import path from 'node:path'
311
+ import { seedSampleContent } from './trokky/seed.js'
312
+ ` : ''}import config from '../trokky.config.js'
313
+
314
+ const server = await startServer(config)
315
+ const core = server.integration.core
316
+ ${hasStudio(config) ? `
317
+ // Mount Studio before anything that could swallow it.
318
+ server.app.use('/studio', studioRouter({ apiPath: '/api' }))
319
+ ` : ''}${site ? `
320
+ // Middleware mode does no file serving of its own, so the built client assets come first.
321
+ server.app.use(express.static('dist/client'))
322
+ // Then every remaining path is Astro's. The core goes in as locals, which is how a page reads
323
+ // content in-process rather than calling back out over HTTP.
324
+ server.app.use((req, res, next) => ssrHandler(req, res, next, { trokky: core }))
325
+ ` : ''}${seeds ? `
326
+ // Sample content, on a store that has none. A Worker seeds after the claim, inside waitUntil;
327
+ // a server has no such hook and boots under the operator's control, so first run is the moment.
328
+ if (core) {
329
+ const seedDir = path.join(process.cwd(), 'public', 'seed')
330
+ seedSampleContent(core, async file => {
331
+ const bytes = await readFile(path.join(seedDir, file))
332
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
333
+ }).catch(error => console.error('seed failed', error))
334
+ }
335
+ ` : ''}
336
+ const info = server.getInfo()
337
+ console.log(\`Trokky listening on http://localhost:\${info.port}\${info.apiPath}\`)
338
+ `;
339
+ }
340
+ export function trokkyConfig(config) {
341
+ const dataOptions = config.data === 'postgres-data'
342
+ ? ` options: {
343
+ connection: process.env.DATABASE_URL,
344
+ schema: 'public',
345
+ tablePrefix: 'trokky_',
346
+ },`
347
+ : ` options: {
348
+ // All six directories, or none: naming only one leaves the rest beside the process.
349
+ contentDir: path.join(dataDir, 'content'),
350
+ usersDir: path.join(dataDir, 'users'),
351
+ tokensDir: path.join(dataDir, 'tokens'),
352
+ webhooksDir: path.join(dataDir, 'webhooks'),
353
+ settingsDir: path.join(dataDir, 'settings'),
354
+ auditLogsDir: path.join(dataDir, 'audit-logs'),
355
+ },`;
356
+ const mediaConfig = config.media === 's3-media'
357
+ ? `{
358
+ adapter: 's3-media' as const,
359
+ options: {
360
+ endpoint: process.env.S3_ENDPOINT,
361
+ bucket: process.env.S3_BUCKET,
362
+ accessKeyId: process.env.S3_ACCESS_KEY_ID,
363
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
364
+ // SigV4 signs over a region whether or not the backend has one. R2 wants 'auto'.
365
+ region: process.env.S3_REGION ?? 'auto',
366
+ },
367
+ }`
368
+ : `{
369
+ adapter: 'filesystem-media' as const,
370
+ options: {
371
+ mediaDir: path.join(dataDir, 'media'),
372
+ createDirs: true,
373
+ },
374
+ }`;
375
+ // Only when something actually lands on disk. Emitting it regardless left a postgres + S3
376
+ // project with a dead const reading an env var that appears in none of its files -- the last
377
+ // place a reader could still infer a disk that is not there.
378
+ const usesDisk = config.data === 'filesystem-data' || config.media === 'filesystem-media';
379
+ return `/**
380
+ * Trokky configuration.
381
+ */
382
+ ${usesDisk ? "import path from 'node:path'\n" : ''}import dotenv from 'dotenv'
383
+ ${config.content === 'blank' ? '' : "import { schemas } from './src/trokky/schemas.js'\nimport { structure } from './src/trokky/structure.js'\n"}
384
+ dotenv.config()
385
+ ${usesDisk ? "\nconst dataDir = process.env.TROKKY_DATA_DIR ?? path.join(process.cwd(), 'data')\n" : ''}
386
+ export default {
387
+ schemas: ${config.content === 'blank' ? '[]' : 'schemas'},
388
+ ${config.content === 'blank' ? '' : ' structure,\n'}
389
+ storage: {
390
+ data: {
391
+ adapter: '${config.data}' as const,
392
+ ${dataOptions}
393
+ },
394
+ media: ${mediaConfig},
395
+ },
396
+
397
+ media: {
398
+ processor: '${config.images}' as const,
399
+ ${config.images === 'none' ? '' : ` // Without this list nothing is generated: the Express layer passes \`variants\` straight
400
+ // through, and an empty one means every thumbnail URL is a 404.
401
+ variants: [
402
+ { name: 'thumbnail', width: 480, height: 320, format: 'webp' as const, quality: 80, fit: 'cover' as const },
403
+ { name: 'large', width: 1600, height: 1000, format: 'webp' as const, quality: 85, fit: 'inside' as const },
404
+ ],
405
+ `} },
406
+
407
+ security: {
408
+ enabled: true,
409
+ // Generate it; do not invent it. A missing secret means every restart invalidates sessions.
410
+ jwtSecret: process.env.TROKKY_JWT_SECRET,
411
+ claimSecret: process.env.TROKKY_CLAIM_SECRET,
412
+ },
413
+
414
+ server: {
415
+ port: Number(process.env.PORT ?? 3000),
416
+ },
417
+ }
418
+ `;
419
+ }
420
+ /** Wrap a `why` into comment lines, so a long one does not run off the edge of the file. */
421
+ const COMMENT_WIDTH = 88;
422
+ function comment(text, width = COMMENT_WIDTH) {
423
+ const lines = [];
424
+ let line = '#';
425
+ for (const word of text.trim().split(/\s+/).filter(Boolean)) {
426
+ if (line.length + 1 + word.length > width && line !== '#') {
427
+ lines.push(line);
428
+ line = '#';
429
+ }
430
+ line += ` ${word}`;
431
+ }
432
+ if (line !== '#')
433
+ lines.push(line);
434
+ return lines.length > 0 ? lines : ['#'];
435
+ }
436
+ export function envExample(config) {
437
+ const secrets = secretsFor(config);
438
+ const edge = config.target === 'workers';
439
+ const lines = edge
440
+ ? [
441
+ ...comment("Secrets for this Worker. Cloudflare's deploy button asks for these; `wrangler dev`"),
442
+ ...comment('reads them from a `.dev.vars` file locally. GENERATE both — do not invent them:'),
443
+ '# node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
444
+ '',
445
+ ]
446
+ : [
447
+ '# Generate these; do not invent them:',
448
+ '# node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
449
+ '',
450
+ ];
451
+ for (const secret of secrets) {
452
+ lines.push(...comment(secret.why), `${secret.name}=`, '');
453
+ }
454
+ if (config.data === 'postgres-data')
455
+ lines.push('# Postgres connection string.', 'DATABASE_URL=', '');
456
+ // Only where something is actually written to disk. A Worker has none, and with s3-media the
457
+ // uploads are in a bucket — emitting TROKKY_DATA_DIR there described storage that does not
458
+ // exist, in the one file a deployer reads before their first boot.
459
+ const onDisk = [
460
+ config.data === 'filesystem-data' ? 'content' : null,
461
+ config.media === 'filesystem-media' ? 'uploads' : null,
462
+ ].filter(Boolean);
463
+ if (!edge && onDisk.length > 0) {
464
+ // The verb follows the NOUN, not the count: "uploads" is one item and still plural.
465
+ const plural = onDisk.length > 1 || onDisk.includes('uploads');
466
+ lines.push(...comment(`Where ${onDisk.join(' and ')} ${plural ? 'are' : 'is'} written. Must survive a restart.`), 'TROKKY_DATA_DIR=./data', '');
467
+ }
468
+ return lines.join('\n');
469
+ }
470
+ export function tsconfig(config) {
471
+ return config.target === 'workers'
472
+ ? JSON.stringify({
473
+ extends: hasFrontend(config) ? 'astro/tsconfigs/strict' : undefined,
474
+ compilerOptions: {
475
+ target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
476
+ types: ['@cloudflare/workers-types/2023-07-01'],
477
+ strict: true, skipLibCheck: true, noEmit: true,
478
+ },
479
+ include: ['src/**/*', 'scripts/**/*'],
480
+ }, (_k, v) => (v === undefined ? undefined : v), 2) + '\n'
481
+ : JSON.stringify({
482
+ compilerOptions: {
483
+ target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler',
484
+ outDir: 'dist', rootDir: '.', strict: true, skipLibCheck: true,
485
+ esModuleInterop: true, types: ['node'],
486
+ },
487
+ include: ['src/**/*', 'trokky.config.ts'],
488
+ }, null, 2) + '\n';
489
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The filesystem-backed file source, for the CLI. Imported separately so nothing that runs in a
3
+ * browser or a Worker ever pulls `node:fs` in behind it.
4
+ */
5
+ import { type FileSource } from './files.js';
6
+ /** `files/` sits beside `dist/` in the published package and beside `src/` in the repo. */
7
+ export declare const FILES_ROOT: string;
8
+ export declare function fsFileSource(root?: string): FileSource;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The filesystem-backed file source, for the CLI. Imported separately so nothing that runs in a
3
+ * browser or a Worker ever pulls `node:fs` in behind it.
4
+ */
5
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { dirname, join, relative, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { isBinaryPath } from './files.js';
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ /** `files/` sits beside `dist/` in the published package and beside `src/` in the repo. */
11
+ export const FILES_ROOT = join(here, '..', 'files');
12
+ export function fsFileSource(root = FILES_ROOT) {
13
+ return {
14
+ read(set) {
15
+ const setRoot = join(root, set);
16
+ // Loud, not silent: a set that is missing from the package would otherwise produce a
17
+ // project quietly short of files instead of an error anyone can act on.
18
+ if (!existsSync(setRoot)) {
19
+ throw new Error(`File set missing from the package: ${setRoot}. Check the "files" field and npm's ignore rules.`);
20
+ }
21
+ const files = new Map();
22
+ const walk = (dir) => {
23
+ for (const entry of readdirSync(dir)) {
24
+ const full = join(dir, entry);
25
+ if (statSync(full).isDirectory()) {
26
+ walk(full);
27
+ continue;
28
+ }
29
+ const rel = relative(setRoot, full).split(sep).join('/');
30
+ files.set(rel, isBinaryPath(rel) ? new Uint8Array(readFileSync(full)) : readFileSync(full, 'utf8'));
31
+ }
32
+ };
33
+ walk(setRoot);
34
+ return files;
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where the copied template files come from.
3
+ *
4
+ * The CLI reads them off disk. A browser or a Worker cannot, so the same tree is inlined into a
5
+ * module at build time and read from there. `generate()` takes whichever it is handed, which is
6
+ * what lets one generator serve a terminal, trokky.build and an API install.
7
+ */
8
+ /** One file set — `base`, `workers`, `content/magazine`, `frontend` — keyed by path within it. */
9
+ export type FileSet = Map<string, string | Uint8Array>;
10
+ export interface FileSource {
11
+ /** Every file in a set. Throws if the set is not there: a missing set is a packaging bug. */
12
+ read(set: string): FileSet;
13
+ }
14
+ /** Files that are not text; everything else round-trips as UTF-8. */
15
+ export declare const isBinaryPath: (path: string) => boolean;
16
+ /**
17
+ * The shape the build step emits. Text inline, binary base64 — base64 because a generated
18
+ * module has to be valid source, and 1.5MB of JPEG is not.
19
+ */
20
+ export interface InlinedFiles {
21
+ text: Record<string, string>;
22
+ binary: Record<string, string>;
23
+ }
24
+ /** A source backed by the inlined bundle — no filesystem, so it runs anywhere. */
25
+ export declare function inlineFileSource(inlined: InlinedFiles): FileSource;
package/dist/files.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where the copied template files come from.
3
+ *
4
+ * The CLI reads them off disk. A browser or a Worker cannot, so the same tree is inlined into a
5
+ * module at build time and read from there. `generate()` takes whichever it is handed, which is
6
+ * what lets one generator serve a terminal, trokky.build and an API install.
7
+ */
8
+ /** Files that are not text; everything else round-trips as UTF-8. */
9
+ export const isBinaryPath = (path) => /\.(jpg|jpeg|png|gif|webp|pdf|ico|woff2?)$/i.test(path);
10
+ const decodeBase64 = (value) => {
11
+ // Node and the browser disagree about which of these exists.
12
+ if (typeof atob === 'function') {
13
+ const binary = atob(value);
14
+ const bytes = new Uint8Array(binary.length);
15
+ for (let i = 0; i < binary.length; i++)
16
+ bytes[i] = binary.charCodeAt(i);
17
+ return bytes;
18
+ }
19
+ return new Uint8Array(Buffer.from(value, 'base64'));
20
+ };
21
+ /** A source backed by the inlined bundle — no filesystem, so it runs anywhere. */
22
+ export function inlineFileSource(inlined) {
23
+ const setOf = (path) => {
24
+ // `content/<model>/…` and `site/<model>/…` are two-segment sets; `base/…` is one.
25
+ const parts = path.split('/');
26
+ return parts[0] === 'content' || parts[0] === 'site' ? `${parts[0]}/${parts[1]}` : parts[0];
27
+ };
28
+ const sets = new Map();
29
+ const add = (path, contents) => {
30
+ const set = setOf(path);
31
+ const within = path.slice(set.length + 1);
32
+ if (!sets.has(set))
33
+ sets.set(set, new Map());
34
+ sets.get(set).set(within, contents);
35
+ };
36
+ for (const [path, contents] of Object.entries(inlined.text))
37
+ add(path, contents);
38
+ for (const [path, base64] of Object.entries(inlined.binary))
39
+ add(path, decodeBase64(base64));
40
+ return {
41
+ read(set) {
42
+ const found = sets.get(set);
43
+ if (!found)
44
+ throw new Error(`File set missing from the bundle: ${set}. Re-run the inline build.`);
45
+ return found;
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * config → file tree. Pure: nothing here touches the disk, so it is testable, it runs in a
3
+ * browser as readily as in a terminal, and the same function serves the CLI, a zip download and
4
+ * an API install.
5
+ */
6
+ import type { ProjectConfig } from './config.js';
7
+ import type { FileSource } from './files.js';
8
+ export type FileTree = Map<string, string | Uint8Array>;
9
+ export declare function generate(config: ProjectConfig, source: FileSource): FileTree;