create-ortha-app 0.4.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +277 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +32 -0
  9. package/dist/lib/conditionals.d.ts +37 -0
  10. package/dist/lib/conditionals.d.ts.map +1 -0
  11. package/dist/lib/conditionals.js +115 -0
  12. package/dist/lib/features.d.ts +185 -0
  13. package/dist/lib/features.d.ts.map +1 -0
  14. package/dist/lib/features.js +328 -0
  15. package/dist/lib/template.d.ts +46 -0
  16. package/dist/lib/template.d.ts.map +1 -0
  17. package/dist/lib/template.js +115 -0
  18. package/dist/lib/ui.d.ts +76 -0
  19. package/dist/lib/ui.d.ts.map +1 -0
  20. package/dist/lib/ui.js +310 -0
  21. package/dist/lib/validate.d.ts +13 -0
  22. package/dist/lib/validate.d.ts.map +1 -0
  23. package/dist/lib/validate.js +51 -0
  24. package/package.json +37 -0
  25. package/templates/default/README.md.tmpl +199 -0
  26. package/templates/default/_gitignore +8 -0
  27. package/templates/default/apps/admin/index.html +38 -0
  28. package/templates/default/apps/admin/src/main.tsx +5 -0
  29. package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
  30. package/templates/default/apps/admin/src/plugins.ts +57 -0
  31. package/templates/default/apps/admin/src/styles.css +254 -0
  32. package/templates/default/apps/admin/tsconfig.json +29 -0
  33. package/templates/default/apps/admin/vite.config.mts +67 -0
  34. package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
  35. package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
  36. package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
  37. package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
  38. package/templates/default/apps/server/jest.config.js +38 -0
  39. package/templates/default/apps/server/jest.setup.js +20 -0
  40. package/templates/default/apps/server/ortha.config.ts +505 -0
  41. package/templates/default/apps/server/src/main.ts +26 -0
  42. package/templates/default/apps/server/src/plugins.spec.ts +71 -0
  43. package/templates/default/apps/server/src/plugins.ts +229 -0
  44. package/templates/default/apps/server/tsconfig.json +31 -0
  45. package/templates/default/apps/server-e2e/jest.config.js +47 -0
  46. package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
  47. package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
  48. package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
  49. package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
  50. package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
  51. package/templates/default/apps/server-e2e/tsconfig.json +26 -0
  52. package/templates/default/docker-compose.yml +21 -0
  53. package/templates/default/env.tmpl +140 -0
  54. package/templates/default/package.json.tmpl +51 -0
  55. package/templates/default/tsconfig.json +18 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # create-ortha-app
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install create-ortha-app
7
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const tslib_1 = require("tslib");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
9
+ const promises_1 = require("node:readline/promises");
10
+ const features_1 = require("./lib/features");
11
+ const template_1 = require("./lib/template");
12
+ const ui = tslib_1.__importStar(require("./lib/ui"));
13
+ const validate_1 = require("./lib/validate");
14
+ const USAGE = `create-ortha-app — scaffold an Ortha CMS app
15
+
16
+ Usage: npx create-ortha-app <directory> [options]
17
+
18
+ Options:
19
+ --yes Accept every default, asking nothing
20
+ --media <id> Storage adapter (default: media-local)
21
+ --copilot <ids> Comma-separated copilot providers, or "none"
22
+ --sso <ids> Comma-separated identity providers (sso-oidc), or "none"
23
+ --protocols <ids> Comma-separated protocols beyond REST (graphql, mcp), or "none"
24
+ --no-install Skip installing dependencies
25
+ --no-git Skip initialising a git repository
26
+ -h, --help Show this message
27
+ `;
28
+ /** Whether a bare `--flag` is present. */
29
+ function flag(argv, name) {
30
+ return argv.includes(`--${name}`);
31
+ }
32
+ /** Reads `--name=value` or `--name value` from argv. */
33
+ function option(argv, name) {
34
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
35
+ if (inline)
36
+ return inline.slice(`--${name}=`.length);
37
+ const index = argv.indexOf(`--${name}`);
38
+ const next = index === -1 ? undefined : argv[index + 1];
39
+ return next && !next.startsWith('-') ? next : undefined;
40
+ }
41
+ /** Splits a comma-separated flag value; `none` means an empty selection. */
42
+ function idList(raw) {
43
+ if (raw === undefined)
44
+ return undefined;
45
+ if (raw.trim() === 'none')
46
+ return [];
47
+ return raw
48
+ .split(',')
49
+ .map((id) => id.trim())
50
+ .filter(Boolean);
51
+ }
52
+ /**
53
+ * This package's own version, which every `@orthacms/*` dependency in the
54
+ * generated app is pinned to.
55
+ *
56
+ * The scaffolder is released in lockstep with the packages it scaffolds, so its
57
+ * version *is* the matching set — which is the whole mechanism keeping a
58
+ * generated app internally consistent. Reading it from the manifest rather than
59
+ * resolving `latest` from the registry also means `npx create-ortha-app@0.3.0`
60
+ * generates a 0.3.0 app, not whatever shipped since.
61
+ */
62
+ function ownVersion() {
63
+ const manifest = (0, node_path_1.join)(__dirname, '../package.json');
64
+ return JSON.parse((0, node_fs_1.readFileSync)(manifest, 'utf8'))
65
+ .version;
66
+ }
67
+ /** Runs a command in `cwd`, returning whether it succeeded. */
68
+ function runCommand(command, args, cwd) {
69
+ return ((0, node_child_process_1.spawnSync)(command, args, {
70
+ cwd,
71
+ stdio: 'inherit',
72
+ shell: process.platform === 'win32'
73
+ }).status === 0);
74
+ }
75
+ /** Turns a feature into a picker row. */
76
+ function toChoice(feature) {
77
+ return {
78
+ value: feature.id,
79
+ label: feature.label,
80
+ hint: feature.hint,
81
+ selected: feature.enabledByDefault,
82
+ disabled: !feature.available,
83
+ locked: feature.locked
84
+ };
85
+ }
86
+ /**
87
+ * The ids a feature group falls back to when nothing is picked.
88
+ *
89
+ * A locked feature is in every answer, including `--protocols none`: REST is
90
+ * not something the flag can switch off.
91
+ */
92
+ function defaultsOf(features) {
93
+ return features
94
+ .filter((feature) => (feature.enabledByDefault || feature.locked) &&
95
+ feature.available)
96
+ .map((feature) => feature.id);
97
+ }
98
+ /** The ids that are on regardless of what was asked or passed. */
99
+ function lockedOf(features) {
100
+ return features
101
+ .filter((feature) => feature.locked && feature.available)
102
+ .map((feature) => feature.id);
103
+ }
104
+ /** Asks a free-text question, re-asking until it validates. */
105
+ async function askText(label, fallback, validate) {
106
+ const rl = (0, promises_1.createInterface)({
107
+ input: process.stdin,
108
+ output: process.stdout
109
+ });
110
+ try {
111
+ for (;;) {
112
+ const raw = await rl.question(`${ui.cyan('◆')} ${ui.bold(label)} ${ui.dim(`(${fallback})`)} `);
113
+ const answer = raw.trim() || fallback;
114
+ const problem = validate?.(answer);
115
+ if (!problem)
116
+ return answer;
117
+ ui.error(problem);
118
+ }
119
+ }
120
+ finally {
121
+ rl.close();
122
+ }
123
+ }
124
+ /**
125
+ * Resolves the answers, asking only when there is a terminal to ask in.
126
+ *
127
+ * Non-interactive is not an error case to warn about — it is CI, a piped
128
+ * install, and `--yes`. A scaffolder that blocks on a prompt nobody can answer
129
+ * hangs a pipeline until it times out, which is a far worse failure than
130
+ * defaulting, so the flags and the defaults cover every question.
131
+ */
132
+ async function resolveAnswers(argv, defaultName) {
133
+ const asked = !flag(argv, 'yes') && ui.interactive();
134
+ const defaultDatabaseUrl = `postgresql://ortha:ortha@localhost:5432/${defaultName.replace(/[^a-z0-9_]/gi, '_')}`;
135
+ const media = idList(option(argv, 'media')) ?? defaultsOf(features_1.MEDIA_PROVIDERS);
136
+ const copilot = idList(option(argv, 'copilot')) ?? defaultsOf(features_1.COPILOT_PROVIDERS);
137
+ const sso = idList(option(argv, 'sso')) ?? defaultsOf(features_1.SSO_PROVIDERS);
138
+ const protocols = [
139
+ ...lockedOf(features_1.PROTOCOLS),
140
+ ...(idList(option(argv, 'protocols')) ?? defaultsOf(features_1.PROTOCOLS))
141
+ ];
142
+ if (!asked) {
143
+ return {
144
+ appName: defaultName,
145
+ databaseUrl: defaultDatabaseUrl,
146
+ adminEmail: 'admin@example.com',
147
+ selection: {
148
+ enabled: new Set([...media, ...copilot, ...sso, ...protocols])
149
+ }
150
+ };
151
+ }
152
+ ui.section('Project');
153
+ const appName = await askText('App name', defaultName, validate_1.validateAppName);
154
+ const databaseUrl = await askText('Database URL', defaultDatabaseUrl, validate_1.validateDatabaseUrl);
155
+ const adminEmail = await askText('Admin email', 'admin@example.com');
156
+ ui.section('Features');
157
+ ui.note('Everything else is installed for you. These are the choices.');
158
+ console.log('');
159
+ // Only ask when there is more than one answer available. S3 is not
160
+ // published, so today this is a question with a single possible reply, and
161
+ // asking it would be noise pretending to be a choice.
162
+ const selectable = features_1.MEDIA_PROVIDERS.filter((provider) => provider.available);
163
+ const chosenMedia = selectable.length > 1
164
+ ? [
165
+ (await ui.select('Where should uploads be stored?', features_1.MEDIA_PROVIDERS.map(toChoice))) ?? media[0]
166
+ ]
167
+ : media;
168
+ const chosenCopilot = (await ui.multiselect('AI copilot — which model backends?', features_1.COPILOT_PROVIDERS.map(toChoice))) ?? copilot;
169
+ const chosenSso = (await ui.multiselect('Single sign-on — which identity providers?', features_1.SSO_PROVIDERS.map(toChoice))) ?? sso;
170
+ const chosenProtocols = (await ui.multiselect('Which protocols should the content API speak?', features_1.PROTOCOLS.map(toChoice))) ?? protocols;
171
+ return {
172
+ appName,
173
+ databaseUrl,
174
+ adminEmail,
175
+ selection: {
176
+ enabled: new Set([
177
+ ...chosenMedia.filter(Boolean),
178
+ ...chosenCopilot,
179
+ ...chosenSso,
180
+ ...chosenProtocols
181
+ ])
182
+ }
183
+ };
184
+ }
185
+ /** Human-readable summary of what was chosen. */
186
+ function describeSelection(selection) {
187
+ const labelsFor = (features) => {
188
+ const chosen = features
189
+ .filter((feature) => selection.enabled.has(feature.id))
190
+ .map((feature) => feature.label);
191
+ return chosen.length > 0 ? chosen.join(', ') : ui.dim('none');
192
+ };
193
+ const providers = features_1.COPILOT_PROVIDERS.filter((provider) => selection.enabled.has(provider.id));
194
+ return [
195
+ ['Storage', labelsFor(features_1.MEDIA_PROVIDERS)],
196
+ ['Protocols', labelsFor(features_1.PROTOCOLS)],
197
+ [
198
+ 'Copilot',
199
+ providers.length > 0
200
+ ? `${providers.map((p) => p.label).join(', ')} + offline fake`
201
+ : 'offline fake only'
202
+ ],
203
+ ['Sign-in', labelsFor(features_1.SSO_PROVIDERS)],
204
+ ['Ortha packages', String((0, features_1.resolvePackages)(selection).length + 1)]
205
+ ];
206
+ }
207
+ async function main() {
208
+ const argv = process.argv.slice(2);
209
+ if (flag(argv, 'help') || argv.includes('-h')) {
210
+ console.log(USAGE);
211
+ return;
212
+ }
213
+ const positional = argv.filter((arg) => !arg.startsWith('-'));
214
+ const target = (0, node_path_1.resolve)(positional[0] ?? '.');
215
+ ui.banner('Ortha CMS', `Creating an app in ${target}`);
216
+ if ((0, template_1.isNonEmptyDirectory)(target)) {
217
+ throw new Error(`${target} already exists and is not empty. Pick a new directory, ` +
218
+ `or empty that one first.`);
219
+ }
220
+ const answers = await resolveAnswers(argv, (0, node_path_1.basename)(target));
221
+ // Generated rather than prompted: a password typed at a prompt is echoed to
222
+ // the terminal and lands in shell history. This one is written only to the
223
+ // git-ignored .env, and printed once below.
224
+ const adminPassword = (0, node_crypto_1.randomBytes)(12).toString('base64url');
225
+ ui.summary('Your app', [
226
+ ['Name', answers.appName],
227
+ ['Database', (0, validate_1.databaseNameFrom)(answers.databaseUrl)],
228
+ ...describeSelection(answers.selection)
229
+ ]);
230
+ (0, node_fs_1.mkdirSync)(target, { recursive: true });
231
+ (0, template_1.renderTemplate)((0, node_path_1.join)(__dirname, '../templates/default'), target, {
232
+ appName: answers.appName,
233
+ appTitle: answers.appName,
234
+ databaseUrl: answers.databaseUrl,
235
+ databaseName: (0, validate_1.databaseNameFrom)(answers.databaseUrl),
236
+ adminEmail: answers.adminEmail,
237
+ adminPassword,
238
+ orthaVersion: ownVersion(),
239
+ selection: answers.selection
240
+ });
241
+ console.log('');
242
+ ui.success('Files written');
243
+ if (!flag(argv, 'no-git') && !(0, node_fs_1.existsSync)((0, node_path_1.join)(target, '.git'))) {
244
+ if (runCommand('git', ['init', '--quiet'], target)) {
245
+ ui.success('Git repository initialised');
246
+ }
247
+ }
248
+ let installed = true;
249
+ if (!flag(argv, 'no-install')) {
250
+ console.log('');
251
+ ui.note('Installing dependencies…');
252
+ installed = runCommand('npm', ['install', '--no-audit', '--no-fund'], target);
253
+ if (installed)
254
+ ui.success('Dependencies installed');
255
+ }
256
+ if (!installed) {
257
+ ui.error('Dependency installation failed — fix the error above, then run ' +
258
+ '`npm install` in the new directory.');
259
+ process.exitCode = 1;
260
+ }
261
+ ui.summary('Your admin account', [
262
+ ['Email', answers.adminEmail],
263
+ ['Password', ui.bold(adminPassword)],
264
+ ['', ui.dim('Also written to .env')]
265
+ ]);
266
+ ui.nextSteps([
267
+ `cd ${(0, node_path_1.basename)(target)}`,
268
+ ...(flag(argv, 'no-install') ? ['npm install'] : []),
269
+ 'docker compose up -d',
270
+ 'npm run migrate',
271
+ `npm run dev ${ui.dim('→ http://localhost:4200')}`
272
+ ]);
273
+ }
274
+ main().catch((error) => {
275
+ ui.error(error instanceof Error ? error.message : String(error));
276
+ process.exit(1);
277
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public API of `create-ortha-app`.
3
+ *
4
+ * The package is used through `npx`; these exports exist so the rendering,
5
+ * feature resolution and validation are testable without spawning the binary.
6
+ */
7
+ export { renderTemplate, renderManifest, render, isNonEmptyDirectory, type TemplateValues } from './lib/template';
8
+ export { applyConditionals } from './lib/conditionals';
9
+ export { CORE_PACKAGES, CORE_DEV_PACKAGES, TRANSITIVE_PACKAGES, MEDIA_PROVIDERS, COPILOT_PROVIDERS, SSO_PROVIDERS, PROTOCOLS, ALL_FEATURES, resolvePackages, resolveDevPackages, resolveFlags, type Feature, type FeatureSelection } from './lib/features';
10
+ export { validateAppName, validateDatabaseUrl, databaseNameFrom } from './lib/validate';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACH,cAAc,EACd,cAAc,EACd,MAAM,EACN,mBAAmB,EACnB,KAAK,cAAc,EACtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EACH,aAAa,EACb,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,SAAS,EACT,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,YAAY,EACZ,KAAK,OAAO,EACZ,KAAK,gBAAgB,EACxB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACH,eAAe,EACf,mBAAmB,EACnB,gBAAgB,EACnB,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.databaseNameFrom = exports.validateDatabaseUrl = exports.validateAppName = exports.resolveFlags = exports.resolveDevPackages = exports.resolvePackages = exports.ALL_FEATURES = exports.PROTOCOLS = exports.SSO_PROVIDERS = exports.COPILOT_PROVIDERS = exports.MEDIA_PROVIDERS = exports.TRANSITIVE_PACKAGES = exports.CORE_DEV_PACKAGES = exports.CORE_PACKAGES = exports.applyConditionals = exports.isNonEmptyDirectory = exports.render = exports.renderManifest = exports.renderTemplate = void 0;
4
+ /**
5
+ * Public API of `create-ortha-app`.
6
+ *
7
+ * The package is used through `npx`; these exports exist so the rendering,
8
+ * feature resolution and validation are testable without spawning the binary.
9
+ */
10
+ var template_1 = require("./lib/template");
11
+ Object.defineProperty(exports, "renderTemplate", { enumerable: true, get: function () { return template_1.renderTemplate; } });
12
+ Object.defineProperty(exports, "renderManifest", { enumerable: true, get: function () { return template_1.renderManifest; } });
13
+ Object.defineProperty(exports, "render", { enumerable: true, get: function () { return template_1.render; } });
14
+ Object.defineProperty(exports, "isNonEmptyDirectory", { enumerable: true, get: function () { return template_1.isNonEmptyDirectory; } });
15
+ var conditionals_1 = require("./lib/conditionals");
16
+ Object.defineProperty(exports, "applyConditionals", { enumerable: true, get: function () { return conditionals_1.applyConditionals; } });
17
+ var features_1 = require("./lib/features");
18
+ Object.defineProperty(exports, "CORE_PACKAGES", { enumerable: true, get: function () { return features_1.CORE_PACKAGES; } });
19
+ Object.defineProperty(exports, "CORE_DEV_PACKAGES", { enumerable: true, get: function () { return features_1.CORE_DEV_PACKAGES; } });
20
+ Object.defineProperty(exports, "TRANSITIVE_PACKAGES", { enumerable: true, get: function () { return features_1.TRANSITIVE_PACKAGES; } });
21
+ Object.defineProperty(exports, "MEDIA_PROVIDERS", { enumerable: true, get: function () { return features_1.MEDIA_PROVIDERS; } });
22
+ Object.defineProperty(exports, "COPILOT_PROVIDERS", { enumerable: true, get: function () { return features_1.COPILOT_PROVIDERS; } });
23
+ Object.defineProperty(exports, "SSO_PROVIDERS", { enumerable: true, get: function () { return features_1.SSO_PROVIDERS; } });
24
+ Object.defineProperty(exports, "PROTOCOLS", { enumerable: true, get: function () { return features_1.PROTOCOLS; } });
25
+ Object.defineProperty(exports, "ALL_FEATURES", { enumerable: true, get: function () { return features_1.ALL_FEATURES; } });
26
+ Object.defineProperty(exports, "resolvePackages", { enumerable: true, get: function () { return features_1.resolvePackages; } });
27
+ Object.defineProperty(exports, "resolveDevPackages", { enumerable: true, get: function () { return features_1.resolveDevPackages; } });
28
+ Object.defineProperty(exports, "resolveFlags", { enumerable: true, get: function () { return features_1.resolveFlags; } });
29
+ var validate_1 = require("./lib/validate");
30
+ Object.defineProperty(exports, "validateAppName", { enumerable: true, get: function () { return validate_1.validateAppName; } });
31
+ Object.defineProperty(exports, "validateDatabaseUrl", { enumerable: true, get: function () { return validate_1.validateDatabaseUrl; } });
32
+ Object.defineProperty(exports, "databaseNameFrom", { enumerable: true, get: function () { return validate_1.databaseNameFrom; } });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `ortha:if` blocks — how one template serves every feature combination.
3
+ *
4
+ * A marker is any line containing the directive, whatever comment syntax wraps
5
+ * it, so the same three forms work in TypeScript, YAML, `.env` and Markdown:
6
+ *
7
+ * // ortha:if copilot
8
+ * import { CopilotPlugin } from '@orthacms/copilot-server';
9
+ * // ortha:end
10
+ *
11
+ * # ortha:ifnot graphql
12
+ * # (REST only — no GraphQL endpoint is mounted.)
13
+ * # ortha:end
14
+ *
15
+ * The marker lines are always removed; the body survives only when the
16
+ * condition holds. Blocks may nest, and an inner block is only reached when
17
+ * the outer one is kept.
18
+ *
19
+ * Deliberately line-based rather than an expression language. The templates are
20
+ * read far more often than they are written — someone opening
21
+ * `plugins.ts` should see the real file with a few comments in it, not a
22
+ * templating dialect — and every condition this needs is "is this feature on".
23
+ *
24
+ * **Not used for `package.json`.** Dropping lines from JSON produces a trailing
25
+ * comma and an app that will not install, blaming the template rather than the
26
+ * feature that was switched off; dependencies are assembled in `features.ts`
27
+ * instead.
28
+ */
29
+ /**
30
+ * Applies every `ortha:if` block in `contents` against the enabled `flags`.
31
+ *
32
+ * @throws when a block is left open, which would otherwise silently swallow the
33
+ * rest of the file — a missing `ortha:end` in `plugins.ts` deletes every plugin
34
+ * below it, and the app boots with no API rather than failing to render.
35
+ */
36
+ export declare function applyConditionals(contents: string, flags: ReadonlySet<string>, file?: string): string;
37
+ //# sourceMappingURL=conditionals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conditionals.d.ts","sourceRoot":"","sources":["../../src/lib/conditionals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAmCH;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,EAC1B,IAAI,SAAa,GAClB,MAAM,CA0CR"}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ /**
3
+ * `ortha:if` blocks — how one template serves every feature combination.
4
+ *
5
+ * A marker is any line containing the directive, whatever comment syntax wraps
6
+ * it, so the same three forms work in TypeScript, YAML, `.env` and Markdown:
7
+ *
8
+ * // ortha:if copilot
9
+ * import { CopilotPlugin } from '@orthacms/copilot-server';
10
+ * // ortha:end
11
+ *
12
+ * # ortha:ifnot graphql
13
+ * # (REST only — no GraphQL endpoint is mounted.)
14
+ * # ortha:end
15
+ *
16
+ * The marker lines are always removed; the body survives only when the
17
+ * condition holds. Blocks may nest, and an inner block is only reached when
18
+ * the outer one is kept.
19
+ *
20
+ * Deliberately line-based rather than an expression language. The templates are
21
+ * read far more often than they are written — someone opening
22
+ * `plugins.ts` should see the real file with a few comments in it, not a
23
+ * templating dialect — and every condition this needs is "is this feature on".
24
+ *
25
+ * **Not used for `package.json`.** Dropping lines from JSON produces a trailing
26
+ * comma and an app that will not install, blaming the template rather than the
27
+ * feature that was switched off; dependencies are assembled in `features.ts`
28
+ * instead.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.applyConditionals = applyConditionals;
32
+ /**
33
+ * A trailing comment closer, so a block-comment directive works too — `*\/`
34
+ * for CSS and C-style block comments, `-->` for HTML.
35
+ */
36
+ const CLOSER = String.raw `(?:\s*(?:\*\/|-->))?`;
37
+ const IF = new RegExp(String.raw `(^|\s)ortha:if\s+([a-z0-9-]+)${CLOSER}\s*$`);
38
+ const IFNOT = new RegExp(String.raw `(^|\s)ortha:ifnot\s+([a-z0-9-]+)${CLOSER}\s*$`);
39
+ const END = new RegExp(String.raw `(^|\s)ortha:end${CLOSER}\s*$`);
40
+ /** Reads the directive on `line`, or `undefined` for an ordinary line. */
41
+ function directiveOf(line) {
42
+ const trimmed = line.trimEnd();
43
+ const ifMatch = IF.exec(trimmed);
44
+ if (ifMatch)
45
+ return { kind: 'if', flag: ifMatch[2] };
46
+ const ifNotMatch = IFNOT.exec(trimmed);
47
+ if (ifNotMatch)
48
+ return { kind: 'ifnot', flag: ifNotMatch[2] };
49
+ if (END.test(trimmed))
50
+ return { kind: 'end' };
51
+ return undefined;
52
+ }
53
+ /**
54
+ * Applies every `ortha:if` block in `contents` against the enabled `flags`.
55
+ *
56
+ * @throws when a block is left open, which would otherwise silently swallow the
57
+ * rest of the file — a missing `ortha:end` in `plugins.ts` deletes every plugin
58
+ * below it, and the app boots with no API rather than failing to render.
59
+ */
60
+ function applyConditionals(contents, flags, file = 'template') {
61
+ const lines = contents.split('\n');
62
+ const kept = [];
63
+ // One entry per open block: whether its body is being written out. A nested
64
+ // block inside a dropped one stays dropped whatever its own condition says.
65
+ const stack = [];
66
+ const writing = () => stack.every(Boolean);
67
+ for (const line of lines) {
68
+ const directive = directiveOf(line);
69
+ if (!directive) {
70
+ if (writing())
71
+ kept.push(line);
72
+ continue;
73
+ }
74
+ if (directive.kind === 'end') {
75
+ if (stack.length === 0) {
76
+ throw new Error(`${file}: an \`ortha:end\` with no matching \`ortha:if\`.`);
77
+ }
78
+ stack.pop();
79
+ continue;
80
+ }
81
+ const holds = directive.kind === 'if'
82
+ ? flags.has(directive.flag)
83
+ : !flags.has(directive.flag);
84
+ stack.push(holds);
85
+ }
86
+ if (stack.length > 0) {
87
+ throw new Error(`${file}: ${stack.length} unclosed \`ortha:if\` block(s) — every ` +
88
+ `one needs an \`ortha:end\`, or the rest of the file is dropped.`);
89
+ }
90
+ return collapseBlankRuns(kept).join('\n');
91
+ }
92
+ /**
93
+ * Collapses runs of three or more blank lines into one.
94
+ *
95
+ * Removing a block almost always leaves the blank line that separated it behind
96
+ * next to the one after it. Without this every generated file carries a scar
97
+ * wherever a feature was switched off, which reads as sloppiness in the very
98
+ * first file anyone opens.
99
+ */
100
+ function collapseBlankRuns(lines) {
101
+ const out = [];
102
+ let blanks = 0;
103
+ for (const line of lines) {
104
+ if (line.trim() === '') {
105
+ blanks += 1;
106
+ if (blanks > 1)
107
+ continue;
108
+ }
109
+ else {
110
+ blanks = 0;
111
+ }
112
+ out.push(line);
113
+ }
114
+ return out;
115
+ }