@nonext/husk 0.2.3

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 (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,3360 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync, mkdirSync, renameSync, writeFileSync, readdirSync, statSync } from 'fs';
3
+ import { resolve, join, posix, dirname } from 'path';
4
+ import { createHash } from 'crypto';
5
+ import { pathToFileURL } from 'url';
6
+ import { createInterface } from 'readline/promises';
7
+ import { stdout, stdin } from 'process';
8
+
9
+ // src/cli/args.ts
10
+ var ArgumentError = class extends Error {
11
+ name = "ArgumentError";
12
+ };
13
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
14
+ "yes",
15
+ "dry-run",
16
+ "force",
17
+ "media",
18
+ "auth",
19
+ "emulators",
20
+ "help",
21
+ "version"
22
+ ]);
23
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["cwd", "project-id", "storage-bucket", "admin-route"]);
24
+ function base() {
25
+ return {
26
+ command: "help",
27
+ cwd: void 0,
28
+ yes: false,
29
+ dryRun: false,
30
+ force: false,
31
+ projectId: void 0,
32
+ storageBucket: void 0,
33
+ adminRoute: void 0,
34
+ media: void 0,
35
+ auth: void 0,
36
+ emulators: void 0
37
+ };
38
+ }
39
+ function parseArgs(argv) {
40
+ const parsed = base();
41
+ const flags = /* @__PURE__ */ new Map();
42
+ let commandSeen = false;
43
+ for (let index = 0; index < argv.length; index += 1) {
44
+ const token = argv[index] ?? "";
45
+ if (token === "-y") {
46
+ flags.set("yes", true);
47
+ continue;
48
+ }
49
+ if (token === "-h") {
50
+ flags.set("help", true);
51
+ continue;
52
+ }
53
+ if (!token.startsWith("--")) {
54
+ if (commandSeen) {
55
+ throw new ArgumentError(`Unexpected argument "${token}".`);
56
+ }
57
+ if (token !== "init" && token !== "doctor" && token !== "help") {
58
+ throw new ArgumentError(
59
+ `Unknown command "${token}". Expected "init", "doctor" or "help".`
60
+ );
61
+ }
62
+ parsed.command = token;
63
+ commandSeen = true;
64
+ continue;
65
+ }
66
+ const body = token.slice(2);
67
+ const equals = body.indexOf("=");
68
+ const name = equals === -1 ? body : body.slice(0, equals);
69
+ const inlineValue = equals === -1 ? void 0 : body.slice(equals + 1);
70
+ if (name.startsWith("no-") && BOOLEAN_FLAGS.has(name.slice(3))) {
71
+ if (inlineValue !== void 0) {
72
+ throw new ArgumentError(`--${name} takes no value.`);
73
+ }
74
+ flags.set(name.slice(3), false);
75
+ continue;
76
+ }
77
+ if (BOOLEAN_FLAGS.has(name)) {
78
+ if (inlineValue !== void 0) {
79
+ throw new ArgumentError(`--${name} takes no value.`);
80
+ }
81
+ flags.set(name, true);
82
+ continue;
83
+ }
84
+ if (VALUE_FLAGS.has(name)) {
85
+ const value = inlineValue ?? argv[index + 1];
86
+ if (value === void 0 || value.startsWith("--")) {
87
+ throw new ArgumentError(`--${name} needs a value.`);
88
+ }
89
+ if (inlineValue === void 0) index += 1;
90
+ flags.set(name, value);
91
+ continue;
92
+ }
93
+ throw new ArgumentError(`Unknown option "${token}".`);
94
+ }
95
+ if (flags.get("version") === true) return { ...parsed, command: "version" };
96
+ if (flags.get("help") === true) return { ...parsed, command: "help" };
97
+ return {
98
+ ...parsed,
99
+ yes: flags.get("yes") === true,
100
+ dryRun: flags.get("dry-run") === true,
101
+ force: flags.get("force") === true,
102
+ cwd: stringFlag(flags, "cwd"),
103
+ projectId: stringFlag(flags, "project-id"),
104
+ storageBucket: stringFlag(flags, "storage-bucket"),
105
+ adminRoute: stringFlag(flags, "admin-route"),
106
+ media: booleanFlag(flags, "media"),
107
+ auth: booleanFlag(flags, "auth"),
108
+ emulators: booleanFlag(flags, "emulators")
109
+ };
110
+ }
111
+ function stringFlag(flags, name) {
112
+ const value = flags.get(name);
113
+ return typeof value === "string" ? value : void 0;
114
+ }
115
+ function booleanFlag(flags, name) {
116
+ const value = flags.get(name);
117
+ return typeof value === "boolean" ? value : void 0;
118
+ }
119
+ var HELP = `nonext-husk - Husk, the Nonext CMS
120
+
121
+ Usage
122
+ npx nonext-husk init [options] scaffold Husk into this Next.js app
123
+ npx nonext-husk doctor [options] check an existing Husk installation
124
+
125
+ Options
126
+ -y, --yes take every default and ask nothing
127
+ --cwd <dir> the project directory (default: the current one)
128
+ --project-id <id> the Firebase project id
129
+ --storage-bucket <b> the Storage bucket (default: <project-id>.firebasestorage.app)
130
+ --admin-route <path> where the admin mounts (default: /admin)
131
+ --no-media leave out the media library
132
+ --no-auth leave out the sign-in screen and the session route
133
+ --no-emulators target a real Firebase project instead of the emulators
134
+ --dry-run list what would be written and write nothing
135
+ --force overwrite files that exist and differ
136
+ -h, --help this text
137
+ --version the installed @nonext/husk version
138
+ `;
139
+
140
+ // src/core/errors.ts
141
+ var SchemaError = class extends Error {
142
+ name = "SchemaError";
143
+ constructor(message) {
144
+ super(message);
145
+ }
146
+ };
147
+
148
+ // src/core/list-view.ts
149
+ var FILTERABLE = ["select", "boolean"];
150
+ function sortableFieldKeys(type) {
151
+ const keys = /* @__PURE__ */ new Set();
152
+ if (type.titleField !== void 0) keys.add(type.titleField);
153
+ if (type.defaultSort !== void 0) keys.add(type.defaultSort.field);
154
+ keys.add("updatedAt");
155
+ return [...keys];
156
+ }
157
+ function isFilterableField(field) {
158
+ return FILTERABLE.includes(field.type);
159
+ }
160
+
161
+ // src/core/indexes.ts
162
+ var ENTRY_COLLECTION_GROUP = "entries";
163
+ var MAX_ENTRY_INDEXES = 200;
164
+ function order(direction) {
165
+ return direction === "asc" ? "ASCENDING" : "DESCENDING";
166
+ }
167
+ function subsets(keys) {
168
+ return keys.reduce(
169
+ (found, key) => [...found, ...found.map((subset) => [...subset, key])],
170
+ [[]]
171
+ );
172
+ }
173
+ function filterableKeys(type) {
174
+ return Object.entries(type.fields).filter(([, field]) => field.hidden !== true && isFilterableField(field)).map(([key]) => key);
175
+ }
176
+ function keyOf(index) {
177
+ return index.fields.map((field) => `${field.fieldPath}:${field.order}`).join("|");
178
+ }
179
+ function requiredEntryIndexes(types) {
180
+ const found = /* @__PURE__ */ new Map();
181
+ for (const type of types) {
182
+ if (type.kind !== "collection") continue;
183
+ for (const filters of subsets(filterableKeys(type))) {
184
+ for (const sortField of sortableFieldKeys(type)) {
185
+ for (const direction of ["asc", "desc"]) {
186
+ for (const withStatus of [true, false]) {
187
+ if (!withStatus && filters.length === 0) continue;
188
+ if (filters.includes(sortField)) continue;
189
+ const index = {
190
+ collectionGroup: ENTRY_COLLECTION_GROUP,
191
+ queryScope: "COLLECTION",
192
+ fields: [
193
+ ...withStatus ? [{ fieldPath: "status", order: "ASCENDING" }] : [],
194
+ ...filters.map((key) => ({
195
+ fieldPath: key,
196
+ order: "ASCENDING"
197
+ })),
198
+ { fieldPath: sortField, order: order(direction) },
199
+ { fieldPath: "__name__", order: order(direction) }
200
+ ]
201
+ };
202
+ found.set(keyOf(index), index);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ const indexes = [...found.values()].sort(
209
+ (left, right) => keyOf(left).localeCompare(keyOf(right))
210
+ );
211
+ if (indexes.length > MAX_ENTRY_INDEXES) {
212
+ throw new SchemaError(
213
+ `These content types need ${indexes.length} composite indexes on "entries", and Firestore allows ${MAX_ENTRY_INDEXES} per database. Reduce the number of filterable fields or sort fields.`
214
+ );
215
+ }
216
+ return indexes;
217
+ }
218
+ function indexCovered(file, required) {
219
+ return (file.indexes ?? []).some(
220
+ (candidate) => candidate.collectionGroup === required.collectionGroup && keyOf(candidate) === keyOf(required)
221
+ );
222
+ }
223
+ function missingEntryIndexes(file, types) {
224
+ return requiredEntryIndexes(types).filter((required) => !indexCovered(file, required));
225
+ }
226
+ var FILE_NAME = {
227
+ firestore: "firestore.rules",
228
+ storage: "storage.rules"
229
+ };
230
+ function createFileTemplates(directory) {
231
+ return {
232
+ read(kind) {
233
+ return readFileSync(join(directory, FILE_NAME[kind]), "utf8").replace(/\r\n/g, "\n");
234
+ }
235
+ };
236
+ }
237
+ function checksum(body) {
238
+ return createHash("sha256").update(body.replace(/\r\n/g, "\n")).digest("hex").slice(0, 12);
239
+ }
240
+ var STAMP = /husk-template:\s*(firestore|storage)\.rules@([0-9a-f]{12})/;
241
+ function stampRules(kind, body) {
242
+ return `// Husk security rules, generated by nonext-husk init.
243
+ //
244
+ // They are the third layer of the same permission model as can() and the adapter, and they
245
+ // are tested against the emulators in the @nonext/husk repository. Edit them if your project
246
+ // needs more, but do not weaken them: the admin assumes they hold.
247
+ //
248
+ // Deploy with: npx firebase deploy --only ${kind === "firestore" ? "firestore:rules,firestore:indexes" : "storage"}
249
+ //
250
+ // husk-template: ${FILE_NAME[kind]}@${checksum(body)}
251
+
252
+ ${body.replace(/^\s+/, "")}`;
253
+ }
254
+ function readStamp(text) {
255
+ const found = STAMP.exec(text);
256
+ if (found === null) return null;
257
+ return { kind: found[1], checksum: found[2] ?? "" };
258
+ }
259
+
260
+ // src/cli/doctor.ts
261
+ function runChecks(input) {
262
+ return [
263
+ configCheck(input),
264
+ versionCheck(input),
265
+ rulesCheck(input, "firestore", "firestore.rules"),
266
+ ...shouldCheckStorage(input) ? [rulesCheck(input, "storage", "storage.rules")] : [],
267
+ indexCoverageCheck(input),
268
+ firebaseWiringCheck(input),
269
+ environmentCheck(input),
270
+ ...undeployableChecks()
271
+ ];
272
+ }
273
+ function exitCode(checks) {
274
+ return checks.some((check) => check.status === "problem") ? 1 : 0;
275
+ }
276
+ function shouldCheckStorage(input) {
277
+ return "error" in input.config ? input.fs.exists("storage.rules") : input.config.media;
278
+ }
279
+ function configCheck(input) {
280
+ if (!input.fs.exists("cms.config.ts")) {
281
+ return {
282
+ name: "config",
283
+ status: "problem",
284
+ detail: "cms.config.ts is missing.",
285
+ fix: "Run `npx nonext-husk init` in this project."
286
+ };
287
+ }
288
+ if ("error" in input.config) {
289
+ return {
290
+ name: "config",
291
+ status: "problem",
292
+ detail: `cms.config.ts could not be read: ${input.config.error}`,
293
+ fix: "Check that cms.config.ts exports `huskConfig` and `codeContentTypes`."
294
+ };
295
+ }
296
+ return {
297
+ name: "config",
298
+ status: "ok",
299
+ detail: `admin at ${input.config.adminRoute}, project ${input.config.projectId}, media ${input.config.media ? "on" : "off"}, auth ${input.config.auth ? "on" : "off"}.`
300
+ };
301
+ }
302
+ function versionCheck(input) {
303
+ if ("error" in input.config) {
304
+ return {
305
+ name: "version skew",
306
+ status: "unchecked",
307
+ detail: "The config could not be read, so there is nothing to compare against."
308
+ };
309
+ }
310
+ const generated = input.config.generatedWith;
311
+ const installed = input.installedVersion;
312
+ if (generated === installed) {
313
+ return { name: "version skew", status: "ok", detail: `@nonext/husk ${installed}.` };
314
+ }
315
+ const breaking = breakingApart(generated, installed);
316
+ return {
317
+ name: "version skew",
318
+ status: breaking ? "problem" : "ok",
319
+ detail: `Scaffolded with ${generated}, running ${installed}.`,
320
+ fix: breaking ? "Read the CHANGELOG for the versions in between. Generated files under app/ and lib/husk/ may need updating by hand, and `npx nonext-husk init` will show a diff for each of them rather than overwriting your work." : "Patch releases do not change the generated files."
321
+ };
322
+ }
323
+ function breakingApart(left, right) {
324
+ const [leftMajor = "", leftMinor = ""] = left.split(".");
325
+ const [rightMajor = "", rightMinor = ""] = right.split(".");
326
+ if (leftMajor !== rightMajor) return true;
327
+ return leftMajor === "0" && leftMinor !== rightMinor;
328
+ }
329
+ function rulesCheck(input, kind, path) {
330
+ const name = `${path}`;
331
+ if (!input.fs.exists(path)) {
332
+ return {
333
+ name,
334
+ status: "problem",
335
+ detail: `${path} is missing, so nothing is protecting this project's data.`,
336
+ fix: "Run `npx nonext-husk init` to write it, then deploy it."
337
+ };
338
+ }
339
+ const text = input.fs.read(path);
340
+ const shipped = input.templates.read(kind);
341
+ const expected = checksum(shipped);
342
+ const stamp = readStamp(text);
343
+ if (stamp === null) {
344
+ return {
345
+ name,
346
+ status: "problem",
347
+ detail: `${path} carries no Husk stamp, so it did not come from this tool and cannot be compared with the rules the installed package expects.`,
348
+ fix: `Compare it with the rules in node_modules/@nonext/husk/dist/cli/templates/${path}, or let \`npx nonext-husk init --force\` replace it.`
349
+ };
350
+ }
351
+ if (stamp.checksum !== expected) {
352
+ return {
353
+ name,
354
+ status: "problem",
355
+ detail: `${path} was generated by an older @nonext/husk (template ${stamp.checksum}, installed ${expected}). The admin assumes the newer rules.`,
356
+ fix: `Run \`npx nonext-husk init --force\` to rewrite it, then \`npx firebase deploy --only ${kind === "firestore" ? "firestore:rules" : "storage"}\`.`
357
+ };
358
+ }
359
+ const edited = text !== stampedShape(text, shipped);
360
+ return {
361
+ name,
362
+ status: "ok",
363
+ detail: edited ? `Current template (${expected}), with local edits. Husk does not check whether those edits still satisfy the admin.` : `Current template (${expected}).`
364
+ };
365
+ }
366
+ function stampedShape(existing, shipped) {
367
+ const marker = existing.indexOf("rules_version");
368
+ return marker === -1 ? shipped : existing.slice(0, marker) + shipped.trimStart();
369
+ }
370
+ function indexCoverageCheck(input) {
371
+ const name = "index coverage";
372
+ if ("error" in input.contentTypes) {
373
+ return {
374
+ name,
375
+ status: "unchecked",
376
+ detail: `The content types could not be loaded: ${input.contentTypes.error}`,
377
+ fix: "Without them there is no way to know which composite indexes the list views need."
378
+ };
379
+ }
380
+ if (!input.fs.exists("firestore.indexes.json")) {
381
+ return {
382
+ name,
383
+ status: "problem",
384
+ detail: "firestore.indexes.json is missing.",
385
+ fix: "Run `npx nonext-husk init` to write it."
386
+ };
387
+ }
388
+ let file;
389
+ try {
390
+ file = JSON.parse(input.fs.read("firestore.indexes.json"));
391
+ } catch {
392
+ return {
393
+ name,
394
+ status: "problem",
395
+ detail: "firestore.indexes.json is not valid JSON.",
396
+ fix: "Fix the file, or delete it and run `npx nonext-husk init`."
397
+ };
398
+ }
399
+ const missing = missingEntryIndexes(file, input.contentTypes);
400
+ if (missing.length === 0) {
401
+ return {
402
+ name,
403
+ status: "ok",
404
+ detail: `${String((file.indexes ?? []).length)} composite index(es) cover every query the list views generate.`
405
+ };
406
+ }
407
+ const described = missing.slice(0, 5).map(
408
+ (index) => index.fields.map((field) => `${field.fieldPath} ${field.order}`).join(", ")
409
+ ).map((line) => ` - entries: ${line}`).join("\n");
410
+ return {
411
+ name,
412
+ status: "problem",
413
+ detail: `${String(missing.length)} composite index(es) are missing from firestore.indexes.json:
414
+ ${described}${missing.length > 5 ? `
415
+ - ... and ${String(missing.length - 5)} more` : ""}`,
416
+ fix: "Add the entries above to firestore.indexes.json, then `npx firebase deploy --only firestore:indexes`."
417
+ };
418
+ }
419
+ function firebaseWiringCheck(input) {
420
+ const name = "firebase.json";
421
+ if (!input.fs.exists("firebase.json")) {
422
+ return {
423
+ name,
424
+ status: "problem",
425
+ detail: "firebase.json is missing, so there is nothing to deploy the rules or the indexes with.",
426
+ fix: "Run `npx nonext-husk init`."
427
+ };
428
+ }
429
+ let config;
430
+ try {
431
+ config = JSON.parse(input.fs.read("firebase.json"));
432
+ } catch {
433
+ return {
434
+ name,
435
+ status: "problem",
436
+ detail: "firebase.json is not valid JSON.",
437
+ fix: "Fix the file by hand."
438
+ };
439
+ }
440
+ const firestore = config["firestore"];
441
+ const storage = config["storage"];
442
+ const wantsStorage = shouldCheckStorage(input);
443
+ const missing = [
444
+ firestore?.["rules"] === "firestore.rules" ? null : "firestore.rules",
445
+ firestore?.["indexes"] === "firestore.indexes.json" ? null : "firestore.indexes.json",
446
+ !wantsStorage || storage?.["rules"] === "storage.rules" ? null : "storage.rules"
447
+ ].filter((entry) => entry !== null);
448
+ return missing.length === 0 ? { name, status: "ok", detail: "Points at the rules and the index file." } : {
449
+ name,
450
+ status: "problem",
451
+ detail: `firebase.json does not point at ${missing.join(", ")}, so a deploy would not carry ${missing.length === 1 ? "it" : "them"}.`,
452
+ fix: "Add the missing entries to firebase.json."
453
+ };
454
+ }
455
+ function environmentCheck(input) {
456
+ const name = "environment";
457
+ if ("error" in input.config) {
458
+ return {
459
+ name,
460
+ status: "unchecked",
461
+ detail: "The config could not be read, so there is no way to know whether this project targets the emulators."
462
+ };
463
+ }
464
+ if (!input.config.emulators) {
465
+ return {
466
+ name,
467
+ status: "ok",
468
+ detail: "This project does not target the Firebase emulators."
469
+ };
470
+ }
471
+ if (!input.fs.exists(".env.local")) {
472
+ return {
473
+ name,
474
+ status: "problem",
475
+ detail: ".env.local is missing, so FIREBASE_AUTH_EMULATOR_HOST is not set and every admin route will redirect to sign-in.",
476
+ fix: "Run `npx nonext-husk init` to write it."
477
+ };
478
+ }
479
+ const text = input.fs.read(".env.local");
480
+ const missing = [
481
+ "FIREBASE_AUTH_EMULATOR_HOST",
482
+ "NEXT_PUBLIC_FIREBASE_PROJECT_ID"
483
+ ].filter((key) => !new RegExp(`^\\s*${key}\\s*=\\s*\\S`, "m").test(text));
484
+ return missing.length === 0 ? { name, status: "ok", detail: ".env.local carries the emulator environment." } : {
485
+ name,
486
+ status: "problem",
487
+ detail: `.env.local is missing ${missing.join(" and ")}.`,
488
+ fix: "Add the missing lines. Without FIREBASE_AUTH_EMULATOR_HOST the server verifier trusts nothing and the admin is unreachable."
489
+ };
490
+ }
491
+ function undeployableChecks() {
492
+ return [
493
+ {
494
+ name: "rules deployment",
495
+ status: "unchecked",
496
+ detail: "Whether the rules on disk are the rules Firestore and Storage are enforcing is a property of the Firebase project, not of this directory.",
497
+ fix: "Run `npx firebase deploy --only firestore:rules,firestore:indexes,storage` and check the console."
498
+ },
499
+ {
500
+ name: "stored schema versions",
501
+ status: "unchecked",
502
+ detail: "Whether stored entries carry the schemaVersion the code declares today needs a Firestore read, credentials, and the migration runner.",
503
+ fix: "Open the Developer area in the admin, which reads it from the project you are signed in to."
504
+ }
505
+ ];
506
+ }
507
+ function createNodeFs(root) {
508
+ const absolute = (path) => resolve(root, ...path.split("/"));
509
+ return {
510
+ exists(path) {
511
+ try {
512
+ statSync(absolute(path));
513
+ return true;
514
+ } catch {
515
+ return false;
516
+ }
517
+ },
518
+ isDirectory(path) {
519
+ try {
520
+ return statSync(absolute(path)).isDirectory();
521
+ } catch {
522
+ return false;
523
+ }
524
+ },
525
+ read(path) {
526
+ return readFileSync(absolute(path), "utf8").replace(/\r\n/g, "\n");
527
+ },
528
+ list(path) {
529
+ try {
530
+ return readdirSync(absolute(path)).sort();
531
+ } catch {
532
+ return [];
533
+ }
534
+ },
535
+ write(path, contents) {
536
+ const target = absolute(path);
537
+ mkdirSync(dirname(target), { recursive: true });
538
+ writeFileSync(target, contents, "utf8");
539
+ },
540
+ move(from, to) {
541
+ const target = absolute(to);
542
+ mkdirSync(dirname(target), { recursive: true });
543
+ renameSync(absolute(from), target);
544
+ }
545
+ };
546
+ }
547
+ function importSpecifier(fromFile, toFile) {
548
+ const from = posix.dirname(fromFile);
549
+ const to = toFile.replace(/\.tsx?$/, "");
550
+ const relativePath = posix.relative(from, to);
551
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
552
+ }
553
+ function projectJoin(...parts) {
554
+ return posix.join(...parts.filter((part) => part.length > 0));
555
+ }
556
+ async function loadHuskConfig(root) {
557
+ const path = join(root, "cms.config.ts");
558
+ if (!existsSync(path)) {
559
+ const reason = "cms.config.ts is not in this directory.";
560
+ return { config: { error: reason }, contentTypes: { error: reason } };
561
+ }
562
+ let module;
563
+ try {
564
+ module = await import(`${pathToFileURL(path).href}?at=${String(Date.now())}`);
565
+ } catch (thrown) {
566
+ const reason = describe(thrown);
567
+ return { config: { error: reason }, contentTypes: { error: reason } };
568
+ }
569
+ return {
570
+ config: summarize(module.huskConfig),
571
+ contentTypes: contentTypesOf(module.codeContentTypes)
572
+ };
573
+ }
574
+ function describe(thrown) {
575
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
576
+ return message.includes("Unknown file extension") || message.includes("ERR_UNKNOWN_FILE_EXTENSION") ? `this Node cannot import a TypeScript file (${process.version}). Node 22.18 or newer erases types on import; below that, run doctor with --experimental-strip-types.` : message;
577
+ }
578
+ function summarize(value) {
579
+ if (value === null || typeof value !== "object") {
580
+ return { error: "cms.config.ts does not export `huskConfig`." };
581
+ }
582
+ const record = value;
583
+ const strings = ["generatedWith", "adminRoute", "projectId"];
584
+ const booleans = ["media", "auth", "emulators"];
585
+ for (const key of strings) {
586
+ if (typeof record[key] !== "string") {
587
+ return { error: `huskConfig.${key} is missing or is not a string.` };
588
+ }
589
+ }
590
+ for (const key of booleans) {
591
+ if (typeof record[key] !== "boolean") {
592
+ return { error: `huskConfig.${key} is missing or is not a boolean.` };
593
+ }
594
+ }
595
+ return {
596
+ generatedWith: record["generatedWith"],
597
+ adminRoute: record["adminRoute"],
598
+ projectId: record["projectId"],
599
+ media: record["media"],
600
+ auth: record["auth"],
601
+ emulators: record["emulators"]
602
+ };
603
+ }
604
+ function contentTypesOf(value) {
605
+ if (!Array.isArray(value)) {
606
+ return { error: "cms.config.ts does not export a `codeContentTypes` array." };
607
+ }
608
+ const invalid = value.filter(
609
+ (entry) => entry === null || typeof entry !== "object" || typeof entry.slug !== "string" || typeof entry.fields !== "object"
610
+ );
611
+ if (invalid.length > 0) {
612
+ return { error: "codeContentTypes holds something that is not a content type." };
613
+ }
614
+ return value;
615
+ }
616
+ function installedVersion(root, fallback) {
617
+ const manifest = join(root, "node_modules", "@nonext", "cms", "package.json");
618
+ try {
619
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
620
+ return typeof parsed.version === "string" ? parsed.version : fallback;
621
+ } catch {
622
+ return fallback;
623
+ }
624
+ }
625
+
626
+ // src/cli/options.ts
627
+ var DEFAULT_ADMIN_ROUTE = "/admin";
628
+ var RESERVED_SEGMENTS = /* @__PURE__ */ new Set(["api", "_next", "favicon.ico"]);
629
+ function normalizeAdminRoute(input) {
630
+ const trimmed = input.trim();
631
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
632
+ const withoutTrailing = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
633
+ if (withoutTrailing === "/") {
634
+ throw new ArgumentError(
635
+ 'The admin route cannot be "/". The website lives there; pick something like "/admin".'
636
+ );
637
+ }
638
+ if (!/^(\/[a-z0-9][a-z0-9-]*)+$/.test(withoutTrailing)) {
639
+ throw new ArgumentError(
640
+ `"${input}" is not a usable admin route. Use lower-case segments of letters, digits and hyphens, for example "/admin" or "/studio/cms".`
641
+ );
642
+ }
643
+ for (const segment of withoutTrailing.slice(1).split("/")) {
644
+ if (RESERVED_SEGMENTS.has(segment)) {
645
+ throw new ArgumentError(
646
+ `"${segment}" is a reserved route segment in a Next app. Pick another admin route.`
647
+ );
648
+ }
649
+ }
650
+ return withoutTrailing;
651
+ }
652
+ function validateProjectId(input) {
653
+ const trimmed = input.trim();
654
+ if (!/^[a-z][a-z0-9-]{3,29}$/.test(trimmed)) {
655
+ throw new ArgumentError(
656
+ `"${input}" is not a Firebase project id. Ids are 4 to 30 characters, start with a letter, and contain lower-case letters, digits and hyphens.`
657
+ );
658
+ }
659
+ return trimmed;
660
+ }
661
+ function validateStorageBucket(input) {
662
+ const trimmed = input.trim();
663
+ if (!/^[a-z0-9][a-z0-9._-]{2,221}$/.test(trimmed)) {
664
+ throw new ArgumentError(`"${input}" is not a Storage bucket name.`);
665
+ }
666
+ return trimmed;
667
+ }
668
+ function defaultStorageBucket(projectId) {
669
+ return `${projectId}.firebasestorage.app`;
670
+ }
671
+ function optionsFromArgs(args) {
672
+ const projectId = validateProjectId(args.projectId ?? "husk-dev-project");
673
+ return {
674
+ projectId,
675
+ storageBucket: args.storageBucket === void 0 ? defaultStorageBucket(projectId) : validateStorageBucket(args.storageBucket),
676
+ adminRoute: normalizeAdminRoute(args.adminRoute ?? DEFAULT_ADMIN_ROUTE),
677
+ media: args.media ?? true,
678
+ auth: args.auth ?? true,
679
+ emulators: args.emulators ?? true
680
+ };
681
+ }
682
+ function adminSegments(adminRoute) {
683
+ return adminRoute.slice(1).split("/");
684
+ }
685
+
686
+ // src/cli/generate/context.ts
687
+ function createContext(options, project, version) {
688
+ const huskDir = projectJoin(project.appDir, "(husk)");
689
+ const adminDir = projectJoin(huskDir, ...adminSegments(options.adminRoute));
690
+ return {
691
+ options,
692
+ project,
693
+ huskDir,
694
+ adminDir,
695
+ shellDir: projectJoin(adminDir, "(shell)"),
696
+ siteDir: projectJoin(project.appDir, "(site)"),
697
+ libDir: projectJoin(project.libDir, "husk"),
698
+ version
699
+ };
700
+ }
701
+ function specifier(fromFile, toFile) {
702
+ return importSpecifier(fromFile, toFile);
703
+ }
704
+
705
+ // src/cli/generate/admin-files.ts
706
+ function adminFiles(context) {
707
+ const { huskDir, shellDir, adminDir, project, options } = context;
708
+ const notFound = projectJoin(huskDir, "admin-not-found.tsx");
709
+ const routeError = projectJoin(huskDir, "admin-route-error.tsx");
710
+ const chrome = projectJoin(huskDir, "admin-chrome.tsx");
711
+ const provider = projectJoin(huskDir, "husk-provider.tsx");
712
+ const shell = projectJoin(huskDir, "live-admin-shell.tsx");
713
+ const session = projectJoin(context.libDir, "session.ts");
714
+ const metadata = projectJoin(context.libDir, "admin-metadata.ts");
715
+ const firebaseClient = projectJoin(context.libDir, "firebase-client.ts");
716
+ const config = "cms.config.ts";
717
+ const contentDir = projectJoin(shellDir, "content", "[type]");
718
+ const contentRoute = projectJoin(contentDir, "content-route.tsx");
719
+ const files = {
720
+ [projectJoin(huskDir, "layout.tsx")]: adminRootLayout(),
721
+ [notFound]: adminNotFound(options.adminRoute),
722
+ [routeError]: adminRouteErrorBody(options.adminRoute),
723
+ [provider]: huskProvider(context, {
724
+ config: specifier(provider, config),
725
+ firebaseClient: specifier(provider, firebaseClient)
726
+ }),
727
+ [shell]: liveAdminShell(specifier(shell, provider)),
728
+ [chrome]: adminChrome(context, {
729
+ provider: specifier(chrome, provider),
730
+ shell: specifier(chrome, shell),
731
+ accountMenu: specifier(chrome, projectJoin(shellDir, "account-menu.tsx")),
732
+ navigationGuard: specifier(chrome, projectJoin(shellDir, "navigation-guard.tsx"))
733
+ }),
734
+ [projectJoin(huskDir, "error.tsx")]: boundary(
735
+ specifier(projectJoin(huskDir, "error.tsx"), routeError),
736
+ "Admin",
737
+ "The boundary above the shell layout. A segment's own error.tsx does not catch a throw\n * in that segment's layout, and the shell layout is where the session is read."
738
+ ),
739
+ [projectJoin(huskDir, "not-found.tsx")]: huskNotFound(context, {
740
+ session: specifier(projectJoin(huskDir, "not-found.tsx"), session),
741
+ chrome: specifier(projectJoin(huskDir, "not-found.tsx"), chrome),
742
+ notFound: specifier(projectJoin(huskDir, "not-found.tsx"), notFound)
743
+ }),
744
+ [projectJoin(shellDir, "layout.tsx")]: shellLayout(context, {
745
+ session: specifier(projectJoin(shellDir, "layout.tsx"), session),
746
+ chrome: specifier(projectJoin(shellDir, "layout.tsx"), chrome)
747
+ }),
748
+ [projectJoin(shellDir, "page.tsx")]: dashboardPage(context, {
749
+ session: specifier(projectJoin(shellDir, "page.tsx"), session),
750
+ metadata: specifier(projectJoin(shellDir, "page.tsx"), metadata),
751
+ panel: specifier(
752
+ projectJoin(shellDir, "page.tsx"),
753
+ projectJoin(shellDir, "dashboard-panel.tsx")
754
+ )
755
+ }),
756
+ [projectJoin(shellDir, "dashboard-panel.tsx")]: dashboardPanel(
757
+ specifier(projectJoin(shellDir, "dashboard-panel.tsx"), provider)
758
+ ),
759
+ [projectJoin(shellDir, "account-menu.tsx")]: accountMenu(
760
+ context,
761
+ specifier(projectJoin(shellDir, "account-menu.tsx"), firebaseClient)
762
+ ),
763
+ [projectJoin(shellDir, "navigation-guard.tsx")]: navigationGuard(),
764
+ [projectJoin(shellDir, "error.tsx")]: boundary(
765
+ specifier(projectJoin(shellDir, "error.tsx"), routeError),
766
+ "Admin",
767
+ "The boundary for every route under the shell that has no closer one. It renders inside\n * the shell layout, so the sidebar survives the failure and the reader still has somewhere\n * to go."
768
+ ),
769
+ [projectJoin(shellDir, "[...missing]", "page.tsx")]: catchAll(
770
+ specifier(projectJoin(shellDir, "[...missing]", "page.tsx"), notFound)
771
+ ),
772
+ [projectJoin(contentDir, "page.tsx")]: contentListPage(context, {
773
+ session: specifier(projectJoin(contentDir, "page.tsx"), session),
774
+ metadata: specifier(projectJoin(contentDir, "page.tsx"), metadata),
775
+ route: specifier(projectJoin(contentDir, "page.tsx"), contentRoute)
776
+ }),
777
+ [contentRoute]: contentRouteFile(context, {
778
+ notFound: specifier(contentRoute, notFound),
779
+ provider: specifier(contentRoute, provider),
780
+ collection: specifier(
781
+ contentRoute,
782
+ projectJoin(contentDir, "collection-panel.tsx")
783
+ ),
784
+ entry: specifier(contentRoute, projectJoin(contentDir, "entry-panel.tsx"))
785
+ }),
786
+ [projectJoin(contentDir, "collection-panel.tsx")]: collectionPanel(
787
+ context,
788
+ specifier(projectJoin(contentDir, "collection-panel.tsx"), provider)
789
+ ),
790
+ [projectJoin(contentDir, "entry-panel.tsx")]: entryPanel(context, {
791
+ provider: specifier(projectJoin(contentDir, "entry-panel.tsx"), provider),
792
+ firebaseClient: specifier(
793
+ projectJoin(contentDir, "entry-panel.tsx"),
794
+ firebaseClient
795
+ )
796
+ }),
797
+ [projectJoin(contentDir, "error.tsx")]: boundary(
798
+ specifier(projectJoin(contentDir, "error.tsx"), routeError),
799
+ "Content",
800
+ "The boundary for one content type's list and entry screens. A throw here takes down the\n * content area and nothing else."
801
+ ),
802
+ [projectJoin(contentDir, "new", "page.tsx")]: contentCreatePage(context, {
803
+ session: specifier(projectJoin(contentDir, "new", "page.tsx"), session),
804
+ metadata: specifier(projectJoin(contentDir, "new", "page.tsx"), metadata),
805
+ route: specifier(projectJoin(contentDir, "new", "page.tsx"), contentRoute)
806
+ }),
807
+ [projectJoin(contentDir, "[entry]", "page.tsx")]: contentEditPage(context, {
808
+ session: specifier(projectJoin(contentDir, "[entry]", "page.tsx"), session),
809
+ metadata: specifier(projectJoin(contentDir, "[entry]", "page.tsx"), metadata),
810
+ route: specifier(projectJoin(contentDir, "[entry]", "page.tsx"), contentRoute)
811
+ }),
812
+ [projectJoin(shellDir, "developer", "[[...section]]", "page.tsx")]: developerPage(
813
+ context,
814
+ {
815
+ session: specifier(
816
+ projectJoin(shellDir, "developer", "[[...section]]", "page.tsx"),
817
+ session
818
+ ),
819
+ metadata: specifier(
820
+ projectJoin(shellDir, "developer", "[[...section]]", "page.tsx"),
821
+ metadata
822
+ ),
823
+ config: specifier(
824
+ projectJoin(shellDir, "developer", "[[...section]]", "page.tsx"),
825
+ config
826
+ )
827
+ }
828
+ ),
829
+ [projectJoin(shellDir, "developer", "error.tsx")]: boundary(
830
+ specifier(projectJoin(shellDir, "developer", "error.tsx"), routeError),
831
+ "Developer",
832
+ "The boundary for the Developer area, including the Schema Builder. A malformed stored\n * content type throws while it renders, and that must not take down the one route that\n * would let an admin fix it."
833
+ ),
834
+ [projectJoin(shellDir, "developer", "content-types", "page.tsx")]: contentTypesPage(
835
+ context,
836
+ {
837
+ session: specifier(
838
+ projectJoin(shellDir, "developer", "content-types", "page.tsx"),
839
+ session
840
+ ),
841
+ metadata: specifier(
842
+ projectJoin(shellDir, "developer", "content-types", "page.tsx"),
843
+ metadata
844
+ ),
845
+ panel: specifier(
846
+ projectJoin(shellDir, "developer", "content-types", "page.tsx"),
847
+ projectJoin(shellDir, "developer", "content-types", "content-types-panel.tsx")
848
+ )
849
+ }
850
+ ),
851
+ [projectJoin(shellDir, "developer", "content-types", "content-types-panel.tsx")]: contentTypesPanel(
852
+ context,
853
+ specifier(
854
+ projectJoin(shellDir, "developer", "content-types", "content-types-panel.tsx"),
855
+ provider
856
+ )
857
+ ),
858
+ [projectJoin(shellDir, "developer", "content-types", "[slug]", "page.tsx")]: contentTypePage(context, {
859
+ session: specifier(
860
+ projectJoin(shellDir, "developer", "content-types", "[slug]", "page.tsx"),
861
+ session
862
+ ),
863
+ metadata: specifier(
864
+ projectJoin(shellDir, "developer", "content-types", "[slug]", "page.tsx"),
865
+ metadata
866
+ ),
867
+ panel: specifier(
868
+ projectJoin(shellDir, "developer", "content-types", "[slug]", "page.tsx"),
869
+ projectJoin(
870
+ shellDir,
871
+ "developer",
872
+ "content-types",
873
+ "[slug]",
874
+ "content-type-panel.tsx"
875
+ )
876
+ )
877
+ }),
878
+ [projectJoin(
879
+ shellDir,
880
+ "developer",
881
+ "content-types",
882
+ "[slug]",
883
+ "content-type-panel.tsx"
884
+ )]: contentTypePanel(context, {
885
+ provider: specifier(
886
+ projectJoin(
887
+ shellDir,
888
+ "developer",
889
+ "content-types",
890
+ "[slug]",
891
+ "content-type-panel.tsx"
892
+ ),
893
+ provider
894
+ ),
895
+ notFound: specifier(
896
+ projectJoin(
897
+ shellDir,
898
+ "developer",
899
+ "content-types",
900
+ "[slug]",
901
+ "content-type-panel.tsx"
902
+ ),
903
+ notFound
904
+ )
905
+ }),
906
+ [projectJoin(project.appDir, "global-error.tsx")]: globalError()
907
+ };
908
+ if (options.media) {
909
+ const mediaDir = projectJoin(shellDir, "media");
910
+ files[projectJoin(mediaDir, "page.tsx")] = mediaPage(context, {
911
+ session: specifier(projectJoin(mediaDir, "page.tsx"), session),
912
+ metadata: specifier(projectJoin(mediaDir, "page.tsx"), metadata),
913
+ panel: specifier(
914
+ projectJoin(mediaDir, "page.tsx"),
915
+ projectJoin(mediaDir, "media-panel.tsx")
916
+ )
917
+ });
918
+ files[projectJoin(mediaDir, "media-panel.tsx")] = mediaPanel(
919
+ specifier(projectJoin(mediaDir, "media-panel.tsx"), provider)
920
+ );
921
+ files[projectJoin(mediaDir, "error.tsx")] = boundary(
922
+ specifier(projectJoin(mediaDir, "error.tsx"), routeError),
923
+ "Media",
924
+ "The boundary for the media library. Uploads and Storage reads fail in ways entry screens\n * do not, and a failed thumbnail scan must not blank the rest of the admin."
925
+ );
926
+ }
927
+ if (options.auth) {
928
+ const loginDir = projectJoin(adminDir, "login");
929
+ files[projectJoin(loginDir, "page.tsx")] = loginPage(
930
+ specifier(
931
+ projectJoin(loginDir, "page.tsx"),
932
+ projectJoin(loginDir, "login-panel.tsx")
933
+ )
934
+ );
935
+ files[projectJoin(loginDir, "login-panel.tsx")] = loginPanel(
936
+ context,
937
+ specifier(projectJoin(loginDir, "login-panel.tsx"), firebaseClient)
938
+ );
939
+ files[projectJoin(loginDir, "error.tsx")] = boundary(
940
+ specifier(projectJoin(loginDir, "error.tsx"), routeError),
941
+ "Sign in",
942
+ "The boundary for the sign-in screen. It sits outside the shell, so it has no sidebar to\n * fall back on and the link back to the admin is the only way out."
943
+ );
944
+ files[projectJoin(project.appDir, "api", "husk", "session", "route.ts")] = sessionRoute(context);
945
+ }
946
+ return files;
947
+ }
948
+ function adminRootLayout() {
949
+ return `import "@nonext/husk/admin.css"
950
+
951
+ import type { Metadata } from "next"
952
+ import { Inter } from "next/font/google"
953
+
954
+ const inter = Inter({
955
+ subsets: ["latin"],
956
+ variable: "--font-inter",
957
+ display: "swap",
958
+ })
959
+
960
+ export const metadata: Metadata = {
961
+ title: { default: "Husk", template: "%s | Husk" },
962
+ }
963
+
964
+ /**
965
+ * How this app mounts Husk: a second root layout, one stylesheet import, one font.
966
+ *
967
+ * It is a root layout, not a nested one, so the admin renders its own \`<html>\` and
968
+ * \`<body>\` and never inherits the website's stylesheet, fonts, providers or body classes,
969
+ * and nothing the admin loads can leak back out to the website. Next picks one root layout
970
+ * per request, so the two never coexist in a document. This is why your own root layout
971
+ * lives under \`(site)\`.
972
+ *
973
+ * The font is wired here rather than inside the package because \`next/font\` is a
974
+ * build-time transform in this app; the package only asks for the \`--font-inter\` variable
975
+ * its token layer reads.
976
+ */
977
+ export default function HuskRootLayout({ children }: { children: React.ReactNode }) {
978
+ return (
979
+ <html lang="en" className={inter.variable}>
980
+ <body className="font-sans antialiased">{children}</body>
981
+ </html>
982
+ )
983
+ }
984
+ `;
985
+ }
986
+ function adminNotFound(adminRoute) {
987
+ return `import { NotFoundScreen } from "@nonext/husk/admin/ui"
988
+ import Link from "next/link"
989
+
990
+ /** The one 404 body in the admin, so the catch-all route and the boundary cannot drift. */
991
+ export function AdminNotFoundScreen() {
992
+ return (
993
+ <NotFoundScreen>
994
+ <Link href="${adminRoute}" className="text-body-sm text-text-primary underline">
995
+ Back to the dashboard
996
+ </Link>
997
+ </NotFoundScreen>
998
+ )
999
+ }
1000
+ `;
1001
+ }
1002
+ function adminRouteErrorBody(adminRoute) {
1003
+ return `"use client"
1004
+
1005
+ import { RouteError } from "@nonext/husk/admin/ui"
1006
+ import Link from "next/link"
1007
+
1008
+ /**
1009
+ * The one error-boundary body in the admin, so every \`error.tsx\` apologises the same way.
1010
+ *
1011
+ * Each route segment still gets its own \`error.tsx\`. That is what decides how much of the
1012
+ * screen a failure takes down: a throw inside the media library replaces the media library
1013
+ * and leaves the sidebar and every other route reachable. One boundary at the top would
1014
+ * have replaced the whole admin with an apology.
1015
+ */
1016
+ export function AdminRouteError({
1017
+ error,
1018
+ reset,
1019
+ area,
1020
+ }: {
1021
+ error: Error & { digest?: string }
1022
+ reset: () => void
1023
+ /** Names the part of the admin that failed, so the screen says where it happened. */
1024
+ area: string
1025
+ }) {
1026
+ return (
1027
+ <RouteError error={error} reset={reset} title={area}>
1028
+ <Link href="${adminRoute}" className="text-body-sm text-text-primary underline">
1029
+ Back to the dashboard
1030
+ </Link>
1031
+ </RouteError>
1032
+ )
1033
+ }
1034
+ `;
1035
+ }
1036
+ function huskProvider(context, imports) {
1037
+ const { media } = context.options;
1038
+ return `"use client"
1039
+
1040
+ import { fieldTypes, useLiveRegistry } from "@nonext/husk/admin/ui"
1041
+ ${media ? 'import type { MediaUploader } from "@nonext/husk/admin/ui"\n' : ""}import { createRegistryStore } from "@nonext/husk/core"
1042
+ import type { CmsAdapter, Registry } from "@nonext/husk/core"
1043
+ import { createFirestoreAdapter${media ? ", createMediaUploader" : ""} } from "@nonext/husk/firebase"
1044
+ import type { AuthUser } from "@nonext/husk/types"
1045
+ import { createContext, useContext, useMemo, useState } from "react"
1046
+ import type { ReactNode } from "react"
1047
+
1048
+ import { codeContentTypes } from "${imports.config}"
1049
+ import { huskFirestore${media ? ", huskStorage" : ""} } from "${imports.firebaseClient}"
1050
+
1051
+ /**
1052
+ * One adapter, one ${media ? "uploader, one " : ""}live registry for the whole admin.
1053
+ *
1054
+ * The registry is fed by a Firestore listener over \`schemas\`, which is what puts a content
1055
+ * type created in the Schema Builder into the sidebar, with working routes, without a
1056
+ * rebuild and without a reload. Code-defined types come from cms.config.ts and always win a
1057
+ * slug conflict.
1058
+ *
1059
+ * It is a host concern rather than a package one because only a host can hold the Firebase
1060
+ * handle. The package supplies the store and the hook; this is the wiring.
1061
+ */
1062
+
1063
+ export interface HuskContextValue {
1064
+ registry: Registry
1065
+ adapter: CmsAdapter
1066
+ ${media ? " uploader: MediaUploader\n" : ""} user: AuthUser
1067
+ /** False until the first schemas snapshot has arrived. */
1068
+ synced: boolean
1069
+ /** A dead schema listener, surfaced so a stale admin does not look like a working one. */
1070
+ liveError: Error | null
1071
+ }
1072
+
1073
+ const HuskContext = createContext<HuskContextValue | null>(null)
1074
+
1075
+ export function HuskProvider({ user, children }: { user: AuthUser; children: ReactNode }) {
1076
+ // Created once per mount. The code half never changes for the lifetime of the process;
1077
+ // only the database half moves, which is exactly what the store is for.
1078
+ const [store] = useState(() => createRegistryStore(codeContentTypes))
1079
+
1080
+ const adapter = useMemo(
1081
+ () =>
1082
+ createFirestoreAdapter({
1083
+ db: huskFirestore(),
1084
+ // A getter, not a value: the adapter has to see the registry as it is now, or a
1085
+ // write against a type created a moment ago would be refused as unknown.
1086
+ registry: () => store.registry,
1087
+ currentUser: () => user,
1088
+ // The admin declares its session, so a read of a content type this account may not
1089
+ // read is refused by the adapter and not only hidden by the sidebar.
1090
+ session: () => user,
1091
+ // Without the field types the adapter falls back to the default converters and
1092
+ // each field type's own validate never runs on the write path.
1093
+ fieldTypes,
1094
+ }),
1095
+ [store, user],
1096
+ )
1097
+
1098
+ // Memoized, or the listener would be closed and reopened on every render.
1099
+ const watch = useMemo(() => adapter.schemas.watch, [adapter])
1100
+ const { registry, synced, error } = useLiveRegistry(store, watch)
1101
+ ${media ? `
1102
+ const uploader = useMemo(
1103
+ () =>
1104
+ createMediaUploader({
1105
+ storage: huskStorage(),
1106
+ media: adapter.media,
1107
+ currentUser: () => user,
1108
+ }),
1109
+ [adapter, user],
1110
+ )
1111
+ ` : ""}
1112
+ const value = useMemo(
1113
+ () => ({ registry, adapter,${media ? " uploader," : ""} user, synced, liveError: error }),
1114
+ [registry, adapter,${media ? " uploader," : ""} user, synced, error],
1115
+ )
1116
+
1117
+ return <HuskContext.Provider value={value}>{children}</HuskContext.Provider>
1118
+ }
1119
+
1120
+ /** Everything an admin screen needs. Only callable under \`HuskProvider\`. */
1121
+ export function useHusk(): HuskContextValue {
1122
+ const value = useContext(HuskContext)
1123
+ if (value === null) {
1124
+ throw new Error("useHusk was called outside HuskProvider.")
1125
+ }
1126
+ return value
1127
+ }
1128
+
1129
+ /** Names a content type for a usage list. Falls back to the slug for an unknown one. */
1130
+ export function useContentTypeLabel(): (slug: string) => string {
1131
+ const { registry } = useHusk()
1132
+ return (slug) => registry.get(slug)?.type.label ?? slug
1133
+ }
1134
+ `;
1135
+ }
1136
+ function liveAdminShell(providerImport) {
1137
+ return `"use client"
1138
+
1139
+ import { buildAdminNavigation } from "@nonext/husk/admin"
1140
+ import { AdminShell } from "@nonext/husk/admin/ui"
1141
+ import type { AuthUser } from "@nonext/husk/types"
1142
+ import { useMemo } from "react"
1143
+ import type { ReactNode } from "react"
1144
+
1145
+ import { useHusk } from "${providerImport}"
1146
+
1147
+ /**
1148
+ * The shell, with a sidebar that follows the live registry.
1149
+ *
1150
+ * The navigation is computed in the browser rather than on the server, because a
1151
+ * database-defined content type exists only in Firestore and the server has no Firebase
1152
+ * session to read \`schemas\` with. Same \`buildAdminNavigation\`, same \`can()\`, over a
1153
+ * registry that is a subscription.
1154
+ */
1155
+ export function LiveAdminShell({
1156
+ user,
1157
+ accountMenu,
1158
+ children,
1159
+ }: {
1160
+ user: AuthUser
1161
+ accountMenu: ReactNode
1162
+ children: ReactNode
1163
+ }) {
1164
+ const { registry } = useHusk()
1165
+ const navigation = useMemo(
1166
+ () => buildAdminNavigation({ registry, user }),
1167
+ [registry, user],
1168
+ )
1169
+
1170
+ return (
1171
+ <AdminShell navigation={navigation} user={user} accountMenu={accountMenu}>
1172
+ {children}
1173
+ </AdminShell>
1174
+ )
1175
+ }
1176
+ `;
1177
+ }
1178
+ function adminChrome(_context, imports) {
1179
+ return `import { ErrorCapture, ToastProvider } from "@nonext/husk/admin/ui"
1180
+ import type { AuthUser } from "@nonext/husk/types"
1181
+
1182
+ import { AccountMenu } from "${imports.accountMenu}"
1183
+ import { NavigationGuard } from "${imports.navigationGuard}"
1184
+ import { HuskProvider } from "${imports.provider}"
1185
+ import { LiveAdminShell } from "${imports.shell}"
1186
+
1187
+ /**
1188
+ * The admin chrome: the adapter, the live registry, the sidebar and the account menu around
1189
+ * a screen.
1190
+ *
1191
+ * It is a component rather than only a layout because two entries need it. The shell layout
1192
+ * wraps every admin route in it, and the not-found boundary wraps itself in it, and a
1193
+ * boundary cannot reuse a layout it did not render under.
1194
+ */
1195
+ export function AdminChrome({
1196
+ user,
1197
+ children,
1198
+ }: {
1199
+ user: AuthUser
1200
+ children: React.ReactNode
1201
+ }) {
1202
+ return (
1203
+ <HuskProvider user={user}>
1204
+ <LiveAdminShell user={user} accountMenu={<AccountMenu />}>
1205
+ {/* One toast stack for the whole admin. A screen raises a toast through useToast
1206
+ from three levels down; threading the state up is how stacks get duplicated. */}
1207
+ <ToastProvider>
1208
+ {/* What a React error boundary cannot see: a rejected promise. An adapter call
1209
+ whose catch somebody forgets would otherwise fail into a console line. */}
1210
+ <ErrorCapture />
1211
+ {/* Around every screen rather than inside one, because the navigation it has to
1212
+ intercept is the sidebar, which lives out here. */}
1213
+ <NavigationGuard>{children}</NavigationGuard>
1214
+ </ToastProvider>
1215
+ </LiveAdminShell>
1216
+ </HuskProvider>
1217
+ )
1218
+ }
1219
+ `;
1220
+ }
1221
+ function boundary(bodyImport, area, why) {
1222
+ return `"use client"
1223
+
1224
+ import { AdminRouteError } from "${bodyImport}"
1225
+
1226
+ /**
1227
+ * ${why}
1228
+ */
1229
+ export default function Boundary({
1230
+ error,
1231
+ reset,
1232
+ }: {
1233
+ error: Error & { digest?: string }
1234
+ reset: () => void
1235
+ }) {
1236
+ return <AdminRouteError error={error} reset={reset} area="${area}" />
1237
+ }
1238
+ `;
1239
+ }
1240
+ function huskNotFound(context, imports) {
1241
+ return `import type { Metadata } from "next"
1242
+
1243
+ import { adminAccess } from "${imports.session}"
1244
+ import { AdminChrome } from "${imports.chrome}"
1245
+ import { AdminNotFoundScreen } from "${imports.notFound}"
1246
+
1247
+ export const metadata: Metadata = { title: "Not found" }
1248
+
1249
+ /**
1250
+ * The boundary for a \`notFound()\` thrown anywhere under the admin.
1251
+ *
1252
+ * It draws the chrome itself rather than relying on the shell layout, because a boundary
1253
+ * does not render under a layout it did not itself enter. A visitor with no session gets
1254
+ * the bare screen: a 404 must not become a second way to find out what a project contains.
1255
+ */
1256
+ export default async function HuskNotFound() {
1257
+ const access = await adminAccess("${context.options.adminRoute}")
1258
+ if (access.outcome !== "allow") return <AdminNotFoundScreen />
1259
+
1260
+ return (
1261
+ <AdminChrome user={access.user}>
1262
+ <AdminNotFoundScreen />
1263
+ </AdminChrome>
1264
+ )
1265
+ }
1266
+ `;
1267
+ }
1268
+ function shellLayout(context, imports) {
1269
+ return `import { redirect } from "next/navigation"
1270
+
1271
+ import { adminAccess } from "${imports.session}"
1272
+ import { AdminChrome } from "${imports.chrome}"
1273
+
1274
+ /**
1275
+ * Everything behind the guard renders inside the shell. The sign-in screen sits outside
1276
+ * this route group on purpose: a sidebar on a sign-in page would offer navigation to
1277
+ * someone who has no session.
1278
+ */
1279
+ export default async function ShellLayout({ children }: { children: React.ReactNode }) {
1280
+ const access = await adminAccess("${context.options.adminRoute}")
1281
+ if (access.outcome === "sign-in") redirect(access.location)
1282
+
1283
+ return <AdminChrome user={access.user}>{children}</AdminChrome>
1284
+ }
1285
+ `;
1286
+ }
1287
+ function dashboardPage(context, imports) {
1288
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
1289
+ import type { Metadata } from "next"
1290
+ import { redirect } from "next/navigation"
1291
+
1292
+ import { adminMetadata } from "${imports.metadata}"
1293
+ import { adminAccess } from "${imports.session}"
1294
+
1295
+ import { DashboardPanel } from "${imports.panel}"
1296
+
1297
+ export function generateMetadata(): Promise<Metadata> {
1298
+ return adminMetadata("${context.options.adminRoute}")
1299
+ }
1300
+
1301
+ /**
1302
+ * The dashboard. It resolves who the visitor is and hands that to the client panel, which
1303
+ * owns the adapter. The screen itself is generated from the registry, so this file names no
1304
+ * content type and no figure.
1305
+ */
1306
+ export default async function DashboardPage() {
1307
+ const access = await adminAccess("${context.options.adminRoute}")
1308
+ if (access.outcome === "sign-in") redirect(access.location)
1309
+ if (access.outcome === "unauthorized") {
1310
+ return <UnauthorizedScreen action={access.action} role={access.user.role} />
1311
+ }
1312
+
1313
+ return <DashboardPanel user={access.user} />
1314
+ }
1315
+ `;
1316
+ }
1317
+ function dashboardPanel(providerImport) {
1318
+ return `"use client"
1319
+
1320
+ import { Dashboard } from "@nonext/husk/admin/ui"
1321
+ import { can } from "@nonext/husk/permissions"
1322
+ import type { AuthUser } from "@nonext/husk/types"
1323
+ import { useMemo } from "react"
1324
+
1325
+ import { useHusk } from "${providerImport}"
1326
+
1327
+ /**
1328
+ * The host's half of the dashboard: an adapter and the content types this account may read.
1329
+ *
1330
+ * It runs in the browser because that is where the Firebase session lives, so every read is
1331
+ * subject to the same security rules as every other read. The tiles are filtered with
1332
+ * \`can()\` rather than by hand; hiding a tile is presentation only, and the adapter and the
1333
+ * rules refuse the same reads on their own.
1334
+ */
1335
+ export function DashboardPanel({ user }: { user: AuthUser }) {
1336
+ const { adapter, registry } = useHusk()
1337
+ const types = useMemo(
1338
+ () =>
1339
+ registry.types
1340
+ .filter((entry) => can(user, "read", { type: entry.type }))
1341
+ .map((entry) => entry.type),
1342
+ [registry, user],
1343
+ )
1344
+
1345
+ return <Dashboard adapter={adapter} types={types} />
1346
+ }
1347
+ `;
1348
+ }
1349
+ function accountMenu(context, firebaseClientImport) {
1350
+ const { auth, adminRoute } = context.options;
1351
+ if (!auth) {
1352
+ return `"use client"
1353
+
1354
+ import { Menu } from "@nonext/husk/admin/ui"
1355
+
1356
+ /**
1357
+ * The sidebar footer's menu trigger.
1358
+ *
1359
+ * This app was scaffolded with --no-auth, so there is no generated sign-out. Wire this to
1360
+ * whatever ends a session in your app, and delete the Husk session cookie along with it, or
1361
+ * the server will keep treating the visitor as signed in.
1362
+ */
1363
+ export function AccountMenu() {
1364
+ return <Menu label="Account" items={[]} onSelect={() => undefined} triggerSize="sm" />
1365
+ }
1366
+ `;
1367
+ }
1368
+ return `"use client"
1369
+
1370
+ import { Menu } from "@nonext/husk/admin/ui"
1371
+ import { useRouter } from "next/navigation"
1372
+
1373
+ import { huskAuth } from "${firebaseClientImport}"
1374
+
1375
+ /**
1376
+ * The sidebar footer's menu trigger.
1377
+ *
1378
+ * The host owns it because the session is the host's: the package knows nothing about this
1379
+ * app's Firebase client or its session route. The sidebar only asks for a node.
1380
+ */
1381
+ export function AccountMenu() {
1382
+ const router = useRouter()
1383
+
1384
+ return (
1385
+ <Menu
1386
+ label="Account"
1387
+ items={[{ kind: "item", id: "sign-out", label: "Sign out" }]}
1388
+ onSelect={async (id) => {
1389
+ if (id !== "sign-out") return
1390
+
1391
+ // Cookie first: if the browser session were dropped first and this failed, the
1392
+ // server would keep treating the visitor as signed in.
1393
+ await fetch("/api/husk/session", { method: "DELETE" })
1394
+ await huskAuth().signOut()
1395
+ router.replace("${adminRoute}/login")
1396
+ router.refresh()
1397
+ }}
1398
+ triggerSize="sm"
1399
+ />
1400
+ )
1401
+ }
1402
+ `;
1403
+ }
1404
+ function navigationGuard() {
1405
+ return `"use client"
1406
+
1407
+ import { UnsavedChangesProvider } from "@nonext/husk/admin/ui"
1408
+ import { useRouter } from "next/navigation"
1409
+
1410
+ /**
1411
+ * The host half of the unsaved-changes guard.
1412
+ *
1413
+ * The package intercepts the click and asks the question; it cannot know how this app
1414
+ * navigates. Handing it \`router.push\` is what lets a confirmed "discard" resume the
1415
+ * intercepted link as a client navigation instead of a full document load.
1416
+ */
1417
+ export function NavigationGuard({ children }: { children: React.ReactNode }) {
1418
+ const router = useRouter()
1419
+
1420
+ return (
1421
+ <UnsavedChangesProvider navigate={(href) => router.push(href)}>
1422
+ {children}
1423
+ </UnsavedChangesProvider>
1424
+ )
1425
+ }
1426
+ `;
1427
+ }
1428
+ function catchAll(notFoundImport) {
1429
+ return `import type { Metadata } from "next"
1430
+
1431
+ import { AdminNotFoundScreen } from "${notFoundImport}"
1432
+
1433
+ export const metadata: Metadata = { title: "Not found" }
1434
+
1435
+ /**
1436
+ * Every unmatched path under the admin, answered inside the shell.
1437
+ *
1438
+ * It renders the 404 itself instead of calling \`notFound()\`. Next serves a thrown
1439
+ * not-found as the framework's own error document, so the nested boundary only appears
1440
+ * after hydration, behind a flash of a bare page with no way back.
1441
+ *
1442
+ * The cost is the status code: this route answers 200 where 404 would be truthful. The
1443
+ * admin sits behind a session and is not crawled, and an editor who mistypes a URL is
1444
+ * better served by the sidebar than by a correct header nobody sees.
1445
+ */
1446
+ export default function AdminCatchAll() {
1447
+ return <AdminNotFoundScreen />
1448
+ }
1449
+ `;
1450
+ }
1451
+ function contentListPage(context, imports) {
1452
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
1453
+ import type { Metadata } from "next"
1454
+ import { redirect } from "next/navigation"
1455
+
1456
+ import { adminMetadata } from "${imports.metadata}"
1457
+ import { adminAccess } from "${imports.session}"
1458
+
1459
+ import { ContentRoute } from "${imports.route}"
1460
+
1461
+ type ContentParams = { params: Promise<{ type: string }> }
1462
+
1463
+ export async function generateMetadata({ params }: ContentParams): Promise<Metadata> {
1464
+ const { type } = await params
1465
+ return adminMetadata(\`${context.options.adminRoute}/content/\${type}\`)
1466
+ }
1467
+
1468
+ /**
1469
+ * One route for every content type, which is the whole point of a generated admin.
1470
+ *
1471
+ * The route checks the session; which screen it is comes from the live registry in the
1472
+ * browser, because a database-defined content type exists only in Firestore and the server
1473
+ * has no session to read it with. A singleton is not a list, so the same route is its
1474
+ * editor instead. Nothing here is per content type.
1475
+ */
1476
+ export default async function ContentTypePage({ params }: ContentParams) {
1477
+ const { type: slug } = await params
1478
+ const access = await adminAccess(\`${context.options.adminRoute}/content/\${slug}\`)
1479
+ if (access.outcome === "sign-in") redirect(access.location)
1480
+ if (access.outcome === "unauthorized") {
1481
+ return <UnauthorizedScreen action={access.action} role={access.user.role} />
1482
+ }
1483
+
1484
+ return <ContentRoute user={access.user} slug={slug} />
1485
+ }
1486
+ `;
1487
+ }
1488
+ function contentCreatePage(context, imports) {
1489
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
1490
+ import type { Metadata } from "next"
1491
+ import { redirect } from "next/navigation"
1492
+
1493
+ import { adminMetadata } from "${imports.metadata}"
1494
+ import { adminAccess } from "${imports.session}"
1495
+
1496
+ import { ContentRoute } from "${imports.route}"
1497
+
1498
+ type ContentParams = { params: Promise<{ type: string }> }
1499
+
1500
+ export async function generateMetadata({ params }: ContentParams): Promise<Metadata> {
1501
+ const { type } = await params
1502
+ return adminMetadata(\`${context.options.adminRoute}/content/\${type}\`)
1503
+ }
1504
+
1505
+ /**
1506
+ * The create screen, for every collection at once.
1507
+ *
1508
+ * A singleton has no create route, because it always exists exactly once. The permission to
1509
+ * create is checked here as well as by the adapter and by the security rules.
1510
+ */
1511
+ export default async function NewEntryPage({ params }: ContentParams) {
1512
+ const { type: slug } = await params
1513
+ const access = await adminAccess(\`${context.options.adminRoute}/content/\${slug}/new\`)
1514
+ if (access.outcome === "sign-in") redirect(access.location)
1515
+ if (access.outcome === "unauthorized") {
1516
+ return <UnauthorizedScreen action={access.action} role={access.user.role} />
1517
+ }
1518
+
1519
+ return <ContentRoute user={access.user} slug={slug} creating />
1520
+ }
1521
+ `;
1522
+ }
1523
+ function contentEditPage(context, imports) {
1524
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
1525
+ import type { Metadata } from "next"
1526
+ import { redirect } from "next/navigation"
1527
+
1528
+ import { adminMetadata } from "${imports.metadata}"
1529
+ import { adminAccess } from "${imports.session}"
1530
+
1531
+ import { ContentRoute } from "${imports.route}"
1532
+
1533
+ type EntryParams = { params: Promise<{ type: string; entry: string }> }
1534
+
1535
+ export async function generateMetadata({ params }: EntryParams): Promise<Metadata> {
1536
+ const { type } = await params
1537
+ return adminMetadata(\`${context.options.adminRoute}/content/\${type}\`)
1538
+ }
1539
+
1540
+ /**
1541
+ * The edit screen, for every collection at once.
1542
+ *
1543
+ * Whether this user may change this entry depends on who created it, which only the entry
1544
+ * can answer, so the screen reads it and renders read-only rather than refusing the route.
1545
+ */
1546
+ export default async function EditEntryPage({ params }: EntryParams) {
1547
+ const { type: slug, entry: entryId } = await params
1548
+ const access = await adminAccess(
1549
+ \`${context.options.adminRoute}/content/\${slug}/\${entryId}\`,
1550
+ )
1551
+ if (access.outcome === "sign-in") redirect(access.location)
1552
+ if (access.outcome === "unauthorized") {
1553
+ return <UnauthorizedScreen action={access.action} role={access.user.role} />
1554
+ }
1555
+
1556
+ return <ContentRoute user={access.user} slug={slug} entryId={entryId} />
1557
+ }
1558
+ `;
1559
+ }
1560
+ function contentRouteFile(_context, imports) {
1561
+ return `"use client"
1562
+
1563
+ import { Skeleton, UnauthorizedScreen } from "@nonext/husk/admin/ui"
1564
+ import { can } from "@nonext/husk/permissions"
1565
+ import type { AuthUser } from "@nonext/husk/types"
1566
+
1567
+ import { AdminNotFoundScreen } from "${imports.notFound}"
1568
+ import { useHusk } from "${imports.provider}"
1569
+
1570
+ import { CollectionPanel } from "${imports.collection}"
1571
+ import { EntryPanel } from "${imports.entry}"
1572
+
1573
+ /**
1574
+ * Which content screen this route is, decided in the browser.
1575
+ *
1576
+ * The server has no Firebase session, cannot read \`schemas\`, and would answer 404 for a
1577
+ * content type that exists but was created in the Schema Builder. The session lives in the
1578
+ * browser, so the resolution lives there too, over the live registry.
1579
+ *
1580
+ * \`synced\` is why an unknown slug does not immediately 404: until the first schemas
1581
+ * snapshot lands, "not in the registry" and "not read yet" are the same observation, and
1582
+ * only one of them is a missing page.
1583
+ */
1584
+ export function ContentRoute({
1585
+ user,
1586
+ slug,
1587
+ entryId,
1588
+ creating = false,
1589
+ }: {
1590
+ user: AuthUser
1591
+ slug: string
1592
+ entryId?: string
1593
+ creating?: boolean
1594
+ }) {
1595
+ const { registry, synced } = useHusk()
1596
+ const entry = registry.get(slug)
1597
+
1598
+ if (entry === undefined) {
1599
+ return synced ? (
1600
+ <AdminNotFoundScreen />
1601
+ ) : (
1602
+ <Skeleton line="heading-md" className="w-64" label="Reading the content types" />
1603
+ )
1604
+ }
1605
+
1606
+ const { type } = entry
1607
+
1608
+ // A singleton has one entry at a derived id, so it has no list, no create route and no
1609
+ // id in its URL.
1610
+ if ((creating || entryId !== undefined) && type.kind !== "collection") {
1611
+ return <AdminNotFoundScreen />
1612
+ }
1613
+
1614
+ // The adapter and the security rules refuse the write too; this only keeps the screen
1615
+ // from opening a form nobody may submit.
1616
+ if (creating && !can(user, "create", { type })) {
1617
+ return <UnauthorizedScreen action="create" role={user.role} />
1618
+ }
1619
+
1620
+ if (creating || entryId !== undefined || type.kind === "singleton") {
1621
+ return (
1622
+ <EntryPanel
1623
+ user={user}
1624
+ slug={slug}
1625
+ {...(entryId === undefined ? {} : { entryId })}
1626
+ />
1627
+ )
1628
+ }
1629
+
1630
+ return <CollectionPanel user={user} slug={slug} />
1631
+ }
1632
+ `;
1633
+ }
1634
+ function collectionPanel(context, providerImport) {
1635
+ return `"use client"
1636
+
1637
+ import { CollectionList, DeleteEntriesDialog } from "@nonext/husk/admin/ui"
1638
+ import type { AuthUser } from "@nonext/husk/types"
1639
+ import { useRouter } from "next/navigation"
1640
+ import { useState } from "react"
1641
+
1642
+ import { useHusk } from "${providerImport}"
1643
+
1644
+ /**
1645
+ * The host's half of a collection list: an adapter, the content type, and where a row leads.
1646
+ *
1647
+ * It reads in the browser through the signed-in Firebase session, so every query is subject
1648
+ * to the same security rules as any other read. The screen is the package's, and it is the
1649
+ * same screen for every content type.
1650
+ */
1651
+ export function CollectionPanel({ user, slug }: { user: AuthUser; slug: string }) {
1652
+ const router = useRouter()
1653
+ /** The ids the confirmation dialog is pointed at. Empty means it is closed. */
1654
+ const [deleting, setDeleting] = useState<readonly string[]>([])
1655
+ /** Bumped after a delete, so the list re-reads the page it is on. */
1656
+ const [refreshToken, setRefreshToken] = useState(0)
1657
+
1658
+ const { adapter, registry } = useHusk()
1659
+ const type = registry.require(slug).type
1660
+
1661
+ return (
1662
+ <>
1663
+ <CollectionList
1664
+ adapter={adapter}
1665
+ type={type}
1666
+ user={user}
1667
+ refreshToken={refreshToken}
1668
+ onOpen={(entryId) => router.push(\`${context.options.adminRoute}/content/\${slug}/\${entryId}\`)}
1669
+ onCreate={() => router.push(\`${context.options.adminRoute}/content/\${slug}/new\`)}
1670
+ onDelete={(entryIds) => setDeleting(entryIds)}
1671
+ />
1672
+
1673
+ <DeleteEntriesDialog
1674
+ adapter={adapter}
1675
+ type={type}
1676
+ role={user.role}
1677
+ entryIds={deleting}
1678
+ onCancel={() => setDeleting([])}
1679
+ onDeleted={(_entryIds, complete) => {
1680
+ // A batch that failed halfway keeps the dialog open to say so, and the list still
1681
+ // re-reads, because the rows that did go are gone.
1682
+ if (complete) setDeleting([])
1683
+ setRefreshToken((token) => token + 1)
1684
+ }}
1685
+ />
1686
+ </>
1687
+ )
1688
+ }
1689
+ `;
1690
+ }
1691
+ function entryPanel(context, imports) {
1692
+ const { media, adminRoute } = context.options;
1693
+ const uiImports = media ? `import {
1694
+ AdminMediaPickerProvider,
1695
+ DeleteEntriesDialog,
1696
+ EntryEditor,
1697
+ } from "@nonext/husk/admin/ui"` : `import { DeleteEntriesDialog, EntryEditor } from "@nonext/husk/admin/ui"`;
1698
+ const huskImport = media ? `import { useContentTypeLabel, useHusk } from "${imports.provider}"` : `import { useHusk } from "${imports.provider}"`;
1699
+ const uploaderLine = media ? ` const { adapter, uploader, registry } = useHusk()
1700
+ const labelOf = useContentTypeLabel()` : ` const { adapter, registry } = useHusk()`;
1701
+ const open = media ? ` // The real media picker, over the same adapter the editor writes through. Without it
1702
+ // the image and file controls render their "library not available" state.
1703
+ <AdminMediaPickerProvider
1704
+ adapter={adapter}
1705
+ user={user}
1706
+ uploader={uploader}
1707
+ contentTypeLabel={labelOf}
1708
+ >` : ` <>`;
1709
+ const close = media ? ` </AdminMediaPickerProvider>` : ` </>`;
1710
+ return `"use client"
1711
+
1712
+ ${uiImports}
1713
+ import { isSingleton } from "@nonext/husk/core"
1714
+ import { readUser } from "@nonext/husk/firebase"
1715
+ import type { AuthUser } from "@nonext/husk/types"
1716
+ import { useRouter } from "next/navigation"
1717
+ import { useMemo, useState } from "react"
1718
+
1719
+ import { huskFirestore } from "${imports.firebaseClient}"
1720
+ ${huskImport}
1721
+
1722
+ /**
1723
+ * The host's half of the entry editor: an adapter, the content type, and the routes.
1724
+ *
1725
+ * The screen is the package's and it is the same screen for every content type, for both
1726
+ * create and edit, and for a collection entry and a singleton. This file supplies what only
1727
+ * a host can know: where "back" goes, where a freshly created entry lives, who can turn a
1728
+ * uid into a name, and where the reader lands once an entry is gone.
1729
+ *
1730
+ * A singleton differs in configuration only. It has no list to cancel back to, no id in its
1731
+ * URL and no delete, so those props are simply not passed.
1732
+ */
1733
+ export function EntryPanel({
1734
+ user,
1735
+ slug,
1736
+ entryId,
1737
+ }: {
1738
+ user: AuthUser
1739
+ slug: string
1740
+ entryId?: string
1741
+ }) {
1742
+ const router = useRouter()
1743
+ /** The entry the confirmation dialog is pointed at. Empty means it is closed. */
1744
+ const [deleting, setDeleting] = useState<readonly string[]>([])
1745
+
1746
+ ${uploaderLine}
1747
+ const type = registry.require(slug).type
1748
+ const getType = useMemo(
1749
+ () => (candidate: string) => registry.get(candidate)?.type,
1750
+ [registry],
1751
+ )
1752
+
1753
+ const list = \`${adminRoute}/content/\${slug}\`
1754
+ const single = isSingleton(type)
1755
+
1756
+ return (
1757
+ ${open}
1758
+ <EntryEditor
1759
+ adapter={adapter}
1760
+ type={type}
1761
+ user={user}
1762
+ getType={getType}
1763
+ {...(entryId === undefined ? {} : { entryId })}
1764
+ {...(single ? {} : { onCancel: () => router.push(list) })}
1765
+ {...(single ? {} : { onDelete: (id: string) => setDeleting([id]) })}
1766
+ onSaved={(entry, created) => {
1767
+ // A created entry stops being a create screen the moment it exists, and the URL
1768
+ // has to say so, or a reload would start a second one. A singleton is already at
1769
+ // its own URL, because the derived id never appears in a route.
1770
+ if (created && !single) router.replace(\`\${list}/\${entry.id}\`)
1771
+ }}
1772
+ resolveAuthor={async (uid) => {
1773
+ const stored = await readUser(huskFirestore(), uid)
1774
+ return stored === null
1775
+ ? null
1776
+ : { uid, displayName: stored.displayName, email: stored.email }
1777
+ }}
1778
+ />
1779
+
1780
+ <DeleteEntriesDialog
1781
+ adapter={adapter}
1782
+ type={type}
1783
+ role={user.role}
1784
+ entryIds={deleting}
1785
+ onCancel={() => setDeleting([])}
1786
+ onDeleted={(_entryIds, complete) => {
1787
+ // Back to the list. Staying on the editor would leave the reader looking at a
1788
+ // form for a document that no longer exists.
1789
+ if (!complete) return
1790
+ setDeleting([])
1791
+ router.push(list)
1792
+ }}
1793
+ />
1794
+ ${close}
1795
+ )
1796
+ }
1797
+ `;
1798
+ }
1799
+ function mediaPage(context, imports) {
1800
+ return `import type { Metadata } from "next"
1801
+ import { redirect } from "next/navigation"
1802
+
1803
+ import { adminMetadata } from "${imports.metadata}"
1804
+ import { adminAccess } from "${imports.session}"
1805
+
1806
+ import { MediaPanel } from "${imports.panel}"
1807
+
1808
+ export async function generateMetadata(): Promise<Metadata> {
1809
+ return adminMetadata("${context.options.adminRoute}/media")
1810
+ }
1811
+
1812
+ /**
1813
+ * The media library.
1814
+ *
1815
+ * Every signed-in role reaches it. What each of them may then do differs, and it differs in
1816
+ * the library itself rather than here: an author may upload and may replace their own
1817
+ * files, and only an editor may delete. The rules enforce the same split a second time.
1818
+ */
1819
+ export default async function MediaPage() {
1820
+ const access = await adminAccess("${context.options.adminRoute}/media")
1821
+ if (access.outcome === "sign-in") redirect(access.location)
1822
+
1823
+ return <MediaPanel user={access.user} />
1824
+ }
1825
+ `;
1826
+ }
1827
+ function mediaPanel(providerImport) {
1828
+ return `"use client"
1829
+
1830
+ import { MediaLibrary } from "@nonext/husk/admin/ui"
1831
+ import type { AuthUser } from "@nonext/husk/types"
1832
+
1833
+ import { useContentTypeLabel, useHusk } from "${providerImport}"
1834
+
1835
+ /**
1836
+ * The host's half of the media library: an adapter, an uploader, and a way to name a
1837
+ * content type in a usage list. The screen is the package's, and it is the same screen the
1838
+ * media picker renders inside its modal.
1839
+ */
1840
+ export function MediaPanel({ user }: { user: AuthUser }) {
1841
+ const { adapter, uploader } = useHusk()
1842
+ const contentTypeLabel = useContentTypeLabel()
1843
+
1844
+ return (
1845
+ <MediaLibrary
1846
+ adapter={adapter}
1847
+ user={user}
1848
+ uploader={uploader}
1849
+ contentTypeLabel={contentTypeLabel}
1850
+ />
1851
+ )
1852
+ }
1853
+ `;
1854
+ }
1855
+ function developerPage(context, imports) {
1856
+ const route = context.options.adminRoute;
1857
+ return `import { adminPageTitle, buildAdminNavigation } from "@nonext/husk/admin"
1858
+ import { PageHeader, UnauthorizedScreen } from "@nonext/husk/admin/ui"
1859
+ import { buildRegistry } from "@nonext/husk/core"
1860
+ import type { Metadata } from "next"
1861
+ import Link from "next/link"
1862
+ import { redirect } from "next/navigation"
1863
+
1864
+ import { adminMetadata } from "${imports.metadata}"
1865
+ import { codeContentTypes } from "${imports.config}"
1866
+ import { adminAccess } from "${imports.session}"
1867
+
1868
+ type DeveloperParams = { params: Promise<{ section?: string[] }> }
1869
+
1870
+ /** Derived from the navigation, so each Developer screen titles its own tab. */
1871
+ export async function generateMetadata({ params }: DeveloperParams): Promise<Metadata> {
1872
+ const { section } = await params
1873
+ return adminMetadata(developerPath(section))
1874
+ }
1875
+
1876
+ function developerPath(section: string[] | undefined): string {
1877
+ return ["${route}/developer", ...(section ?? [])].join("/")
1878
+ }
1879
+
1880
+ /**
1881
+ * The Developer area boundary: an editor or author who navigates straight here gets the
1882
+ * unauthorized screen rather than a redirect loop. The adapter and the security rules
1883
+ * refuse the same actions independently.
1884
+ *
1885
+ * One optional catch-all stands in for the Developer screens that have no page of their
1886
+ * own, so every item the sidebar generates leads somewhere. Content Types has its own
1887
+ * route and takes precedence, because a static segment beats a catch-all.
1888
+ */
1889
+ export default async function DeveloperPage({ params }: DeveloperParams) {
1890
+ const { section } = await params
1891
+ const access = await adminAccess(developerPath(section), "manage-schema")
1892
+ if (access.outcome === "sign-in") redirect(access.location)
1893
+
1894
+ if (access.outcome === "unauthorized") {
1895
+ return (
1896
+ <UnauthorizedScreen action={access.action} role={access.user.role}>
1897
+ <Link href="${route}" className="text-body-sm text-text-primary underline">
1898
+ Back to the admin
1899
+ </Link>
1900
+ </UnauthorizedScreen>
1901
+ )
1902
+ }
1903
+
1904
+ // The heading and the trail come from the same navigation data as the sidebar and the
1905
+ // document title, so a Developer screen never names itself in three places.
1906
+ const navigation = buildAdminNavigation({
1907
+ registry: buildRegistry(codeContentTypes, []),
1908
+ user: access.user,
1909
+ })
1910
+ const label = adminPageTitle(navigation, developerPath(section))
1911
+
1912
+ return (
1913
+ <PageHeader
1914
+ title={label ?? "Developer"}
1915
+ {...(label === undefined
1916
+ ? {}
1917
+ : {
1918
+ breadcrumb: [
1919
+ { label: "Developer", href: "${route}/developer" },
1920
+ { label },
1921
+ ],
1922
+ })}
1923
+ />
1924
+ )
1925
+ }
1926
+ `;
1927
+ }
1928
+ function contentTypesPage(context, imports) {
1929
+ const route = context.options.adminRoute;
1930
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
1931
+ import type { Metadata } from "next"
1932
+ import Link from "next/link"
1933
+ import { redirect } from "next/navigation"
1934
+
1935
+ import { adminMetadata } from "${imports.metadata}"
1936
+ import { adminAccess } from "${imports.session}"
1937
+
1938
+ import { ContentTypesPanel } from "${imports.panel}"
1939
+
1940
+ const PATH = "${route}/developer/content-types"
1941
+
1942
+ export function generateMetadata(): Promise<Metadata> {
1943
+ return adminMetadata(PATH)
1944
+ }
1945
+
1946
+ /**
1947
+ * The Schema Builder's Content Types list.
1948
+ *
1949
+ * The route guards \`manage-schema\`, which is admin only. Hiding the Developer group in the
1950
+ * sidebar is presentation; this is the check that stops an editor who types the URL, and
1951
+ * the adapter and the security rules refuse the same actions again below it.
1952
+ */
1953
+ export default async function ContentTypesPage() {
1954
+ const access = await adminAccess(PATH, "manage-schema")
1955
+ if (access.outcome === "sign-in") redirect(access.location)
1956
+
1957
+ if (access.outcome === "unauthorized") {
1958
+ return (
1959
+ <UnauthorizedScreen action={access.action} role={access.user.role}>
1960
+ <Link href="${route}" className="text-body-sm text-text-primary underline">
1961
+ Back to the admin
1962
+ </Link>
1963
+ </UnauthorizedScreen>
1964
+ )
1965
+ }
1966
+
1967
+ return <ContentTypesPanel user={access.user} />
1968
+ }
1969
+ `;
1970
+ }
1971
+ function contentTypesPanel(context, providerImport) {
1972
+ const route = context.options.adminRoute;
1973
+ return `"use client"
1974
+
1975
+ import { ContentTypesScreen } from "@nonext/husk/admin/ui"
1976
+ import type { AuthUser } from "@nonext/husk/types"
1977
+ import { useRouter } from "next/navigation"
1978
+
1979
+ import { useHusk } from "${providerImport}"
1980
+
1981
+ /**
1982
+ * The host's half of the Content Types list: the live registry, the adapter, and where a
1983
+ * row leads. A type created here reaches the sidebar through the same registry this screen
1984
+ * renders from, so there is nothing to refresh and nothing to invalidate.
1985
+ */
1986
+ export function ContentTypesPanel({ user }: { user: AuthUser }) {
1987
+ const router = useRouter()
1988
+ const { registry, adapter, liveError } = useHusk()
1989
+
1990
+ return (
1991
+ <ContentTypesScreen
1992
+ registry={registry}
1993
+ adapter={adapter}
1994
+ user={user}
1995
+ liveError={liveError}
1996
+ onOpen={(slug) => router.push(\`${route}/developer/content-types/\${slug}\`)}
1997
+ onCreated={(slug) => router.push(\`${route}/developer/content-types/\${slug}\`)}
1998
+ />
1999
+ )
2000
+ }
2001
+ `;
2002
+ }
2003
+ function contentTypePage(context, imports) {
2004
+ const route = context.options.adminRoute;
2005
+ return `import { UnauthorizedScreen } from "@nonext/husk/admin/ui"
2006
+ import type { Metadata } from "next"
2007
+ import Link from "next/link"
2008
+ import { redirect } from "next/navigation"
2009
+
2010
+ import { adminMetadata } from "${imports.metadata}"
2011
+ import { adminAccess } from "${imports.session}"
2012
+
2013
+ import { ContentTypePanel } from "${imports.panel}"
2014
+
2015
+ type TypeParams = { params: Promise<{ slug: string }> }
2016
+
2017
+ export function generateMetadata(): Promise<Metadata> {
2018
+ return adminMetadata("${route}/developer/content-types")
2019
+ }
2020
+
2021
+ /**
2022
+ * One content type in the Schema Builder.
2023
+ *
2024
+ * The slug is resolved in the browser rather than here, for the same reason the content
2025
+ * routes are: a database-defined type lives in Firestore and the server has no session to
2026
+ * read it with.
2027
+ */
2028
+ export default async function ContentTypePage({ params }: TypeParams) {
2029
+ const { slug } = await params
2030
+ const access = await adminAccess(
2031
+ \`${route}/developer/content-types/\${slug}\`,
2032
+ "manage-schema",
2033
+ )
2034
+ if (access.outcome === "sign-in") redirect(access.location)
2035
+
2036
+ if (access.outcome === "unauthorized") {
2037
+ return (
2038
+ <UnauthorizedScreen action={access.action} role={access.user.role}>
2039
+ <Link href="${route}" className="text-body-sm text-text-primary underline">
2040
+ Back to the admin
2041
+ </Link>
2042
+ </UnauthorizedScreen>
2043
+ )
2044
+ }
2045
+
2046
+ return <ContentTypePanel user={access.user} slug={slug} />
2047
+ }
2048
+ `;
2049
+ }
2050
+ function contentTypePanel(context, imports) {
2051
+ return `"use client"
2052
+
2053
+ import { ContentTypeEditor, Skeleton } from "@nonext/husk/admin/ui"
2054
+ import type { AuthUser } from "@nonext/husk/types"
2055
+
2056
+ import { AdminNotFoundScreen } from "${imports.notFound}"
2057
+ import { useHusk } from "${imports.provider}"
2058
+
2059
+ /**
2060
+ * The host's half of the Schema Builder editor.
2061
+ *
2062
+ * Whether the type may be edited is not decided here. \`ContentTypeEditor\` asks
2063
+ * \`isSchemaReadOnly\`, which answers for a locked type and for every code-defined one, and
2064
+ * the adapter refuses the write independently of both.
2065
+ */
2066
+ export function ContentTypePanel({ user, slug }: { user: AuthUser; slug: string }) {
2067
+ const { registry, adapter, synced } = useHusk()
2068
+ const entry = registry.get(slug)
2069
+
2070
+ if (entry === undefined) {
2071
+ // Until the first schemas snapshot lands, "unknown" and "not read yet" look the same.
2072
+ return synced ? (
2073
+ <AdminNotFoundScreen />
2074
+ ) : (
2075
+ <Skeleton line="heading-md" className="w-64" label="Reading the content types" />
2076
+ )
2077
+ }
2078
+
2079
+ return (
2080
+ <ContentTypeEditor
2081
+ adapter={adapter}
2082
+ registry={registry}
2083
+ entry={entry}
2084
+ user={user}
2085
+ backHref="${context.options.adminRoute}/developer/content-types"
2086
+ />
2087
+ )
2088
+ }
2089
+ `;
2090
+ }
2091
+ function loginPage(panelImport) {
2092
+ return `import type { Metadata } from "next"
2093
+
2094
+ import { LoginPanel } from "${panelImport}"
2095
+
2096
+ export const metadata: Metadata = { title: "Sign in" }
2097
+
2098
+ export default async function LoginPage({
2099
+ searchParams,
2100
+ }: {
2101
+ searchParams: Promise<{ next?: string; reason?: string }>
2102
+ }) {
2103
+ const { next, reason } = await searchParams
2104
+
2105
+ return <LoginPanel next={next ?? null} expired={reason === "expired"} />
2106
+ }
2107
+ `;
2108
+ }
2109
+ function loginPanel(_context, firebaseClientImport) {
2110
+ return `"use client"
2111
+
2112
+ import { safeReturnPath } from "@nonext/husk/admin"
2113
+ import { LoginScreen } from "@nonext/husk/admin/ui"
2114
+ import { useRouter } from "next/navigation"
2115
+
2116
+ import { currentIdToken, huskAuth } from "${firebaseClientImport}"
2117
+
2118
+ export function LoginPanel({ next, expired }: { next: string | null; expired: boolean }) {
2119
+ const router = useRouter()
2120
+ const destination = safeReturnPath(next)
2121
+
2122
+ return (
2123
+ <LoginScreen
2124
+ expired={expired}
2125
+ onSignIn={async ({ email, password }) => {
2126
+ await huskAuth().signIn(email, password)
2127
+
2128
+ // The Firebase session alone is invisible to the server, so the cookie is what
2129
+ // actually ends the sign-in. A failure here has to surface as a failed sign-in.
2130
+ const response = await fetch("/api/husk/session", {
2131
+ method: "POST",
2132
+ headers: { "Content-Type": "application/json" },
2133
+ body: JSON.stringify({ idToken: await currentIdToken() }),
2134
+ })
2135
+ if (!response.ok) throw new Error("Could not start a server session.")
2136
+
2137
+ router.replace(destination)
2138
+ router.refresh()
2139
+ }}
2140
+ />
2141
+ )
2142
+ }
2143
+ `;
2144
+ }
2145
+ function sessionRoute(context) {
2146
+ const { emulators, projectId } = context.options;
2147
+ const verifierBody = emulators ? ` try {
2148
+ return createEmulatorTokenVerifier({ projectId: PROJECT_ID })
2149
+ } catch {
2150
+ return createUnavailableTokenVerifier()
2151
+ }` : ` void PROJECT_ID
2152
+ return createUnavailableTokenVerifier()`;
2153
+ return `import {
2154
+ ${emulators ? "createEmulatorTokenVerifier,\n " : ""}createSessionRoutes,
2155
+ createUnavailableTokenVerifier,
2156
+ } from "@nonext/husk/admin"
2157
+ import type { TokenVerifier } from "@nonext/husk/admin"
2158
+
2159
+ /**
2160
+ * The browser signs in with the Firebase SDK, then posts the fresh ID token here so the
2161
+ * server has something to read on the next navigation. Sign-out deletes the cookie.
2162
+ */
2163
+ const PROJECT_ID = process.env["NEXT_PUBLIC_FIREBASE_PROJECT_ID"] ?? "${projectId}"
2164
+
2165
+ function buildVerifier(): TokenVerifier {
2166
+ ${verifierBody}
2167
+ }
2168
+
2169
+ // ${emulators ? "Development runs on plain-HTTP localhost, which is the only case where the cookie may\n// be non-Secure. Flip this to true, or drop the option, before deploying." : "The cookie is Secure, so it is only sent over HTTPS."}
2170
+ const routes = createSessionRoutes({
2171
+ verifier: buildVerifier(),
2172
+ secure: ${emulators ? "false" : "true"},
2173
+ })
2174
+
2175
+ export const POST = routes.POST
2176
+ export const DELETE = routes.DELETE
2177
+ `;
2178
+ }
2179
+ function globalError() {
2180
+ return `"use client"
2181
+
2182
+ import "@nonext/husk/admin.css"
2183
+
2184
+ import { RouteError } from "@nonext/husk/admin/ui"
2185
+
2186
+ /**
2187
+ * The global fallback: the one failure a route boundary cannot catch.
2188
+ *
2189
+ * A segment's \`error.tsx\` renders inside its layout, which means it cannot catch a throw in
2190
+ * the root layout itself, and this app has two root layouts. When one of them fails there is
2191
+ * no chrome, no font and no stylesheet unless this file brings them, so it renders its own
2192
+ * \`<html>\` and \`<body>\` and imports the admin stylesheet.
2193
+ *
2194
+ * Importing that stylesheet here is not a leak into the public site. Next scopes a CSS
2195
+ * import to the segment that makes it, and this segment only ever renders in place of the
2196
+ * entire document, after the document that was meant to render has already failed.
2197
+ */
2198
+ export default function GlobalError({
2199
+ error,
2200
+ reset,
2201
+ }: {
2202
+ error: Error & { digest?: string }
2203
+ reset: () => void
2204
+ }) {
2205
+ return (
2206
+ <html lang="en">
2207
+ <body className="min-h-dvh bg-surface-base font-sans text-body text-text-secondary antialiased">
2208
+ <main className="mx-auto max-w-reading px-6 py-12">
2209
+ <RouteError error={error} reset={reset} title="Husk" />
2210
+ </main>
2211
+ </body>
2212
+ </html>
2213
+ )
2214
+ }
2215
+ `;
2216
+ }
2217
+
2218
+ // src/cli/generate/lib-files.ts
2219
+ function libFiles(context) {
2220
+ const { libDir } = context;
2221
+ const session = projectJoin(libDir, "session.ts");
2222
+ const cmsServer = projectJoin(libDir, "cms-server.ts");
2223
+ const metadata = projectJoin(libDir, "admin-metadata.ts");
2224
+ const config = "cms.config.ts";
2225
+ return {
2226
+ [projectJoin(libDir, "firebase-client.ts")]: firebaseClientFile(context),
2227
+ [session]: sessionFile(context),
2228
+ [cmsServer]: cmsServerFile(context, specifier(cmsServer, config)),
2229
+ [metadata]: adminMetadataFile(
2230
+ specifier(metadata, config),
2231
+ specifier(metadata, session)
2232
+ )
2233
+ };
2234
+ }
2235
+ function firebaseClientFile(context) {
2236
+ const { emulators, media, projectId, storageBucket } = context.options;
2237
+ const storageImport = media ? `import { ${emulators ? "connectStorageEmulator, " : ""}getStorage } from "firebase/storage"
2238
+ import type { FirebaseStorage } from "firebase/storage"
2239
+ ` : "";
2240
+ const storageAccessor = media ? `
2241
+ let cachedStorage: FirebaseStorage | undefined
2242
+
2243
+ /** The one Storage instance in the browser. Media uploads go through it. */
2244
+ export function huskStorage(): FirebaseStorage {
2245
+ if (cachedStorage !== undefined) return cachedStorage
2246
+
2247
+ const storage = getStorage(huskApp())${emulators ? `
2248
+ const [host, port] = STORAGE_HOST.split(":")
2249
+ connectStorageEmulator(storage, host ?? "127.0.0.1", Number(port ?? "9199"))` : ""}
2250
+
2251
+ cachedStorage = storage
2252
+ return storage
2253
+ }
2254
+ ` : "";
2255
+ const hosts = emulators ? `const AUTH_HOST = process.env["NEXT_PUBLIC_AUTH_EMULATOR_HOST"] ?? "127.0.0.1:9099"
2256
+ const FIRESTORE_HOST =
2257
+ process.env["NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST"] ?? "127.0.0.1:8085"
2258
+ ${media ? 'const STORAGE_HOST = process.env["NEXT_PUBLIC_STORAGE_EMULATOR_HOST"] ?? "127.0.0.1:9199"\n' : ""}` : "";
2259
+ const claimSync = emulators ? ` // The emulator claim writer. A real project needs a privileged one instead: a Cloud
2260
+ // Function or an Admin SDK service that re-checks settings/bootstrap and users/{uid}
2261
+ // itself rather than trusting a role sent from a browser.
2262
+ claimSync: createEmulatorClaimSync({ authHost: AUTH_HOST, projectId: PROJECT_ID }),` : ` // Supply the privileged claim writer for this project. Without one, sign-in cannot
2263
+ // mint the role claim firestore.rules reads, and every read is denied.
2264
+ claimSync: createUnavailableClaimSync(),`;
2265
+ const authImport = emulators ? "createEmulatorClaimSync, createHuskAuth" : "createHuskAuth, createUnavailableClaimSync";
2266
+ return `"use client"
2267
+
2268
+ import { ${authImport} } from "@nonext/husk/auth"
2269
+ import type { HuskAuth } from "@nonext/husk/auth"
2270
+ import { getApp, getApps, initializeApp } from "firebase/app"
2271
+ import type { FirebaseApp } from "firebase/app"
2272
+ import { ${emulators ? "connectAuthEmulator, " : ""}getAuth } from "firebase/auth"
2273
+ import { ${emulators ? "connectFirestoreEmulator, " : ""}getFirestore } from "firebase/firestore"
2274
+ import type { Firestore } from "firebase/firestore"
2275
+ ${storageImport}
2276
+ /**
2277
+ * This app's browser-side Firebase handles, and the Husk auth layer over them.
2278
+ *
2279
+ * Every instance is cached at module scope. The admin, the auth layer and the media
2280
+ * uploader need the same Firestore instance, and the emulator connectors may only run
2281
+ * before the client is used and only once.
2282
+ ${emulators ? ` *
2283
+ * Emulator wiring. Drop the connect calls, and the emulator variables in .env.local, when
2284
+ * this app points at a real Firebase project.` : ""}
2285
+ */
2286
+ const PROJECT_ID = process.env["NEXT_PUBLIC_FIREBASE_PROJECT_ID"] ?? "${projectId}"
2287
+ const API_KEY =
2288
+ process.env["NEXT_PUBLIC_FIREBASE_API_KEY"] ?? "emulator-placeholder-key"
2289
+ const STORAGE_BUCKET =
2290
+ process.env["NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET"] ?? "${storageBucket}"
2291
+ ${hosts}
2292
+ let cachedAuth: HuskAuth | undefined
2293
+ let cachedDb: Firestore | undefined
2294
+
2295
+ function huskApp(): FirebaseApp {
2296
+ return getApps().length > 0
2297
+ ? getApp()
2298
+ : initializeApp({
2299
+ projectId: PROJECT_ID,
2300
+ apiKey: API_KEY,
2301
+ storageBucket: STORAGE_BUCKET,
2302
+ })
2303
+ }
2304
+
2305
+ /** The one Firestore instance in the browser. */
2306
+ export function huskFirestore(): Firestore {
2307
+ if (cachedDb !== undefined) return cachedDb
2308
+
2309
+ const db = getFirestore(huskApp())${emulators ? `
2310
+ const [host, port] = FIRESTORE_HOST.split(":")
2311
+ connectFirestoreEmulator(db, host ?? "127.0.0.1", Number(port ?? "8085"))` : ""}
2312
+
2313
+ cachedDb = db
2314
+ return db
2315
+ }
2316
+ ${storageAccessor}
2317
+ /** Sign-in, sign-up, roles and claim sync, over the handles above. */
2318
+ export function huskAuth(): HuskAuth {
2319
+ if (cachedAuth !== undefined) return cachedAuth
2320
+
2321
+ const auth = getAuth(huskApp())${emulators ? `
2322
+ connectAuthEmulator(auth, \`http://\${AUTH_HOST}\`, { disableWarnings: true })` : ""}
2323
+
2324
+ cachedAuth = createHuskAuth({
2325
+ auth,
2326
+ db: huskFirestore(),
2327
+ ${claimSync}
2328
+ })
2329
+
2330
+ return cachedAuth
2331
+ }
2332
+
2333
+ /** The raw ID token. This is what the server session cookie carries. */
2334
+ export async function currentIdToken(): Promise<string> {
2335
+ const auth = getAuth(getApp())
2336
+ const user = auth.currentUser
2337
+ if (user === null) throw new Error("Signed in without a Firebase session.")
2338
+ return user.getIdToken(true)
2339
+ }
2340
+ `;
2341
+ }
2342
+ function sessionFile(context) {
2343
+ const { emulators, projectId } = context.options;
2344
+ const verifierBody = emulators ? ` try {
2345
+ return createEmulatorTokenVerifier({ projectId: PROJECT_ID })
2346
+ } catch {
2347
+ return createUnavailableTokenVerifier()
2348
+ }` : ` // Replace this with a verifier that checks the token signature against Google's
2349
+ // public keys before this app leaves development. Until then every admin route
2350
+ // redirects to the sign-in screen, which is the fail-closed half of the same seam.
2351
+ void PROJECT_ID
2352
+ return createUnavailableTokenVerifier()`;
2353
+ const explanation = emulators ? "The emulator one checks issuer, audience, subject, expiry and the role claim, and\n * cannot check a signature, because emulator tokens are unsigned. It refuses to be\n * constructed unless FIREBASE_AUTH_EMULATOR_HOST points at loopback." : "No verifier is wired up for this project yet.";
2354
+ return `import {
2355
+ ${emulators ? "createEmulatorTokenVerifier,\n " : ""}createUnavailableTokenVerifier,
2356
+ readSession,
2357
+ resolveAdminAccess,
2358
+ SESSION_COOKIE,
2359
+ } from "@nonext/husk/admin"
2360
+ import type { AdminAccess, TokenVerifier } from "@nonext/husk/admin"
2361
+ import type { Action } from "@nonext/husk/types"
2362
+ import { cookies } from "next/headers"
2363
+
2364
+ /**
2365
+ * How this app answers "who is the visitor" on the server.
2366
+ *
2367
+ * The verifier is a seam. ${explanation}
2368
+ * The fallback trusts nothing, so a misconfigured deployment locks everyone out instead of
2369
+ * letting everyone in.
2370
+ *
2371
+ * Read the result as a presentation guard. What protects data is \`can()\` inside the
2372
+ * adapter and firestore.rules, both of which run against a real Firebase session that a
2373
+ * forged cookie does not produce.
2374
+ */
2375
+ const PROJECT_ID = process.env["NEXT_PUBLIC_FIREBASE_PROJECT_ID"] ?? "${projectId}"
2376
+
2377
+ function buildVerifier(): TokenVerifier {
2378
+ ${verifierBody}
2379
+ }
2380
+
2381
+ const verifier = buildVerifier()
2382
+
2383
+ export async function adminAccess(
2384
+ pathname: string,
2385
+ action?: Action,
2386
+ ): Promise<AdminAccess> {
2387
+ const store = await cookies()
2388
+ const session = await readSession(store, verifier, SESSION_COOKIE)
2389
+
2390
+ return resolveAdminAccess({
2391
+ session,
2392
+ pathname,
2393
+ ...(action === undefined ? {} : { action }),
2394
+ })
2395
+ }
2396
+ `;
2397
+ }
2398
+ function cmsServerFile(context, configImport) {
2399
+ const { emulators, projectId } = context.options;
2400
+ return `import { buildRegistry } from "@nonext/husk/core"
2401
+ import { createFirestoreAdapter } from "@nonext/husk/firebase"
2402
+ import { createServerCMS } from "@nonext/husk/server"
2403
+ import type { ServerCms } from "@nonext/husk/server"
2404
+ import type { AuthUser } from "@nonext/husk/types"
2405
+ import { initializeApp } from "firebase/app"
2406
+ import type { FirebaseApp } from "firebase/app"
2407
+ import { ${emulators ? "connectFirestoreEmulator, " : ""}getFirestore } from "firebase/firestore"
2408
+ import type { Firestore } from "firebase/firestore"
2409
+
2410
+ import { codeContentTypes, siteContentTypes } from "${configImport}"
2411
+ import type { SiteContentTypes } from "${configImport}"
2412
+
2413
+ /**
2414
+ * The public website's reader, and the only file in your own code that knows Husk stores
2415
+ * content in Firebase.
2416
+ *
2417
+ * Everything above it is written against \`cms.collection()\` and \`cms.single()\`. That is
2418
+ * the trade the adapter boundary exists to make: a change to the internal Firestore layout
2419
+ * costs a new version of the package, not a rewrite of your website.
2420
+ *
2421
+ * Three things are deliberate. A visitor has no session, so the connection is
2422
+ * unauthenticated and \`currentUser\` throws; nothing on the read path calls it. There is no
2423
+ * preview, so a draft cannot leak through a forwarded query or a URL parameter. And the
2424
+ * Firebase app is a named second one, so it never shares an instance with the admin's.
2425
+ */
2426
+ const PROJECT_ID = process.env["NEXT_PUBLIC_FIREBASE_PROJECT_ID"] ?? "${projectId}"
2427
+ ${emulators ? `const FIRESTORE_HOST =
2428
+ process.env["NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST"] ?? "127.0.0.1:8085"
2429
+ ` : ""}const SITE_APP = "husk-site-reader"
2430
+
2431
+ let cachedDb: Firestore | undefined
2432
+ let cachedCms: ServerCms<SiteContentTypes> | undefined
2433
+
2434
+ /**
2435
+ * Code-defined types only. The admin also merges the database half from a live Firestore
2436
+ * listener, which a website neither needs nor should pay for: a page renders the content
2437
+ * types its own code names, and a type created in the Schema Builder has no page until
2438
+ * somebody writes one.
2439
+ */
2440
+ const registry = buildRegistry(codeContentTypes, [])
2441
+
2442
+ function refuseUser(): AuthUser {
2443
+ throw new Error(
2444
+ "A public page asked Husk who the visitor is. Reads on this path are anonymous and published-only.",
2445
+ )
2446
+ }
2447
+
2448
+ function siteFirestore(): Firestore {
2449
+ if (cachedDb !== undefined) return cachedDb
2450
+
2451
+ const app: FirebaseApp = initializeApp({ projectId: PROJECT_ID }, SITE_APP)
2452
+ const db = getFirestore(app)${emulators ? `
2453
+
2454
+ const [host, port] = FIRESTORE_HOST.split(":")
2455
+ connectFirestoreEmulator(db, host ?? "127.0.0.1", Number(port ?? "8085"))` : ""}
2456
+
2457
+ cachedDb = db
2458
+ return db
2459
+ }
2460
+
2461
+ /** The reader every public page uses. One instance per server process. */
2462
+ export function siteCms(): ServerCms<SiteContentTypes> {
2463
+ if (typeof window !== "undefined") {
2464
+ throw new Error(
2465
+ "The site reader was built in a browser. It belongs to server components; a client component reads through an API route or props.",
2466
+ )
2467
+ }
2468
+
2469
+ if (cachedCms !== undefined) return cachedCms
2470
+
2471
+ cachedCms = createServerCMS({
2472
+ adapter: createFirestoreAdapter({
2473
+ db: siteFirestore(),
2474
+ registry: () => registry,
2475
+ currentUser: refuseUser,
2476
+ }),
2477
+ contentTypes: siteContentTypes,
2478
+ })
2479
+
2480
+ return cachedCms
2481
+ }
2482
+ `;
2483
+ }
2484
+ function adminMetadataFile(configImport, sessionImport) {
2485
+ return `import { adminPageTitle, buildAdminNavigation } from "@nonext/husk/admin"
2486
+ import { buildRegistry } from "@nonext/husk/core"
2487
+ import type { Metadata } from "next"
2488
+
2489
+ import { codeContentTypes } from "${configImport}"
2490
+ import { adminAccess } from "${sessionImport}"
2491
+
2492
+ /**
2493
+ * The document title for an admin route, taken from the navigation the registry generated.
2494
+ *
2495
+ * No route writes its own title string, so the sidebar and the browser tab read the same
2496
+ * data and cannot drift. An unknown route, or a visitor with no session, falls back to the
2497
+ * root layout's default: a 404 must not leak the label of a page the visitor may not see.
2498
+ */
2499
+ export async function adminMetadata(pathname: string): Promise<Metadata> {
2500
+ const access = await adminAccess(pathname)
2501
+ if (access.outcome !== "allow") return {}
2502
+
2503
+ const registry = buildRegistry(codeContentTypes, [])
2504
+ const navigation = buildAdminNavigation({ registry, user: access.user })
2505
+ const title = adminPageTitle(navigation, pathname)
2506
+
2507
+ return title === undefined ? {} : { title }
2508
+ }
2509
+ `;
2510
+ }
2511
+
2512
+ // src/cli/generate/starter.ts
2513
+ function starterContentTypes(media) {
2514
+ const pages = {
2515
+ slug: "pages",
2516
+ kind: "collection",
2517
+ label: "Page",
2518
+ labelPlural: "Pages",
2519
+ icon: "file-text",
2520
+ titleField: "title",
2521
+ defaultSort: { field: "title", direction: "asc" },
2522
+ fields: {
2523
+ title: {
2524
+ type: "text",
2525
+ label: "Title",
2526
+ validation: { required: true, maxLength: 120 }
2527
+ },
2528
+ slug: {
2529
+ type: "slug",
2530
+ label: "Slug",
2531
+ from: "title",
2532
+ validation: { required: true, unique: true }
2533
+ },
2534
+ section: {
2535
+ type: "select",
2536
+ label: "Section",
2537
+ options: [
2538
+ { value: "main", label: "Main" },
2539
+ { value: "legal", label: "Legal" }
2540
+ ],
2541
+ validation: { required: true }
2542
+ },
2543
+ summary: { type: "textarea", label: "Summary", rows: 3 },
2544
+ body: { type: "richtext", label: "Body" },
2545
+ ...media ? {
2546
+ cover: {
2547
+ type: "image",
2548
+ label: "Cover image",
2549
+ accept: ["image/*"],
2550
+ placeholder: "No cover chosen"
2551
+ }
2552
+ } : {}
2553
+ }
2554
+ };
2555
+ const siteSettings = {
2556
+ slug: "site-settings",
2557
+ kind: "singleton",
2558
+ label: "Site settings",
2559
+ icon: "settings",
2560
+ fields: {
2561
+ siteName: { type: "text", label: "Site name", validation: { required: true } },
2562
+ tagline: { type: "text", label: "Tagline" }
2563
+ }
2564
+ };
2565
+ return [pages, siteSettings];
2566
+ }
2567
+ function printValue(value, indent = 0) {
2568
+ const pad = " ".repeat(indent);
2569
+ const inner = " ".repeat(indent + 1);
2570
+ if (value === null) return "null";
2571
+ if (typeof value === "string") return JSON.stringify(value);
2572
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2573
+ if (Array.isArray(value)) {
2574
+ if (value.length === 0) return "[]";
2575
+ const items = value.map((item) => `${inner}${printValue(item, indent + 1)},`);
2576
+ return `[
2577
+ ${items.join("\n")}
2578
+ ${pad}]`;
2579
+ }
2580
+ if (typeof value === "object") {
2581
+ const entries = Object.entries(value).filter(
2582
+ ([, item]) => item !== void 0
2583
+ );
2584
+ if (entries.length === 0) return "{}";
2585
+ const lines2 = entries.map(
2586
+ ([key, item]) => `${inner}${printKey(key)}: ${printValue(item, indent + 1)},`
2587
+ );
2588
+ return `{
2589
+ ${lines2.join("\n")}
2590
+ ${pad}}`;
2591
+ }
2592
+ throw new Error(`Cannot print a ${typeof value} into generated TypeScript.`);
2593
+ }
2594
+ function printKey(key) {
2595
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
2596
+ }
2597
+ function exportName(slug) {
2598
+ const camel = slug.replace(/[-_](.)/g, (_match, letter) => letter.toUpperCase());
2599
+ return /^[a-z]/.test(camel) ? camel : `type${camel}`;
2600
+ }
2601
+
2602
+ // src/cli/generate/project-files.ts
2603
+ function ownedProjectFiles(context) {
2604
+ const files = { "cms.config.ts": cmsConfigFile(context) };
2605
+ if (context.options.auth && context.options.emulators) {
2606
+ files["scripts/husk-first-admin.mjs"] = firstAdminScript(context);
2607
+ }
2608
+ return files;
2609
+ }
2610
+ function firstAdminScript(context) {
2611
+ return `/**
2612
+ * Creates the first Husk account in the Auth emulator and makes it the admin.
2613
+ *
2614
+ * node scripts/husk-first-admin.mjs you@example.com a-password
2615
+ *
2616
+ * The first account in an empty project claims settings/bootstrap and becomes admin; every
2617
+ * account after it is created by that admin. The marker can be written exactly once, so
2618
+ * running this twice does not mint a second admin.
2619
+ */
2620
+ import { createEmulatorClaimSync, createHuskAuth } from "@nonext/husk/auth"
2621
+ import { initializeApp } from "firebase/app"
2622
+ import { connectAuthEmulator, getAuth } from "firebase/auth"
2623
+ import { connectFirestoreEmulator, getFirestore } from "firebase/firestore"
2624
+
2625
+ const PROJECT_ID = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? "${context.options.projectId}"
2626
+ const AUTH_HOST = process.env.NEXT_PUBLIC_AUTH_EMULATOR_HOST ?? "127.0.0.1:9099"
2627
+ const FIRESTORE_HOST = process.env.NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST ?? "127.0.0.1:8085"
2628
+
2629
+ const [email, password] = process.argv.slice(2)
2630
+ if (!email || !password) {
2631
+ console.error("Usage: node scripts/husk-first-admin.mjs <email> <password>")
2632
+ process.exit(2)
2633
+ }
2634
+
2635
+ const app = initializeApp({ projectId: PROJECT_ID, apiKey: "emulator-placeholder-key" })
2636
+
2637
+ const auth = getAuth(app)
2638
+ connectAuthEmulator(auth, \`http://\${AUTH_HOST}\`, { disableWarnings: true })
2639
+
2640
+ const db = getFirestore(app)
2641
+ const [host, port] = FIRESTORE_HOST.split(":")
2642
+ connectFirestoreEmulator(db, host, Number(port))
2643
+
2644
+ const husk = createHuskAuth({
2645
+ auth,
2646
+ db,
2647
+ claimSync: createEmulatorClaimSync({ authHost: AUTH_HOST, projectId: PROJECT_ID }),
2648
+ })
2649
+
2650
+ const user = await husk.signUp({ email, password, displayName: email })
2651
+ console.log(\`Created \${user.email} with role \${user.role}.\`)
2652
+ process.exit(0)
2653
+ `;
2654
+ }
2655
+ function createOnlyProjectFiles(context) {
2656
+ const files = {
2657
+ "firebase.json": firebaseJson(context),
2658
+ ".firebaserc": firebaserc(context),
2659
+ "firestore.indexes.json": indexesJson(context),
2660
+ ".env.local": envLocal(context)
2661
+ };
2662
+ if (!context.project.hasPostcssConfig) {
2663
+ files["postcss.config.mjs"] = postcssConfig();
2664
+ }
2665
+ return files;
2666
+ }
2667
+ function cmsConfigFile(context) {
2668
+ const types = starterContentTypes(context.options.media);
2669
+ const definitions = types.map(
2670
+ (type) => `export const ${exportName(type.slug)} = defineContentType(${printValue(type)})`
2671
+ ).join("\n\n");
2672
+ const list = types.map((type) => exportName(type.slug)).join(", ");
2673
+ const map = types.map((type) => {
2674
+ const name = exportName(type.slug);
2675
+ return type.slug === name ? ` ${name},` : ` ${JSON.stringify(type.slug)}: ${name},`;
2676
+ }).join("\n");
2677
+ return `import { defineContentType } from "@nonext/husk"
2678
+ import type { HuskConfig } from "@nonext/husk/types"
2679
+
2680
+ /**
2681
+ * Husk configuration and this project's code-defined content types.
2682
+ *
2683
+ * A type declared here lives in git, is reviewed like any other code, and always wins a
2684
+ * slug conflict against one created in the admin Schema Builder. Add \`locked: true\` to a
2685
+ * type whose structure your page components depend on and the Schema Builder will refuse to
2686
+ * touch it, at the domain layer and in firestore.rules, not only in the interface.
2687
+ *
2688
+ * After changing a content type here, regenerate the composite indexes that its list view
2689
+ * needs and check the installation:
2690
+ *
2691
+ * npx nonext-husk doctor
2692
+ */
2693
+
2694
+ ${definitions}
2695
+
2696
+ /** Every code-defined type, as the registry and the admin sidebar read them. */
2697
+ export const codeContentTypes = [${list}]
2698
+
2699
+ /**
2700
+ * The same types keyed by slug, which is what carries inference into a read.
2701
+ *
2702
+ * \`cms.collection("${types[0]?.slug ?? "pages"}").findBySlug(slug)\` types its fields because the key is what
2703
+ * the lookup is indexed by. The array above is what the registry merges; this map is what
2704
+ * the SDK reads.
2705
+ */
2706
+ export const siteContentTypes = {
2707
+ ${map}
2708
+ } as const
2709
+
2710
+ export type SiteContentTypes = typeof siteContentTypes
2711
+
2712
+ /** Read by \`nonext-husk doctor\`. Everything else here is read by your own code. */
2713
+ export const huskConfig: HuskConfig = {
2714
+ generatedWith: ${JSON.stringify(context.version)},
2715
+ adminRoute: ${JSON.stringify(context.options.adminRoute)},
2716
+ projectId: ${JSON.stringify(context.options.projectId)},
2717
+ storageBucket: ${JSON.stringify(context.options.storageBucket)},
2718
+ media: ${String(context.options.media)},
2719
+ auth: ${String(context.options.auth)},
2720
+ emulators: ${String(context.options.emulators)},
2721
+ }
2722
+ `;
2723
+ }
2724
+ function firebaseJson(context) {
2725
+ const storage = context.options.media ? `,
2726
+ "storage": {
2727
+ "rules": "storage.rules"
2728
+ }` : "";
2729
+ const emulators = context.options.emulators ? `,
2730
+ "emulators": {
2731
+ "auth": { "port": 9099 },
2732
+ "firestore": { "port": 8085 },${context.options.media ? `
2733
+ "storage": { "port": 9199 },` : ""}
2734
+ "ui": { "enabled": true, "port": 4000 },
2735
+ "hub": { "port": 4400 },
2736
+ "singleProjectMode": true
2737
+ }` : "";
2738
+ return `{
2739
+ "firestore": {
2740
+ "rules": "firestore.rules",
2741
+ "indexes": "firestore.indexes.json"
2742
+ }${storage}${emulators}
2743
+ }
2744
+ `;
2745
+ }
2746
+ function firebaserc(context) {
2747
+ return `{
2748
+ "projects": {
2749
+ "default": "${context.options.projectId}"
2750
+ }
2751
+ }
2752
+ `;
2753
+ }
2754
+ function indexesJson(context) {
2755
+ const indexes = requiredEntryIndexes(starterContentTypes(context.options.media));
2756
+ return `${JSON.stringify({ indexes, fieldOverrides: [] }, null, 2)}
2757
+ `;
2758
+ }
2759
+ function envLocal(context) {
2760
+ const { projectId, storageBucket, emulators, media } = context.options;
2761
+ const emulatorBlock = emulators ? `
2762
+ # The Firebase Emulator Suite. Delete this block when this app points at a real project.
2763
+ #
2764
+ # FIREBASE_AUTH_EMULATOR_HOST is read on the server. Without it the token verifier falls
2765
+ # back to the one that trusts nothing and every admin route redirects to sign-in.
2766
+ FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
2767
+ NEXT_PUBLIC_AUTH_EMULATOR_HOST=127.0.0.1:9099
2768
+ NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST=127.0.0.1:8085${media ? `
2769
+ NEXT_PUBLIC_STORAGE_EMULATOR_HOST=127.0.0.1:9199` : ""}
2770
+ ` : "";
2771
+ return `# Husk. Generated by nonext-husk init.
2772
+ #
2773
+ # Not committed: .env.local is in the Next.js .gitignore. A web API key is not a secret,
2774
+ # but the emulator hosts below are local to this machine.
2775
+
2776
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID=${projectId}
2777
+ NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${storageBucket}
2778
+ NEXT_PUBLIC_FIREBASE_API_KEY=${emulators ? "emulator-placeholder-key" : ""}
2779
+ ${emulatorBlock}`;
2780
+ }
2781
+ function postcssConfig() {
2782
+ return `const config = {
2783
+ plugins: {
2784
+ "@tailwindcss/postcss": {},
2785
+ },
2786
+ }
2787
+
2788
+ export default config
2789
+ `;
2790
+ }
2791
+
2792
+ // src/cli/plan.ts
2793
+ function buildPlan(fs, project, options, version, templates) {
2794
+ const context = createContext(options, project, version);
2795
+ const owned = {
2796
+ ...ownedProjectFiles(context),
2797
+ ...libFiles(context),
2798
+ ...adminFiles(context),
2799
+ "firestore.rules": stampRules("firestore", templates.read("firestore")),
2800
+ ...options.media ? { "storage.rules": stampRules("storage", templates.read("storage")) } : {}
2801
+ };
2802
+ const files = [
2803
+ ...Object.entries(owned).map(([path, contents]) => ({
2804
+ path,
2805
+ contents,
2806
+ kind: "owned"
2807
+ })),
2808
+ ...Object.entries(createOnlyProjectFiles(context)).map(([path, contents]) => ({
2809
+ path,
2810
+ contents,
2811
+ kind: "create-only"
2812
+ }))
2813
+ ].sort((left, right) => left.path.localeCompare(right.path));
2814
+ return { files, moves: planSiteMove(fs, project) };
2815
+ }
2816
+ function planSiteMove(fs, project) {
2817
+ return project.siteFilesToMove.map((name) => {
2818
+ const from = projectJoin(project.appDir, name);
2819
+ return {
2820
+ from,
2821
+ to: projectJoin(project.appDir, "(site)", name),
2822
+ contents: deepenRelativeImports(fs.read(from))
2823
+ };
2824
+ });
2825
+ }
2826
+ var SPECIFIER = /(\bfrom\s*|\bimport\s*|\brequire\(\s*)(["'])(\.{1,2}\/[^"']*)\2/g;
2827
+ function deepenRelativeImports(source) {
2828
+ return source.replace(
2829
+ SPECIFIER,
2830
+ (_match, lead, quote, path) => {
2831
+ const deepened = path.startsWith("./") ? `../${path.slice(2)}` : `../${path}`;
2832
+ return `${lead}${quote}${deepened}${quote}`;
2833
+ }
2834
+ );
2835
+ }
2836
+ function classifyPlan(fs, plan) {
2837
+ const create = [];
2838
+ const unchanged = [];
2839
+ const kept = [];
2840
+ const conflicts = [];
2841
+ for (const file of plan.files) {
2842
+ if (!fs.exists(file.path)) {
2843
+ create.push(file);
2844
+ continue;
2845
+ }
2846
+ if (file.kind === "create-only") {
2847
+ kept.push(file);
2848
+ continue;
2849
+ }
2850
+ const existing = fs.read(file.path);
2851
+ if (existing === file.contents) unchanged.push(file);
2852
+ else conflicts.push({ path: file.path, existing, generated: file.contents });
2853
+ }
2854
+ return {
2855
+ create,
2856
+ unchanged,
2857
+ kept,
2858
+ conflicts,
2859
+ moves: plan.moves,
2860
+ moveConflicts: plan.moves.filter((move) => fs.exists(move.to)).map((move) => move.to)
2861
+ };
2862
+ }
2863
+ function applyPlan(fs, outcome, force = false) {
2864
+ for (const move of outcome.moves) {
2865
+ fs.move(move.from, move.to);
2866
+ fs.write(move.to, move.contents);
2867
+ }
2868
+ for (const file of outcome.create) {
2869
+ fs.write(file.path, file.contents);
2870
+ }
2871
+ if (force) {
2872
+ for (const conflict of outcome.conflicts) {
2873
+ fs.write(conflict.path, conflict.generated);
2874
+ }
2875
+ }
2876
+ }
2877
+
2878
+ // src/cli/project.ts
2879
+ var ROUTE_FILES = /* @__PURE__ */ new Set([
2880
+ "page.tsx",
2881
+ "page.ts",
2882
+ "page.jsx",
2883
+ "page.js",
2884
+ "route.tsx",
2885
+ "route.ts",
2886
+ "route.jsx",
2887
+ "route.js",
2888
+ "default.tsx",
2889
+ "default.ts"
2890
+ ]);
2891
+ var ROOT_SEGMENT_FILES = [
2892
+ "layout.tsx",
2893
+ "page.tsx",
2894
+ "loading.tsx",
2895
+ "error.tsx",
2896
+ "not-found.tsx",
2897
+ "template.tsx",
2898
+ "default.tsx"
2899
+ ];
2900
+ var ProjectError = class extends Error {
2901
+ name = "ProjectError";
2902
+ };
2903
+ function readManifest(fs) {
2904
+ if (!fs.exists("package.json")) {
2905
+ throw new ProjectError(
2906
+ "No package.json here. Run `nonext-husk init` inside a Next.js project, or pass --cwd."
2907
+ );
2908
+ }
2909
+ try {
2910
+ return JSON.parse(fs.read("package.json"));
2911
+ } catch {
2912
+ throw new ProjectError("package.json is not valid JSON.");
2913
+ }
2914
+ }
2915
+ function dependencyNames(manifest) {
2916
+ return ["dependencies", "devDependencies", "peerDependencies"].flatMap((key) => {
2917
+ const block = manifest[key];
2918
+ return block === null || typeof block !== "object" ? [] : Object.keys(block);
2919
+ });
2920
+ }
2921
+ function routeSegments(fs, appDir) {
2922
+ const found = [];
2923
+ const walk = (relativeDir) => {
2924
+ const dir = projectJoin(appDir, relativeDir);
2925
+ let routable = false;
2926
+ for (const name of fs.list(dir)) {
2927
+ const path = projectJoin(dir, name);
2928
+ if (fs.isDirectory(path)) {
2929
+ if (walk(projectJoin(relativeDir, name))) routable = true;
2930
+ } else if (ROUTE_FILES.has(name)) {
2931
+ routable = true;
2932
+ }
2933
+ }
2934
+ return routable;
2935
+ };
2936
+ for (const name of fs.list(appDir)) {
2937
+ if (!fs.isDirectory(projectJoin(appDir, name))) continue;
2938
+ if (walk(name)) found.push(name);
2939
+ }
2940
+ return found;
2941
+ }
2942
+ function inspectProject(fs) {
2943
+ const manifest = readManifest(fs);
2944
+ const dependencies = dependencyNames(manifest);
2945
+ if (!dependencies.includes("next")) {
2946
+ throw new ProjectError(
2947
+ "This project does not depend on next. Husk mounts inside a Next.js App Router app; create one with `npx create-next-app@latest` first."
2948
+ );
2949
+ }
2950
+ if (!dependencies.includes("@nonext/husk")) {
2951
+ throw new ProjectError(
2952
+ "@nonext/husk is not installed here. Install the package first, then run `npx nonext-husk init`."
2953
+ );
2954
+ }
2955
+ const missingTailwind = ["tailwindcss", "@tailwindcss/postcss"].filter(
2956
+ (name) => !dependencies.includes(name)
2957
+ );
2958
+ if (missingTailwind.length > 0) {
2959
+ throw new ProjectError(
2960
+ `Husk's admin stylesheet needs Tailwind CSS v4. Install the missing package${missingTailwind.length === 1 ? "" : "s"} first:
2961
+
2962
+ npm install -D ${missingTailwind.join(" ")}
2963
+ `
2964
+ );
2965
+ }
2966
+ const srcDir = fs.isDirectory("src/app");
2967
+ const appDir = srcDir ? "src/app" : "app";
2968
+ if (!fs.isDirectory(appDir)) {
2969
+ throw new ProjectError(
2970
+ "No app directory found. Husk needs the App Router; the Pages Router is not supported."
2971
+ );
2972
+ }
2973
+ const rootLayout = fs.exists(projectJoin(appDir, "layout.tsx"));
2974
+ const segments = routeSegments(fs, appDir).filter(
2975
+ // A route group is not a segment with its own root layout requirement: whatever is
2976
+ // inside it already sits under a group, so it is either already served by a grouped
2977
+ // root layout or it is the group this run is about to create.
2978
+ (name) => !name.startsWith("(")
2979
+ );
2980
+ return {
2981
+ appDir,
2982
+ libDir: srcDir ? "src/lib" : "lib",
2983
+ srcDir,
2984
+ siteFilesToMove: rootLayout ? ROOT_SEGMENT_FILES.filter((name) => fs.exists(projectJoin(appDir, name))) : [],
2985
+ orphanedSegments: rootLayout ? segments : [],
2986
+ rootLayoutAlreadyGrouped: !rootLayout,
2987
+ hasPostcssConfig: [
2988
+ "postcss.config.mjs",
2989
+ "postcss.config.js",
2990
+ "postcss.config.ts"
2991
+ ].some((name) => fs.exists(name))
2992
+ };
2993
+ }
2994
+ function rootLayoutBlocker(project) {
2995
+ if (project.orphanedSegments.length === 0) return null;
2996
+ const list = project.orphanedSegments.map((name) => `${project.appDir}/${name}/`).join(", ");
2997
+ return [
2998
+ "Husk needs its own root layout, so the website's root layout has to sit inside a route group.",
2999
+ `Moving ${project.appDir}/layout.tsx into ${project.appDir}/(site)/ would leave these route segments with no root layout: ${list}.`,
3000
+ "",
3001
+ "Do this once by hand, then run init again:",
3002
+ ` 1. create ${project.appDir}/(site)/`,
3003
+ ` 2. move layout.tsx, page.tsx and every route segment of your website into it`,
3004
+ ` 3. fix the relative imports in the files you moved (../ instead of ./)`,
3005
+ "",
3006
+ "Why: the admin renders its own <html> and <body> so your stylesheet, fonts and providers never reach it, and nothing it loads leaks back into your site. Next allows one root layout per route, so both have to live in a group."
3007
+ ].join("\n");
3008
+ }
3009
+ function createReadlinePrompter() {
3010
+ const rl = createInterface({ input: stdin, output: stdout });
3011
+ return {
3012
+ async ask(question, fallback) {
3013
+ const answer = (await rl.question(`${question} (${fallback}) `)).trim();
3014
+ return answer.length === 0 ? fallback : answer;
3015
+ },
3016
+ async confirm(question, fallback) {
3017
+ const answer = (await rl.question(`${question} (${fallback ? "Y/n" : "y/N"}) `)).trim().toLowerCase();
3018
+ if (answer.length === 0) return fallback;
3019
+ return answer.startsWith("y");
3020
+ },
3021
+ close() {
3022
+ rl.close();
3023
+ }
3024
+ };
3025
+ }
3026
+ async function promptForOptions(prompter, args) {
3027
+ const projectId = validateProjectId(
3028
+ args.projectId ?? await prompter.ask("Firebase project id?", "husk-dev-project")
3029
+ );
3030
+ const adminRoute = normalizeAdminRoute(
3031
+ args.adminRoute ?? await prompter.ask("Admin route?", DEFAULT_ADMIN_ROUTE)
3032
+ );
3033
+ const media = args.media ?? await prompter.confirm("Enable the media library?", true);
3034
+ const auth = args.auth ?? await prompter.confirm("Generate the sign-in screen?", true);
3035
+ const emulators = args.emulators ?? await prompter.confirm("Develop against the Firebase emulators?", true);
3036
+ const storageBucket = args.storageBucket !== void 0 ? validateStorageBucket(args.storageBucket) : media ? validateStorageBucket(
3037
+ await prompter.ask("Storage bucket?", defaultStorageBucket(projectId))
3038
+ ) : defaultStorageBucket(projectId);
3039
+ return { projectId, storageBucket, adminRoute, media, auth, emulators };
3040
+ }
3041
+
3042
+ // src/cli/diff.ts
3043
+ var CONTEXT = 3;
3044
+ var MAX_HUNKS = 4;
3045
+ var MAX_HUNK_LINES = 24;
3046
+ function lines(text) {
3047
+ const split = text.replace(/\r\n/g, "\n").split("\n");
3048
+ return split.length > 0 && split[split.length - 1] === "" ? split.slice(0, -1) : split;
3049
+ }
3050
+ function commonSubsequence(left, right) {
3051
+ const rows = left.length;
3052
+ const columns = right.length;
3053
+ const table = Array.from(
3054
+ { length: rows + 1 },
3055
+ () => new Array(columns + 1).fill(0)
3056
+ );
3057
+ for (let row2 = rows - 1; row2 >= 0; row2 -= 1) {
3058
+ for (let column2 = columns - 1; column2 >= 0; column2 -= 1) {
3059
+ const current = table[row2] ?? [];
3060
+ const next = table[row2 + 1] ?? [];
3061
+ current[column2] = left[row2] === right[column2] ? (next[column2 + 1] ?? 0) + 1 : Math.max(next[column2] ?? 0, current[column2 + 1] ?? 0);
3062
+ }
3063
+ }
3064
+ const result = [];
3065
+ let row = 0;
3066
+ let column = 0;
3067
+ while (row < rows && column < columns) {
3068
+ if (left[row] === right[column]) {
3069
+ result.push({ kind: "context", text: left[row] ?? "" });
3070
+ row += 1;
3071
+ column += 1;
3072
+ continue;
3073
+ }
3074
+ const down = table[row + 1]?.[column] ?? 0;
3075
+ const across = table[row]?.[column + 1] ?? 0;
3076
+ if (down >= across) {
3077
+ result.push({ kind: "yours", text: left[row] ?? "" });
3078
+ row += 1;
3079
+ } else {
3080
+ result.push({ kind: "husk", text: right[column] ?? "" });
3081
+ column += 1;
3082
+ }
3083
+ }
3084
+ while (row < rows) {
3085
+ result.push({ kind: "yours", text: left[row] ?? "" });
3086
+ row += 1;
3087
+ }
3088
+ while (column < columns) {
3089
+ result.push({ kind: "husk", text: right[column] ?? "" });
3090
+ column += 1;
3091
+ }
3092
+ return result;
3093
+ }
3094
+ function diffFiles(existing, generated) {
3095
+ const script = commonSubsequence(lines(existing), lines(generated));
3096
+ const changed = script.filter((line) => line.kind !== "context").length;
3097
+ if (changed === 0) return { hunks: [], changed: 0, omittedHunks: 0 };
3098
+ const interesting = script.flatMap(
3099
+ (line, index) => line.kind === "context" ? [] : [index]
3100
+ );
3101
+ const ranges = [];
3102
+ for (const index of interesting) {
3103
+ const from = Math.max(0, index - CONTEXT);
3104
+ const to = Math.min(script.length - 1, index + CONTEXT);
3105
+ const last = ranges[ranges.length - 1];
3106
+ if (last !== void 0 && from <= last.to + 1) last.to = Math.max(last.to, to);
3107
+ else ranges.push({ from, to });
3108
+ }
3109
+ let existingLine = 0;
3110
+ const startAt = /* @__PURE__ */ new Map();
3111
+ script.forEach((line, index) => {
3112
+ startAt.set(index, existingLine + 1);
3113
+ if (line.kind !== "husk") existingLine += 1;
3114
+ });
3115
+ const hunks = ranges.slice(0, MAX_HUNKS).map((range) => ({
3116
+ start: startAt.get(range.from) ?? 1,
3117
+ lines: script.slice(range.from, range.to + 1).slice(0, MAX_HUNK_LINES)
3118
+ }));
3119
+ return { hunks, changed, omittedHunks: Math.max(0, ranges.length - MAX_HUNKS) };
3120
+ }
3121
+ var MARK = {
3122
+ context: " ",
3123
+ yours: "- ",
3124
+ husk: "+ "
3125
+ };
3126
+ function renderDiff(path, diff) {
3127
+ const header = `${path} (${String(diff.changed)} line${diff.changed === 1 ? "" : "s"} differ, - yours / + Husk)`;
3128
+ const body = diff.hunks.map(
3129
+ (hunk) => ` @@ line ${String(hunk.start)}
3130
+ ` + hunk.lines.map((line) => ` ${MARK[line.kind]}${line.text}`).join("\n")
3131
+ );
3132
+ const tail = diff.omittedHunks > 0 ? [
3133
+ ` ... and ${String(diff.omittedHunks)} further change${diff.omittedHunks === 1 ? "" : "s"} in this file`
3134
+ ] : [];
3135
+ return [header, ...body, ...tail].join("\n");
3136
+ }
3137
+
3138
+ // src/cli/report.ts
3139
+ function renderPlan(outcome, options) {
3140
+ const lines2 = [];
3141
+ for (const move of outcome.moves) {
3142
+ lines2.push(` move ${move.from} -> ${move.to}`);
3143
+ }
3144
+ for (const file of outcome.create) {
3145
+ lines2.push(` create ${file.path}`);
3146
+ }
3147
+ for (const file of outcome.kept) {
3148
+ lines2.push(` keep ${file.path} (already here, left alone)`);
3149
+ }
3150
+ if (outcome.unchanged.length > 0) {
3151
+ lines2.push(
3152
+ ` same ${String(outcome.unchanged.length)} file(s) already match what Husk would write`
3153
+ );
3154
+ }
3155
+ lines2.push("");
3156
+ lines2.push(
3157
+ ` admin route ${options.adminRoute}, project ${options.projectId}, media ${options.media ? "on" : "off"}, auth ${options.auth ? "on" : "off"}, emulators ${options.emulators ? "on" : "off"}`
3158
+ );
3159
+ return lines2.join("\n");
3160
+ }
3161
+ function renderConflicts(outcome) {
3162
+ const lines2 = [
3163
+ "Husk did not write anything.",
3164
+ "",
3165
+ `${String(outcome.conflicts.length + outcome.moveConflicts.length)} file(s) already exist here and differ from what init would write:`,
3166
+ ""
3167
+ ];
3168
+ for (const conflict of outcome.conflicts) {
3169
+ lines2.push(
3170
+ renderDiff(conflict.path, diffFiles(conflict.existing, conflict.generated))
3171
+ );
3172
+ lines2.push("");
3173
+ }
3174
+ for (const path of outcome.moveConflicts) {
3175
+ lines2.push(
3176
+ `${path} (init wanted to move your root layout here and something is already there)`
3177
+ );
3178
+ lines2.push("");
3179
+ }
3180
+ lines2.push("What to do, in order of how much you want to keep:");
3181
+ lines2.push("");
3182
+ lines2.push(" - Keep yours: nothing to do. Husk has changed nothing.");
3183
+ lines2.push(" - Take Husk's for one file: delete that file and run init again.");
3184
+ lines2.push(" - Take Husk's for all of them: run `npx nonext-husk init --force`.");
3185
+ lines2.push("");
3186
+ lines2.push("`--dry-run` lists everything init would write without writing any of it.");
3187
+ return lines2.join("\n");
3188
+ }
3189
+ var MARK2 = {
3190
+ ok: " ok ",
3191
+ problem: " PROBLEM ",
3192
+ unchecked: " unchecked"
3193
+ };
3194
+ function renderChecks(checks) {
3195
+ const lines2 = [];
3196
+ for (const check of checks) {
3197
+ lines2.push(`${MARK2[check.status]} ${check.name}`);
3198
+ for (const line of check.detail.split("\n")) lines2.push(` ${line}`);
3199
+ if (check.fix !== void 0 && check.status !== "ok") {
3200
+ lines2.push(` -> ${check.fix}`);
3201
+ }
3202
+ lines2.push("");
3203
+ }
3204
+ const problems = checks.filter((check) => check.status === "problem").length;
3205
+ const unchecked = checks.filter((check) => check.status === "unchecked").length;
3206
+ lines2.push(
3207
+ problems === 0 ? `Nothing wrong that this can see. ${String(unchecked)} thing(s) it cannot check from files alone are listed above.` : `${String(problems)} problem(s). ${String(unchecked)} thing(s) it cannot check from files alone are listed above.`
3208
+ );
3209
+ return lines2.join("\n");
3210
+ }
3211
+ function renderNextSteps(options) {
3212
+ const steps = [];
3213
+ if (options.emulators) {
3214
+ steps.push(" 1. npx firebase emulators:start --only auth,firestore,storage");
3215
+ if (options.auth) {
3216
+ steps.push(" 2. node scripts/husk-first-admin.mjs you@example.com a-password");
3217
+ steps.push(" 3. npm run dev");
3218
+ steps.push(` 4. open http://localhost:3000${options.adminRoute} and sign in`);
3219
+ } else {
3220
+ steps.push(" 2. npm run dev");
3221
+ steps.push(` 3. open http://localhost:3000${options.adminRoute}`);
3222
+ }
3223
+ } else {
3224
+ steps.push(
3225
+ " 1. npx firebase deploy --only firestore:rules,firestore:indexes" + (options.media ? ",storage" : "")
3226
+ );
3227
+ steps.push(" 2. create the first admin account in your Firebase project");
3228
+ steps.push(" 3. npm run dev");
3229
+ steps.push(` 4. open http://localhost:3000${options.adminRoute}`);
3230
+ }
3231
+ return [
3232
+ "Next:",
3233
+ "",
3234
+ ...steps,
3235
+ "",
3236
+ "Content types live in cms.config.ts. Change one, then run `npx nonext-husk doctor`",
3237
+ "to see whether firestore.indexes.json still covers the queries the list views make."
3238
+ ].join("\n");
3239
+ }
3240
+
3241
+ // src/cli/nonext-husk.ts
3242
+ var HERE = import.meta.dirname;
3243
+ var PACKAGE_ROOT = resolve(HERE, "..", "..");
3244
+ function packageVersion() {
3245
+ try {
3246
+ const manifest = JSON.parse(
3247
+ readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")
3248
+ );
3249
+ return typeof manifest.version === "string" ? manifest.version : "0.0.0";
3250
+ } catch {
3251
+ return "0.0.0";
3252
+ }
3253
+ }
3254
+ function out(text) {
3255
+ process.stdout.write(`${text}
3256
+ `);
3257
+ }
3258
+ function fail(text) {
3259
+ process.stderr.write(`${text}
3260
+ `);
3261
+ }
3262
+ async function initCommand(args) {
3263
+ const root = resolve(args.cwd ?? process.cwd());
3264
+ const fs = createNodeFs(root);
3265
+ const project = inspectProject(fs);
3266
+ const blocker = rootLayoutBlocker(project);
3267
+ if (blocker !== null) {
3268
+ fail(blocker);
3269
+ return 1;
3270
+ }
3271
+ const options = await collectOptions(args);
3272
+ const templates = createFileTemplates(join(HERE, "templates"));
3273
+ const version = packageVersion();
3274
+ const plan = buildPlan(fs, project, options, version, templates);
3275
+ const outcome = classifyPlan(fs, plan);
3276
+ if (args.dryRun) {
3277
+ out(`Husk ${version} would write, in ${root}:
3278
+ `);
3279
+ out(renderPlan(outcome, options));
3280
+ if (outcome.conflicts.length > 0 || outcome.moveConflicts.length > 0) {
3281
+ out("");
3282
+ out(renderConflicts(outcome));
3283
+ return 1;
3284
+ }
3285
+ return 0;
3286
+ }
3287
+ const blocked = outcome.conflicts.length > 0 || outcome.moveConflicts.length > 0;
3288
+ if (blocked && !args.force) {
3289
+ fail(renderConflicts(outcome));
3290
+ return 1;
3291
+ }
3292
+ applyPlan(fs, outcome, args.force);
3293
+ out(`Husk ${version} scaffolded into ${root}:
3294
+ `);
3295
+ out(renderPlan(outcome, options));
3296
+ out("");
3297
+ out(renderNextSteps(options));
3298
+ return 0;
3299
+ }
3300
+ async function collectOptions(args) {
3301
+ if (args.yes) return optionsFromArgs(args);
3302
+ const prompter = createReadlinePrompter();
3303
+ try {
3304
+ return await promptForOptions(prompter, args);
3305
+ } finally {
3306
+ prompter.close();
3307
+ }
3308
+ }
3309
+ async function doctorCommand(args) {
3310
+ const root = resolve(args.cwd ?? process.cwd());
3311
+ const fs = createNodeFs(root);
3312
+ const loaded = await loadHuskConfig(root);
3313
+ const checks = runChecks({
3314
+ fs,
3315
+ templates: createFileTemplates(join(HERE, "templates")),
3316
+ installedVersion: installedVersion(root, packageVersion()),
3317
+ config: loaded.config,
3318
+ contentTypes: loaded.contentTypes
3319
+ });
3320
+ out(`Husk doctor, in ${root}:
3321
+ `);
3322
+ out(renderChecks(checks));
3323
+ return exitCode(checks);
3324
+ }
3325
+ async function main(argv) {
3326
+ let args;
3327
+ try {
3328
+ args = parseArgs(argv);
3329
+ } catch (thrown) {
3330
+ fail(thrown instanceof ArgumentError ? thrown.message : String(thrown));
3331
+ fail("");
3332
+ fail(HELP);
3333
+ return 2;
3334
+ }
3335
+ try {
3336
+ switch (args.command) {
3337
+ case "init":
3338
+ return await initCommand(args);
3339
+ case "doctor":
3340
+ return await doctorCommand(args);
3341
+ case "version":
3342
+ out(packageVersion());
3343
+ return 0;
3344
+ case "help":
3345
+ out(HELP);
3346
+ return 0;
3347
+ }
3348
+ } catch (thrown) {
3349
+ if (thrown instanceof ProjectError || thrown instanceof ArgumentError) {
3350
+ fail(thrown.message);
3351
+ return 1;
3352
+ }
3353
+ throw thrown;
3354
+ }
3355
+ }
3356
+ process.exitCode = await main(process.argv.slice(2));
3357
+
3358
+ export { main };
3359
+ //# sourceMappingURL=nonext-husk.js.map
3360
+ //# sourceMappingURL=nonext-husk.js.map