@clubedge/create-clubedge-app 0.4.0 → 0.5.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/CHANGELOG.md +9 -0
- package/README.md +31 -11
- package/dist/cli/args.js +15 -0
- package/dist/cli/ui.js +4 -0
- package/dist/core/customize.js +142 -15
- package/dist/core/manifest.js +91 -2
- package/dist/core/plan.js +143 -8
- package/dist/index.js +50 -1
- package/package.json +3 -3
- package/template/.env.example +19 -6
- package/template/.github/workflows/ci.yml +16 -33
- package/template/AGENTS.md +11 -4
- package/template/CONTRIBUTING.md +1 -1
- package/template/README.md +69 -63
- package/template/SETUP.md +94 -44
- package/template/_gitignore +2 -0
- package/template/apps/start/.env.example +18 -5
- package/template/apps/start/components.json +21 -0
- package/template/apps/start/package.json +8 -2
- package/template/apps/start/src/components/dashboard/account-button.tsx +1 -1
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +1 -1
- package/template/apps/start/src/components/dashboard/variants/account-button.none.tsx +9 -0
- package/template/apps/start/src/env/auth.test.ts +26 -0
- package/template/apps/start/src/env/auth.ts +46 -0
- package/template/apps/start/src/env/server.test.ts +0 -16
- package/template/apps/start/src/env/server.ts +18 -42
- package/template/apps/start/src/routes/dashboard.tsx +18 -8
- package/template/apps/start/src/routes/index.tsx +7 -3
- package/template/apps/start/src/routes/login.tsx +1 -1
- package/template/apps/start/src/server/auth.ts +2 -2
- package/template/apps/start/src/server/cache.test.ts +65 -0
- package/template/apps/start/src/server/cache.ts +36 -16
- package/template/apps/start/src/server/storage.ts +22 -19
- package/template/apps/start/src/server/variants/auth.better-auth.ts +57 -0
- package/template/apps/start/src/server/variants/auth.functions.none.ts +9 -0
- package/template/apps/start/src/server/variants/auth.none.ts +28 -0
- package/template/apps/start/src/server/variants/cache.memory.ts +26 -0
- package/template/apps/start/src/server/variants/storage.supabase.ts +35 -0
- package/template/apps/start/tsr.config.json +7 -0
- package/template/apps/web/next.config.ts +9 -9
- package/template/apps/web/package.json +6 -1
- package/template/apps/web/src/app/dashboard/_components/account-button.tsx +1 -1
- package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +1 -1
- package/template/apps/web/src/app/dashboard/_components/variants/account-button.none.tsx +9 -0
- package/template/apps/web/src/app/dashboard/page.tsx +18 -8
- package/template/apps/web/src/app/login/page.tsx +5 -4
- package/template/apps/web/src/app/page.tsx +7 -3
- package/template/apps/web/src/env/auth.ts +48 -0
- package/template/apps/web/src/env/client.ts +1 -8
- package/template/apps/web/src/env/server.test.ts +27 -4
- package/template/apps/web/src/env/server.ts +19 -29
- package/template/apps/web/src/proxy.ts +5 -8
- package/template/apps/web/src/server/auth.ts +14 -26
- package/template/apps/web/src/server/cache.ts +36 -16
- package/template/apps/web/src/server/cookies.ts +18 -0
- package/template/apps/web/src/server/storage.ts +22 -19
- package/template/apps/web/src/server/variants/auth.better-auth.ts +63 -0
- package/template/apps/web/src/server/variants/auth.none.ts +26 -0
- package/template/apps/web/src/server/variants/cache.memory.ts +26 -0
- package/template/apps/web/src/server/variants/storage.supabase.ts +35 -0
- package/template/apps/web/src/variants/proxy.better-auth.ts +46 -0
- package/template/clubedge.template.json +157 -2
- package/template/e2e/auth-flow.spec.ts +29 -0
- package/template/e2e/auth.spec.ts +2 -12
- package/template/e2e/home.spec.ts +14 -5
- package/template/eslint.config.mjs +2 -1
- package/template/package.json +4 -4
- package/template/packages/auth/package.json +3 -6
- package/template/packages/auth-better-auth/drizzle/0000_initial.sql +50 -0
- package/template/packages/auth-better-auth/drizzle/meta/0000_snapshot.json +320 -0
- package/template/packages/auth-better-auth/drizzle/meta/_journal.json +13 -0
- package/template/packages/auth-better-auth/drizzle.config.ts +12 -0
- package/template/packages/auth-better-auth/package.json +31 -0
- package/template/packages/auth-better-auth/scripts/migrate.ts +14 -0
- package/template/packages/auth-better-auth/src/index.test.ts +118 -0
- package/template/packages/auth-better-auth/src/index.ts +135 -0
- package/template/packages/auth-better-auth/src/schema.ts +74 -0
- package/template/packages/auth-better-auth/tsconfig.json +4 -0
- package/template/packages/auth-supabase/package.json +24 -0
- package/template/packages/{auth/src/supabase.test.ts → auth-supabase/src/index.test.ts} +2 -2
- package/template/packages/{auth/src/supabase.ts → auth-supabase/src/index.ts} +1 -1
- package/template/packages/auth-supabase/tsconfig.json +4 -0
- package/template/packages/cache/package.json +1 -4
- package/template/packages/cache/src/index.ts +3 -13
- package/template/packages/cache/src/memory-cache.test.ts +40 -0
- package/template/packages/cache/src/memory-cache.ts +37 -0
- package/template/packages/cache-redis/package.json +22 -0
- package/template/packages/{cache → cache-redis}/src/index.test.ts +6 -15
- package/template/packages/{cache/src/redis.ts → cache-redis/src/index.ts} +2 -6
- package/template/packages/cache-redis/tsconfig.json +4 -0
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +3 -2
- package/template/packages/storage/package.json +2 -9
- package/template/packages/storage-s3/package.json +23 -0
- package/template/packages/{storage/src/s3.test.ts → storage-s3/src/index.test.ts} +1 -1
- package/template/packages/{storage/src/s3.ts → storage-s3/src/index.ts} +1 -1
- package/template/packages/storage-s3/tsconfig.json +4 -0
- package/template/packages/storage-supabase/package.json +22 -0
- package/template/packages/{storage/src/supabase.ts → storage-supabase/src/index.ts} +1 -1
- package/template/packages/storage-supabase/tsconfig.json +4 -0
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +524 -2
- package/template.lock.json +49 -10
- package/template/apps/start/src/routeTree.gen.ts +0 -220
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.5.0`, which adds optional modules, Better Auth, and template schema 3.
|
|
8
|
+
- Add `--auth`, `--storage`, and `--cache`, and offer to customize them interactively. Options whose requirements are not met (Supabase Storage without Supabase Auth) are refused with an explanation.
|
|
9
|
+
- Read template schema 3 modules: unselected options' packages, files, provider dependencies, environment variables, and lockfile entries are removed, and selected variants replace the default files.
|
|
10
|
+
- Process `clubedge:if` blocks in the Starter's documentation, environment examples, and sources, so a project only describes what it contains.
|
|
11
|
+
- Support Mermaid (`%%`) markers and single-line `clubedge:only` conditions, and leave no blank lines at file edges after removing blocks.
|
|
12
|
+
- Record the selected modules in the generated `package.json` and `README.md`. The smoke test skips the sign-up check for projects without authentication.
|
|
13
|
+
|
|
5
14
|
## 0.4.0
|
|
6
15
|
|
|
7
16
|
- Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
|
package/README.md
CHANGED
|
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
|
|
|
12
12
|
Each CLI release is tested against one specific Starter release:
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
|
-
create-clubedge-app v0.
|
|
15
|
+
create-clubedge-app v0.5.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.5.0
|
|
19
|
+
commit d9375c2c3b520323ff0255ef8ec65da19251c3c1
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The current relationship is:
|
|
23
23
|
|
|
24
24
|
| CLI release | Default Starter release | Meaning |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
-
| `create-clubedge-app@0.
|
|
26
|
+
| `create-clubedge-app@0.5.0` | `clubedge-starter@v0.5.0` | The CLI's required, tested default |
|
|
27
27
|
|
|
28
28
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
29
29
|
|
|
30
|
-
The `v0.
|
|
31
|
-
`
|
|
30
|
+
The `v0.5.0` tag is expected to resolve to commit
|
|
31
|
+
`d9375c2c3b520323ff0255ef8ec65da19251c3c1`. The CLI verifies this before
|
|
32
32
|
scaffolding with the default reference and fails rather than silently using a
|
|
33
33
|
moved tag.
|
|
34
34
|
|
|
35
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
35
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.5.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
36
36
|
|
|
37
37
|
## Requirements
|
|
38
38
|
|
|
@@ -83,12 +83,32 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
|
83
83
|
|
|
84
84
|
Either way, the app lives in `apps/web` and uses the same shared packages, so the project layout, root scripts, and Docker commands are identical.
|
|
85
85
|
|
|
86
|
+
### Choosing services
|
|
87
|
+
|
|
88
|
+
Authentication, file storage, and caching are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries.
|
|
89
|
+
|
|
90
|
+
| Flag | Options | Default |
|
|
91
|
+
| ----------- | ------------------------------------------ | ---------- |
|
|
92
|
+
| `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
|
|
93
|
+
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
94
|
+
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
# Only the monorepo, UI, database, and tooling
|
|
98
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Interactive runs ask whether to customize these after the framework question. The options come from the Starter's manifest, so `--help` lists the ones the bundled Starter offers.
|
|
102
|
+
|
|
86
103
|
## Options
|
|
87
104
|
|
|
88
105
|
```text
|
|
89
106
|
Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
90
107
|
|
|
91
108
|
--framework <id> App framework: next (default) or tanstack-start
|
|
109
|
+
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
110
|
+
--storage <id> File storage: s3 (default), supabase, or none
|
|
111
|
+
--cache <id> Cache and rate limits: redis (default) or memory
|
|
92
112
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
93
113
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
94
114
|
--dry-run Show what would be created without writing anything
|
|
@@ -101,7 +121,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
|
101
121
|
|
|
102
122
|
The target directory must be empty or not exist. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
|
|
103
123
|
|
|
104
|
-
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, and
|
|
124
|
+
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
105
125
|
|
|
106
126
|
```sh
|
|
107
127
|
# Preview the files and edits without writing anything
|
|
@@ -114,7 +134,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
114
134
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
115
135
|
|
|
116
136
|
# Reproduce a specific Starter release or commit
|
|
117
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
137
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.5.0
|
|
118
138
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
119
139
|
|
|
120
140
|
# Test local Starter changes before releasing them
|
|
@@ -125,7 +145,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
|
|
125
145
|
|
|
126
146
|
Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
|
|
127
147
|
|
|
128
|
-
The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
|
|
148
|
+
The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
|
|
129
149
|
|
|
130
150
|
The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
|
|
131
151
|
|
|
@@ -159,7 +179,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
159
179
|
1. Make and validate changes in `clubedge-starter`.
|
|
160
180
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
161
181
|
3. Update the Starter package versions.
|
|
162
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
182
|
+
4. Commit `main`, create a release tag such as `v0.5.0`, and publish the Starter release.
|
|
163
183
|
|
|
164
184
|
### CLI maintainers
|
|
165
185
|
|
package/dist/cli/args.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
+
/** Module flags the CLI accepts. Which options exist is up to the Starter's manifest. */
|
|
3
|
+
export const MODULE_FLAGS = ["auth", "storage", "cache"];
|
|
2
4
|
export class UsageError extends Error {
|
|
3
5
|
name = "UsageError";
|
|
4
6
|
}
|
|
@@ -12,6 +14,9 @@ export function parseCliArgs(argv) {
|
|
|
12
14
|
strict: true,
|
|
13
15
|
options: {
|
|
14
16
|
framework: { type: "string" },
|
|
17
|
+
auth: { type: "string" },
|
|
18
|
+
storage: { type: "string" },
|
|
19
|
+
cache: { type: "string" },
|
|
15
20
|
ref: { type: "string" },
|
|
16
21
|
"template-dir": { type: "string" },
|
|
17
22
|
install: { type: "boolean", default: true },
|
|
@@ -33,6 +38,15 @@ export function parseCliArgs(argv) {
|
|
|
33
38
|
if (values.framework !== undefined && !values.framework.trim()) {
|
|
34
39
|
throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
|
|
35
40
|
}
|
|
41
|
+
const modules = {};
|
|
42
|
+
for (const module of MODULE_FLAGS) {
|
|
43
|
+
const value = values[module];
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
if (!value.trim())
|
|
47
|
+
throw new UsageError(`--${module} requires an option, such as none.`);
|
|
48
|
+
modules[module] = value.trim();
|
|
49
|
+
}
|
|
36
50
|
if (values.ref !== undefined && !values.ref.trim()) {
|
|
37
51
|
throw new UsageError("--ref requires a tag, branch, or commit.");
|
|
38
52
|
}
|
|
@@ -42,6 +56,7 @@ export function parseCliArgs(argv) {
|
|
|
42
56
|
return {
|
|
43
57
|
projectDirectory: positionals[0],
|
|
44
58
|
framework: values.framework?.trim(),
|
|
59
|
+
modules,
|
|
45
60
|
ref: values.ref,
|
|
46
61
|
templateDir: values["template-dir"],
|
|
47
62
|
install: values.install,
|
package/dist/cli/ui.js
CHANGED
|
@@ -64,6 +64,9 @@ Usage:
|
|
|
64
64
|
|
|
65
65
|
Options:
|
|
66
66
|
--framework <id> App framework: next (default) or tanstack-start
|
|
67
|
+
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
68
|
+
--storage <id> File storage: s3 (default), supabase, or none
|
|
69
|
+
--cache <id> Cache and rate limits: redis (default) or memory
|
|
67
70
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
68
71
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
69
72
|
--dry-run Show what would be created without writing anything
|
|
@@ -76,6 +79,7 @@ Options:
|
|
|
76
79
|
Examples:
|
|
77
80
|
pnpm dlx @clubedge/create-clubedge-app my-app
|
|
78
81
|
pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
82
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
|
|
79
83
|
pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
|
|
80
84
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
81
85
|
pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
package/dist/core/customize.js
CHANGED
|
@@ -10,6 +10,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
|
|
|
10
10
|
starterRef: provenance.starterRef,
|
|
11
11
|
...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
|
|
12
12
|
...(provenance.framework ? { framework: provenance.framework } : {}),
|
|
13
|
+
...(provenance.modules ? { modules: provenance.modules } : {}),
|
|
13
14
|
};
|
|
14
15
|
for (const script of ["docker:build", "docker:start"]) {
|
|
15
16
|
if (typeof manifest.scripts?.[script] === "string") {
|
|
@@ -23,9 +24,12 @@ export function customizeReadme(source, { displayName }, provenance) {
|
|
|
23
24
|
? `\n- Starter commit: \`${provenance.starterCommit}\``
|
|
24
25
|
: "";
|
|
25
26
|
const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
|
|
27
|
+
const modulesLine = provenance.moduleSummary?.length
|
|
28
|
+
? `\n- Modules: ${provenance.moduleSummary.join(", ")}`
|
|
29
|
+
: "";
|
|
26
30
|
const origin = `## Generated from\n\n` +
|
|
27
31
|
`- Starter repository: \`${provenance.starterRepository}\`\n` +
|
|
28
|
-
`- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}\n` +
|
|
32
|
+
`- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}${modulesLine}\n` +
|
|
29
33
|
`- CLI version: \`${provenance.cliVersion}\``;
|
|
30
34
|
// Only the first heading is the project title; leave every other line as written.
|
|
31
35
|
return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
|
|
@@ -67,29 +71,152 @@ export function customizeAppManifest(source, appPackage) {
|
|
|
67
71
|
* works. Importers are keyed by workspace path at two-space indentation; package entries that
|
|
68
72
|
* are no longer used are harmless, and pnpm prunes them on the next lockfile update.
|
|
69
73
|
*/
|
|
70
|
-
export function customizeLockfile(source, { remove, rename }) {
|
|
74
|
+
export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
|
|
71
75
|
const output = [];
|
|
72
76
|
let inImporters = false;
|
|
73
|
-
let
|
|
77
|
+
let skippingImporter = false;
|
|
78
|
+
let skippingDependency = false;
|
|
79
|
+
// A dependency group header (" dependencies:") is held until it gets an entry, so a group
|
|
80
|
+
// whose entries were all removed disappears instead of becoming an empty YAML value.
|
|
81
|
+
let pendingGroup = null;
|
|
74
82
|
for (const line of source.split("\n")) {
|
|
75
83
|
if (/^\S/.test(line)) {
|
|
76
84
|
inImporters = line === "importers:";
|
|
77
|
-
|
|
85
|
+
skippingImporter = skippingDependency = false;
|
|
86
|
+
pendingGroup = null;
|
|
87
|
+
output.push(line);
|
|
88
|
+
continue;
|
|
78
89
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
+
if (!inImporters) {
|
|
91
|
+
output.push(line);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// An importer key, with its value on the next lines or inline ("packages/core: {}").
|
|
95
|
+
const importer = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
|
|
96
|
+
if (importer !== undefined) {
|
|
97
|
+
skippingImporter = remove.includes(importer);
|
|
98
|
+
skippingDependency = false;
|
|
99
|
+
pendingGroup = null;
|
|
100
|
+
if (!skippingImporter)
|
|
101
|
+
output.push(importer === rename?.from ? ` ${rename.to}:` : line);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (skippingImporter)
|
|
105
|
+
continue;
|
|
106
|
+
if (/^ {4}\S/.test(line)) {
|
|
107
|
+
skippingDependency = false;
|
|
108
|
+
pendingGroup = line;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const dependency = /^ {6}(['"]?)([^'":]+)\1:\s*$/.exec(line)?.[2];
|
|
112
|
+
if (dependency !== undefined) {
|
|
113
|
+
skippingDependency = removeDependencies.includes(dependency);
|
|
114
|
+
if (skippingDependency)
|
|
90
115
|
continue;
|
|
116
|
+
if (pendingGroup !== null) {
|
|
117
|
+
output.push(pendingGroup);
|
|
118
|
+
pendingGroup = null;
|
|
119
|
+
}
|
|
120
|
+
output.push(line);
|
|
121
|
+
continue;
|
|
91
122
|
}
|
|
123
|
+
if (skippingDependency && /^ {8}/.test(line))
|
|
124
|
+
continue;
|
|
125
|
+
if (pendingGroup !== null && line.trim() !== "") {
|
|
126
|
+
output.push(pendingGroup);
|
|
127
|
+
pendingGroup = null;
|
|
128
|
+
}
|
|
129
|
+
skippingDependency = false;
|
|
92
130
|
output.push(line);
|
|
93
131
|
}
|
|
94
132
|
return output.join("\n");
|
|
95
133
|
}
|
|
134
|
+
export class ConditionalError extends Error {
|
|
135
|
+
name = "ConditionalError";
|
|
136
|
+
}
|
|
137
|
+
// A marker line in any comment style the Starter uses: //, #, %% (Mermaid), <!-- -->, /* */,
|
|
138
|
+
// or {/* */}.
|
|
139
|
+
const MARKER = /^\s*(?:\/\/|#|%%|<!--|\{?\/\*)\s*clubedge:(if|end)\b\s*(.*?)\s*(?:-->|\*\/\}?)?\s*$/;
|
|
140
|
+
// A single line kept only when its condition holds: "- Item <!-- clubedge:only auth=supabase -->".
|
|
141
|
+
const LINE_MARKER = /\s*(?:\/\/|#|<!--|\{?\/\*)\s*clubedge:only\s+(.+?)\s*(?:-->|\*\/\}?)?\s*$/;
|
|
142
|
+
function evaluateCondition(expression, selection, location) {
|
|
143
|
+
// Maintainer-only sections of the Starter repository never reach a generated project.
|
|
144
|
+
if (expression.trim() === "starter-repository")
|
|
145
|
+
return false;
|
|
146
|
+
return expression.split("&&").every((part) => {
|
|
147
|
+
const match = /^\s*([a-z][a-z0-9-]*)\s*(!?=)\s*([a-z0-9|-]+)\s*$/.exec(part);
|
|
148
|
+
if (!match)
|
|
149
|
+
throw new ConditionalError(`${location}: cannot read the condition "${expression}".`);
|
|
150
|
+
const [, key, operator, values] = match;
|
|
151
|
+
const selected = selection[key];
|
|
152
|
+
if (selected === undefined)
|
|
153
|
+
throw new ConditionalError(`${location}: unknown key "${key}".`);
|
|
154
|
+
const matches = values.split("|").includes(selected);
|
|
155
|
+
return operator === "=" ? matches : !matches;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Keeps or drops `clubedge:if <condition>` ... `clubedge:end` blocks for the selection and
|
|
160
|
+
* always removes the marker lines. Conditions look like `auth=none`, `storage!=none`,
|
|
161
|
+
* `auth=supabase|better-auth`, or several joined with `&&`. Blocks do not nest.
|
|
162
|
+
*/
|
|
163
|
+
export function applyConditionals(source, selection, path = "file") {
|
|
164
|
+
const output = [];
|
|
165
|
+
let open = null;
|
|
166
|
+
let removed = false;
|
|
167
|
+
source.split("\n").forEach((line, index) => {
|
|
168
|
+
const marker = MARKER.exec(line);
|
|
169
|
+
const location = `${path}:${index + 1}`;
|
|
170
|
+
if (marker?.[1] === "if") {
|
|
171
|
+
if (open)
|
|
172
|
+
throw new ConditionalError(`${location}: clubedge:if blocks cannot nest.`);
|
|
173
|
+
open = { keep: evaluateCondition(marker[2] ?? "", selection, location), line: index + 1 };
|
|
174
|
+
removed = removed || !open.keep;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (marker?.[1] === "end") {
|
|
178
|
+
if (!open)
|
|
179
|
+
throw new ConditionalError(`${location}: clubedge:end without clubedge:if.`);
|
|
180
|
+
open = null;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (open && !open.keep)
|
|
184
|
+
return;
|
|
185
|
+
const lineMarker = LINE_MARKER.exec(line);
|
|
186
|
+
if (lineMarker && line.trim() !== lineMarker[0].trim()) {
|
|
187
|
+
if (!evaluateCondition(lineMarker[1], selection, location)) {
|
|
188
|
+
removed = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
output.push(line.slice(0, lineMarker.index));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
output.push(line);
|
|
195
|
+
});
|
|
196
|
+
if (open)
|
|
197
|
+
throw new ConditionalError(`${path}:${open.line}: clubedge:if is never closed.`);
|
|
198
|
+
const text = output.join("\n");
|
|
199
|
+
if (!removed)
|
|
200
|
+
return text;
|
|
201
|
+
// A removed block can leave two blank lines in a row, or blank lines at the start or end of
|
|
202
|
+
// the file; formatted sources never have them.
|
|
203
|
+
const collapsed = text.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
204
|
+
return source.endsWith("\n") ? collapsed.replace(/\n+$/, "\n") : collapsed;
|
|
205
|
+
}
|
|
206
|
+
/** Removes dependencies on workspace packages that were left out of the project. */
|
|
207
|
+
export function removePackageDependencies(source, names) {
|
|
208
|
+
const manifest = JSON.parse(source);
|
|
209
|
+
let changed = false;
|
|
210
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
211
|
+
const dependencies = manifest[field];
|
|
212
|
+
if (!dependencies || typeof dependencies !== "object")
|
|
213
|
+
continue;
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
if (name in dependencies) {
|
|
216
|
+
delete dependencies[name];
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return changed ? json(manifest) : source;
|
|
222
|
+
}
|
package/dist/core/manifest.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
export const MANIFEST_FILE = "clubedge.template.json";
|
|
4
|
-
export const SUPPORTED_SCHEMA_VERSION =
|
|
4
|
+
export const SUPPORTED_SCHEMA_VERSION = 3;
|
|
5
5
|
/** Schema 1 Starters ship a single Next.js app at the generated location. */
|
|
6
6
|
function singleNextFramework(app, envExample) {
|
|
7
7
|
return {
|
|
@@ -19,6 +19,8 @@ export const legacyManifest = {
|
|
|
19
19
|
env: { example: ".env.example", target: "apps/web/.env.local" },
|
|
20
20
|
dockerImage: "clubedge-starter",
|
|
21
21
|
...singleNextFramework("apps/web", ".env.example"),
|
|
22
|
+
modules: {},
|
|
23
|
+
conditional: [],
|
|
22
24
|
exclude: [],
|
|
23
25
|
};
|
|
24
26
|
export class ManifestError extends Error {
|
|
@@ -63,9 +65,96 @@ export function parseTemplateManifest(source) {
|
|
|
63
65
|
env: { example: envExample, target: requireString(env, "target", "env.") },
|
|
64
66
|
dockerImage: requireString(data, "dockerImage"),
|
|
65
67
|
...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
|
|
68
|
+
modules: schemaVersion >= 3 ? parseModules(data.modules) : {},
|
|
69
|
+
conditional: schemaVersion >= 3 ? optionalStringArray(data, "conditional") : [],
|
|
66
70
|
exclude: data.exclude ?? [],
|
|
67
71
|
};
|
|
68
72
|
}
|
|
73
|
+
const ID = /^[a-z][a-z0-9-]*$/;
|
|
74
|
+
function parseModules(value) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return {};
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules must be an object.`);
|
|
79
|
+
const modules = {};
|
|
80
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
81
|
+
if (!ID.test(id) || id === "framework") {
|
|
82
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid module id.`);
|
|
83
|
+
}
|
|
84
|
+
if (!isRecord(entry))
|
|
85
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules.${id} must be an object.`);
|
|
86
|
+
const prefix = `modules.${id}.`;
|
|
87
|
+
const optionsValue = entry.options;
|
|
88
|
+
if (!isRecord(optionsValue) || Object.keys(optionsValue).length === 0) {
|
|
89
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}options must describe at least one option.`);
|
|
90
|
+
}
|
|
91
|
+
const options = {};
|
|
92
|
+
for (const [optionId, option] of Object.entries(optionsValue)) {
|
|
93
|
+
const optionPrefix = `${prefix}options.${optionId}.`;
|
|
94
|
+
if (!ID.test(optionId)) {
|
|
95
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${optionId}" is not a valid option id in ${prefix.slice(0, -1)}.`);
|
|
96
|
+
}
|
|
97
|
+
if (!isRecord(option))
|
|
98
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${optionPrefix.slice(0, -1)} must be an object.`);
|
|
99
|
+
options[optionId] = {
|
|
100
|
+
name: requireString(option, "name", optionPrefix),
|
|
101
|
+
packages: optionalStringRecord(option, "packages", optionPrefix),
|
|
102
|
+
files: optionalStringArray(option, "files", optionPrefix),
|
|
103
|
+
replace: optionalStringRecord(option, "replace", optionPrefix),
|
|
104
|
+
requires: parseRequires(option.requires, optionPrefix),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const defaultOption = requireString(entry, "default", prefix);
|
|
108
|
+
if (!options[defaultOption]) {
|
|
109
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}default "${defaultOption}" is not in its options.`);
|
|
110
|
+
}
|
|
111
|
+
modules[id] = { name: requireString(entry, "name", prefix), default: defaultOption, options };
|
|
112
|
+
}
|
|
113
|
+
// Requirements must name real modules and options, or a selection could never be valid.
|
|
114
|
+
for (const [id, module] of Object.entries(modules)) {
|
|
115
|
+
for (const [optionId, option] of Object.entries(module.options)) {
|
|
116
|
+
for (const [requiredModule, allowed] of Object.entries(option.requires)) {
|
|
117
|
+
const unknown = allowed.find((value) => !modules[requiredModule]?.options[value]);
|
|
118
|
+
if (unknown !== undefined) {
|
|
119
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules.${id}.options.${optionId}.requires names unknown option ${requiredModule}=${unknown}.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return modules;
|
|
125
|
+
}
|
|
126
|
+
function parseRequires(value, prefix) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return {};
|
|
129
|
+
if (!isRecord(value))
|
|
130
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires must be an object.`);
|
|
131
|
+
const requires = {};
|
|
132
|
+
for (const [module, allowed] of Object.entries(value)) {
|
|
133
|
+
const list = typeof allowed === "string" ? [allowed] : allowed;
|
|
134
|
+
if (!isStringArray(list) || list.length === 0) {
|
|
135
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires.${module} must name one or more options.`);
|
|
136
|
+
}
|
|
137
|
+
requires[module] = list;
|
|
138
|
+
}
|
|
139
|
+
return requires;
|
|
140
|
+
}
|
|
141
|
+
function optionalStringArray(record, key, prefix = "") {
|
|
142
|
+
const value = record[key];
|
|
143
|
+
if (value === undefined)
|
|
144
|
+
return [];
|
|
145
|
+
if (!isStringArray(value))
|
|
146
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must be an array of paths.`);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function optionalStringRecord(record, key, prefix = "") {
|
|
150
|
+
const value = record[key];
|
|
151
|
+
if (value === undefined)
|
|
152
|
+
return {};
|
|
153
|
+
if (!isRecord(value) || !Object.values(value).every((item) => typeof item === "string" && item)) {
|
|
154
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must map names to paths.`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
69
158
|
function parseFrameworks(data) {
|
|
70
159
|
const { frameworks } = data;
|
|
71
160
|
if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
|
|
@@ -73,7 +162,7 @@ function parseFrameworks(data) {
|
|
|
73
162
|
}
|
|
74
163
|
const parsed = {};
|
|
75
164
|
for (const [id, entry] of Object.entries(frameworks)) {
|
|
76
|
-
if (
|
|
165
|
+
if (!ID.test(id)) {
|
|
77
166
|
throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
|
|
78
167
|
}
|
|
79
168
|
if (!isRecord(entry))
|