@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.
- package/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- 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
|