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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trokky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # create-trokky
2
+
3
+ Compose a [Trokky](https://trokky.dev) project: pick where it runs, where content lives, and how
4
+ much of the stack you want.
5
+
6
+ ```bash
7
+ npm create trokky@latest
8
+ ```
9
+
10
+ Not on npm yet? It runs straight from the repo, no install:
11
+
12
+ ```bash
13
+ npx github:Trokky/generator my-site
14
+ ```
15
+
16
+ ```
17
+ Where will it run?
18
+ 1) Cloudflare Workers — No server, no disk. D1 + R2.
19
+ 2) Node / Express — A server you run: VPS, Docker, Railway.
20
+
21
+ What do you want?
22
+ 1) API only — Headless. Bring your own frontend.
23
+ 2) API + Studio — The admin UI, for editors.
24
+ 3) API + Studio + a site — An Astro frontend as well.
25
+ ```
26
+
27
+ Non-interactive, for scripts and CI:
28
+
29
+ ```bash
30
+ npm create trokky@latest my-site -- --yes --target=workers --parts=full-site --content=magazine
31
+ ```
32
+
33
+ ## What it will not let you build
34
+
35
+ The generator refuses impossible combinations and says why, instead of handing you a project that
36
+ compiles and dies on startup:
37
+
38
+ ```
39
+ $ npm create trokky@latest x -- --yes --target=workers --images=sharp
40
+ That combination cannot be built:
41
+ images: "sharp" is not available here.
42
+ sharp is a native libvips binding and Workers runs no native code.
43
+ Try: cloudflare-images, none
44
+ ```
45
+
46
+ Every such rule lives in [`manifest.json`](./manifest.json) as data — axes, options, constraints,
47
+ each with the reason it exists. **Nothing reimplements them.** `npm create trokky@latest`, the
48
+ trokky.build configurator and `trokky new` in the Go CLI all read that one file, so they cannot
49
+ drift apart.
50
+
51
+ ## The same site, either runtime
52
+
53
+ `--parts=full-site` gives you an Astro frontend on **both** targets, and the pages are
54
+ byte-identical between them:
55
+
56
+ | | Workers | Node |
57
+ |---|---|---|
58
+ | Astro adapter | `@astrojs/cloudflare` | `@astrojs/node` in middleware mode, mounted on Express |
59
+ | How a page reaches Trokky | bindings from `cloudflare:workers` | the core, passed in as `Astro.locals` |
60
+ | Sample content appears | after the claim, in `waitUntil` | at boot, when the store is empty |
61
+ | Thumbnails | Cloudflare Images at upload | sharp at upload |
62
+
63
+ Only `src/trokky/page.ts` and `astro.config.mjs` differ. Every page, every layout and the query
64
+ helper are the same file — and on both runtimes a page reads content **in-process**, with no API
65
+ token and no HTTP round trip.
66
+
67
+ ## Adding a content model
68
+
69
+ A content model is two directories and a manifest entry. Nothing in the generator learns its
70
+ name.
71
+
72
+ 1. `files/content/<id>/` — `src/trokky/schemas.ts`, `structure.ts`, `seed.ts`, and any
73
+ `public/seed/` assets the seed uploads.
74
+ 2. `files/site/<id>/` — the Astro pages, layouts and `src/trokky/site.ts` that render it.
75
+ **Optional**: leave it out and the model is headless-only, and the configurator will not offer
76
+ it a frontend. Pages are written against particular collections, which is why a site belongs
77
+ to its model rather than being shared.
78
+ 3. `manifest.json` — add the option under the `content` axis. If it has a site, add its id to the
79
+ `content-needs-schemas` constraint's `allow` list.
80
+
81
+ `test/extensibility.test.ts` holds that contract: it fails if a model has pages the configurator
82
+ will not offer, or is offered a frontend it has no pages for.
83
+
84
+ ## Using it as a library
85
+
86
+ ```ts
87
+ import { generate, validate, optionsFor, withDefaults } from 'create-trokky'
88
+
89
+ optionsFor('data', { target: 'workers' }) // ['cloudflare-d1']
90
+ validate({ target: 'workers', images: 'sharp' }) // { valid: false, problems: [...] }
91
+
92
+ const files = generate(withDefaults({ name: 'my-site', target: 'workers' }))
93
+ // Map<path, string | Uint8Array> — pure, nothing touches the disk
94
+ ```
95
+
96
+ `generate` is pure, and it takes its template files from a source you hand it — so the same
97
+ function serves a terminal, a browser and an API install:
98
+
99
+ ```ts
100
+ import { generate, inlineFileSource } from 'create-trokky'
101
+ import { text } from 'create-trokky/generated/files-text'
102
+
103
+ // No filesystem: this runs in a browser or a Worker.
104
+ const files = generate(config, inlineFileSource({ text, binary: {} }))
105
+ ```
106
+
107
+ `create-trokky/node` adds `fsFileSource()` and `writeTree()`, which the CLI uses. Nothing in the
108
+ main export touches `node:fs`, so a browser bundle never pulls it in.
109
+
110
+ ## Releasing
111
+
112
+ Tag a version and the workflow publishes it, authenticating through npm **trusted publishing**:
113
+ GitHub Actions mints a short-lived OIDC token and npm exchanges it for publish rights, so no
114
+ long-lived token lives in this repo. npm attaches a provenance attestation automatically.
115
+
116
+ Requires npm CLI 11.5.1 or later, which is why the workflow upgrades npm before publishing —
117
+ Node 22 ships npm 10, and without the upgrade npm quietly falls back to looking for a token. Before it does, it installs the packed tarball into
118
+ an empty project and generates from *that* — because `files/` is most of this package, and npm's
119
+ own ignore rules can quietly drop parts of it. Both failures that shipped this way looked perfect
120
+ in the source tree: `.gitignore` is never packaged, and an empty directory survives neither git
121
+ nor npm.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ npm install
127
+ npm test # the rules and the shape of generated trees
128
+ npm run smoke # generate → install → build → boot → claim, per composition
129
+ ```
130
+
131
+ `npm test` proves a generated tree is coherent. **`npm run smoke` proves it runs**: it generates,
132
+ installs, builds, boots, claims the instance the way a first visitor would, checks the claim
133
+ cannot happen twice, and fetches a thumbnail — the far end of the whole media pipeline.
134
+
135
+ It has earned every step of that. It caught a Node project that typechecked, generated cleanly and died
136
+ on startup because nothing imported its adapters; two type errors in the emitted server; a
137
+ frontend that could not build on Node because a shared file imported a Workers-only one; and a
138
+ Node config with no image variants, where every page looked fine and every thumbnail was a 404.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm create trokky@latest` — compose a project and write it out.
4
+ *
5
+ * Every rule it applies comes from the manifest, so this file knows nothing about what can go
6
+ * with what. It asks, it validates, it writes.
7
+ */
8
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npm create trokky@latest` — compose a project and write it out.
4
+ *
5
+ * Every rule it applies comes from the manifest, so this file knows nothing about what can go
6
+ * with what. It asks, it validates, it writes.
7
+ */
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { stdin, stdout, argv, exit } from 'node:process';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { resolve } from 'node:path';
12
+ import { manifest, secretsFor } from './manifest.js';
13
+ import { withDefaults } from './config.js';
14
+ import { validate, optionsFor, describe } from './validate.js';
15
+ import { generate } from './generate.js';
16
+ import { fsFileSource } from './files-node.js';
17
+ import { writeTree } from './write.js';
18
+ function parseFlags(args) {
19
+ const flags = {};
20
+ for (const arg of args) {
21
+ const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
22
+ if (match)
23
+ flags[match[1]] = match[2] ?? 'true';
24
+ else if (!arg.startsWith('-'))
25
+ flags.name ??= arg;
26
+ }
27
+ return flags;
28
+ }
29
+ async function main() {
30
+ const flags = parseFlags(argv.slice(2));
31
+ const interactive = flags.yes !== 'true' && stdin.isTTY;
32
+ const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
33
+ const ask = async (question, options, fallback) => {
34
+ if (!rl)
35
+ return fallback;
36
+ const axisOptions = manifest.axes.find(a => options.includes(a.options[0]?.id));
37
+ console.log(`\n${question}`);
38
+ options.forEach((id, i) => {
39
+ const option = manifest.axes.flatMap(a => a.options).find(o => o.id === id);
40
+ console.log(` ${i + 1}) ${option?.title ?? id}${option?.hint ? ` — ${option.hint}` : ''}`);
41
+ });
42
+ void axisOptions;
43
+ const answer = (await rl.question(`> [${options.indexOf(fallback) + 1}] `)).trim();
44
+ if (!answer)
45
+ return fallback;
46
+ const index = Number(answer) - 1;
47
+ return options[index] ?? (options.includes(answer) ? answer : fallback);
48
+ };
49
+ const name = flags.name ?? (rl ? (await rl.question('\nProject name > [my-trokky-site] ')).trim() || 'my-trokky-site' : 'my-trokky-site');
50
+ const target = (flags.target ?? (await ask('Where will it run?', ['workers', 'node'], 'workers')));
51
+ const chosen = { target };
52
+ for (const axisId of ['parts', 'content', 'data', 'media', 'images']) {
53
+ const available = optionsFor(axisId, chosen);
54
+ const preset = flags[axisId];
55
+ if (preset) {
56
+ chosen[axisId] = preset;
57
+ continue;
58
+ }
59
+ const fallback = manifest.defaults[target]?.[axisId] ?? available[0];
60
+ // One option left is not a question; say what it is and move on.
61
+ chosen[axisId] = available.length <= 1
62
+ ? available[0] ?? fallback
63
+ : await ask(manifest.axes.find(a => a.id === axisId).title, available, available.includes(fallback) ? fallback : available[0]);
64
+ }
65
+ await rl?.close();
66
+ const config = withDefaults({ name, ...chosen });
67
+ const result = validate(config);
68
+ if (!result.valid) {
69
+ console.error(`\nThat combination cannot be built:\n${describe(result.problems)}`);
70
+ exit(1);
71
+ }
72
+ const target_dir = resolve(flags.out ?? name);
73
+ writeTree(generate(config, fsFileSource()), target_dir);
74
+ const secrets = secretsFor(config);
75
+ console.log(`\nCreated ${config.name} in ${target_dir}\n`);
76
+ console.log(` ${config.parts === 'api' ? 'API' : config.parts === 'studio' ? 'API + Studio' : 'API + Studio + site'} · ${config.data} · ${config.media} · thumbnails: ${config.images}\n`);
77
+ console.log('Next:');
78
+ console.log(` cd ${config.name}`);
79
+ console.log(` cp ${config.target === 'workers' ? '.dev.vars.example .dev.vars' : '.env.example .env'}`);
80
+ for (const secret of secrets) {
81
+ // Only the generated ones get a value here; an endpoint or a bucket name is yours to fill in.
82
+ if (secret.generate) {
83
+ console.log(` # ${secret.name}=${randomBytes(secret.generate === 'hex32' ? 32 : 24).toString('hex')}`);
84
+ }
85
+ else {
86
+ console.log(` # ${secret.name}= (${secret.optional ? 'optional — ' : ''}${secret.why})`);
87
+ }
88
+ }
89
+ console.log(' npm install');
90
+ console.log(config.target === 'workers' ? ' npm run build && npm run preview' : ' npm run dev');
91
+ }
92
+ main().catch(error => {
93
+ console.error(error instanceof Error ? error.message : error);
94
+ exit(1);
95
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A composition, and the defaults that fill in what the caller did not say.
3
+ */
4
+ export interface ProjectConfig {
5
+ /** Directory and package name; also the Worker name on Cloudflare. */
6
+ name: string;
7
+ target: 'workers' | 'node';
8
+ data: 'filesystem-data' | 'postgres-data' | 'cloudflare-d1';
9
+ media: 'filesystem-media' | 'cloudflare-r2' | 's3-media';
10
+ images: 'sharp' | 'cloudflare-images' | 'none';
11
+ parts: 'api' | 'studio' | 'full-site';
12
+ /** Open on purpose: a content model is a pair of directories, not a value this file knows. */
13
+ content: string;
14
+ /** Version range written into the generated package.json for @trokky/* . */
15
+ trokkyVersion: string;
16
+ }
17
+ export type PartialConfig = Partial<ProjectConfig> & {
18
+ name: string;
19
+ target: ProjectConfig['target'];
20
+ };
21
+ /** Fill a partial choice from the manifest's per-target defaults. */
22
+ export declare function withDefaults(input: PartialConfig): ProjectConfig;
23
+ export declare const hasStudio: (config: ProjectConfig) => boolean;
24
+ export declare const hasFrontend: (config: ProjectConfig) => boolean;
package/dist/config.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A composition, and the defaults that fill in what the caller did not say.
3
+ */
4
+ import { manifest } from './manifest.js';
5
+ /** Fill a partial choice from the manifest's per-target defaults. */
6
+ export function withDefaults(input) {
7
+ const defaults = manifest.defaults[input.target] ?? {};
8
+ const pick = (key) => {
9
+ const chosen = input[key];
10
+ if (typeof chosen === 'string')
11
+ return chosen;
12
+ const fallback = defaults[key];
13
+ if (!fallback)
14
+ throw new Error(`No default for "${key}" on target "${input.target}"`);
15
+ return fallback;
16
+ };
17
+ return {
18
+ name: input.name,
19
+ target: input.target,
20
+ data: pick('data'),
21
+ media: pick('media'),
22
+ images: pick('images'),
23
+ parts: pick('parts'),
24
+ content: pick('content'),
25
+ trokkyVersion: input.trokkyVersion ?? '^3.4.1',
26
+ };
27
+ }
28
+ export const hasStudio = (config) => config.parts !== 'api';
29
+ export const hasFrontend = (config) => config.parts === 'full-site';
package/dist/emit.d.ts ADDED
@@ -0,0 +1,21 @@
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 type { ProjectConfig } from './config.js';
6
+ export declare function packageJson(config: ProjectConfig): string;
7
+ /** Astro renders on request on both targets; only the adapter differs. */
8
+ export declare function astroConfig(config: ProjectConfig): string;
9
+ /**
10
+ * How a page gets hold of Trokky. The one file that cannot be shared between targets: a Worker
11
+ * takes its bindings from `cloudflare:workers`, a server is handed the core through Astro locals.
12
+ */
13
+ export declare function pageHelper(config: ProjectConfig): string;
14
+ export declare function wranglerConfig(config: ProjectConfig): string;
15
+ /** The Worker's Trokky wiring: which adapters are imported is the composition, literally. */
16
+ export declare function workersCore(config: ProjectConfig): string;
17
+ export declare function workersEntry(config: ProjectConfig): string;
18
+ export declare function nodeEntry(config: ProjectConfig): string;
19
+ export declare function trokkyConfig(config: ProjectConfig): string;
20
+ export declare function envExample(config: ProjectConfig): string;
21
+ export declare function tsconfig(config: ProjectConfig): string;