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
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# create-zerodirs
|
|
2
|
+
|
|
3
|
+
Scaffold a [ZeroDirs](https://zerodirs.com) directory site: pick a sample dataset, answer a few
|
|
4
|
+
questions, and get a project that builds.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm create zerodirs@latest my-directory -- --pro # npm needs the `--`
|
|
8
|
+
pnpm create zerodirs my-directory --pro
|
|
9
|
+
yarn create zerodirs my-directory --pro
|
|
10
|
+
bun create zerodirs my-directory --pro
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node 22.18 or newer.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
1. Copies the template — the free MIT edition by default, the paid starter with `--pro`.
|
|
18
|
+
2. Asks for the name, the domain, a theme and (paid only) the plan, whether to take public
|
|
19
|
+
submissions, and how to send email.
|
|
20
|
+
3. Writes `site.config.ts` **through its AST**, so the file keeps its comments, keeps
|
|
21
|
+
type-checking and does not depend on how it happened to be formatted.
|
|
22
|
+
4. Rewrites `wrangler.jsonc` — Worker name, database name, bucket name, custom-domain route —
|
|
23
|
+
with `jsonc-parser`, so every comment in it survives.
|
|
24
|
+
5. Generates `.dev.vars` (a fresh 32-byte `ADMIN_SECRET` and `TOKEN_SECRET`) and `.env` from the
|
|
25
|
+
template's own `*.example` files, so their key sets stay equal to it.
|
|
26
|
+
6. Loads the chosen dataset's listings, blog posts and logos into `src/content` and `public/logos`.
|
|
27
|
+
7. Optionally creates a D1 database and an R2 bucket, using your existing wrangler sign-in.
|
|
28
|
+
8. Writes `SETUP.md` — everything it deliberately left to you.
|
|
29
|
+
|
|
30
|
+
Everything the site *is* — the URL scheme, the listing noun, the structured-data type, the
|
|
31
|
+
categories and tags — comes from the dataset's own `meta.json`, `categories.json` and `tags.json`.
|
|
32
|
+
A dataset with `kind: "business"` produces `seo.jsonLd.listing: 'localBusiness'`, which is what
|
|
33
|
+
makes the detail pages emit LocalBusiness structured data.
|
|
34
|
+
|
|
35
|
+
## What it never does
|
|
36
|
+
|
|
37
|
+
- No sign-in. Your Cloudflare credentials are yours; a browser OAuth window opening in the middle
|
|
38
|
+
of a scaffold is a surprise. If wrangler cannot identify an account, resource creation is skipped
|
|
39
|
+
and `SETUP.md` says where to authenticate.
|
|
40
|
+
- No secret upload. Secrets never pass through this process, an argument list or a log.
|
|
41
|
+
- No deploy, and nothing written to a remote database.
|
|
42
|
+
- No dev server. It prints the command.
|
|
43
|
+
- No overwriting: it refuses a directory that already has files in it.
|
|
44
|
+
- No telemetry, no global installs, no `git init` and no remote rewriting.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
create-zerodirs [directory] [options]
|
|
50
|
+
|
|
51
|
+
--pro Use the paid starter template (default: the free MIT edition)
|
|
52
|
+
--dataset <name> ai-tools | indie-tools | local-businesses
|
|
53
|
+
--template-dir <path> Copy the template from a local directory instead of fetching it
|
|
54
|
+
(env: ZERODIRS_TEMPLATE_DIR)
|
|
55
|
+
--name <name> Site name
|
|
56
|
+
--domain <host> Production domain, e.g. dir.example.com
|
|
57
|
+
--theme <preset> default | warm | mono
|
|
58
|
+
--package-manager <pm> pnpm | npm | yarn | bun
|
|
59
|
+
-y, --yes Accept every default; ask nothing
|
|
60
|
+
--no-cloudflare Never call wrangler; create no Cloudflare resources
|
|
61
|
+
--no-install Do not install dependencies
|
|
62
|
+
-h, --help Show this
|
|
63
|
+
-v, --version Print the version
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--yes` takes the conservative answer to every question — no submissions, no payments, `console`
|
|
67
|
+
email, the free plan — because each of the alternatives needs an account, a key or a domain that
|
|
68
|
+
a non-interactive run cannot have.
|
|
69
|
+
|
|
70
|
+
`--template-dir` is not only a test hook. It is how the starter's own CI exercises the whole
|
|
71
|
+
flow, and how you scaffold from a copy of the starter you already have.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pnpm test # typecheck + unit tests
|
|
77
|
+
pnpm build # dist/
|
|
78
|
+
|
|
79
|
+
# end to end, against a local checkout of the starter (or its derived free tree)
|
|
80
|
+
node dist/index.js /tmp/zd-free --yes --no-cloudflare \
|
|
81
|
+
--dataset ai-tools --template-dir <path-to-zerodirs>/.derived/free
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The unit tests run against copies of the starter's files in `tests/fixtures/starter/`; set
|
|
85
|
+
`ZERODIRS_STARTER_DIR` to a checkout of `zerodirs/zerodirs` to run them against the real thing.
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
|
|
2
|
+
export const USAGE = `Usage: create-zerodirs [directory] [options]
|
|
3
|
+
|
|
4
|
+
Options:
|
|
5
|
+
--pro Use the paid starter template (default: the free MIT edition)
|
|
6
|
+
--dataset <name> Sample data to start from: ai-tools | indie-tools | local-businesses
|
|
7
|
+
--template-dir <path> Copy the template from a local directory instead of fetching it
|
|
8
|
+
(env: ZERODIRS_TEMPLATE_DIR)
|
|
9
|
+
--name <name> Site name (skips the question)
|
|
10
|
+
--domain <host> Production domain, e.g. dir.example.com
|
|
11
|
+
--theme <preset> Theme preset name
|
|
12
|
+
--package-manager <pm> pnpm | npm | yarn | bun (default: whichever ran this command)
|
|
13
|
+
-y, --yes Accept every default; ask nothing
|
|
14
|
+
--no-cloudflare Never call wrangler; create no Cloudflare resources
|
|
15
|
+
--no-install Do not install dependencies
|
|
16
|
+
-h, --help Show this
|
|
17
|
+
-v, --version Print the version
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
npm create zerodirs@latest my-directory -- --pro --dataset local-businesses
|
|
21
|
+
pnpm create zerodirs my-directory --dataset ai-tools --yes --no-cloudflare`;
|
|
22
|
+
/** Flags that take a value. */
|
|
23
|
+
const VALUE_FLAGS = new Set(['--dataset', '--template-dir', '--name', '--domain', '--theme', '--package-manager']);
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const options = { pro: false, yes: false, cloudflare: true, install: true, help: false, version: false };
|
|
26
|
+
const positional = [];
|
|
27
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
28
|
+
const arg = argv[index];
|
|
29
|
+
if (!arg.startsWith('-')) {
|
|
30
|
+
positional.push(arg);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const equals = arg.indexOf('=');
|
|
34
|
+
const flag = equals === -1 ? arg : arg.slice(0, equals);
|
|
35
|
+
const inline = equals === -1 ? undefined : arg.slice(equals + 1);
|
|
36
|
+
if (VALUE_FLAGS.has(flag)) {
|
|
37
|
+
const value = inline ?? argv[++index];
|
|
38
|
+
if (value === undefined || value === '')
|
|
39
|
+
return { ok: false, message: `${flag} needs a value\n\n${USAGE}` };
|
|
40
|
+
switch (flag) {
|
|
41
|
+
case '--dataset':
|
|
42
|
+
options.dataset = value;
|
|
43
|
+
break;
|
|
44
|
+
case '--template-dir':
|
|
45
|
+
options.templateDir = value;
|
|
46
|
+
break;
|
|
47
|
+
case '--name':
|
|
48
|
+
options.name = value;
|
|
49
|
+
break;
|
|
50
|
+
case '--domain':
|
|
51
|
+
options.domain = value;
|
|
52
|
+
break;
|
|
53
|
+
case '--theme':
|
|
54
|
+
options.theme = value;
|
|
55
|
+
break;
|
|
56
|
+
case '--package-manager': {
|
|
57
|
+
if (!PACKAGE_MANAGERS.includes(value)) {
|
|
58
|
+
return { ok: false, message: `--package-manager must be one of ${PACKAGE_MANAGERS.join(', ')}, got "${value}"` };
|
|
59
|
+
}
|
|
60
|
+
options.packageManager = value;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
switch (flag) {
|
|
67
|
+
case '--pro':
|
|
68
|
+
options.pro = true;
|
|
69
|
+
break;
|
|
70
|
+
case '--free':
|
|
71
|
+
options.pro = false;
|
|
72
|
+
break;
|
|
73
|
+
case '-y':
|
|
74
|
+
case '--yes':
|
|
75
|
+
options.yes = true;
|
|
76
|
+
break;
|
|
77
|
+
case '--no-cloudflare':
|
|
78
|
+
options.cloudflare = false;
|
|
79
|
+
break;
|
|
80
|
+
case '--cloudflare':
|
|
81
|
+
options.cloudflare = true;
|
|
82
|
+
break;
|
|
83
|
+
case '--no-install':
|
|
84
|
+
options.install = false;
|
|
85
|
+
break;
|
|
86
|
+
case '-h':
|
|
87
|
+
case '--help':
|
|
88
|
+
options.help = true;
|
|
89
|
+
break;
|
|
90
|
+
case '-v':
|
|
91
|
+
case '--version':
|
|
92
|
+
options.version = true;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
return { ok: false, message: `unknown option "${flag}"\n\n${USAGE}` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (positional.length > 1)
|
|
99
|
+
return { ok: false, message: `expected at most one directory, got ${positional.length}: ${positional.join(', ')}\n\n${USAGE}` };
|
|
100
|
+
if (positional[0])
|
|
101
|
+
options.dir = positional[0];
|
|
102
|
+
return { ok: true, options };
|
|
103
|
+
}
|
|
104
|
+
/** `npm_config_user_agent` is set by every package manager that ran us. */
|
|
105
|
+
export function detectPackageManager(userAgent) {
|
|
106
|
+
if (!userAgent)
|
|
107
|
+
return 'pnpm';
|
|
108
|
+
const name = userAgent.split('/')[0];
|
|
109
|
+
return PACKAGE_MANAGERS.includes(name) ? name : 'pnpm';
|
|
110
|
+
}
|
|
111
|
+
/** How this package manager runs a package script (`pnpm build` vs `npm run build`). */
|
|
112
|
+
export function runPrefix(manager) {
|
|
113
|
+
return manager === 'npm' ? 'npm run' : manager;
|
|
114
|
+
}
|
|
115
|
+
export function installCommand(manager) {
|
|
116
|
+
return `${manager} install`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Node ≥ 22.18. Checked before anything else runs, so an old Node produces a
|
|
120
|
+
* sentence instead of a SyntaxError from inside a dependency.
|
|
121
|
+
*/
|
|
122
|
+
export function nodeVersionError(version, minimum = [22, 18]) {
|
|
123
|
+
const parts = version.replace(/^v/, '').split('.').map((piece) => Number.parseInt(piece, 10));
|
|
124
|
+
const [major = 0, minor = 0] = parts;
|
|
125
|
+
if (major > minimum[0] || (major === minimum[0] && minor >= minimum[1]))
|
|
126
|
+
return null;
|
|
127
|
+
return `create-zerodirs needs Node ${minimum[0]}.${minimum[1]} or newer; this is Node ${version.replace(/^v/, '')}.\nInstall a current Node (https://nodejs.org) and run it again.`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The question flow.
|
|
3
|
+
*
|
|
4
|
+
* Short on purpose. Everything the dataset already declares — what kind of
|
|
5
|
+
* directory this is, what the listing route and noun are, what structured data
|
|
6
|
+
* the detail pages emit, what the categories and tags are — is read from its
|
|
7
|
+
* `meta.json`, `categories.json` and `tags.json` rather than asked for. The
|
|
8
|
+
* questions that remain are the ones only the buyer can answer.
|
|
9
|
+
*
|
|
10
|
+
* `--yes` skips all of it and takes the conservative answer to each: no
|
|
11
|
+
* submissions, no payments, `console` email, the free plan. Every one of those
|
|
12
|
+
* is a thing that needs an account, a key or a domain, and a scaffold should
|
|
13
|
+
* not pretend to have any of them.
|
|
14
|
+
*/
|
|
15
|
+
import * as p from '@clack/prompts';
|
|
16
|
+
import { datasetIdentity } from '../core/dataset.js';
|
|
17
|
+
export const THEME_PRESETS = [
|
|
18
|
+
{ value: 'default', label: 'default', hint: 'blue, 0.5rem radius' },
|
|
19
|
+
{ value: 'warm', label: 'warm', hint: 'orange, 1rem radius' },
|
|
20
|
+
{ value: 'mono', label: 'mono', hint: 'black, no radius' },
|
|
21
|
+
];
|
|
22
|
+
export class CancelledError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('cancelled');
|
|
25
|
+
this.name = 'CancelledError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function unwrap(value) {
|
|
29
|
+
if (p.isCancel(value))
|
|
30
|
+
throw new CancelledError();
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** The `--yes` answers: nothing that needs an account, a key or a domain. */
|
|
34
|
+
export function defaultAnswers(context, dataset) {
|
|
35
|
+
return {
|
|
36
|
+
edition: context.edition,
|
|
37
|
+
projectDir: context.projectDir,
|
|
38
|
+
siteName: context.options.name ?? suggestedName(context, dataset),
|
|
39
|
+
...(context.options.domain ? { domain: context.options.domain } : {}),
|
|
40
|
+
dataset,
|
|
41
|
+
themePreset: context.options.theme ?? 'default',
|
|
42
|
+
plan: 'free',
|
|
43
|
+
submissions: false,
|
|
44
|
+
payments: false,
|
|
45
|
+
email: 'console',
|
|
46
|
+
createResources: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function suggestedName(context, dataset) {
|
|
50
|
+
const found = context.datasets.find((entry) => entry.name === dataset);
|
|
51
|
+
return found?.meta.suggestedName ?? 'My Directory';
|
|
52
|
+
}
|
|
53
|
+
/** Which dataset to start from, when `--dataset` did not say. */
|
|
54
|
+
export async function askDataset(context) {
|
|
55
|
+
if (context.options.dataset)
|
|
56
|
+
return context.options.dataset;
|
|
57
|
+
if (context.datasets.length === 0)
|
|
58
|
+
throw new Error('the template ships no seed datasets');
|
|
59
|
+
if (context.datasets.length === 1 || context.options.yes)
|
|
60
|
+
return context.datasets[0].name;
|
|
61
|
+
return unwrap(await p.select({
|
|
62
|
+
message: 'Which sample data do you want to start from?',
|
|
63
|
+
options: context.datasets.map((entry) => {
|
|
64
|
+
const identity = datasetIdentity(entry.meta);
|
|
65
|
+
return {
|
|
66
|
+
value: entry.name,
|
|
67
|
+
label: entry.name,
|
|
68
|
+
hint: `${entry.listings} ${identity.listingNoun.plural} · /${identity.listingBase}/ · ${identity.kind}`,
|
|
69
|
+
};
|
|
70
|
+
}),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
export async function ask(context, dataset) {
|
|
74
|
+
if (context.options.yes)
|
|
75
|
+
return defaultAnswers(context, dataset);
|
|
76
|
+
const identity = datasetIdentity(context.datasets.find((entry) => entry.name === dataset)?.meta ?? {});
|
|
77
|
+
const isPro = context.edition === 'pro';
|
|
78
|
+
const siteName = context.options.name ??
|
|
79
|
+
unwrap(await p.text({
|
|
80
|
+
message: 'What is the directory called?',
|
|
81
|
+
placeholder: suggestedName(context, dataset),
|
|
82
|
+
defaultValue: suggestedName(context, dataset),
|
|
83
|
+
}));
|
|
84
|
+
const domain = context.options.domain ??
|
|
85
|
+
unwrap(await p.text({
|
|
86
|
+
message: 'Which domain will it live on? (blank if you do not know yet)',
|
|
87
|
+
placeholder: 'directory.example.com',
|
|
88
|
+
defaultValue: '',
|
|
89
|
+
validate: (value) => (value && /\s|^https?:/i.test(value) ? 'Just the host, e.g. directory.example.com' : undefined),
|
|
90
|
+
}));
|
|
91
|
+
p.note([
|
|
92
|
+
`kind: ${identity.kind}`,
|
|
93
|
+
`listing URLs: /${identity.listingBase}/<slug>/`,
|
|
94
|
+
`listing noun: ${identity.listingNoun.singular} / ${identity.listingNoun.plural}`,
|
|
95
|
+
`structured data: ${identity.jsonLd}`,
|
|
96
|
+
].join('\n'), `From the ${dataset} dataset`);
|
|
97
|
+
const themePreset = context.options.theme ?? unwrap(await p.select({ message: 'Theme preset?', options: THEME_PRESETS, initialValue: 'default' }));
|
|
98
|
+
let plan = 'free';
|
|
99
|
+
let submissions = false;
|
|
100
|
+
let payments = false;
|
|
101
|
+
let email = 'console';
|
|
102
|
+
let createResources = false;
|
|
103
|
+
if (isPro) {
|
|
104
|
+
plan = unwrap(await p.select({
|
|
105
|
+
message: 'Which Cloudflare Workers plan is this account on?',
|
|
106
|
+
options: [
|
|
107
|
+
{ value: 'free', label: 'Free', hint: 'tighter budget assertions in the build' },
|
|
108
|
+
{ value: 'paid', label: 'Paid ($5/mo)', hint: 'needed for Cloudflare Email Sending' },
|
|
109
|
+
],
|
|
110
|
+
initialValue: 'free',
|
|
111
|
+
}));
|
|
112
|
+
createResources = context.cloudflareAllowed
|
|
113
|
+
? unwrap(await p.confirm({
|
|
114
|
+
message: 'Create the D1 database and R2 bucket now? (uses your existing wrangler sign-in)',
|
|
115
|
+
initialValue: false,
|
|
116
|
+
}))
|
|
117
|
+
: false;
|
|
118
|
+
if (createResources) {
|
|
119
|
+
submissions = unwrap(await p.confirm({ message: 'Accept public submissions? (form + review queue)', initialValue: true }));
|
|
120
|
+
if (submissions) {
|
|
121
|
+
payments = unwrap(await p.confirm({ message: 'Charge for express / featured listings? (Stripe)', initialValue: false }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
email = unwrap(await p.select({
|
|
125
|
+
message: 'How should the site send email?',
|
|
126
|
+
options: [
|
|
127
|
+
{ value: 'console', label: 'Not yet', hint: 'log messages; decide later' },
|
|
128
|
+
{ value: 'resend', label: 'Resend', hint: 'API key + verified domain' },
|
|
129
|
+
{ value: 'cloudflare', label: 'Cloudflare Email Sending', hint: 'Workers paid plan + Email Routing on the zone' },
|
|
130
|
+
],
|
|
131
|
+
initialValue: plan === 'paid' ? 'cloudflare' : 'resend',
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
edition: context.edition,
|
|
136
|
+
projectDir: context.projectDir,
|
|
137
|
+
siteName: siteName.trim() || suggestedName(context, dataset),
|
|
138
|
+
...(domain.trim() ? { domain: domain.trim() } : {}),
|
|
139
|
+
dataset,
|
|
140
|
+
themePreset,
|
|
141
|
+
plan,
|
|
142
|
+
submissions,
|
|
143
|
+
payments,
|
|
144
|
+
email,
|
|
145
|
+
createResources,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export const prompts = p;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers -> the values every generated file is written from.
|
|
3
|
+
*
|
|
4
|
+
* This module is pure, and it is the one the unit tests aim at: given a set of
|
|
5
|
+
* answers and a dataset, `planProject()` returns the exact edits that
|
|
6
|
+
* `site.config.ts`, `wrangler.jsonc`, `.dev.vars`, `.env` and `SETUP.md` are
|
|
7
|
+
* produced from. Nothing here touches the filesystem, the network or a prompt.
|
|
8
|
+
*
|
|
9
|
+
* Every value it produces has to survive `src/config/schema.ts`, so the
|
|
10
|
+
* constraints that schema enforces are enforced here instead of being
|
|
11
|
+
* discovered by the buyer at the first `pnpm check:config`:
|
|
12
|
+
* `site.description` ≤ 160 chars, `providers.emailFrom` shaped like
|
|
13
|
+
* `Name <addr@host>`, a Worker name that is a valid subdomain label, and a
|
|
14
|
+
* priced tier only ever enabled together with payments.
|
|
15
|
+
*/
|
|
16
|
+
import { datasetIdentity, } from './dataset.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Small pure helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* A Worker name / resource-name stem: lowercase, `[a-z0-9-]`, no leading or
|
|
22
|
+
* trailing dash, ≤ 40 chars so `<stem>-media` still fits Cloudflare's 63-char
|
|
23
|
+
* limit. Falls back to `my-directory` when the input has nothing usable in it
|
|
24
|
+
* (a name written entirely in a non-Latin script, for instance).
|
|
25
|
+
*/
|
|
26
|
+
export function slugifyName(value, fallback = 'my-directory') {
|
|
27
|
+
const slug = value
|
|
28
|
+
.normalize('NFKD')
|
|
29
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replace(/^-+|-+$/g, '')
|
|
33
|
+
.slice(0, 40)
|
|
34
|
+
.replace(/-+$/g, '');
|
|
35
|
+
return slug || fallback;
|
|
36
|
+
}
|
|
37
|
+
/** `https://host`, or the schema-valid placeholder when no domain is known. */
|
|
38
|
+
export function siteUrlFor(domain) {
|
|
39
|
+
const host = normalizeDomain(domain);
|
|
40
|
+
return host ? `https://${host}` : 'https://example.com';
|
|
41
|
+
}
|
|
42
|
+
/** Strips scheme, path, `www.` and any trailing slash from whatever the buyer typed. */
|
|
43
|
+
export function normalizeDomain(value) {
|
|
44
|
+
if (!value)
|
|
45
|
+
return '';
|
|
46
|
+
const trimmed = value.trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/\.$/, '');
|
|
47
|
+
return trimmed.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
/** `hello@<domain>`, or the placeholder address. `site.contactEmail` must parse as an email. */
|
|
50
|
+
export function contactEmailFor(domain) {
|
|
51
|
+
const host = normalizeDomain(domain);
|
|
52
|
+
return host ? `hello@${host}` : 'hello@example.com';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `providers.emailFrom` — cross-rule 7 wants exactly `Name <addr@host>`, and
|
|
56
|
+
* the name half may not contain angle brackets.
|
|
57
|
+
*/
|
|
58
|
+
export function emailFromFor(siteName, domain) {
|
|
59
|
+
const name = siteName.replace(/[<>]/g, '').trim() || 'My Directory';
|
|
60
|
+
return `${name} <${contactEmailFor(domain)}>`;
|
|
61
|
+
}
|
|
62
|
+
/** `site.description` is capped at 160 characters (it is the home meta description). */
|
|
63
|
+
export function clampDescription(text, max = 160) {
|
|
64
|
+
if (text.length <= max)
|
|
65
|
+
return text;
|
|
66
|
+
const cut = text.slice(0, max);
|
|
67
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
68
|
+
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd();
|
|
69
|
+
}
|
|
70
|
+
export function defaultTagline(noun) {
|
|
71
|
+
return `Every ${noun} worth knowing, hand-picked and fast`;
|
|
72
|
+
}
|
|
73
|
+
export function defaultDescription(siteName, nouns) {
|
|
74
|
+
return clampDescription(`${siteName} is a curated directory of ${nouns} — hand-picked, sorted into categories and tags, searchable, and fast on every page.`);
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// The plan
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Answers + dataset -> every value the generated files carry.
|
|
81
|
+
*
|
|
82
|
+
* Two rules are load-bearing and both come from `crossRules()` in
|
|
83
|
+
* `src/config/schema.ts`:
|
|
84
|
+
*
|
|
85
|
+
* - a priced tier may only be `enabled` when `features.payments` is true AND
|
|
86
|
+
* `providers.payment` is `'stripe'`, so those three move together;
|
|
87
|
+
* - `providers.emailFrom` is required as soon as `providers.email` is not
|
|
88
|
+
* `'console'`.
|
|
89
|
+
*
|
|
90
|
+
* And one comes from the starter's shape rather than its schema: `submissions`
|
|
91
|
+
* and the `d1` newsletter both write to D1, so neither is turned on unless the
|
|
92
|
+
* CLI actually created a database.
|
|
93
|
+
*/
|
|
94
|
+
export function planProject(answers, dataset) {
|
|
95
|
+
const identity = datasetIdentity(dataset.meta);
|
|
96
|
+
const domain = normalizeDomain(answers.domain);
|
|
97
|
+
const siteUrl = siteUrlFor(domain);
|
|
98
|
+
const contactEmail = contactEmailFor(domain);
|
|
99
|
+
const siteName = answers.siteName.trim() || dataset.meta.suggestedName || 'My Directory';
|
|
100
|
+
const nouns = identity.listingNoun.plural;
|
|
101
|
+
const tagline = (answers.tagline ?? '').trim() || defaultTagline(identity.listingNoun.singular);
|
|
102
|
+
const description = defaultDescription(siteName, nouns);
|
|
103
|
+
const stem = slugifyName(siteName, slugifyName(answers.projectDir));
|
|
104
|
+
const isPro = answers.edition === 'pro';
|
|
105
|
+
// Submissions need a database; payments need submissions.
|
|
106
|
+
const submissions = isPro && answers.submissions && answers.createResources;
|
|
107
|
+
const payments = submissions && answers.payments;
|
|
108
|
+
const siteConfig = {
|
|
109
|
+
datasetName: dataset.name,
|
|
110
|
+
site: { name: siteName, tagline, description, url: siteUrl, kind: identity.kind, contactEmail },
|
|
111
|
+
routes: { listingBase: identity.listingBase, listingNoun: identity.listingNoun },
|
|
112
|
+
categories: dataset.categories,
|
|
113
|
+
tags: dataset.tags,
|
|
114
|
+
...(dataset.meta.suggestedPricingTypes?.length ? { pricingTypes: dataset.meta.suggestedPricingTypes } : {}),
|
|
115
|
+
...(dataset.meta.suggestedCustomFields?.length ? { customFields: dataset.meta.suggestedCustomFields } : {}),
|
|
116
|
+
jsonLdListing: identity.jsonLd,
|
|
117
|
+
themePreset: answers.themePreset,
|
|
118
|
+
enabledTierIds: payments ? 'all' : 'free-only',
|
|
119
|
+
features: {
|
|
120
|
+
submissions,
|
|
121
|
+
payments,
|
|
122
|
+
// The d1 newsletter posts to /api/newsletter/, which needs the binding.
|
|
123
|
+
newsletter: submissions ? 'd1' : 'off',
|
|
124
|
+
},
|
|
125
|
+
...(isPro
|
|
126
|
+
? {
|
|
127
|
+
providers: {
|
|
128
|
+
email: answers.email,
|
|
129
|
+
...(answers.email === 'console' ? {} : { emailFrom: emailFromFor(siteName, domain) }),
|
|
130
|
+
payment: payments ? 'stripe' : 'none',
|
|
131
|
+
// The rebuild provider posts to a Deploy Hook that does not exist yet;
|
|
132
|
+
// SETUP.md is where it gets created.
|
|
133
|
+
rebuild: 'none',
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
137
|
+
newsletter: {
|
|
138
|
+
heading: `New ${nouns}, weekly`,
|
|
139
|
+
blurb: `One email a week with the best new ${nouns}. No spam, unsubscribe any time.`,
|
|
140
|
+
},
|
|
141
|
+
budgetPlan: answers.plan,
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
answers,
|
|
145
|
+
identity,
|
|
146
|
+
siteUrl,
|
|
147
|
+
contactEmail,
|
|
148
|
+
workerName: stem,
|
|
149
|
+
databaseName: `${stem}-db`,
|
|
150
|
+
bucketName: `${stem}-media`,
|
|
151
|
+
siteConfig,
|
|
152
|
+
wrangler: {
|
|
153
|
+
name: stem,
|
|
154
|
+
databaseName: `${stem}-db`,
|
|
155
|
+
bucketName: `${stem}-media`,
|
|
156
|
+
...(domain ? { domain } : {}),
|
|
157
|
+
// R8: only valid once Email Routing is enabled on the zone, so it is opt-in
|
|
158
|
+
// via the email provider rather than always present.
|
|
159
|
+
sendEmail: isPro && answers.email === 'cloudflare',
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare steps — and, more importantly, the ones that are not here.
|
|
3
|
+
*
|
|
4
|
+
* This CLI creates two resources and nothing else. It does not sign anyone in
|
|
5
|
+
* (the account is the buyer's, and an OAuth browser window opening in the
|
|
6
|
+
* middle of a scaffold is a surprise), it does not upload secrets, it does not
|
|
7
|
+
* deploy, and it never writes to a remote database. Those all live in
|
|
8
|
+
* `SETUP.md` as commands the buyer runs when they mean to.
|
|
9
|
+
*
|
|
10
|
+
* `wrangler d1 create` and `wrangler r2 bucket create` have no `--json` output,
|
|
11
|
+
* so there is no id to parse: `--binding DB --update-config` makes wrangler
|
|
12
|
+
* write the finished binding back into `wrangler.jsonc` itself. The existing
|
|
13
|
+
* placeholder binding is removed first so wrangler writes a complete block
|
|
14
|
+
* rather than appending a second one, and is restored if the create fails.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { dropBinding } from './wrangler-config.js';
|
|
19
|
+
import { run } from './exec.js';
|
|
20
|
+
export const WRANGLER_AUTH_DOCS = 'https://developers.cloudflare.com/workers/wrangler/commands/#auth';
|
|
21
|
+
export const R2_SUBSCRIPTION_DOCS = 'https://developers.cloudflare.com/r2/get-started/';
|
|
22
|
+
/** Prefers the project's own pinned wrangler; falls back to fetching one. */
|
|
23
|
+
export function wranglerFor(projectDir) {
|
|
24
|
+
const local = join(projectDir, 'node_modules/.bin/wrangler');
|
|
25
|
+
if (existsSync(local))
|
|
26
|
+
return { command: local, prefix: [] };
|
|
27
|
+
return { command: 'npx', prefix: ['--yes', 'wrangler'] };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read-only account check. When it says no, the CLI skips resource creation and
|
|
31
|
+
* says where to authenticate — it does not start an auth flow on the buyer's
|
|
32
|
+
* behalf.
|
|
33
|
+
*/
|
|
34
|
+
export async function whoami(projectDir) {
|
|
35
|
+
const { command, prefix } = wranglerFor(projectDir);
|
|
36
|
+
const result = await run(command, [...prefix, 'whoami'], { cwd: projectDir, timeout: 120_000 });
|
|
37
|
+
if (result.ok && /account/i.test(result.stdout))
|
|
38
|
+
return { signedIn: true, message: firstAccountLine(result.stdout) };
|
|
39
|
+
return { signedIn: false, message: result.message || 'wrangler could not identify an account' };
|
|
40
|
+
}
|
|
41
|
+
function firstAccountLine(stdout) {
|
|
42
|
+
const line = stdout.split('\n').find((entry) => /@|account id/i.test(entry));
|
|
43
|
+
return (line ?? '').trim() || 'signed in';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `wrangler d1 create <name> --binding DB --update-config`.
|
|
47
|
+
*
|
|
48
|
+
* Note what is absent: no `--use-remote` (that flag makes the new binding a
|
|
49
|
+
* remote one, which is not what a fresh project wants), no location hint (the
|
|
50
|
+
* buyer's account default is a better guess than ours), and no migration run
|
|
51
|
+
* against the production database.
|
|
52
|
+
*/
|
|
53
|
+
export async function createD1(projectDir, databaseName) {
|
|
54
|
+
return createResource(projectDir, 'd1_databases', ['d1', 'create', databaseName, '--binding', 'DB', '--update-config'], (message) => /already exists/i.test(message) ? `a D1 database called "${databaseName}" already exists on this account` : undefined);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `wrangler r2 bucket create <name> --binding MEDIA --update-config`.
|
|
58
|
+
*
|
|
59
|
+
* R2 needs the subscription checkout completed once in the dashboard before the
|
|
60
|
+
* first bucket can be created. That failure is detected and reported rather
|
|
61
|
+
* than left as a half-made project — the rest of the scaffold is fine, and
|
|
62
|
+
* SETUP.md carries the command to run afterwards.
|
|
63
|
+
*/
|
|
64
|
+
export async function createR2(projectDir, bucketName) {
|
|
65
|
+
return createResource(projectDir, 'r2_buckets', ['r2', 'bucket', 'create', bucketName, '--binding', 'MEDIA', '--update-config'], (message) => /not (yet )?(been )?(enabled|signed up)|subscri|10042|must be enabled/i.test(message)
|
|
66
|
+
? `R2 is not enabled on this account yet — complete the R2 subscription checkout in the dashboard (${R2_SUBSCRIPTION_DOCS}), then run the command in SETUP.md`
|
|
67
|
+
: /already exists|BucketAlreadyOwnedByYou/i.test(message)
|
|
68
|
+
? `an R2 bucket called "${bucketName}" already exists on this account`
|
|
69
|
+
: undefined);
|
|
70
|
+
}
|
|
71
|
+
async function createResource(projectDir, bindingKey, args, explain) {
|
|
72
|
+
const configPath = join(projectDir, 'wrangler.jsonc');
|
|
73
|
+
const before = existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined;
|
|
74
|
+
if (before !== undefined)
|
|
75
|
+
writeFileSync(configPath, dropBinding(before, bindingKey), 'utf8');
|
|
76
|
+
const { command, prefix } = wranglerFor(projectDir);
|
|
77
|
+
const result = await run(command, [...prefix, ...args], { cwd: projectDir, timeout: 180_000 });
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
// Put the placeholder binding back: a project with a named-but-uncreated
|
|
80
|
+
// binding is something SETUP.md can talk about; one with no binding at all
|
|
81
|
+
// silently loses the shape the buyer is meant to fill in.
|
|
82
|
+
if (before !== undefined)
|
|
83
|
+
writeFileSync(configPath, before, 'utf8');
|
|
84
|
+
const combined = `${result.stderr}\n${result.stdout}`;
|
|
85
|
+
return { ok: false, reason: result.message, ...(explain(combined) ? { hint: explain(combined) } : {}) };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|