create-zerodirs 0.1.0
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/README.md +85 -0
- package/dist/cli/args.js +128 -0
- package/dist/cli/prompts.js +148 -0
- package/dist/core/answers.js +162 -0
- package/dist/core/cloudflare.js +88 -0
- package/dist/core/dataset.js +88 -0
- package/dist/core/env-files.js +45 -0
- package/dist/core/exec.js +47 -0
- package/dist/core/files.js +151 -0
- package/dist/core/setup-md.js +118 -0
- package/dist/core/site-config.js +175 -0
- package/dist/core/template.js +66 -0
- package/dist/core/wrangler-config.js +76 -0
- package/dist/create.js +247 -0
- package/dist/index.js +39 -0
- package/package.json +37 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed datasets — the taxonomy and identity a generated project starts from.
|
|
3
|
+
*
|
|
4
|
+
* Every dataset under `seed/datasets/<name>/` already declares what kind of
|
|
5
|
+
* directory it is (`meta.json`) and what its categories and tags are
|
|
6
|
+
* (`categories.json`, `tags.json`). The CLI reads all of that instead of asking
|
|
7
|
+
* the buyer to retype it: `kind: 'business'` is what turns
|
|
8
|
+
* `seo.jsonLd.listing` into `'localBusiness'`, which is what makes the built
|
|
9
|
+
* pages emit LocalBusiness structured data.
|
|
10
|
+
*
|
|
11
|
+
* Everything above `readDataset()` is pure so the mapping can be unit-tested
|
|
12
|
+
* without a template on disk.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** Where datasets live inside a template. */
|
|
17
|
+
export const DATASETS_DIR = 'seed/datasets';
|
|
18
|
+
/**
|
|
19
|
+
* `site.kind` -> `seo.jsonLd.listing`, mirroring `JSONLD_BY_KIND` in
|
|
20
|
+
* `src/config/defaults.ts`. The starter applies the same fallback, but the CLI
|
|
21
|
+
* writes the value explicitly: a config that says `localBusiness` out loud is
|
|
22
|
+
* one the buyer can find and change.
|
|
23
|
+
*/
|
|
24
|
+
export const JSONLD_BY_KIND = {
|
|
25
|
+
software: 'software',
|
|
26
|
+
service: 'organization',
|
|
27
|
+
business: 'localBusiness',
|
|
28
|
+
};
|
|
29
|
+
/** `site.kind` -> default `routes.listingBase` (design.md §11.1 step 3). */
|
|
30
|
+
export const LISTING_BASE_BY_KIND = {
|
|
31
|
+
software: 'tools',
|
|
32
|
+
service: 'services',
|
|
33
|
+
business: 'places',
|
|
34
|
+
};
|
|
35
|
+
/** `site.kind` -> default `routes.listingNoun`. */
|
|
36
|
+
export const LISTING_NOUN_BY_KIND = {
|
|
37
|
+
software: { singular: 'tool', plural: 'tools' },
|
|
38
|
+
service: { singular: 'service', plural: 'services' },
|
|
39
|
+
business: { singular: 'place', plural: 'places' },
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* What a dataset says about the directory it describes. Pure.
|
|
43
|
+
*
|
|
44
|
+
* `meta.json` wins over every default, and the per-kind defaults only fill the
|
|
45
|
+
* gaps, so a dataset that declares nothing but `kind` still produces a coherent
|
|
46
|
+
* set of routes.
|
|
47
|
+
*/
|
|
48
|
+
export function datasetIdentity(meta) {
|
|
49
|
+
const kind = meta.kind ?? 'software';
|
|
50
|
+
return {
|
|
51
|
+
kind,
|
|
52
|
+
listingBase: meta.listingBase ?? LISTING_BASE_BY_KIND[kind],
|
|
53
|
+
listingNoun: meta.listingNoun ?? LISTING_NOUN_BY_KIND[kind],
|
|
54
|
+
jsonLd: meta.jsonLd ?? JSONLD_BY_KIND[kind],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Dataset directory names inside a template, sorted. `[]` when there are none. */
|
|
58
|
+
export function listDatasets(templateDir) {
|
|
59
|
+
const dir = join(templateDir, DATASETS_DIR);
|
|
60
|
+
if (!existsSync(dir))
|
|
61
|
+
return [];
|
|
62
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isDirectory())
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
function readJson(path, fallback) {
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
return fallback;
|
|
70
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
/** Reads `meta.json` + `categories.json` + `tags.json` for one dataset. Throws when it is not there. */
|
|
73
|
+
export function readDataset(templateDir, name) {
|
|
74
|
+
const dir = join(templateDir, DATASETS_DIR, name);
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
const available = listDatasets(templateDir);
|
|
77
|
+
throw new Error(`unknown dataset "${name}"\n` +
|
|
78
|
+
(available.length ? `available in ${DATASETS_DIR}: ${available.join(', ')}` : `${DATASETS_DIR} has no datasets`));
|
|
79
|
+
}
|
|
80
|
+
const meta = readJson(join(dir, 'meta.json'), {});
|
|
81
|
+
const categories = readJson(join(dir, 'categories.json'), []);
|
|
82
|
+
const tags = readJson(join(dir, 'tags.json'), []);
|
|
83
|
+
if (categories.length === 0)
|
|
84
|
+
throw new Error(`dataset "${name}" has no categories.json entries — site.config.ts needs at least one category`);
|
|
85
|
+
const listingsDir = join(dir, 'listings');
|
|
86
|
+
const listingCount = existsSync(listingsDir) ? readdirSync(listingsDir).filter((f) => f.endsWith('.md')).length : 0;
|
|
87
|
+
return { name, dir, meta, categories, tags, listingCount };
|
|
88
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.dev.vars` and `.env`, generated from the template's own `*.example` files.
|
|
3
|
+
*
|
|
4
|
+
* Neither file is written from a hard-coded key list. `wrangler types` derives
|
|
5
|
+
* the `Env` type from `.dev.vars`, so §11.6 requires the key set to equal
|
|
6
|
+
* `.dev.vars.example` exactly — a CLI with its own list would drift from the
|
|
7
|
+
* template the first time a milestone adds a secret. Reading the example keeps
|
|
8
|
+
* them equal by construction, and keeps every comment the template wrote to
|
|
9
|
+
* explain what each key is for.
|
|
10
|
+
*
|
|
11
|
+
* Both functions are pure. `randomSecret()` is the only impure thing here, and
|
|
12
|
+
* the values it produces are written to the project and never printed.
|
|
13
|
+
*/
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
/** 32 random bytes, base64url — the shape `.dev.vars.example` documents for ADMIN_SECRET / TOKEN_SECRET. */
|
|
16
|
+
export function randomSecret() {
|
|
17
|
+
return randomBytes(32).toString('base64url');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Rewrites a `KEY=value` example file: every key listed in `values` gets that
|
|
21
|
+
* value, and comments, blank lines and key order survive untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function renderEnvFile(example, values, options = {}) {
|
|
24
|
+
const blankUnlisted = options.blankUnlisted ?? false;
|
|
25
|
+
const lines = example.split('\n').map((line) => {
|
|
26
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
27
|
+
if (!match)
|
|
28
|
+
return line;
|
|
29
|
+
const key = match[1];
|
|
30
|
+
if (key in values)
|
|
31
|
+
return `${key}=${values[key]}`;
|
|
32
|
+
return blankUnlisted ? `${key}=` : line;
|
|
33
|
+
});
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
/** The keys a `KEY=value` file declares, in order. */
|
|
37
|
+
export function envKeys(example) {
|
|
38
|
+
const keys = [];
|
|
39
|
+
for (const line of example.split('\n')) {
|
|
40
|
+
const match = /^([A-Z][A-Z0-9_]*)=/.exec(line);
|
|
41
|
+
if (match?.[1])
|
|
42
|
+
keys.push(match[1]);
|
|
43
|
+
}
|
|
44
|
+
return keys;
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running other people's commands.
|
|
3
|
+
*
|
|
4
|
+
* Every external call goes through here so there is one place to look for what
|
|
5
|
+
* this CLI is capable of executing. It never runs a command that signs anyone
|
|
6
|
+
* in, uploads a secret, deploys, or touches a remote database: those are
|
|
7
|
+
* printed into `SETUP.md` for the buyer to run deliberately.
|
|
8
|
+
*
|
|
9
|
+
* Failures are values, not exceptions. A failed `pnpm install` should leave a
|
|
10
|
+
* complete project on disk and a line in the summary, not a half-written
|
|
11
|
+
* directory and a stack trace.
|
|
12
|
+
*/
|
|
13
|
+
import { execa } from 'execa';
|
|
14
|
+
export async function run(command, args, options) {
|
|
15
|
+
try {
|
|
16
|
+
const result = await execa(command, args, {
|
|
17
|
+
cwd: options.cwd,
|
|
18
|
+
stdio: options.stdio === 'inherit' ? 'inherit' : 'pipe',
|
|
19
|
+
env: options.env,
|
|
20
|
+
timeout: options.timeout,
|
|
21
|
+
reject: false,
|
|
22
|
+
});
|
|
23
|
+
const stdout = String(result.stdout ?? '');
|
|
24
|
+
const stderr = String(result.stderr ?? '');
|
|
25
|
+
return {
|
|
26
|
+
ok: result.exitCode === 0,
|
|
27
|
+
stdout,
|
|
28
|
+
stderr,
|
|
29
|
+
message: firstLine(stderr) || firstLine(stdout) || `${command} exited ${String(result.exitCode)}`,
|
|
30
|
+
exitCode: result.exitCode,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const message = error.message ?? String(error);
|
|
35
|
+
return { ok: false, stdout: '', stderr: message, message: firstLine(message), exitCode: undefined };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function firstLine(text) {
|
|
39
|
+
return (text
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.length > 0 && !line.startsWith('▲'))[0] ?? '');
|
|
43
|
+
}
|
|
44
|
+
/** `npx wrangler …` — wrangler is never installed globally, and never `-g`. */
|
|
45
|
+
export function wranglerArgs(args) {
|
|
46
|
+
return { command: 'npx', args: ['--yes', 'wrangler', ...args] };
|
|
47
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem work: copying a template into the project, and loading the
|
|
3
|
+
* dataset's own content into `src/content` and `public/logos`.
|
|
4
|
+
*
|
|
5
|
+
* Copy by exclusion, never by allowlist. An allowlist silently drops whatever
|
|
6
|
+
* the template gains next — the same reasoning `scripts/dist-starter.ts` gives
|
|
7
|
+
* for its denylist, and the same list, because the two are copying the same
|
|
8
|
+
* tree for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* The content load mirrors `scripts/content.ts use <dataset> --yes` exactly, so
|
|
11
|
+
* a generated project passes its own `pnpm content:check <dataset>`. It is done
|
|
12
|
+
* here rather than by shelling out to that script because it has to work before
|
|
13
|
+
* `node_modules` exists.
|
|
14
|
+
*/
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Build output, dependency trees, audit artifacts, and the free edition's
|
|
19
|
+
* replacement files (which describe a build the buyer does not have).
|
|
20
|
+
* Matched as an exact relative path or as a `<entry>/` prefix.
|
|
21
|
+
*/
|
|
22
|
+
export const DENY_PATHS = [
|
|
23
|
+
'node_modules',
|
|
24
|
+
'dist',
|
|
25
|
+
'.astro',
|
|
26
|
+
'.wrangler',
|
|
27
|
+
'.git',
|
|
28
|
+
'seed/.out',
|
|
29
|
+
'.claude/skills',
|
|
30
|
+
'.lighthouseci',
|
|
31
|
+
'test-results',
|
|
32
|
+
'playwright-report',
|
|
33
|
+
'coverage',
|
|
34
|
+
'AGENTS.free.md',
|
|
35
|
+
'README.free.md',
|
|
36
|
+
'LICENSE.free',
|
|
37
|
+
'wrangler.free.jsonc',
|
|
38
|
+
// Never copy a live `.dev.vars` / `.env` out of somebody's working tree.
|
|
39
|
+
'.dev.vars',
|
|
40
|
+
'.env',
|
|
41
|
+
'SETUP.md',
|
|
42
|
+
];
|
|
43
|
+
/** Junk that can appear at any depth. */
|
|
44
|
+
export const DENY_NAMES = new Set(['node_modules', '.DS_Store', '.git']);
|
|
45
|
+
export function isDenied(relativePath, name) {
|
|
46
|
+
if (DENY_NAMES.has(name))
|
|
47
|
+
return true;
|
|
48
|
+
return DENY_PATHS.some((denied) => relativePath === denied || relativePath.startsWith(`${denied}/`));
|
|
49
|
+
}
|
|
50
|
+
/** Recursive copy honouring the denylist. Symlinks are resolved to their target's content. */
|
|
51
|
+
export function copyTemplate(from, to) {
|
|
52
|
+
const result = { files: 0, skipped: [] };
|
|
53
|
+
walk('');
|
|
54
|
+
return result;
|
|
55
|
+
function walk(relative) {
|
|
56
|
+
const source = relative ? join(from, relative) : from;
|
|
57
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
58
|
+
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
59
|
+
if (isDenied(childRelative, entry.name)) {
|
|
60
|
+
if (!relative)
|
|
61
|
+
result.skipped.push(entry.name);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const target = join(to, childRelative);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
mkdirSync(target, { recursive: true });
|
|
67
|
+
walk(childRelative);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (entry.isSymbolicLink() && !existsSync(join(source, entry.name)))
|
|
71
|
+
continue;
|
|
72
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
73
|
+
copyFileSync(join(source, entry.name), target);
|
|
74
|
+
result.files += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Every file under `dir` as paths relative to it, sorted. `[]` when `dir` is missing. */
|
|
79
|
+
export function listFiles(dir, prefix = '') {
|
|
80
|
+
if (!existsSync(dir))
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
84
|
+
if (entry.name === '.DS_Store')
|
|
85
|
+
continue;
|
|
86
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
87
|
+
if (entry.isDirectory())
|
|
88
|
+
out.push(...listFiles(join(dir, entry.name), name));
|
|
89
|
+
else
|
|
90
|
+
out.push(name);
|
|
91
|
+
}
|
|
92
|
+
return out.sort();
|
|
93
|
+
}
|
|
94
|
+
/** True when the path does not exist, or is a directory with nothing in it. */
|
|
95
|
+
export function isEmptyDir(path) {
|
|
96
|
+
if (!existsSync(path))
|
|
97
|
+
return true;
|
|
98
|
+
if (!statSync(path).isDirectory())
|
|
99
|
+
return false;
|
|
100
|
+
return readdirSync(path).filter((name) => name !== '.DS_Store').length === 0;
|
|
101
|
+
}
|
|
102
|
+
function clearDir(dir, options = {}) {
|
|
103
|
+
const { keep = [], match = () => true } = options;
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
for (const file of listFiles(dir)) {
|
|
106
|
+
if (keep.includes(file) || !match(file))
|
|
107
|
+
continue;
|
|
108
|
+
rmSync(join(dir, file), { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function copyDir(from, to) {
|
|
112
|
+
mkdirSync(to, { recursive: true });
|
|
113
|
+
const files = listFiles(from);
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const target = join(to, file);
|
|
116
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
117
|
+
copyFileSync(join(from, file), target);
|
|
118
|
+
}
|
|
119
|
+
return files.length;
|
|
120
|
+
}
|
|
121
|
+
/** `scripts/content.ts` keeps these: they belong to the starter, not to a dataset. */
|
|
122
|
+
const STARTER_BLOG_FILES = ['unpublished-draft.md'];
|
|
123
|
+
/**
|
|
124
|
+
* Loads `seed/datasets/<name>/{listings,blog,logos}` into `src/content` and
|
|
125
|
+
* `public/logos`, replacing whatever the template shipped.
|
|
126
|
+
*
|
|
127
|
+
* Without this the project would still carry the template's own demo content —
|
|
128
|
+
* whose categories belong to a different dataset, which the content loader
|
|
129
|
+
* rejects at build time with an unknown-category error.
|
|
130
|
+
*/
|
|
131
|
+
export function loadDatasetContent(projectDir, datasetDir) {
|
|
132
|
+
const pairs = [
|
|
133
|
+
[join(datasetDir, 'listings'), join(projectDir, 'src/content/listings')],
|
|
134
|
+
[join(datasetDir, 'blog'), join(projectDir, 'src/content/blog')],
|
|
135
|
+
[join(datasetDir, 'logos'), join(projectDir, 'public/logos')],
|
|
136
|
+
];
|
|
137
|
+
for (const [source] of pairs) {
|
|
138
|
+
if (!existsSync(source))
|
|
139
|
+
throw new Error(`dataset is incomplete: ${source} does not exist`);
|
|
140
|
+
}
|
|
141
|
+
clearDir(join(projectDir, 'src/content/listings'), { match: (f) => f.endsWith('.md') });
|
|
142
|
+
clearDir(join(projectDir, 'src/content/blog'), { keep: STARTER_BLOG_FILES, match: (f) => f.endsWith('.md') });
|
|
143
|
+
clearDir(join(projectDir, 'public/logos'));
|
|
144
|
+
const [listings, blog, logos] = pairs.map(([source, target]) => copyDir(source, target));
|
|
145
|
+
return { listings, blog, logos };
|
|
146
|
+
}
|
|
147
|
+
/** Writes a file, creating parents. */
|
|
148
|
+
export function write(path, contents) {
|
|
149
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
150
|
+
writeFileSync(path, contents, 'utf8');
|
|
151
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const WRANGLER_AUTH_DOCS = 'https://developers.cloudflare.com/workers/wrangler/commands/#auth';
|
|
2
|
+
const WORKERS_BUILDS_DOCS = 'https://developers.cloudflare.com/workers/ci-cd/builds/';
|
|
3
|
+
const D1_TOKEN_DOCS = 'https://developers.cloudflare.com/d1/platform/client-api/';
|
|
4
|
+
const R2_DOMAIN_DOCS = 'https://developers.cloudflare.com/r2/buckets/public-buckets/#custom-domains';
|
|
5
|
+
const STRIPE_EVENTS = ['checkout.session.completed', 'checkout.session.async_payment_succeeded'];
|
|
6
|
+
/** The host used in example URLs before the buyer has a domain. */
|
|
7
|
+
function hostOf(plan) {
|
|
8
|
+
return plan.siteUrl.replace(/^https:\/\//, '');
|
|
9
|
+
}
|
|
10
|
+
export function renderSetupMd(context) {
|
|
11
|
+
const { plan } = context;
|
|
12
|
+
const run = context.runPrefix;
|
|
13
|
+
const pro = plan.answers.edition === 'pro';
|
|
14
|
+
const host = hostOf(plan);
|
|
15
|
+
const hasDomain = Boolean(plan.answers.domain);
|
|
16
|
+
const nouns = plan.identity.listingNoun.plural;
|
|
17
|
+
const sections = [];
|
|
18
|
+
sections.push(`# Setup — ${plan.siteConfig.site.name}`, '', `Everything \`create-zerodirs\` did **not** do, in the order it is worth doing. Delete this file when you are through it.`, '', '## What you have already', '', `- \`site.config.ts\` written from your answers: ${plan.siteConfig.categories.length} categories, ${plan.siteConfig.tags.length} tags, \`site.kind: '${plan.identity.kind}'\`, \`seo.jsonLd.listing: '${plan.identity.jsonLd}'\`.`, `- ${context.datasetListings} ${nouns} from the \`${context.dataset}\` sample dataset in \`src/content/listings/\`, with their blog posts and logos. Sample data — replace it before you launch (\`${run} content:reset --yes\` clears it down to one example per category).`, `- Template copied from ${context.templateSource}.`, pro ? '- `.dev.vars` with a freshly generated `ADMIN_SECRET` and `TOKEN_SECRET` (32 random bytes each) and empty placeholders for the rest.' : null, '- `.env` with `LISTINGS_SOURCE=files`.', context.installed ? '- Dependencies installed.' : null, '', '## 1. Run it', '', '```sh', context.installed ? `# dependencies are already installed` : context.installCommand, `${run} dev # http://localhost:4321`, `${run} check # config + types`, `${run} build # production build`, '```', '');
|
|
19
|
+
sections.push('## 2. Make it yours', '', `- \`public/favicon.svg\` is the placeholder mark, and it is \`site.logo\`, so it is in the header of every page.`, `- \`public/og-default.png\` is the social-share fallback. Fix \`site.name\`/\`site.tagline\` first, then \`${run} og:default\`.`, '- `src/pages/about.astro` describes an editorial policy. Make it describe yours.', `- \`site.config.ts\` › \`site.social\` and \`seo.twitterHandle\` are unset; add them when you have the accounts.`, hasDomain ? null : '- `site.url` is `https://example.com`. Set your real domain — it is baked into canonicals, the sitemap, RSS and the OG images at **build** time.', '');
|
|
20
|
+
if (pro) {
|
|
21
|
+
sections.push(...cloudflareSection(context, host));
|
|
22
|
+
sections.push(...secretsSection(context));
|
|
23
|
+
sections.push(...providerSection(context, host));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
sections.push('## 3. Publish', '', `This edition builds to static assets — \`dist/client\` after \`${run} build\` — so it deploys as an assets-only Worker.`, '', `The steady-state way is Workers Builds: in the Cloudflare dashboard, connect this repository, set the root directory to this folder, the build command to \`${run} build\` and the output directory to \`dist/client\`. See ${WORKERS_BUILDS_DOCS}.`, '', `To upload from your machine instead, authenticate the wrangler CLI first (${WRANGLER_AUTH_DOCS}) and use its upload command — this CLI never touches your Cloudflare account.`, '');
|
|
27
|
+
}
|
|
28
|
+
sections.push('## Before you launch', '', `- [ ] Replace the sample ${nouns} with real ones (\`${run} content:reset --yes\`, then \`${run} listings:import\` or write them by hand).`, '- [ ] `site.config.ts` › `seo.strictLinks: true`. It ships as `false` so your first import cannot fail the build, and it is the only check that catches a page with fewer than two inbound internal links — one Google will struggle to find.', '- [ ] Rewrite `src/pages/about.astro`, `/terms/` and `/privacy/`.', `- [ ] \`${run} check && ${run} test\` clean.`, hasDomain ? `- [ ] \`https://${host}/sitemap-index.xml\` submitted to Google Search Console.` : '- [ ] Set `site.url`, then submit your sitemap to Google Search Console.', '');
|
|
29
|
+
return sections.filter((line) => line !== null).join('\n').replace(/\n{3,}/g, '\n\n');
|
|
30
|
+
}
|
|
31
|
+
function cloudflareSection(context, host) {
|
|
32
|
+
const { plan } = context;
|
|
33
|
+
const run = context.runPrefix;
|
|
34
|
+
const created = context.resourcesCreated;
|
|
35
|
+
return [
|
|
36
|
+
'## 3. Cloudflare resources',
|
|
37
|
+
'',
|
|
38
|
+
created
|
|
39
|
+
? `The D1 database \`${plan.databaseName}\` and the R2 bucket \`${plan.bucketName}\` were created and written into \`wrangler.jsonc\`.`
|
|
40
|
+
: `None were created. Authenticate the wrangler CLI first (${WRANGLER_AUTH_DOCS}), then:`,
|
|
41
|
+
'',
|
|
42
|
+
created
|
|
43
|
+
? null
|
|
44
|
+
: [
|
|
45
|
+
'```sh',
|
|
46
|
+
`npx wrangler d1 create ${plan.databaseName} --binding DB --update-config`,
|
|
47
|
+
`npx wrangler r2 bucket create ${plan.bucketName} --binding MEDIA --update-config`,
|
|
48
|
+
'```',
|
|
49
|
+
'',
|
|
50
|
+
'Both write the binding back into `wrangler.jsonc` themselves — there is no `--json` output to copy. R2 needs the subscription checkout completed once in the dashboard before the first bucket can be created.',
|
|
51
|
+
'',
|
|
52
|
+
'Then, in `site.config.ts`, turn `features.submissions` back on (and `features.newsletter: \'d1\'` if you want signups stored) — they were left off because there was no database to write to.',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
'',
|
|
55
|
+
'```sh',
|
|
56
|
+
`${run} cf:typegen # regenerate worker-configuration.d.ts after any binding change`,
|
|
57
|
+
`${run} db:migrate:local # local D1 (miniflare)`,
|
|
58
|
+
`${run} db:seed:local --dataset ${context.dataset}`,
|
|
59
|
+
'```',
|
|
60
|
+
'',
|
|
61
|
+
'For the production database, the same two steps have `:remote` variants (`db:migrate:remote`, `db:seed:remote`). They write to the real database, so run them when you mean to.',
|
|
62
|
+
'',
|
|
63
|
+
'## 4. Deploy',
|
|
64
|
+
'',
|
|
65
|
+
`Connect this repository to Workers Builds (${WORKERS_BUILDS_DOCS}) with the root directory set to this folder. Build variables:`,
|
|
66
|
+
'',
|
|
67
|
+
'| Variable | Value |',
|
|
68
|
+
'|---|---|',
|
|
69
|
+
'| `LISTINGS_SOURCE` | `d1` |',
|
|
70
|
+
'| `CLOUDFLARE_ACCOUNT_ID` | your account id |',
|
|
71
|
+
'| `D1_DATABASE_ID` | the id `wrangler.jsonc` now carries |',
|
|
72
|
+
'| `D1_READ_TOKEN` | **build secret** — an account API token with *D1 Read* scoped to this database |',
|
|
73
|
+
'| `PNPM_VERSION` | `10.12.4` (the build image ships an older pnpm) |',
|
|
74
|
+
'',
|
|
75
|
+
`The read token is a **separate** token from the one Workers Builds deploys with, and it is not \`CLOUDFLARE_API_TOKEN\`. Create it with a custom permission template: *Account* › *D1* › *Read*, restricted to this account. See ${D1_TOKEN_DOCS}.`,
|
|
76
|
+
'',
|
|
77
|
+
'Then create a **Deploy Hook** for the production branch, put its URL in `DEPLOY_HOOK_URL` (see secrets below) and set `providers.rebuild: \'deploy-hook\'` in `site.config.ts`. That is what makes approving a submission rebuild the site.',
|
|
78
|
+
'',
|
|
79
|
+
`\`${run} deploy\` uploads from your machine instead, once the wrangler CLI is authenticated.`,
|
|
80
|
+
'',
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function secretsSection(context) {
|
|
84
|
+
const run = context.runPrefix;
|
|
85
|
+
return [
|
|
86
|
+
'## 5. Secrets',
|
|
87
|
+
'',
|
|
88
|
+
`\`.dev.vars\` holds them for local development. For production, \`${run} cf:secrets\` prompts for each key named in \`.dev.vars.example\` and pipes what you type straight to wrangler — never through an argument list, a log, or this CLI. It does **not** read \`.dev.vars\`, so press Enter to skip a key you have not set yet; \`${run} cf:secrets --from-env\` takes the values from the shell's environment instead.`,
|
|
89
|
+
'',
|
|
90
|
+
'| Key | Needed when |',
|
|
91
|
+
'|---|---|',
|
|
92
|
+
'| `ADMIN_SECRET` | always — it is the admin login. Generated for you. |',
|
|
93
|
+
'| `TOKEN_SECRET` | always — signs status/checkout tokens, cookies and IP hashes. Generated for you. |',
|
|
94
|
+
'| `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` | `providers.payment: \'stripe\'` |',
|
|
95
|
+
'| `RESEND_API_KEY` | `providers.email: \'resend\'` |',
|
|
96
|
+
'| `DEPLOY_HOOK_URL` | `providers.rebuild: \'deploy-hook\'` |',
|
|
97
|
+
'| `GITHUB_TOKEN` / `GITHUB_REPO` | `providers.rebuild: \'github-dispatch\'` |',
|
|
98
|
+
'',
|
|
99
|
+
'Every key in `.dev.vars` must also exist in `.dev.vars.example`: `wrangler types` derives the `Env` type from it, so an undeclared key breaks the build for everyone else.',
|
|
100
|
+
'',
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
function providerSection(context, host) {
|
|
104
|
+
const { plan } = context;
|
|
105
|
+
const payments = plan.siteConfig.features.payments;
|
|
106
|
+
const email = plan.siteConfig.providers?.email ?? 'console';
|
|
107
|
+
const out = ['## 6. Providers', ''];
|
|
108
|
+
out.push('### Stripe', '', payments
|
|
109
|
+
? 'Payments are on. Create the webhook endpoint in the Stripe dashboard:'
|
|
110
|
+
: 'Payments are off (`features.payments: false`). When you turn them on, create the webhook endpoint in the Stripe dashboard:', '', '```', `https://${host}/api/stripe/webhook/`, '```', '', '**Keep the trailing slash.** Every on-demand route redirects to its slashed form with a 308, and Stripe treats a 308 as a failed delivery — it will retry the same event for days.', '', `Events: ${STRIPE_EVENTS.map((event) => `\`${event}\``).join(', ')}. Set the endpoint's API version to match the SDK pin (\`2026-08-26.dahlia\`) — the payload follows the endpoint version, not the SDK.`, '', 'Then copy the signing secret into `STRIPE_WEBHOOK_SECRET`, and a restricted key (Checkout Sessions: write) into `STRIPE_SECRET_KEY`.', '');
|
|
111
|
+
out.push('### Email', '', email === 'resend'
|
|
112
|
+
? 'Verify your sending domain in Resend, then put a sending-only API key in `RESEND_API_KEY`. `providers.emailFrom` must be an address on the verified domain.'
|
|
113
|
+
: email === 'cloudflare'
|
|
114
|
+
? 'Cloudflare Email Sending needs the zone on Cloudflare DNS with Email Routing enabled, and `providers.emailFrom` inside the routing domain. The `send_email` binding is already in `wrangler.jsonc`; deploying it before Email Routing is on will fail the deploy.'
|
|
115
|
+
: 'Email is `console`: messages are logged, not sent. Switch `providers.email` to `resend` (or `cloudflare`) and set `providers.emailFrom` when you are ready.', '');
|
|
116
|
+
out.push('### R2 custom domain', '', 'Uploaded logos and screenshots are served from the bucket. Give it a hostname on your zone:', '', '```sh', `npx wrangler r2 bucket domain add ${plan.bucketName} --domain media.${host} --zone-id <your-zone-id>`, '```', '', `Then set \`media.baseUrl: 'https://media.${host}'\` in \`site.config.ts\`. Until that exists the build has nowhere to read uploaded media from. Zone id: dashboard › the zone › Overview › API section. See ${R2_DOMAIN_DOCS}.`, '');
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `site.config.ts` rewriting, through the module's AST.
|
|
3
|
+
*
|
|
4
|
+
* The file is a typed module — `defineSiteConfig({ ... })` with a validated
|
|
5
|
+
* shape — so the result has to keep type-checking and keep passing
|
|
6
|
+
* `pnpm check:config`. A regex that matches today's formatting would not
|
|
7
|
+
* survive the template being reformatted, a key being reordered, or a comment
|
|
8
|
+
* being added above the key it edits, so every change here is an assignment
|
|
9
|
+
* into the parsed object literal and the printer puts the file back together.
|
|
10
|
+
*
|
|
11
|
+
* `rewriteSiteConfig()` is a pure string -> string function: the tests feed it
|
|
12
|
+
* the real template and assert on what comes back.
|
|
13
|
+
*/
|
|
14
|
+
import { generateCode, parseModule } from 'magicast';
|
|
15
|
+
/**
|
|
16
|
+
* recast reprints the nodes it was asked to change and leaves everything else
|
|
17
|
+
* byte-identical. Without an explicit tab width it guesses, and guesses wrong
|
|
18
|
+
* on this file (one space), so the two-space house style is stated outright.
|
|
19
|
+
*/
|
|
20
|
+
const FORMAT = {
|
|
21
|
+
tabWidth: 2,
|
|
22
|
+
useTabs: false,
|
|
23
|
+
quote: 'single',
|
|
24
|
+
trailingComma: true,
|
|
25
|
+
objectCurlySpacing: true,
|
|
26
|
+
arrowParensAlways: true,
|
|
27
|
+
};
|
|
28
|
+
function hasKey(object, key) {
|
|
29
|
+
return Object.keys(object).includes(key);
|
|
30
|
+
}
|
|
31
|
+
/** Walks (creating plain objects as it goes) and assigns the leaf. */
|
|
32
|
+
function setPath(root, path, value) {
|
|
33
|
+
let node = root;
|
|
34
|
+
for (const key of path.slice(0, -1)) {
|
|
35
|
+
if (!hasKey(node, key) || typeof node[key] !== 'object' || node[key] === null)
|
|
36
|
+
node[key] = {};
|
|
37
|
+
node = node[key];
|
|
38
|
+
}
|
|
39
|
+
node[path[path.length - 1]] = value;
|
|
40
|
+
}
|
|
41
|
+
/** Deletes a key when it is there. `delete` on a missing key would be a no-op anyway; this keeps the intent readable. */
|
|
42
|
+
function dropKey(object, key) {
|
|
43
|
+
if (hasKey(object, key))
|
|
44
|
+
delete object[key];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The one export. Takes the template's `site.config.ts` and the planned edits
|
|
48
|
+
* and returns the buyer's version of the same file.
|
|
49
|
+
*
|
|
50
|
+
* What it deliberately does NOT touch: `pseo.templates`, `seo.templates`,
|
|
51
|
+
* `listing.perPage` and friends, `legal`, `theme.tokens`. Those are the
|
|
52
|
+
* starter's opinions, they are valid for any dataset, and a buyer who wants
|
|
53
|
+
* them different is already in the file.
|
|
54
|
+
*/
|
|
55
|
+
export function rewriteSiteConfig(source, edits) {
|
|
56
|
+
const mod = parseModule(source, { sourceFileName: 'site.config.ts' });
|
|
57
|
+
const exported = mod.exports['siteConfig'];
|
|
58
|
+
if (!exported || exported.$type !== 'function-call' || !exported.$args?.length) {
|
|
59
|
+
throw new Error('site.config.ts does not look like `export const siteConfig = defineSiteConfig({ ... })` — refusing to rewrite it');
|
|
60
|
+
}
|
|
61
|
+
const config = exported.$args[0];
|
|
62
|
+
// --- file header --------------------------------------------------------
|
|
63
|
+
// Every other comment in the file is advice that is still true and is kept.
|
|
64
|
+
// This one is not: it names the template author's demo site and the dataset
|
|
65
|
+
// the template happened to ship with, and both are about to stop being true.
|
|
66
|
+
replaceFileHeader(mod, edits);
|
|
67
|
+
// --- site ---------------------------------------------------------------
|
|
68
|
+
const site = config['site'];
|
|
69
|
+
site['name'] = edits.site.name;
|
|
70
|
+
site['tagline'] = edits.site.tagline;
|
|
71
|
+
site['description'] = edits.site.description;
|
|
72
|
+
site['url'] = edits.site.url;
|
|
73
|
+
site['kind'] = edits.site.kind;
|
|
74
|
+
site['contactEmail'] = edits.site.contactEmail;
|
|
75
|
+
// The template's handles belong to whoever shipped it, not to the buyer.
|
|
76
|
+
dropKey(site, 'social');
|
|
77
|
+
// --- routes -------------------------------------------------------------
|
|
78
|
+
setPath(config, ['routes', 'listingBase'], edits.routes.listingBase);
|
|
79
|
+
setPath(config, ['routes', 'listingNoun'], { singular: edits.routes.listingNoun.singular, plural: edits.routes.listingNoun.plural });
|
|
80
|
+
// --- taxonomy -----------------------------------------------------------
|
|
81
|
+
config['categories'] = edits.categories.map((category) => stripUndefined(category));
|
|
82
|
+
config['tags'] = edits.tags.map((tag) => stripUndefined(tag));
|
|
83
|
+
if (edits.pricingTypes)
|
|
84
|
+
config['pricingTypes'] = edits.pricingTypes.map((type) => stripUndefined(type));
|
|
85
|
+
if (edits.customFields)
|
|
86
|
+
setPath(config, ['listing', 'customFields'], edits.customFields.map((field) => stripUndefined(field)));
|
|
87
|
+
// --- tiers --------------------------------------------------------------
|
|
88
|
+
// Cross-rule 3: an enabled tier with priceCents > 0 needs payments on. The
|
|
89
|
+
// prices stay in the file either way — the pricing table renders from them and
|
|
90
|
+
// `outboundRel()` treats a priced tier as sponsored (§6.4).
|
|
91
|
+
const tiers = config['tiers'];
|
|
92
|
+
if (Array.isArray(tiers)) {
|
|
93
|
+
for (const tier of tiers) {
|
|
94
|
+
const priced = typeof tier['priceCents'] === 'number' && tier['priceCents'] > 0;
|
|
95
|
+
tier['enabled'] = priced ? edits.enabledTierIds === 'all' : true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// --- seo ----------------------------------------------------------------
|
|
99
|
+
// Written out rather than left to the `site.kind` fallback: `localBusiness` in
|
|
100
|
+
// the file is a value the buyer can find, and it is what makes the detail
|
|
101
|
+
// pages emit LocalBusiness instead of SoftwareApplication.
|
|
102
|
+
setPath(config, ['seo', 'jsonLd', 'listing'], edits.jsonLdListing);
|
|
103
|
+
// Orphan pages are a warning while the buyer is importing, not a build
|
|
104
|
+
// failure on day one. SETUP.md says to turn it back on before launch.
|
|
105
|
+
setPath(config, ['seo', 'strictLinks'], false);
|
|
106
|
+
dropKey(config['seo'], 'twitterHandle');
|
|
107
|
+
// --- theme, features, providers ----------------------------------------
|
|
108
|
+
setPath(config, ['theme', 'preset'], edits.themePreset);
|
|
109
|
+
const features = config['features'];
|
|
110
|
+
features['submissions'] = edits.features.submissions;
|
|
111
|
+
features['payments'] = edits.features.payments;
|
|
112
|
+
features['newsletter'] = edits.features.newsletter;
|
|
113
|
+
if (edits.providers) {
|
|
114
|
+
const providers = { email: edits.providers.email };
|
|
115
|
+
if (edits.providers.emailFrom)
|
|
116
|
+
providers['emailFrom'] = edits.providers.emailFrom;
|
|
117
|
+
providers['payment'] = edits.providers.payment;
|
|
118
|
+
providers['rebuild'] = edits.providers.rebuild;
|
|
119
|
+
config['providers'] = providers;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// The free build has no server: 'console' email and 'none' payment are the
|
|
123
|
+
// defaults and the honest values, so the block is better absent than lying.
|
|
124
|
+
dropKey(config, 'providers');
|
|
125
|
+
}
|
|
126
|
+
// --- everything else ----------------------------------------------------
|
|
127
|
+
// `media.baseUrl` names the template author's R2 custom domain. It has a
|
|
128
|
+
// default (DEFAULT_MEDIA), so dropping the block is the correct neutral state;
|
|
129
|
+
// SETUP.md carries the command that creates the buyer's own.
|
|
130
|
+
dropKey(config, 'media');
|
|
131
|
+
setPath(config, ['newsletter', 'heading'], edits.newsletter.heading);
|
|
132
|
+
setPath(config, ['newsletter', 'blurb'], edits.newsletter.blurb);
|
|
133
|
+
setPath(config, ['budget', 'plan'], edits.budgetPlan);
|
|
134
|
+
return generateCode(mod, { format: FORMAT }).code;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Swaps the module's leading block comment for one that describes this project.
|
|
138
|
+
*
|
|
139
|
+
* The comment belongs to the first statement (the `defineSiteConfig` import),
|
|
140
|
+
* which is where both babel and recast attach it, so replacing it is a node
|
|
141
|
+
* edit like any other rather than a slice off the top of the string.
|
|
142
|
+
*/
|
|
143
|
+
function replaceFileHeader(mod, edits) {
|
|
144
|
+
// magicast hands back a `Program`; a plain babel parse would hand back a
|
|
145
|
+
// `File` wrapping one. Accept either rather than guessing.
|
|
146
|
+
const root = mod.$ast;
|
|
147
|
+
const first = (root.program?.body ?? root.body)?.[0];
|
|
148
|
+
if (!first)
|
|
149
|
+
return;
|
|
150
|
+
const text = [
|
|
151
|
+
'*',
|
|
152
|
+
` * Site configuration for ${edits.site.name} — the one file that covers ~80% of`,
|
|
153
|
+
' * customisation.',
|
|
154
|
+
' *',
|
|
155
|
+
` * Generated by create-zerodirs from the \`${edits.datasetName}\` sample dataset.`,
|
|
156
|
+
' * `defineSiteConfig()` validates every field, fills in defaults and freezes the',
|
|
157
|
+
' * result; run `pnpm check:config` after editing. Never put secrets here — they',
|
|
158
|
+
' * live in `.dev.vars` / the Worker\'s own secrets (see `.dev.vars.example`).',
|
|
159
|
+
' *',
|
|
160
|
+
' * SETUP.md lists what is left to do.',
|
|
161
|
+
' ',
|
|
162
|
+
].join('\n');
|
|
163
|
+
const comment = { type: 'CommentBlock', value: text, leading: true, trailing: false };
|
|
164
|
+
first.comments = [comment];
|
|
165
|
+
first.leadingComments = [comment];
|
|
166
|
+
}
|
|
167
|
+
/** JSON round-trips drop `undefined`; an explicit `icon: undefined` would print as a hole. */
|
|
168
|
+
function stripUndefined(value) {
|
|
169
|
+
const out = {};
|
|
170
|
+
for (const [key, item] of Object.entries(value)) {
|
|
171
|
+
if (item !== undefined)
|
|
172
|
+
out[key] = item;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|