@kdrgny/justjson 1.2.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/cli.js +1253 -222
- package/dist/editor/assets/index-DFUrgJEm.js +646 -0
- package/dist/editor/assets/index-Dy62f11G.css +1 -0
- package/dist/editor/index.html +3 -3
- package/package.json +1 -1
- package/dist/editor/assets/index-BoAqd15W.js +0 -612
- package/dist/editor/assets/index-CP_v5575.css +0 -1
package/dist/cli.js
CHANGED
|
@@ -6,11 +6,7 @@ var __export = (target, all) => {
|
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
// src/cli.ts
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
// src/commands/export.ts
|
|
12
|
-
import { writeFile as writeFile2 } from "fs/promises";
|
|
13
|
-
import { join } from "path";
|
|
9
|
+
import { basename as basename3 } from "path";
|
|
14
10
|
|
|
15
11
|
// ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
|
|
16
12
|
var external_exports = {};
|
|
@@ -4054,6 +4050,20 @@ var coerce = {
|
|
|
4054
4050
|
var NEVER = INVALID;
|
|
4055
4051
|
|
|
4056
4052
|
// ../core/dist/index.js
|
|
4053
|
+
var JustJsonError = class extends Error {
|
|
4054
|
+
constructor(message) {
|
|
4055
|
+
super(message);
|
|
4056
|
+
this.name = new.target.name;
|
|
4057
|
+
}
|
|
4058
|
+
};
|
|
4059
|
+
var NotFoundError = class extends JustJsonError {
|
|
4060
|
+
};
|
|
4061
|
+
var UnsafeSlugError = class extends JustJsonError {
|
|
4062
|
+
};
|
|
4063
|
+
var PathEscapeError = class extends JustJsonError {
|
|
4064
|
+
};
|
|
4065
|
+
var SchemaError = class extends JustJsonError {
|
|
4066
|
+
};
|
|
4057
4067
|
var fieldTypes = [
|
|
4058
4068
|
"text",
|
|
4059
4069
|
"richtext",
|
|
@@ -4062,23 +4072,34 @@ var fieldTypes = [
|
|
|
4062
4072
|
"date",
|
|
4063
4073
|
"select",
|
|
4064
4074
|
"relation",
|
|
4065
|
-
"image"
|
|
4075
|
+
"image",
|
|
4076
|
+
"url",
|
|
4077
|
+
"email",
|
|
4078
|
+
"list",
|
|
4079
|
+
"color",
|
|
4080
|
+
"group"
|
|
4066
4081
|
];
|
|
4067
|
-
var zField = external_exports.
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
});
|
|
4082
|
+
var zField = external_exports.lazy(
|
|
4083
|
+
() => external_exports.object({
|
|
4084
|
+
key: external_exports.string().min(1),
|
|
4085
|
+
label: external_exports.string().optional(),
|
|
4086
|
+
type: external_exports.enum(fieldTypes),
|
|
4087
|
+
required: external_exports.boolean().optional(),
|
|
4088
|
+
options: external_exports.array(external_exports.string()).optional(),
|
|
4089
|
+
to: external_exports.string().optional(),
|
|
4090
|
+
fields: external_exports.array(zField).optional()
|
|
4091
|
+
}).superRefine((field, ctx) => {
|
|
4092
|
+
if (field.type === "select" && (!field.options || field.options.length === 0)) {
|
|
4093
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "a select field requires options" });
|
|
4094
|
+
}
|
|
4095
|
+
if (field.type === "relation" && !field.to) {
|
|
4096
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: 'a relation field requires "to"' });
|
|
4097
|
+
}
|
|
4098
|
+
if (field.type === "group" && (!field.fields || field.fields.length === 0)) {
|
|
4099
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "a group field requires fields" });
|
|
4100
|
+
}
|
|
4101
|
+
})
|
|
4102
|
+
);
|
|
4082
4103
|
var zCollection = external_exports.object({
|
|
4083
4104
|
name: external_exports.string().min(1),
|
|
4084
4105
|
label: external_exports.string().optional(),
|
|
@@ -4102,30 +4123,38 @@ var zSchema = external_exports.object({
|
|
|
4102
4123
|
const containers = [...schema.collections, ...schema.singletons];
|
|
4103
4124
|
for (const c of containers) {
|
|
4104
4125
|
if (names.has(c.name)) {
|
|
4105
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4126
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate name: ${c.name}` });
|
|
4106
4127
|
}
|
|
4107
4128
|
names.add(c.name);
|
|
4108
4129
|
if (c.path.includes("..") || c.path.startsWith("/") || c.path.includes("\\")) {
|
|
4109
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4130
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `unsafe path: ${c.path}` });
|
|
4110
4131
|
}
|
|
4111
4132
|
if (paths.has(c.path)) {
|
|
4112
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4133
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate path: ${c.path}` });
|
|
4113
4134
|
}
|
|
4114
4135
|
paths.add(c.path);
|
|
4115
4136
|
const keys = /* @__PURE__ */ new Set();
|
|
4116
4137
|
for (const f of c.fields) {
|
|
4117
4138
|
if (keys.has(f.key)) {
|
|
4118
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4139
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate field key: ${f.key}` });
|
|
4119
4140
|
}
|
|
4120
4141
|
keys.add(f.key);
|
|
4121
4142
|
if (f.type === "relation" && f.to && !collectionNames.has(f.to)) {
|
|
4122
|
-
ctx.addIssue({
|
|
4143
|
+
ctx.addIssue({
|
|
4144
|
+
code: external_exports.ZodIssueCode.custom,
|
|
4145
|
+
message: `relation target does not exist: ${f.to}`
|
|
4146
|
+
});
|
|
4123
4147
|
}
|
|
4124
4148
|
}
|
|
4125
4149
|
}
|
|
4126
4150
|
});
|
|
4127
4151
|
function parseSchema(input) {
|
|
4128
|
-
|
|
4152
|
+
const result = zSchema.safeParse(input);
|
|
4153
|
+
if (result.success) return result.data;
|
|
4154
|
+
const lines = result.error.issues.map(
|
|
4155
|
+
(i) => i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message
|
|
4156
|
+
);
|
|
4157
|
+
throw new SchemaError(lines.join("\n"));
|
|
4129
4158
|
}
|
|
4130
4159
|
function serializeSchema(schema) {
|
|
4131
4160
|
return `${JSON.stringify(schema, null, 2)}
|
|
@@ -4149,7 +4178,7 @@ var TR = {
|
|
|
4149
4178
|
function slugify(input) {
|
|
4150
4179
|
const mapped = input.replace(/[çğıöşüÇĞİÖŞÜI]/g, (c) => TR[c] ?? c);
|
|
4151
4180
|
const slug = mapped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
4152
|
-
return slug || "
|
|
4181
|
+
return slug || "content";
|
|
4153
4182
|
}
|
|
4154
4183
|
var TITLE_KEYS = ["title", "name", "baslik", "ba\u015Fl\u0131k", "ad", "label", "heading", "isim"];
|
|
4155
4184
|
function entryTitle(fields, data) {
|
|
@@ -4171,7 +4200,7 @@ function schemaPath(contentDir) {
|
|
|
4171
4200
|
}
|
|
4172
4201
|
function assertSafeSlug(slug) {
|
|
4173
4202
|
if (slug.length === 0 || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
|
|
4174
|
-
throw new
|
|
4203
|
+
throw new UnsafeSlugError(`Unsafe slug: ${slug}`);
|
|
4175
4204
|
}
|
|
4176
4205
|
}
|
|
4177
4206
|
async function loadSchema(adapter, contentDir = "content") {
|
|
@@ -4193,12 +4222,12 @@ var ContentStore = class {
|
|
|
4193
4222
|
contentDir;
|
|
4194
4223
|
collection(name) {
|
|
4195
4224
|
const col = this.schema.collections.find((c) => c.name === name);
|
|
4196
|
-
if (!col) throw new
|
|
4225
|
+
if (!col) throw new NotFoundError(`Bilinmeyen koleksiyon: ${name}`);
|
|
4197
4226
|
return col;
|
|
4198
4227
|
}
|
|
4199
4228
|
singleton(name) {
|
|
4200
4229
|
const s = this.schema.singletons.find((x) => x.name === name);
|
|
4201
|
-
if (!s) throw new
|
|
4230
|
+
if (!s) throw new NotFoundError(`Bilinmeyen singleton: ${name}`);
|
|
4202
4231
|
return s;
|
|
4203
4232
|
}
|
|
4204
4233
|
entryPath(col, slug) {
|
|
@@ -4251,6 +4280,206 @@ var ContentStore = class {
|
|
|
4251
4280
|
`);
|
|
4252
4281
|
}
|
|
4253
4282
|
};
|
|
4283
|
+
function isEmpty(value) {
|
|
4284
|
+
return value === void 0 || value === null || value === "";
|
|
4285
|
+
}
|
|
4286
|
+
var URL_RE = /^[a-z][a-z0-9+.-]*:\/\/.+/i;
|
|
4287
|
+
var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
4288
|
+
var COLOR_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
4289
|
+
function typeError(field, value) {
|
|
4290
|
+
switch (field.type) {
|
|
4291
|
+
case "text":
|
|
4292
|
+
case "richtext":
|
|
4293
|
+
case "date":
|
|
4294
|
+
case "image":
|
|
4295
|
+
return typeof value === "string" ? null : "expected text";
|
|
4296
|
+
case "url":
|
|
4297
|
+
if (typeof value !== "string") return "expected text";
|
|
4298
|
+
return URL_RE.test(value) ? null : "expected a valid URL";
|
|
4299
|
+
case "email":
|
|
4300
|
+
if (typeof value !== "string") return "expected text";
|
|
4301
|
+
return EMAIL_RE.test(value) ? null : "expected a valid email address";
|
|
4302
|
+
case "color":
|
|
4303
|
+
if (typeof value !== "string") return "expected text";
|
|
4304
|
+
return COLOR_RE.test(value) ? null : "expected a hex color (e.g. #ff0000)";
|
|
4305
|
+
case "list":
|
|
4306
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
4307
|
+
return "expected a list of text";
|
|
4308
|
+
}
|
|
4309
|
+
return null;
|
|
4310
|
+
case "relation":
|
|
4311
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
4312
|
+
return "expected a list of slugs";
|
|
4313
|
+
}
|
|
4314
|
+
return null;
|
|
4315
|
+
case "group":
|
|
4316
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
4317
|
+
return "expected an object";
|
|
4318
|
+
}
|
|
4319
|
+
return null;
|
|
4320
|
+
case "number":
|
|
4321
|
+
return typeof value === "number" ? null : "expected a number";
|
|
4322
|
+
case "boolean":
|
|
4323
|
+
return typeof value === "boolean" ? null : "expected true or false";
|
|
4324
|
+
case "select":
|
|
4325
|
+
if (typeof value !== "string") return "expected text";
|
|
4326
|
+
return field.options?.includes(value) ? null : "not one of the options";
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
function validateEntry(fields, data) {
|
|
4330
|
+
const issues = [];
|
|
4331
|
+
const known = new Set(fields.map((f) => f.key));
|
|
4332
|
+
for (const field of fields) {
|
|
4333
|
+
const value = data[field.key];
|
|
4334
|
+
if (isEmpty(value)) {
|
|
4335
|
+
if (field.required) {
|
|
4336
|
+
issues.push({
|
|
4337
|
+
key: field.key,
|
|
4338
|
+
level: "warning",
|
|
4339
|
+
kind: "required",
|
|
4340
|
+
message: "required field is empty"
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4343
|
+
continue;
|
|
4344
|
+
}
|
|
4345
|
+
const err = typeError(field, value);
|
|
4346
|
+
if (err) issues.push({ key: field.key, level: "error", kind: "type", message: err });
|
|
4347
|
+
}
|
|
4348
|
+
for (const key of Object.keys(data)) {
|
|
4349
|
+
if (key.startsWith("_")) continue;
|
|
4350
|
+
if (!known.has(key)) {
|
|
4351
|
+
issues.push({
|
|
4352
|
+
key,
|
|
4353
|
+
level: "warning",
|
|
4354
|
+
kind: "unknown-key",
|
|
4355
|
+
message: "key is not in the schema"
|
|
4356
|
+
});
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
return { ok: issues.every((i) => i.level !== "error"), issues };
|
|
4360
|
+
}
|
|
4361
|
+
function basename(path) {
|
|
4362
|
+
return path.split(/[/\\]/).pop() ?? path;
|
|
4363
|
+
}
|
|
4364
|
+
function imageFields(fields) {
|
|
4365
|
+
return fields.filter((f) => f.type === "image");
|
|
4366
|
+
}
|
|
4367
|
+
function relationFields(fields) {
|
|
4368
|
+
return fields.filter((f) => f.type === "relation");
|
|
4369
|
+
}
|
|
4370
|
+
function validateProject(schema, content) {
|
|
4371
|
+
const issues = [];
|
|
4372
|
+
const mediaNames = content.media ? new Set(content.media.map(basename)) : null;
|
|
4373
|
+
const schemaCollectionNames = new Set(schema.collections.map((c) => c.name));
|
|
4374
|
+
for (const name of Object.keys(content.collections)) {
|
|
4375
|
+
if (!schemaCollectionNames.has(name)) {
|
|
4376
|
+
issues.push({
|
|
4377
|
+
level: "warning",
|
|
4378
|
+
kind: "schema-content-mismatch",
|
|
4379
|
+
collection: name,
|
|
4380
|
+
message: `Collection folder '${name}' is not in the schema`
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
const slugsByCollection = {};
|
|
4385
|
+
for (const [name, entries] of Object.entries(content.collections)) {
|
|
4386
|
+
slugsByCollection[name] = new Set(entries.map((e) => e.slug));
|
|
4387
|
+
}
|
|
4388
|
+
for (const col of schema.collections) {
|
|
4389
|
+
const entries = content.collections[col.name] ?? [];
|
|
4390
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4391
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4392
|
+
for (const { slug } of entries) {
|
|
4393
|
+
if (seen.has(slug) && !reported.has(slug)) {
|
|
4394
|
+
reported.add(slug);
|
|
4395
|
+
issues.push({
|
|
4396
|
+
level: "error",
|
|
4397
|
+
kind: "duplicate-slug",
|
|
4398
|
+
collection: col.name,
|
|
4399
|
+
slug,
|
|
4400
|
+
message: `Duplicate slug in '${col.name}': ${slug}`
|
|
4401
|
+
});
|
|
4402
|
+
}
|
|
4403
|
+
seen.add(slug);
|
|
4404
|
+
}
|
|
4405
|
+
const rels = relationFields(col.fields);
|
|
4406
|
+
const imgs = imageFields(col.fields);
|
|
4407
|
+
for (const { slug, data } of entries) {
|
|
4408
|
+
for (const issue of validateEntry(col.fields, data).issues) {
|
|
4409
|
+
issues.push({
|
|
4410
|
+
level: issue.level,
|
|
4411
|
+
kind: issue.kind,
|
|
4412
|
+
collection: col.name,
|
|
4413
|
+
slug,
|
|
4414
|
+
field: issue.key,
|
|
4415
|
+
message: issue.message
|
|
4416
|
+
});
|
|
4417
|
+
}
|
|
4418
|
+
for (const field of rels) {
|
|
4419
|
+
const value = data[field.key];
|
|
4420
|
+
if (!Array.isArray(value)) continue;
|
|
4421
|
+
const target = field.to ? slugsByCollection[field.to] : void 0;
|
|
4422
|
+
for (const ref of value) {
|
|
4423
|
+
if (typeof ref !== "string") continue;
|
|
4424
|
+
if (!target || !target.has(ref)) {
|
|
4425
|
+
issues.push({
|
|
4426
|
+
level: "error",
|
|
4427
|
+
kind: "broken-relation",
|
|
4428
|
+
collection: col.name,
|
|
4429
|
+
slug,
|
|
4430
|
+
field: field.key,
|
|
4431
|
+
message: field.to ? `Broken relation: '${ref}' does not exist in '${field.to}'` : `Relation field '${field.key}' has no target collection`
|
|
4432
|
+
});
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
if (mediaNames) {
|
|
4437
|
+
for (const field of imgs) {
|
|
4438
|
+
const value = data[field.key];
|
|
4439
|
+
if (typeof value !== "string" || value === "") continue;
|
|
4440
|
+
if (!mediaNames.has(basename(value))) {
|
|
4441
|
+
issues.push({
|
|
4442
|
+
level: "error",
|
|
4443
|
+
kind: "missing-media",
|
|
4444
|
+
collection: col.name,
|
|
4445
|
+
slug,
|
|
4446
|
+
field: field.key,
|
|
4447
|
+
message: `Missing media: ${value}`
|
|
4448
|
+
});
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
for (const s of schema.singletons) {
|
|
4455
|
+
const data = content.singletons[s.name] ?? {};
|
|
4456
|
+
for (const issue of validateEntry(s.fields, data).issues) {
|
|
4457
|
+
issues.push({
|
|
4458
|
+
level: issue.level,
|
|
4459
|
+
kind: issue.kind,
|
|
4460
|
+
singleton: s.name,
|
|
4461
|
+
field: issue.key,
|
|
4462
|
+
message: issue.message
|
|
4463
|
+
});
|
|
4464
|
+
}
|
|
4465
|
+
if (mediaNames) {
|
|
4466
|
+
for (const field of imageFields(s.fields)) {
|
|
4467
|
+
const value = data[field.key];
|
|
4468
|
+
if (typeof value !== "string" || value === "") continue;
|
|
4469
|
+
if (!mediaNames.has(basename(value))) {
|
|
4470
|
+
issues.push({
|
|
4471
|
+
level: "error",
|
|
4472
|
+
kind: "missing-media",
|
|
4473
|
+
singleton: s.name,
|
|
4474
|
+
field: field.key,
|
|
4475
|
+
message: `Missing media: ${value}`
|
|
4476
|
+
});
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
return issues;
|
|
4482
|
+
}
|
|
4254
4483
|
function pascalCase(name) {
|
|
4255
4484
|
return name.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
4256
4485
|
}
|
|
@@ -4263,7 +4492,14 @@ function fieldTsType(field) {
|
|
|
4263
4492
|
case "select":
|
|
4264
4493
|
return (field.options ?? []).map((o) => `'${o}'`).join(" | ") || "string";
|
|
4265
4494
|
case "relation":
|
|
4495
|
+
case "list":
|
|
4266
4496
|
return "string[]";
|
|
4497
|
+
case "group": {
|
|
4498
|
+
const subs = field.fields ?? [];
|
|
4499
|
+
if (subs.length === 0) return "Record<string, unknown>";
|
|
4500
|
+
const inner = subs.map((f) => `${f.key}${f.required ? "" : "?"}: ${fieldTsType(f)}`).join("; ");
|
|
4501
|
+
return `{ ${inner} }`;
|
|
4502
|
+
}
|
|
4267
4503
|
default:
|
|
4268
4504
|
return "string";
|
|
4269
4505
|
}
|
|
@@ -4278,7 +4514,7 @@ ${lines.join("\n")}
|
|
|
4278
4514
|
}`;
|
|
4279
4515
|
}
|
|
4280
4516
|
function generateTypes(schema) {
|
|
4281
|
-
const blocks = ["//
|
|
4517
|
+
const blocks = ["// Generated by JustJSON \u2014 do not edit by hand."];
|
|
4282
4518
|
for (const col of schema.collections) {
|
|
4283
4519
|
const name = pascalCase(col.name);
|
|
4284
4520
|
blocks.push(emitInterface(name, col.fields));
|
|
@@ -4290,6 +4526,71 @@ function generateTypes(schema) {
|
|
|
4290
4526
|
return `${blocks.join("\n\n")}
|
|
4291
4527
|
`;
|
|
4292
4528
|
}
|
|
4529
|
+
function pascalCase2(name) {
|
|
4530
|
+
return name.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
4531
|
+
}
|
|
4532
|
+
var IMPORTS = `import { readFile, readdir } from 'node:fs/promises'
|
|
4533
|
+
import { dirname, join } from 'node:path'
|
|
4534
|
+
import { fileURLToPath } from 'node:url'`;
|
|
4535
|
+
var READERS = `export type WithSlug<T> = T & { slug: string }
|
|
4536
|
+
|
|
4537
|
+
export interface LoadOptions {
|
|
4538
|
+
/** true ise _status: 'draft' olan kay\u0131tlar da dahil edilir (varsay\u0131lan: yaln\u0131zca yay\u0131nlananlar). */
|
|
4539
|
+
drafts?: boolean
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
async function readCollection<T>(dir: string, opts?: LoadOptions): Promise<WithSlug<T>[]> {
|
|
4543
|
+
let files: string[]
|
|
4544
|
+
try {
|
|
4545
|
+
files = (await readdir(join(contentDir, dir))).filter((f) => f.endsWith('.json'))
|
|
4546
|
+
} catch {
|
|
4547
|
+
return []
|
|
4548
|
+
}
|
|
4549
|
+
const out: WithSlug<T>[] = []
|
|
4550
|
+
for (const file of files.sort()) {
|
|
4551
|
+
const raw = await readFile(join(contentDir, dir, file), 'utf8')
|
|
4552
|
+
const data = JSON.parse(raw) as T & { _status?: string }
|
|
4553
|
+
if (!opts?.drafts && data._status === 'draft') continue
|
|
4554
|
+
out.push({ ...data, slug: file.slice(0, -'.json'.length) })
|
|
4555
|
+
}
|
|
4556
|
+
return out
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
async function readSingleton<T>(file: string): Promise<T | null> {
|
|
4560
|
+
try {
|
|
4561
|
+
return JSON.parse(await readFile(join(contentDir, file), 'utf8')) as T
|
|
4562
|
+
} catch {
|
|
4563
|
+
return null
|
|
4564
|
+
}
|
|
4565
|
+
}`;
|
|
4566
|
+
function generateLoader(schema, contentDir) {
|
|
4567
|
+
const typeNames = [
|
|
4568
|
+
...schema.collections.map((c) => pascalCase2(c.name)),
|
|
4569
|
+
...schema.singletons.map((s) => pascalCase2(s.name))
|
|
4570
|
+
];
|
|
4571
|
+
const header = [
|
|
4572
|
+
"// Generated by JustJSON \u2014 do not edit by hand.",
|
|
4573
|
+
IMPORTS,
|
|
4574
|
+
`import type { ${typeNames.join(", ")} } from './types'`
|
|
4575
|
+
].join("\n");
|
|
4576
|
+
const q = (s) => `'${s.replace(/'/g, "\\'")}'`;
|
|
4577
|
+
const contentDirLine = `const contentDir = join(dirname(fileURLToPath(import.meta.url)), ${q(contentDir)})`;
|
|
4578
|
+
const loaders = [];
|
|
4579
|
+
for (const col of schema.collections) {
|
|
4580
|
+
const name = pascalCase2(col.name);
|
|
4581
|
+
loaders.push(
|
|
4582
|
+
`export const load${name} = (opts?: LoadOptions): Promise<WithSlug<${name}>[]> => readCollection<${name}>(${q(col.path)}, opts)`
|
|
4583
|
+
);
|
|
4584
|
+
}
|
|
4585
|
+
for (const s of schema.singletons) {
|
|
4586
|
+
const name = pascalCase2(s.name);
|
|
4587
|
+
loaders.push(
|
|
4588
|
+
`export const load${name} = (): Promise<${name} | null> => readSingleton<${name}>(${q(s.path)})`
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
return `${[header, contentDirLine, READERS, loaders.join("\n")].join("\n\n")}
|
|
4592
|
+
`;
|
|
4593
|
+
}
|
|
4293
4594
|
function json(data) {
|
|
4294
4595
|
return `${JSON.stringify(data, null, 2)}
|
|
4295
4596
|
`;
|
|
@@ -4342,7 +4643,7 @@ function fieldsFromRows(rows) {
|
|
|
4342
4643
|
return order.map((key) => ({ key, label: key, type: types.get(key) }));
|
|
4343
4644
|
}
|
|
4344
4645
|
function inferProject(data) {
|
|
4345
|
-
if (!isPlainObject(data)) throw new Error("
|
|
4646
|
+
if (!isPlainObject(data)) throw new Error("The imported JSON must be an object.");
|
|
4346
4647
|
const collections = [];
|
|
4347
4648
|
const singletons = [];
|
|
4348
4649
|
const entries = {};
|
|
@@ -4351,7 +4652,7 @@ function inferProject(data) {
|
|
|
4351
4652
|
const generalData = {};
|
|
4352
4653
|
const usedNames = /* @__PURE__ */ new Set();
|
|
4353
4654
|
const uniqueName = (base) => {
|
|
4354
|
-
let name = base || "
|
|
4655
|
+
let name = base || "field";
|
|
4355
4656
|
let n = 2;
|
|
4356
4657
|
while (usedNames.has(name)) name = `${base}-${n++}`;
|
|
4357
4658
|
usedNames.add(name);
|
|
@@ -4400,14 +4701,19 @@ function inferProject(data) {
|
|
|
4400
4701
|
}
|
|
4401
4702
|
}
|
|
4402
4703
|
if (generalFields.length > 0) {
|
|
4403
|
-
const name = uniqueName("
|
|
4404
|
-
singletons.push({ name, label: "
|
|
4704
|
+
const name = uniqueName("general");
|
|
4705
|
+
singletons.push({ name, label: "General", path: `${name}.json`, fields: generalFields });
|
|
4405
4706
|
singletonData[name] = generalData;
|
|
4406
4707
|
}
|
|
4407
4708
|
return { schema: { version: 1, collections, singletons }, entries, singletons: singletonData };
|
|
4408
4709
|
}
|
|
4409
4710
|
|
|
4711
|
+
// src/cli.ts
|
|
4712
|
+
import { Command } from "commander";
|
|
4713
|
+
|
|
4410
4714
|
// src/commands/export.ts
|
|
4715
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
4716
|
+
import { join } from "path";
|
|
4411
4717
|
import { zipSync } from "fflate";
|
|
4412
4718
|
|
|
4413
4719
|
// src/fs-adapter.ts
|
|
@@ -4422,7 +4728,7 @@ var FsAdapter = class {
|
|
|
4422
4728
|
const rootAbs = resolve(this.root);
|
|
4423
4729
|
const full = resolve(rootAbs, path);
|
|
4424
4730
|
if (full !== rootAbs && !full.startsWith(rootAbs + sep)) {
|
|
4425
|
-
throw new
|
|
4731
|
+
throw new PathEscapeError(`Path escapes the project root: ${path}`);
|
|
4426
4732
|
}
|
|
4427
4733
|
return full;
|
|
4428
4734
|
}
|
|
@@ -4481,7 +4787,7 @@ async function resolveContentDir(root2) {
|
|
|
4481
4787
|
// src/commands/export.ts
|
|
4482
4788
|
async function collectExportZip(adapter, contentDir) {
|
|
4483
4789
|
const schema = await loadSchema(adapter, contentDir);
|
|
4484
|
-
if (!schema) throw new Error("
|
|
4790
|
+
if (!schema) throw new Error("No schema found. Run `justjson init` first.");
|
|
4485
4791
|
const store = new ContentStore(adapter, schema, contentDir);
|
|
4486
4792
|
const entries = {};
|
|
4487
4793
|
for (const col of schema.collections) {
|
|
@@ -4518,109 +4824,194 @@ async function exportZip(root2, outFile = "justjson-export.zip") {
|
|
|
4518
4824
|
// src/templates/blog.json
|
|
4519
4825
|
var blog_default = {
|
|
4520
4826
|
title: "Blog",
|
|
4521
|
-
description: "
|
|
4827
|
+
description: "Posts with rich-text content, plus a Settings singleton for the site name.",
|
|
4522
4828
|
schema: {
|
|
4523
4829
|
version: 1,
|
|
4524
4830
|
collections: [
|
|
4525
4831
|
{
|
|
4526
4832
|
name: "posts",
|
|
4527
|
-
label: "
|
|
4833
|
+
label: "Posts",
|
|
4528
4834
|
path: "posts",
|
|
4529
4835
|
fields: [
|
|
4530
|
-
{
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4836
|
+
{
|
|
4837
|
+
key: "title",
|
|
4838
|
+
label: "Title",
|
|
4839
|
+
type: "text",
|
|
4840
|
+
required: true
|
|
4841
|
+
},
|
|
4842
|
+
{
|
|
4843
|
+
key: "slug",
|
|
4844
|
+
label: "Slug",
|
|
4845
|
+
type: "text",
|
|
4846
|
+
required: true
|
|
4847
|
+
},
|
|
4848
|
+
{
|
|
4849
|
+
key: "date",
|
|
4850
|
+
label: "Date",
|
|
4851
|
+
type: "date"
|
|
4852
|
+
},
|
|
4853
|
+
{
|
|
4854
|
+
key: "body",
|
|
4855
|
+
label: "Body",
|
|
4856
|
+
type: "richtext"
|
|
4857
|
+
}
|
|
4534
4858
|
]
|
|
4535
4859
|
}
|
|
4536
4860
|
],
|
|
4537
4861
|
singletons: [
|
|
4538
4862
|
{
|
|
4539
4863
|
name: "settings",
|
|
4540
|
-
label: "
|
|
4864
|
+
label: "Settings",
|
|
4541
4865
|
path: "settings.json",
|
|
4542
|
-
fields: [
|
|
4866
|
+
fields: [
|
|
4867
|
+
{
|
|
4868
|
+
key: "title",
|
|
4869
|
+
label: "Site name",
|
|
4870
|
+
type: "text"
|
|
4871
|
+
}
|
|
4872
|
+
]
|
|
4543
4873
|
}
|
|
4544
4874
|
]
|
|
4545
4875
|
},
|
|
4546
4876
|
samples: {
|
|
4547
|
-
posts: [
|
|
4877
|
+
posts: [
|
|
4878
|
+
{
|
|
4879
|
+
title: "First post",
|
|
4880
|
+
slug: "first-post",
|
|
4881
|
+
body: "Hello."
|
|
4882
|
+
}
|
|
4883
|
+
],
|
|
4884
|
+
settings: {
|
|
4885
|
+
title: "My blog"
|
|
4886
|
+
}
|
|
4548
4887
|
}
|
|
4549
4888
|
};
|
|
4550
4889
|
|
|
4551
4890
|
// src/templates/catalog.json
|
|
4552
4891
|
var catalog_default = {
|
|
4553
|
-
title: "
|
|
4554
|
-
description: "
|
|
4892
|
+
title: "Product catalog",
|
|
4893
|
+
description: "Products with price, category and an image, plus Store settings.",
|
|
4555
4894
|
schema: {
|
|
4556
4895
|
version: 1,
|
|
4557
4896
|
collections: [
|
|
4558
4897
|
{
|
|
4559
|
-
name: "
|
|
4560
|
-
label: "
|
|
4561
|
-
path: "
|
|
4898
|
+
name: "products",
|
|
4899
|
+
label: "Products",
|
|
4900
|
+
path: "products",
|
|
4562
4901
|
fields: [
|
|
4563
|
-
{ key: "title", label: "Ba\u015Fl\u0131k", type: "text", required: true },
|
|
4564
|
-
{ key: "slug", label: "Slug", type: "text", required: true },
|
|
4565
|
-
{ key: "fiyat", label: "Fiyat", type: "number" },
|
|
4566
4902
|
{
|
|
4567
|
-
key: "
|
|
4568
|
-
label: "
|
|
4903
|
+
key: "title",
|
|
4904
|
+
label: "Title",
|
|
4905
|
+
type: "text",
|
|
4906
|
+
required: true
|
|
4907
|
+
},
|
|
4908
|
+
{
|
|
4909
|
+
key: "slug",
|
|
4910
|
+
label: "Slug",
|
|
4911
|
+
type: "text",
|
|
4912
|
+
required: true
|
|
4913
|
+
},
|
|
4914
|
+
{
|
|
4915
|
+
key: "price",
|
|
4916
|
+
label: "Price",
|
|
4917
|
+
type: "number"
|
|
4918
|
+
},
|
|
4919
|
+
{
|
|
4920
|
+
key: "category",
|
|
4921
|
+
label: "Category",
|
|
4569
4922
|
type: "select",
|
|
4570
|
-
options: ["
|
|
4923
|
+
options: ["Home", "Clothing", "Accessories"]
|
|
4924
|
+
},
|
|
4925
|
+
{
|
|
4926
|
+
key: "cover",
|
|
4927
|
+
label: "Image",
|
|
4928
|
+
type: "image"
|
|
4571
4929
|
},
|
|
4572
|
-
{
|
|
4573
|
-
|
|
4930
|
+
{
|
|
4931
|
+
key: "description",
|
|
4932
|
+
label: "Description",
|
|
4933
|
+
type: "richtext"
|
|
4934
|
+
}
|
|
4574
4935
|
]
|
|
4575
4936
|
}
|
|
4576
4937
|
],
|
|
4577
4938
|
singletons: [
|
|
4578
4939
|
{
|
|
4579
|
-
name: "
|
|
4580
|
-
label: "
|
|
4581
|
-
path: "
|
|
4940
|
+
name: "store",
|
|
4941
|
+
label: "Store",
|
|
4942
|
+
path: "store.json",
|
|
4582
4943
|
fields: [
|
|
4583
|
-
{
|
|
4584
|
-
|
|
4944
|
+
{
|
|
4945
|
+
key: "title",
|
|
4946
|
+
label: "Store name",
|
|
4947
|
+
type: "text"
|
|
4948
|
+
},
|
|
4949
|
+
{
|
|
4950
|
+
key: "tagline",
|
|
4951
|
+
label: "Tagline",
|
|
4952
|
+
type: "text"
|
|
4953
|
+
}
|
|
4585
4954
|
]
|
|
4586
4955
|
}
|
|
4587
4956
|
]
|
|
4588
4957
|
},
|
|
4589
4958
|
samples: {
|
|
4590
|
-
|
|
4959
|
+
products: [
|
|
4591
4960
|
{
|
|
4592
|
-
title: "
|
|
4593
|
-
slug: "
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4961
|
+
title: "Ceramic mug",
|
|
4962
|
+
slug: "ceramic-mug",
|
|
4963
|
+
price: 18,
|
|
4964
|
+
category: "Home",
|
|
4965
|
+
description: "Hand-made ceramic mug."
|
|
4597
4966
|
}
|
|
4598
|
-
]
|
|
4967
|
+
],
|
|
4968
|
+
store: {
|
|
4969
|
+
title: "My store",
|
|
4970
|
+
tagline: "Small batch, made well"
|
|
4971
|
+
}
|
|
4599
4972
|
}
|
|
4600
4973
|
};
|
|
4601
4974
|
|
|
4602
4975
|
// src/templates/changelog.json
|
|
4603
4976
|
var changelog_default = {
|
|
4604
4977
|
title: "Changelog",
|
|
4605
|
-
description: "
|
|
4978
|
+
description: "Release notes: version, date, type and a rich-text body.",
|
|
4606
4979
|
schema: {
|
|
4607
4980
|
version: 1,
|
|
4608
4981
|
collections: [
|
|
4609
4982
|
{
|
|
4610
4983
|
name: "releases",
|
|
4611
|
-
label: "
|
|
4984
|
+
label: "Releases",
|
|
4612
4985
|
path: "releases",
|
|
4613
4986
|
fields: [
|
|
4614
|
-
{
|
|
4615
|
-
|
|
4616
|
-
|
|
4987
|
+
{
|
|
4988
|
+
key: "version",
|
|
4989
|
+
label: "Version",
|
|
4990
|
+
type: "text",
|
|
4991
|
+
required: true
|
|
4992
|
+
},
|
|
4993
|
+
{
|
|
4994
|
+
key: "slug",
|
|
4995
|
+
label: "Slug",
|
|
4996
|
+
type: "text",
|
|
4997
|
+
required: true
|
|
4998
|
+
},
|
|
4999
|
+
{
|
|
5000
|
+
key: "date",
|
|
5001
|
+
label: "Date",
|
|
5002
|
+
type: "date"
|
|
5003
|
+
},
|
|
4617
5004
|
{
|
|
4618
5005
|
key: "type",
|
|
4619
|
-
label: "
|
|
5006
|
+
label: "Type",
|
|
4620
5007
|
type: "select",
|
|
4621
|
-
options: ["
|
|
5008
|
+
options: ["Added", "Changed", "Fixed"]
|
|
4622
5009
|
},
|
|
4623
|
-
{
|
|
5010
|
+
{
|
|
5011
|
+
key: "body",
|
|
5012
|
+
label: "Notes",
|
|
5013
|
+
type: "richtext"
|
|
5014
|
+
}
|
|
4624
5015
|
]
|
|
4625
5016
|
}
|
|
4626
5017
|
],
|
|
@@ -4631,8 +5022,8 @@ var changelog_default = {
|
|
|
4631
5022
|
{
|
|
4632
5023
|
version: "1.0.0",
|
|
4633
5024
|
slug: "1-0-0",
|
|
4634
|
-
type: "
|
|
4635
|
-
body: "
|
|
5025
|
+
type: "Added",
|
|
5026
|
+
body: "First release."
|
|
4636
5027
|
}
|
|
4637
5028
|
]
|
|
4638
5029
|
}
|
|
@@ -4641,161 +5032,314 @@ var changelog_default = {
|
|
|
4641
5032
|
// src/templates/cv.json
|
|
4642
5033
|
var cv_default = {
|
|
4643
5034
|
title: "CV",
|
|
4644
|
-
description: "
|
|
5035
|
+
description: "An Experience collection and a Profile singleton for your personal details.",
|
|
4645
5036
|
schema: {
|
|
4646
5037
|
version: 1,
|
|
4647
5038
|
collections: [
|
|
4648
5039
|
{
|
|
4649
5040
|
name: "experience",
|
|
4650
|
-
label: "
|
|
5041
|
+
label: "Experience",
|
|
4651
5042
|
path: "experience",
|
|
4652
5043
|
fields: [
|
|
4653
|
-
{
|
|
4654
|
-
|
|
4655
|
-
|
|
5044
|
+
{
|
|
5045
|
+
key: "role",
|
|
5046
|
+
label: "Role",
|
|
5047
|
+
type: "text",
|
|
5048
|
+
required: true
|
|
5049
|
+
},
|
|
5050
|
+
{
|
|
5051
|
+
key: "company",
|
|
5052
|
+
label: "Company",
|
|
5053
|
+
type: "text",
|
|
5054
|
+
required: true
|
|
5055
|
+
},
|
|
5056
|
+
{
|
|
5057
|
+
key: "summary",
|
|
5058
|
+
label: "Summary",
|
|
5059
|
+
type: "richtext"
|
|
5060
|
+
}
|
|
4656
5061
|
]
|
|
4657
5062
|
}
|
|
4658
5063
|
],
|
|
4659
5064
|
singletons: [
|
|
4660
5065
|
{
|
|
4661
5066
|
name: "profile",
|
|
4662
|
-
label: "
|
|
5067
|
+
label: "Profile",
|
|
4663
5068
|
path: "profile.json",
|
|
4664
5069
|
fields: [
|
|
4665
|
-
{
|
|
4666
|
-
|
|
5070
|
+
{
|
|
5071
|
+
key: "name",
|
|
5072
|
+
label: "Name",
|
|
5073
|
+
type: "text",
|
|
5074
|
+
required: true
|
|
5075
|
+
},
|
|
5076
|
+
{
|
|
5077
|
+
key: "headline",
|
|
5078
|
+
label: "Headline",
|
|
5079
|
+
type: "text"
|
|
5080
|
+
}
|
|
4667
5081
|
]
|
|
4668
5082
|
}
|
|
4669
5083
|
]
|
|
4670
5084
|
},
|
|
4671
5085
|
samples: {
|
|
4672
|
-
experience: [
|
|
5086
|
+
experience: [
|
|
5087
|
+
{
|
|
5088
|
+
role: "Developer",
|
|
5089
|
+
company: "Acme Inc.",
|
|
5090
|
+
summary: "..."
|
|
5091
|
+
}
|
|
5092
|
+
],
|
|
5093
|
+
profile: {
|
|
5094
|
+
name: "Ada Lovelace",
|
|
5095
|
+
headline: "Software engineer"
|
|
5096
|
+
}
|
|
4673
5097
|
}
|
|
4674
5098
|
};
|
|
4675
5099
|
|
|
4676
5100
|
// src/templates/docs.json
|
|
4677
5101
|
var docs_default = {
|
|
4678
|
-
title: "
|
|
4679
|
-
description: "
|
|
5102
|
+
title: "Documentation",
|
|
5103
|
+
description: "Orderable pages with rich-text content, plus a Settings singleton for the site title.",
|
|
4680
5104
|
schema: {
|
|
4681
5105
|
version: 1,
|
|
4682
5106
|
collections: [
|
|
4683
5107
|
{
|
|
4684
5108
|
name: "pages",
|
|
4685
|
-
label: "
|
|
5109
|
+
label: "Pages",
|
|
4686
5110
|
path: "pages",
|
|
4687
5111
|
fields: [
|
|
4688
|
-
{
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
5112
|
+
{
|
|
5113
|
+
key: "title",
|
|
5114
|
+
label: "Title",
|
|
5115
|
+
type: "text",
|
|
5116
|
+
required: true
|
|
5117
|
+
},
|
|
5118
|
+
{
|
|
5119
|
+
key: "slug",
|
|
5120
|
+
label: "Slug",
|
|
5121
|
+
type: "text",
|
|
5122
|
+
required: true
|
|
5123
|
+
},
|
|
5124
|
+
{
|
|
5125
|
+
key: "order",
|
|
5126
|
+
label: "Order",
|
|
5127
|
+
type: "number"
|
|
5128
|
+
},
|
|
5129
|
+
{
|
|
5130
|
+
key: "body",
|
|
5131
|
+
label: "Body",
|
|
5132
|
+
type: "richtext"
|
|
5133
|
+
}
|
|
4692
5134
|
]
|
|
4693
5135
|
}
|
|
4694
5136
|
],
|
|
4695
5137
|
singletons: [
|
|
4696
5138
|
{
|
|
4697
5139
|
name: "settings",
|
|
4698
|
-
label: "
|
|
5140
|
+
label: "Settings",
|
|
4699
5141
|
path: "settings.json",
|
|
4700
5142
|
fields: [
|
|
4701
|
-
{
|
|
4702
|
-
|
|
5143
|
+
{
|
|
5144
|
+
key: "title",
|
|
5145
|
+
label: "Site name",
|
|
5146
|
+
type: "text"
|
|
5147
|
+
},
|
|
5148
|
+
{
|
|
5149
|
+
key: "tagline",
|
|
5150
|
+
label: "Tagline",
|
|
5151
|
+
type: "text"
|
|
5152
|
+
}
|
|
4703
5153
|
]
|
|
4704
5154
|
}
|
|
4705
5155
|
]
|
|
4706
5156
|
},
|
|
4707
5157
|
samples: {
|
|
4708
5158
|
pages: [
|
|
4709
|
-
{
|
|
4710
|
-
|
|
5159
|
+
{
|
|
5160
|
+
title: "Getting started",
|
|
5161
|
+
slug: "getting-started",
|
|
5162
|
+
order: 1,
|
|
5163
|
+
body: "Welcome aboard."
|
|
5164
|
+
}
|
|
5165
|
+
],
|
|
5166
|
+
settings: {
|
|
5167
|
+
title: "Docs",
|
|
5168
|
+
tagline: "Everything you need to know"
|
|
5169
|
+
}
|
|
4711
5170
|
}
|
|
4712
5171
|
};
|
|
4713
5172
|
|
|
4714
5173
|
// src/templates/event.json
|
|
4715
5174
|
var event_default = {
|
|
4716
|
-
title: "
|
|
4717
|
-
description: "
|
|
5175
|
+
title: "Event schedule",
|
|
5176
|
+
description: "An event agenda of sessions with date, time and speaker.",
|
|
4718
5177
|
schema: {
|
|
4719
5178
|
version: 1,
|
|
4720
5179
|
collections: [
|
|
4721
5180
|
{
|
|
4722
|
-
name: "
|
|
4723
|
-
label: "
|
|
4724
|
-
path: "
|
|
5181
|
+
name: "sessions",
|
|
5182
|
+
label: "Sessions",
|
|
5183
|
+
path: "sessions",
|
|
4725
5184
|
fields: [
|
|
4726
|
-
{
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
{
|
|
5185
|
+
{
|
|
5186
|
+
key: "title",
|
|
5187
|
+
label: "Title",
|
|
5188
|
+
type: "text",
|
|
5189
|
+
required: true
|
|
5190
|
+
},
|
|
5191
|
+
{
|
|
5192
|
+
key: "slug",
|
|
5193
|
+
label: "Slug",
|
|
5194
|
+
type: "text",
|
|
5195
|
+
required: true
|
|
5196
|
+
},
|
|
5197
|
+
{
|
|
5198
|
+
key: "date",
|
|
5199
|
+
label: "Date",
|
|
5200
|
+
type: "date"
|
|
5201
|
+
},
|
|
5202
|
+
{
|
|
5203
|
+
key: "time",
|
|
5204
|
+
label: "Time",
|
|
5205
|
+
type: "text"
|
|
5206
|
+
},
|
|
5207
|
+
{
|
|
5208
|
+
key: "speaker",
|
|
5209
|
+
label: "Speaker",
|
|
5210
|
+
type: "text"
|
|
5211
|
+
},
|
|
5212
|
+
{
|
|
5213
|
+
key: "room",
|
|
5214
|
+
label: "Room",
|
|
5215
|
+
type: "text"
|
|
5216
|
+
},
|
|
5217
|
+
{
|
|
5218
|
+
key: "description",
|
|
5219
|
+
label: "Description",
|
|
5220
|
+
type: "richtext"
|
|
5221
|
+
}
|
|
4733
5222
|
]
|
|
4734
5223
|
}
|
|
4735
5224
|
],
|
|
4736
5225
|
singletons: [
|
|
4737
5226
|
{
|
|
4738
|
-
name: "
|
|
4739
|
-
label: "
|
|
4740
|
-
path: "
|
|
5227
|
+
name: "event",
|
|
5228
|
+
label: "Event",
|
|
5229
|
+
path: "event.json",
|
|
4741
5230
|
fields: [
|
|
4742
|
-
{
|
|
4743
|
-
|
|
5231
|
+
{
|
|
5232
|
+
key: "title",
|
|
5233
|
+
label: "Event name",
|
|
5234
|
+
type: "text"
|
|
5235
|
+
},
|
|
5236
|
+
{
|
|
5237
|
+
key: "tagline",
|
|
5238
|
+
label: "Tagline",
|
|
5239
|
+
type: "text"
|
|
5240
|
+
}
|
|
4744
5241
|
]
|
|
4745
5242
|
}
|
|
4746
5243
|
]
|
|
4747
5244
|
},
|
|
4748
5245
|
samples: {
|
|
4749
|
-
|
|
5246
|
+
sessions: [
|
|
4750
5247
|
{
|
|
4751
|
-
title: "
|
|
4752
|
-
slug: "
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
5248
|
+
title: "Opening keynote",
|
|
5249
|
+
slug: "opening-keynote",
|
|
5250
|
+
time: "09:00",
|
|
5251
|
+
speaker: "Ada Lovelace",
|
|
5252
|
+
room: "Main hall",
|
|
5253
|
+
description: "Welcome to the event."
|
|
4757
5254
|
}
|
|
4758
|
-
]
|
|
5255
|
+
],
|
|
5256
|
+
event: {
|
|
5257
|
+
title: "My event",
|
|
5258
|
+
tagline: "One day, one track"
|
|
5259
|
+
}
|
|
4759
5260
|
}
|
|
4760
5261
|
};
|
|
4761
5262
|
|
|
4762
5263
|
// src/templates/portfolio.json
|
|
4763
5264
|
var portfolio_default = {
|
|
4764
|
-
title: "
|
|
4765
|
-
description: "
|
|
5265
|
+
title: "Portfolio",
|
|
5266
|
+
description: "Projects with a cover image, tag and rich-text body, plus an About page.",
|
|
4766
5267
|
schema: {
|
|
4767
5268
|
version: 1,
|
|
4768
5269
|
collections: [
|
|
4769
5270
|
{
|
|
4770
5271
|
name: "projects",
|
|
4771
|
-
label: "
|
|
5272
|
+
label: "Projects",
|
|
4772
5273
|
path: "projects",
|
|
4773
5274
|
fields: [
|
|
4774
|
-
{
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
5275
|
+
{
|
|
5276
|
+
key: "title",
|
|
5277
|
+
label: "Title",
|
|
5278
|
+
type: "text",
|
|
5279
|
+
required: true
|
|
5280
|
+
},
|
|
5281
|
+
{
|
|
5282
|
+
key: "slug",
|
|
5283
|
+
label: "Slug",
|
|
5284
|
+
type: "text",
|
|
5285
|
+
required: true
|
|
5286
|
+
},
|
|
5287
|
+
{
|
|
5288
|
+
key: "summary",
|
|
5289
|
+
label: "Summary",
|
|
5290
|
+
type: "text"
|
|
5291
|
+
},
|
|
5292
|
+
{
|
|
5293
|
+
key: "cover",
|
|
5294
|
+
label: "Cover",
|
|
5295
|
+
type: "image"
|
|
5296
|
+
},
|
|
5297
|
+
{
|
|
5298
|
+
key: "url",
|
|
5299
|
+
label: "Link",
|
|
5300
|
+
type: "url"
|
|
5301
|
+
},
|
|
4779
5302
|
{
|
|
4780
5303
|
key: "kind",
|
|
4781
|
-
label: "
|
|
5304
|
+
label: "Kind",
|
|
4782
5305
|
type: "select",
|
|
4783
|
-
options: ["Web", "
|
|
5306
|
+
options: ["Web", "Mobile", "Design"]
|
|
4784
5307
|
},
|
|
4785
|
-
{
|
|
4786
|
-
|
|
5308
|
+
{
|
|
5309
|
+
key: "body",
|
|
5310
|
+
label: "Body",
|
|
5311
|
+
type: "richtext"
|
|
5312
|
+
},
|
|
5313
|
+
{
|
|
5314
|
+
key: "date",
|
|
5315
|
+
label: "Date",
|
|
5316
|
+
type: "date"
|
|
5317
|
+
}
|
|
4787
5318
|
]
|
|
4788
5319
|
}
|
|
4789
5320
|
],
|
|
4790
5321
|
singletons: [
|
|
4791
5322
|
{
|
|
4792
5323
|
name: "about",
|
|
4793
|
-
label: "
|
|
5324
|
+
label: "About",
|
|
4794
5325
|
path: "about.json",
|
|
4795
5326
|
fields: [
|
|
4796
|
-
{
|
|
4797
|
-
|
|
4798
|
-
|
|
5327
|
+
{
|
|
5328
|
+
key: "name",
|
|
5329
|
+
label: "Name",
|
|
5330
|
+
type: "text",
|
|
5331
|
+
required: true
|
|
5332
|
+
},
|
|
5333
|
+
{
|
|
5334
|
+
key: "avatar",
|
|
5335
|
+
label: "Avatar",
|
|
5336
|
+
type: "image"
|
|
5337
|
+
},
|
|
5338
|
+
{
|
|
5339
|
+
key: "bio",
|
|
5340
|
+
label: "Bio",
|
|
5341
|
+
type: "richtext"
|
|
5342
|
+
}
|
|
4799
5343
|
]
|
|
4800
5344
|
}
|
|
4801
5345
|
]
|
|
@@ -4803,58 +5347,101 @@ var portfolio_default = {
|
|
|
4803
5347
|
samples: {
|
|
4804
5348
|
projects: [
|
|
4805
5349
|
{
|
|
4806
|
-
title: "
|
|
4807
|
-
slug: "
|
|
4808
|
-
summary: "
|
|
5350
|
+
title: "Sample project",
|
|
5351
|
+
slug: "sample-project",
|
|
5352
|
+
summary: "A short description.",
|
|
4809
5353
|
kind: "Web",
|
|
4810
|
-
body: "
|
|
5354
|
+
body: "Project details go here."
|
|
4811
5355
|
}
|
|
4812
|
-
]
|
|
5356
|
+
],
|
|
5357
|
+
about: {
|
|
5358
|
+
name: "Ada Lovelace",
|
|
5359
|
+
bio: "I build things for the web."
|
|
5360
|
+
}
|
|
4813
5361
|
}
|
|
4814
5362
|
};
|
|
4815
5363
|
|
|
4816
5364
|
// src/templates/recipe.json
|
|
4817
5365
|
var recipe_default = {
|
|
4818
|
-
title: "
|
|
4819
|
-
description: "
|
|
5366
|
+
title: "Recipe box",
|
|
5367
|
+
description: "Recipes with ingredients, steps and a cover image, plus Kitchen settings.",
|
|
4820
5368
|
schema: {
|
|
4821
5369
|
version: 1,
|
|
4822
5370
|
collections: [
|
|
4823
5371
|
{
|
|
4824
|
-
name: "
|
|
4825
|
-
label: "
|
|
4826
|
-
path: "
|
|
5372
|
+
name: "recipes",
|
|
5373
|
+
label: "Recipes",
|
|
5374
|
+
path: "recipes",
|
|
4827
5375
|
fields: [
|
|
4828
|
-
{
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
{
|
|
5376
|
+
{
|
|
5377
|
+
key: "title",
|
|
5378
|
+
label: "Title",
|
|
5379
|
+
type: "text",
|
|
5380
|
+
required: true
|
|
5381
|
+
},
|
|
5382
|
+
{
|
|
5383
|
+
key: "slug",
|
|
5384
|
+
label: "Slug",
|
|
5385
|
+
type: "text",
|
|
5386
|
+
required: true
|
|
5387
|
+
},
|
|
5388
|
+
{
|
|
5389
|
+
key: "time",
|
|
5390
|
+
label: "Time",
|
|
5391
|
+
type: "text"
|
|
5392
|
+
},
|
|
5393
|
+
{
|
|
5394
|
+
key: "servings",
|
|
5395
|
+
label: "Servings",
|
|
5396
|
+
type: "number"
|
|
5397
|
+
},
|
|
5398
|
+
{
|
|
5399
|
+
key: "cover",
|
|
5400
|
+
label: "Cover",
|
|
5401
|
+
type: "image"
|
|
5402
|
+
},
|
|
5403
|
+
{
|
|
5404
|
+
key: "ingredients",
|
|
5405
|
+
label: "Ingredients",
|
|
5406
|
+
type: "richtext"
|
|
5407
|
+
},
|
|
5408
|
+
{
|
|
5409
|
+
key: "steps",
|
|
5410
|
+
label: "Steps",
|
|
5411
|
+
type: "richtext"
|
|
5412
|
+
}
|
|
4835
5413
|
]
|
|
4836
5414
|
}
|
|
4837
5415
|
],
|
|
4838
5416
|
singletons: [
|
|
4839
5417
|
{
|
|
4840
|
-
name: "
|
|
4841
|
-
label: "
|
|
4842
|
-
path: "
|
|
4843
|
-
fields: [
|
|
5418
|
+
name: "kitchen",
|
|
5419
|
+
label: "Kitchen",
|
|
5420
|
+
path: "kitchen.json",
|
|
5421
|
+
fields: [
|
|
5422
|
+
{
|
|
5423
|
+
key: "title",
|
|
5424
|
+
label: "Site name",
|
|
5425
|
+
type: "text"
|
|
5426
|
+
}
|
|
5427
|
+
]
|
|
4844
5428
|
}
|
|
4845
5429
|
]
|
|
4846
5430
|
},
|
|
4847
5431
|
samples: {
|
|
4848
|
-
|
|
5432
|
+
recipes: [
|
|
4849
5433
|
{
|
|
4850
|
-
title: "
|
|
4851
|
-
slug: "
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
5434
|
+
title: "Weeknight pasta",
|
|
5435
|
+
slug: "weeknight-pasta",
|
|
5436
|
+
time: "20 min",
|
|
5437
|
+
servings: 2,
|
|
5438
|
+
ingredients: "- 200g pasta\n- 2 cloves garlic\n- Olive oil",
|
|
5439
|
+
steps: "Boil the pasta. Fry the garlic in olive oil. Toss together."
|
|
4856
5440
|
}
|
|
4857
|
-
]
|
|
5441
|
+
],
|
|
5442
|
+
kitchen: {
|
|
5443
|
+
title: "My recipe box"
|
|
5444
|
+
}
|
|
4858
5445
|
}
|
|
4859
5446
|
};
|
|
4860
5447
|
|
|
@@ -4894,10 +5481,15 @@ async function applyTemplate(adapter, contentDir, template) {
|
|
|
4894
5481
|
const schema = parseSchema(template.schema);
|
|
4895
5482
|
await saveSchema(adapter, schema, contentDir);
|
|
4896
5483
|
const store = new ContentStore(adapter, schema, contentDir);
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
await store.
|
|
5484
|
+
const singletonNames = new Set(schema.singletons.map((s) => s.name));
|
|
5485
|
+
for (const [name, sample] of Object.entries(template.samples)) {
|
|
5486
|
+
if (!Array.isArray(sample)) {
|
|
5487
|
+
if (singletonNames.has(name)) await store.writeSingleton(name, sample);
|
|
5488
|
+
continue;
|
|
5489
|
+
}
|
|
5490
|
+
for (const row of sample) {
|
|
5491
|
+
const slug = slugify(typeof row.slug === "string" ? row.slug : String(row.title ?? "content"));
|
|
5492
|
+
await store.writeEntry(name, slug, row);
|
|
4901
5493
|
}
|
|
4902
5494
|
}
|
|
4903
5495
|
return schema;
|
|
@@ -4908,29 +5500,402 @@ async function initProject(root2, templateName) {
|
|
|
4908
5500
|
const adapter = new FsAdapter(root2);
|
|
4909
5501
|
const contentDir = await resolveContentDir(root2);
|
|
4910
5502
|
if (await loadSchema(adapter, contentDir)) {
|
|
4911
|
-
throw new Error("
|
|
5503
|
+
throw new Error("This folder already has a schema (content/_schema.json).");
|
|
4912
5504
|
}
|
|
4913
5505
|
await applyTemplate(adapter, contentDir, template);
|
|
4914
5506
|
}
|
|
4915
5507
|
|
|
4916
5508
|
// src/commands/types.ts
|
|
4917
5509
|
import { join as join2 } from "path";
|
|
4918
|
-
async function generateTypesFile(root2, outPath = "types.ts") {
|
|
5510
|
+
async function generateTypesFile(root2, outPath = "types.ts", loaderPath = "content.ts") {
|
|
4919
5511
|
const adapter = new FsAdapter(root2);
|
|
4920
5512
|
const contentDir = await resolveContentDir(root2);
|
|
4921
5513
|
const schema = await loadSchema(adapter, contentDir);
|
|
4922
|
-
if (!schema) throw new Error("
|
|
5514
|
+
if (!schema) throw new Error("No schema found. Run `justjson init` first.");
|
|
4923
5515
|
await adapter.write(outPath, generateTypes(schema));
|
|
5516
|
+
await adapter.write(loaderPath, generateLoader(schema, contentDir));
|
|
4924
5517
|
return join2(root2, outPath);
|
|
4925
5518
|
}
|
|
4926
5519
|
|
|
5520
|
+
// src/commands/validate.ts
|
|
5521
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
5522
|
+
import { join as join3 } from "path";
|
|
5523
|
+
async function listDir(path) {
|
|
5524
|
+
try {
|
|
5525
|
+
const entries = await readdir2(path, { withFileTypes: true });
|
|
5526
|
+
return entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
5527
|
+
} catch {
|
|
5528
|
+
return [];
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
async function loadProjectContent(root2) {
|
|
5532
|
+
const adapter = new FsAdapter(root2);
|
|
5533
|
+
const contentDir = await resolveContentDir(root2);
|
|
5534
|
+
const schema = await loadSchema(adapter, contentDir);
|
|
5535
|
+
if (!schema) return null;
|
|
5536
|
+
const store = new ContentStore(adapter, schema, contentDir);
|
|
5537
|
+
const collections = {};
|
|
5538
|
+
for (const col of schema.collections) {
|
|
5539
|
+
const slugs = await store.listEntries(col.name);
|
|
5540
|
+
const entries = [];
|
|
5541
|
+
for (const slug of slugs) {
|
|
5542
|
+
entries.push({ slug, data: await store.readEntry(col.name, slug) ?? {} });
|
|
5543
|
+
}
|
|
5544
|
+
collections[col.name] = entries;
|
|
5545
|
+
}
|
|
5546
|
+
const known = new Set(schema.collections.map((c) => c.name));
|
|
5547
|
+
for (const { name, isDir } of await listDir(join3(root2, contentDir))) {
|
|
5548
|
+
if (!isDir || name === "media" || known.has(name) || collections[name]) continue;
|
|
5549
|
+
const files = await listDir(join3(root2, contentDir, name));
|
|
5550
|
+
collections[name] = files.filter((f) => !f.isDir && f.name.endsWith(".json")).map((f) => ({ slug: f.name.slice(0, -".json".length), data: {} }));
|
|
5551
|
+
}
|
|
5552
|
+
const singletons = {};
|
|
5553
|
+
for (const s of schema.singletons) {
|
|
5554
|
+
singletons[s.name] = await store.readSingleton(s.name);
|
|
5555
|
+
}
|
|
5556
|
+
const media = (await listDir(join3(root2, contentDir, "media"))).filter((f) => !f.isDir).map((f) => `${contentDir}/media/${f.name}`);
|
|
5557
|
+
return { schema, content: { collections, singletons, media } };
|
|
5558
|
+
}
|
|
5559
|
+
async function validateProjectAt(root2) {
|
|
5560
|
+
const loaded = await loadProjectContent(root2);
|
|
5561
|
+
if (!loaded) return null;
|
|
5562
|
+
return validateProject(loaded.schema, loaded.content);
|
|
5563
|
+
}
|
|
5564
|
+
function summarize(issues) {
|
|
5565
|
+
let errors = 0;
|
|
5566
|
+
let warnings = 0;
|
|
5567
|
+
for (const i of issues) {
|
|
5568
|
+
if (i.level === "error") errors++;
|
|
5569
|
+
else warnings++;
|
|
5570
|
+
}
|
|
5571
|
+
return { errors, warnings };
|
|
5572
|
+
}
|
|
5573
|
+
function shouldFail(issues, strict) {
|
|
5574
|
+
const { errors, warnings } = summarize(issues);
|
|
5575
|
+
return errors > 0 || strict && warnings > 0;
|
|
5576
|
+
}
|
|
5577
|
+
function location(issue) {
|
|
5578
|
+
const parts = [];
|
|
5579
|
+
if (issue.collection) parts.push(issue.collection);
|
|
5580
|
+
if (issue.singleton) parts.push(issue.singleton);
|
|
5581
|
+
if (issue.slug) parts.push(issue.slug);
|
|
5582
|
+
if (issue.field) parts.push(issue.field);
|
|
5583
|
+
return parts.join(":") || "(project)";
|
|
5584
|
+
}
|
|
5585
|
+
function formatText(issues) {
|
|
5586
|
+
if (issues.length === 0) return "\u2713 No issues \u2014 content matches the schema.";
|
|
5587
|
+
const lines = issues.map((i) => {
|
|
5588
|
+
const tag = i.level === "error" ? "ERROR" : "WARN ";
|
|
5589
|
+
return ` ${tag} ${location(i)} ${i.message}`;
|
|
5590
|
+
});
|
|
5591
|
+
const { errors, warnings } = summarize(issues);
|
|
5592
|
+
lines.push("");
|
|
5593
|
+
lines.push(`${errors} error(s), ${warnings} warning(s)`);
|
|
5594
|
+
return lines.join("\n");
|
|
5595
|
+
}
|
|
5596
|
+
function formatJson(issues) {
|
|
5597
|
+
const { errors, warnings } = summarize(issues);
|
|
5598
|
+
return JSON.stringify({ ok: errors === 0, errors, warnings, issues }, null, 2);
|
|
5599
|
+
}
|
|
5600
|
+
|
|
5601
|
+
// src/scaffold.ts
|
|
5602
|
+
import { access, mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
5603
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
5604
|
+
var ASTRO_VERSION = "^5.18.0";
|
|
5605
|
+
var LOADER_VERSION = "^1.6.0";
|
|
5606
|
+
var STYLE = ` <style is:global>
|
|
5607
|
+
:root { color-scheme: light dark; }
|
|
5608
|
+
body {
|
|
5609
|
+
margin: 0 auto;
|
|
5610
|
+
max-width: 44rem;
|
|
5611
|
+
padding: 3rem 1.25rem 6rem;
|
|
5612
|
+
font: 16px/1.65 ui-sans-serif, system-ui, sans-serif;
|
|
5613
|
+
}
|
|
5614
|
+
h1 { font-size: 1.9rem; line-height: 1.2; margin: 0 0 0.35rem; }
|
|
5615
|
+
h2 { font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.6; margin: 2.75rem 0 0.75rem; }
|
|
5616
|
+
ul { list-style: none; margin: 0; padding: 0; }
|
|
5617
|
+
li + li { margin-top: 0.6rem; }
|
|
5618
|
+
a { color: inherit; }
|
|
5619
|
+
time { opacity: 0.6; font-size: 0.9rem; }
|
|
5620
|
+
img { max-width: 100%; height: auto; border-radius: 8px; }
|
|
5621
|
+
.body { white-space: pre-wrap; }
|
|
5622
|
+
</style>`;
|
|
5623
|
+
function titleFieldOf(fields) {
|
|
5624
|
+
const preferred = fields.find(
|
|
5625
|
+
(f) => ["title", "name", "label", "role", "version"].includes(f.key)
|
|
5626
|
+
);
|
|
5627
|
+
return (preferred ?? fields.find((f) => f.type === "text"))?.key ?? "title";
|
|
5628
|
+
}
|
|
5629
|
+
function safePackageName(name) {
|
|
5630
|
+
return slugify(name) || "my-site";
|
|
5631
|
+
}
|
|
5632
|
+
function detailPage(collection) {
|
|
5633
|
+
const titleKey = titleFieldOf(collection.fields);
|
|
5634
|
+
const image = collection.fields.find((f) => f.type === "image");
|
|
5635
|
+
const rich = collection.fields.find((f) => f.type === "richtext");
|
|
5636
|
+
const date = collection.fields.find((f) => f.type === "date");
|
|
5637
|
+
const parts = [
|
|
5638
|
+
`<h1>{entry.data.${titleKey} ?? entry.id}</h1>`,
|
|
5639
|
+
date && `{entry.data.${date.key} && <time>{entry.data.${date.key}}</time>}`,
|
|
5640
|
+
image && `{entry.data.${image.key} && <img src={\`/media/\${entry.data.${image.key}.split('/').pop()}\`} alt="" />}`,
|
|
5641
|
+
rich && `{entry.data.${rich.key} && <div class="body">{entry.data.${rich.key}}</div>}`
|
|
5642
|
+
].filter(Boolean);
|
|
5643
|
+
return `---
|
|
5644
|
+
import { getCollection } from 'astro:content'
|
|
5645
|
+
|
|
5646
|
+
export async function getStaticPaths() {
|
|
5647
|
+
const entries = await getCollection('${collection.name}')
|
|
5648
|
+
return entries.map((entry) => ({ params: { slug: entry.id }, props: { entry } }))
|
|
5649
|
+
}
|
|
5650
|
+
|
|
5651
|
+
const { entry } = Astro.props
|
|
5652
|
+
---
|
|
5653
|
+
<html lang="en">
|
|
5654
|
+
<head>
|
|
5655
|
+
<meta charset="utf-8" />
|
|
5656
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5657
|
+
<title>{entry.data.${titleKey} ?? entry.id}</title>
|
|
5658
|
+
${STYLE}
|
|
5659
|
+
</head>
|
|
5660
|
+
<body>
|
|
5661
|
+
<p><a href="/">← Back</a></p>
|
|
5662
|
+
${parts.map((p) => ` ${p}`).join("\n")}
|
|
5663
|
+
</body>
|
|
5664
|
+
</html>
|
|
5665
|
+
`;
|
|
5666
|
+
}
|
|
5667
|
+
function indexPage(schema, projectName) {
|
|
5668
|
+
const singleton = schema.singletons[0];
|
|
5669
|
+
const imports = ["getCollection", singleton && "getEntry"].filter(Boolean).join(", ");
|
|
5670
|
+
const loads = schema.collections.map(
|
|
5671
|
+
(c) => `const ${varFor(c.name)} = await getCollection('${c.name}')`
|
|
5672
|
+
);
|
|
5673
|
+
if (singleton) {
|
|
5674
|
+
loads.unshift(`const site = await getEntry('${singleton.name}', '${singleton.name}')`);
|
|
5675
|
+
}
|
|
5676
|
+
const heading = singleton ? `{site?.data.${titleFieldOf(singleton.fields)} ?? '${projectName}'}` : `${projectName}`;
|
|
5677
|
+
const sections = schema.collections.map((c) => {
|
|
5678
|
+
const titleKey = titleFieldOf(c.fields);
|
|
5679
|
+
return ` <h2>${c.label ?? c.name}</h2>
|
|
5680
|
+
<ul>
|
|
5681
|
+
{${varFor(c.name)}.map((entry) => (
|
|
5682
|
+
<li>
|
|
5683
|
+
<a href={\`/${c.name}/\${entry.id}\`}>{entry.data.${titleKey} ?? entry.id}</a>
|
|
5684
|
+
</li>
|
|
5685
|
+
))}
|
|
5686
|
+
</ul>`;
|
|
5687
|
+
});
|
|
5688
|
+
const empty = ` <p>No content yet \u2014 run <code>npx @kdrgny/justjson</code> and add some.</p>`;
|
|
5689
|
+
return `---
|
|
5690
|
+
import { ${imports} } from 'astro:content'
|
|
5691
|
+
|
|
5692
|
+
${loads.join("\n")}
|
|
5693
|
+
---
|
|
5694
|
+
<html lang="en">
|
|
5695
|
+
<head>
|
|
5696
|
+
<meta charset="utf-8" />
|
|
5697
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5698
|
+
<title>${singleton ? `{site?.data.${titleFieldOf(singleton.fields)} ?? '${projectName}'}` : projectName}</title>
|
|
5699
|
+
${STYLE}
|
|
5700
|
+
</head>
|
|
5701
|
+
<body>
|
|
5702
|
+
<h1>${heading}</h1>
|
|
5703
|
+
${sections.length ? sections.join("\n") : empty}
|
|
5704
|
+
</body>
|
|
5705
|
+
</html>
|
|
5706
|
+
`;
|
|
5707
|
+
}
|
|
5708
|
+
function varFor(name) {
|
|
5709
|
+
const camel = name.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase());
|
|
5710
|
+
return /^[a-zA-Z_$]/.test(camel) ? camel : `c${camel}`;
|
|
5711
|
+
}
|
|
5712
|
+
function astroSiteFiles(schema, projectName) {
|
|
5713
|
+
const files = {
|
|
5714
|
+
"package.json": `${JSON.stringify(
|
|
5715
|
+
{
|
|
5716
|
+
name: safePackageName(projectName),
|
|
5717
|
+
type: "module",
|
|
5718
|
+
version: "0.0.1",
|
|
5719
|
+
private: true,
|
|
5720
|
+
scripts: { dev: "astro dev", build: "astro build", preview: "astro preview" },
|
|
5721
|
+
dependencies: {
|
|
5722
|
+
"@kdrgny/justjson-astro": LOADER_VERSION,
|
|
5723
|
+
astro: ASTRO_VERSION
|
|
5724
|
+
}
|
|
5725
|
+
},
|
|
5726
|
+
null,
|
|
5727
|
+
2
|
|
5728
|
+
)}
|
|
5729
|
+
`,
|
|
5730
|
+
"astro.config.mjs": `import { defineConfig } from 'astro/config'
|
|
5731
|
+
|
|
5732
|
+
export default defineConfig({})
|
|
5733
|
+
`,
|
|
5734
|
+
"tsconfig.json": `${JSON.stringify({ extends: "astro/tsconfigs/strict" }, null, 2)}
|
|
5735
|
+
`,
|
|
5736
|
+
".gitignore": `node_modules
|
|
5737
|
+
dist
|
|
5738
|
+
.astro
|
|
5739
|
+
`,
|
|
5740
|
+
"src/content.config.ts": `import { justjsonCollections } from '@kdrgny/justjson-astro'
|
|
5741
|
+
|
|
5742
|
+
// Every collection and singleton in content/_schema.json, typed from your fields.
|
|
5743
|
+
export const collections = await justjsonCollections()
|
|
5744
|
+
`,
|
|
5745
|
+
"src/pages/index.astro": indexPage(schema, projectName)
|
|
5746
|
+
};
|
|
5747
|
+
for (const collection of schema.collections) {
|
|
5748
|
+
files[`src/pages/${collection.name}/[slug].astro`] = detailPage(collection);
|
|
5749
|
+
}
|
|
5750
|
+
return files;
|
|
5751
|
+
}
|
|
5752
|
+
async function exists(path) {
|
|
5753
|
+
try {
|
|
5754
|
+
await access(path);
|
|
5755
|
+
return true;
|
|
5756
|
+
} catch {
|
|
5757
|
+
return false;
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5760
|
+
async function writeAstroSite(root2, schema, projectName) {
|
|
5761
|
+
const files = astroSiteFiles(schema, projectName);
|
|
5762
|
+
const result = { written: [], skipped: [] };
|
|
5763
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
5764
|
+
const target = join4(root2, relative);
|
|
5765
|
+
if (await exists(target)) {
|
|
5766
|
+
result.skipped.push(relative);
|
|
5767
|
+
continue;
|
|
5768
|
+
}
|
|
5769
|
+
await mkdir2(dirname2(target), { recursive: true });
|
|
5770
|
+
await writeFile3(target, contents, "utf8");
|
|
5771
|
+
result.written.push(relative);
|
|
5772
|
+
}
|
|
5773
|
+
return result;
|
|
5774
|
+
}
|
|
5775
|
+
|
|
4927
5776
|
// src/server.ts
|
|
4928
5777
|
import { exec } from "child_process";
|
|
4929
|
-
import { mkdir as
|
|
4930
|
-
import { basename, dirname as
|
|
5778
|
+
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
|
|
5779
|
+
import { basename as basename2, dirname as dirname3, extname, join as join6, normalize } from "path";
|
|
4931
5780
|
import { fileURLToPath } from "url";
|
|
4932
5781
|
import { serve } from "@hono/node-server";
|
|
4933
5782
|
import { Hono } from "hono";
|
|
5783
|
+
|
|
5784
|
+
// src/detect.ts
|
|
5785
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
5786
|
+
import { join as join5 } from "path";
|
|
5787
|
+
var SIGNATURES = [
|
|
5788
|
+
["astro", "astro"],
|
|
5789
|
+
["next", "next"],
|
|
5790
|
+
["nuxt", "nuxt"],
|
|
5791
|
+
["sveltekit", "@sveltejs/kit"],
|
|
5792
|
+
["vite", "vite"]
|
|
5793
|
+
];
|
|
5794
|
+
async function detectFramework(root2) {
|
|
5795
|
+
let pkg;
|
|
5796
|
+
try {
|
|
5797
|
+
pkg = JSON.parse(await readFile2(join5(root2, "package.json"), "utf8"));
|
|
5798
|
+
} catch {
|
|
5799
|
+
return "unknown";
|
|
5800
|
+
}
|
|
5801
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
5802
|
+
for (const [framework, dependency] of SIGNATURES) {
|
|
5803
|
+
if (deps[dependency]) return framework;
|
|
5804
|
+
}
|
|
5805
|
+
return "node";
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5808
|
+
// src/git.ts
|
|
5809
|
+
import { execFile } from "child_process";
|
|
5810
|
+
import { promisify } from "util";
|
|
5811
|
+
var run = promisify(execFile);
|
|
5812
|
+
async function git(root2, args) {
|
|
5813
|
+
const { stdout } = await run("git", args, { cwd: root2 });
|
|
5814
|
+
return stdout.trim();
|
|
5815
|
+
}
|
|
5816
|
+
async function tryGit(root2, args) {
|
|
5817
|
+
try {
|
|
5818
|
+
return await git(root2, args);
|
|
5819
|
+
} catch {
|
|
5820
|
+
return null;
|
|
5821
|
+
}
|
|
5822
|
+
}
|
|
5823
|
+
async function hasGh() {
|
|
5824
|
+
try {
|
|
5825
|
+
await run("gh", ["--version"]);
|
|
5826
|
+
return true;
|
|
5827
|
+
} catch {
|
|
5828
|
+
return false;
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
async function gitStatus(root2, contentDir) {
|
|
5832
|
+
const inside = await tryGit(root2, ["rev-parse", "--is-inside-work-tree"]);
|
|
5833
|
+
if (inside !== "true") {
|
|
5834
|
+
return {
|
|
5835
|
+
isRepo: false,
|
|
5836
|
+
branch: null,
|
|
5837
|
+
hasRemote: false,
|
|
5838
|
+
remoteUrl: null,
|
|
5839
|
+
pendingFiles: 0,
|
|
5840
|
+
hasGh: await hasGh()
|
|
5841
|
+
};
|
|
5842
|
+
}
|
|
5843
|
+
const branch = await tryGit(root2, ["branch", "--show-current"]);
|
|
5844
|
+
const remoteUrl = await tryGit(root2, ["remote", "get-url", "origin"]);
|
|
5845
|
+
const porcelain = await tryGit(root2, ["status", "--porcelain", "-uall", "--", contentDir]) ?? "";
|
|
5846
|
+
return {
|
|
5847
|
+
isRepo: true,
|
|
5848
|
+
branch: branch || null,
|
|
5849
|
+
hasRemote: remoteUrl !== null,
|
|
5850
|
+
remoteUrl,
|
|
5851
|
+
pendingFiles: porcelain ? porcelain.split("\n").filter(Boolean).length : 0,
|
|
5852
|
+
hasGh: await hasGh()
|
|
5853
|
+
};
|
|
5854
|
+
}
|
|
5855
|
+
async function commitContent(root2, contentDir, message) {
|
|
5856
|
+
const inside = await tryGit(root2, ["rev-parse", "--is-inside-work-tree"]);
|
|
5857
|
+
if (inside !== "true") throw new Error("Not a git repository \u2014 run `git init` first.");
|
|
5858
|
+
await git(root2, ["add", "--", contentDir]);
|
|
5859
|
+
const staged = await tryGit(root2, ["diff", "--cached", "--name-only", "--", contentDir]);
|
|
5860
|
+
if (!staged) return { committed: false, count: 0 };
|
|
5861
|
+
await git(root2, ["commit", "-m", message, "--", contentDir]);
|
|
5862
|
+
return { committed: true, count: staged.split("\n").filter(Boolean).length };
|
|
5863
|
+
}
|
|
5864
|
+
async function pushContent(root2) {
|
|
5865
|
+
const branch = await tryGit(root2, ["branch", "--show-current"]);
|
|
5866
|
+
if (!branch || branch === "HEAD") throw new Error("No branch to push \u2014 commit something first.");
|
|
5867
|
+
const hasUpstream = await tryGit(root2, ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`]);
|
|
5868
|
+
const args = hasUpstream ? ["push"] : ["push", "-u", "origin", branch];
|
|
5869
|
+
await git(root2, args);
|
|
5870
|
+
return { branch };
|
|
5871
|
+
}
|
|
5872
|
+
async function createGitHubRepo(root2, options) {
|
|
5873
|
+
if (!await hasGh()) {
|
|
5874
|
+
throw new Error("GitHub CLI (gh) not found \u2014 install it, or add a remote yourself.");
|
|
5875
|
+
}
|
|
5876
|
+
const inside = await tryGit(root2, ["rev-parse", "--is-inside-work-tree"]);
|
|
5877
|
+
if (inside !== "true") {
|
|
5878
|
+
await git(root2, ["init", "-b", "main"]);
|
|
5879
|
+
}
|
|
5880
|
+
await run(
|
|
5881
|
+
"gh",
|
|
5882
|
+
[
|
|
5883
|
+
"repo",
|
|
5884
|
+
"create",
|
|
5885
|
+
options.name,
|
|
5886
|
+
options.private ? "--private" : "--public",
|
|
5887
|
+
"--source",
|
|
5888
|
+
".",
|
|
5889
|
+
"--push"
|
|
5890
|
+
],
|
|
5891
|
+
{
|
|
5892
|
+
cwd: root2
|
|
5893
|
+
}
|
|
5894
|
+
);
|
|
5895
|
+
return { name: options.name };
|
|
5896
|
+
}
|
|
5897
|
+
|
|
5898
|
+
// src/server.ts
|
|
4934
5899
|
var editorDir = fileURLToPath(new URL("./editor", import.meta.url));
|
|
4935
5900
|
var PROVIDER_BASE_URLS = {
|
|
4936
5901
|
groq: "https://api.groq.com/openai/v1",
|
|
@@ -4948,16 +5913,17 @@ async function callGemini(apiKey, model, system, prompt) {
|
|
|
4948
5913
|
})
|
|
4949
5914
|
});
|
|
4950
5915
|
const data = await res.json();
|
|
4951
|
-
if (!res.ok) throw new Error(data.error?.message ?? `Gemini
|
|
5916
|
+
if (!res.ok) throw new Error(data.error?.message ?? `Gemini request failed (${res.status})`);
|
|
4952
5917
|
const text = data.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
|
|
4953
|
-
if (!text) throw new Error("Gemini
|
|
5918
|
+
if (!text) throw new Error("Gemini returned an empty response");
|
|
4954
5919
|
return text;
|
|
4955
5920
|
}
|
|
4956
5921
|
async function listGeminiModels(apiKey) {
|
|
4957
5922
|
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}&pageSize=1000`;
|
|
4958
5923
|
const res = await fetch(url);
|
|
4959
5924
|
const data = await res.json();
|
|
4960
|
-
if (!res.ok)
|
|
5925
|
+
if (!res.ok)
|
|
5926
|
+
throw new Error(data.error?.message ?? `Could not fetch the model list (${res.status})`);
|
|
4961
5927
|
return (data.models ?? []).filter((m) => m.supportedGenerationMethods?.includes("generateContent")).map((m) => ({
|
|
4962
5928
|
id: (m.name ?? "").replace(/^models\//, ""),
|
|
4963
5929
|
label: m.displayName ?? (m.name ?? "").replace(/^models\//, "")
|
|
@@ -4966,16 +5932,17 @@ async function listGeminiModels(apiKey) {
|
|
|
4966
5932
|
);
|
|
4967
5933
|
}
|
|
4968
5934
|
async function listOpenAiCompatibleModels(baseUrl, apiKey) {
|
|
4969
|
-
if (!baseUrl) throw new Error("
|
|
5935
|
+
if (!baseUrl) throw new Error("This provider needs a base URL");
|
|
4970
5936
|
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
|
|
4971
5937
|
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}
|
|
4972
5938
|
});
|
|
4973
5939
|
const data = await res.json();
|
|
4974
|
-
if (!res.ok)
|
|
5940
|
+
if (!res.ok)
|
|
5941
|
+
throw new Error(data.error?.message ?? `Could not fetch the model list (${res.status})`);
|
|
4975
5942
|
return (data.data ?? []).map((m) => ({ id: m.id ?? "", label: m.id ?? "" })).filter((m) => m.id);
|
|
4976
5943
|
}
|
|
4977
5944
|
async function callOpenAiCompatible(baseUrl, apiKey, model, system, prompt) {
|
|
4978
|
-
if (!baseUrl) throw new Error("
|
|
5945
|
+
if (!baseUrl) throw new Error("This provider needs a base URL");
|
|
4979
5946
|
const messages = [
|
|
4980
5947
|
...system ? [{ role: "system", content: system }] : [],
|
|
4981
5948
|
{ role: "user", content: prompt }
|
|
@@ -4986,9 +5953,9 @@ async function callOpenAiCompatible(baseUrl, apiKey, model, system, prompt) {
|
|
|
4986
5953
|
body: JSON.stringify({ model, messages })
|
|
4987
5954
|
});
|
|
4988
5955
|
const data = await res.json();
|
|
4989
|
-
if (!res.ok) throw new Error(data.error?.message ??
|
|
5956
|
+
if (!res.ok) throw new Error(data.error?.message ?? `Request failed (${res.status})`);
|
|
4990
5957
|
const text = data.choices?.[0]?.message?.content ?? "";
|
|
4991
|
-
if (!text) throw new Error("
|
|
5958
|
+
if (!text) throw new Error("The provider returned an empty response");
|
|
4992
5959
|
return text;
|
|
4993
5960
|
}
|
|
4994
5961
|
var MIME = {
|
|
@@ -5009,28 +5976,68 @@ async function createServer(root2) {
|
|
|
5009
5976
|
let store = new ContentStore(adapter, schema, contentDir);
|
|
5010
5977
|
const app = new Hono();
|
|
5011
5978
|
app.onError((err, c) => {
|
|
5012
|
-
|
|
5013
|
-
if (
|
|
5014
|
-
|
|
5015
|
-
|
|
5979
|
+
if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);
|
|
5980
|
+
if (err instanceof UnsafeSlugError || err instanceof PathEscapeError) {
|
|
5981
|
+
return c.json({ error: err.message }, 400);
|
|
5982
|
+
}
|
|
5983
|
+
return c.json({ error: "server error" }, 500);
|
|
5016
5984
|
});
|
|
5017
5985
|
app.get(
|
|
5018
5986
|
"/api/_project",
|
|
5019
5987
|
(c) => c.json({
|
|
5020
|
-
name:
|
|
5988
|
+
name: basename2(root2) || "project",
|
|
5021
5989
|
path: root2,
|
|
5022
5990
|
contentDir,
|
|
5023
5991
|
collections: schema.collections.length,
|
|
5024
5992
|
singletons: schema.singletons.length
|
|
5025
5993
|
})
|
|
5026
5994
|
);
|
|
5995
|
+
app.post("/api/_ship/scaffold", async (c) => {
|
|
5996
|
+
if (schema.collections.length === 0 && schema.singletons.length === 0) {
|
|
5997
|
+
return c.json({ error: "Pick a template or build a schema first." }, 400);
|
|
5998
|
+
}
|
|
5999
|
+
return c.json(await writeAstroSite(root2, schema, basename2(root2) || "my-site"));
|
|
6000
|
+
});
|
|
6001
|
+
app.get(
|
|
6002
|
+
"/api/_ship",
|
|
6003
|
+
async (c) => c.json({
|
|
6004
|
+
framework: await detectFramework(root2),
|
|
6005
|
+
git: await gitStatus(root2, contentDir)
|
|
6006
|
+
})
|
|
6007
|
+
);
|
|
6008
|
+
app.post("/api/_ship/commit", async (c) => {
|
|
6009
|
+
const { message } = await c.req.json();
|
|
6010
|
+
try {
|
|
6011
|
+
return c.json(await commitContent(root2, contentDir, message?.trim() || "content: update"));
|
|
6012
|
+
} catch (e) {
|
|
6013
|
+
return c.json({ error: e.message }, 400);
|
|
6014
|
+
}
|
|
6015
|
+
});
|
|
6016
|
+
app.post("/api/_ship/push", async (c) => {
|
|
6017
|
+
try {
|
|
6018
|
+
return c.json(await pushContent(root2));
|
|
6019
|
+
} catch (e) {
|
|
6020
|
+
return c.json({ error: e.message }, 400);
|
|
6021
|
+
}
|
|
6022
|
+
});
|
|
6023
|
+
app.post("/api/_ship/repo", async (c) => {
|
|
6024
|
+
const { name, private: isPrivate } = await c.req.json();
|
|
6025
|
+
if (!name?.trim()) return c.json({ error: "A repository name is required." }, 400);
|
|
6026
|
+
try {
|
|
6027
|
+
return c.json(
|
|
6028
|
+
await createGitHubRepo(root2, { name: name.trim(), private: isPrivate !== false })
|
|
6029
|
+
);
|
|
6030
|
+
} catch (e) {
|
|
6031
|
+
return c.json({ error: e.message }, 400);
|
|
6032
|
+
}
|
|
6033
|
+
});
|
|
5027
6034
|
app.get("/api/_templates", (c) => c.json({ items: templateList() }));
|
|
5028
6035
|
app.post("/api/_init", async (c) => {
|
|
5029
6036
|
const { template: id } = await c.req.json();
|
|
5030
6037
|
const t = id ? getTemplate(id) : void 0;
|
|
5031
|
-
if (!t) return c.json({ error: "
|
|
6038
|
+
if (!t) return c.json({ error: "Unknown template" }, 404);
|
|
5032
6039
|
if (schema.collections.length > 0 || schema.singletons.length > 0) {
|
|
5033
|
-
return c.json({ error: "
|
|
6040
|
+
return c.json({ error: "This folder already has a schema." }, 400);
|
|
5034
6041
|
}
|
|
5035
6042
|
schema = await applyTemplate(adapter, contentDir, t);
|
|
5036
6043
|
store = new ContentStore(adapter, schema, contentDir);
|
|
@@ -5038,7 +6045,7 @@ async function createServer(root2) {
|
|
|
5038
6045
|
});
|
|
5039
6046
|
app.post("/api/_import", async (c) => {
|
|
5040
6047
|
if (schema.collections.length > 0 || schema.singletons.length > 0) {
|
|
5041
|
-
return c.json({ error: "
|
|
6048
|
+
return c.json({ error: "This folder already has a schema." }, 400);
|
|
5042
6049
|
}
|
|
5043
6050
|
const { raw } = await c.req.json();
|
|
5044
6051
|
let next;
|
|
@@ -5053,7 +6060,7 @@ async function createServer(root2) {
|
|
|
5053
6060
|
entries = inferred.entries;
|
|
5054
6061
|
singletonData = inferred.singletons;
|
|
5055
6062
|
} catch (e) {
|
|
5056
|
-
return c.json({ error:
|
|
6063
|
+
return c.json({ error: `Import failed: ${e.message}` }, 400);
|
|
5057
6064
|
}
|
|
5058
6065
|
}
|
|
5059
6066
|
await saveSchema(adapter, next, contentDir);
|
|
@@ -5061,7 +6068,7 @@ async function createServer(root2) {
|
|
|
5061
6068
|
store = new ContentStore(adapter, schema, contentDir);
|
|
5062
6069
|
for (const [collection, rows] of Object.entries(entries)) {
|
|
5063
6070
|
for (const row of rows) {
|
|
5064
|
-
const slug = slugify(String(row.slug ?? row.title ?? "
|
|
6071
|
+
const slug = slugify(String(row.slug ?? row.title ?? "content")) || "content";
|
|
5065
6072
|
await store.writeEntry(collection, slug, row);
|
|
5066
6073
|
}
|
|
5067
6074
|
}
|
|
@@ -5103,19 +6110,19 @@ async function createServer(root2) {
|
|
|
5103
6110
|
});
|
|
5104
6111
|
app.post("/api/_media", async (c) => {
|
|
5105
6112
|
const body = await c.req.json();
|
|
5106
|
-
if (!body.dataBase64) return c.json({ error: "
|
|
6113
|
+
if (!body.dataBase64) return c.json({ error: "no data" }, 400);
|
|
5107
6114
|
const base = slugify((body.filename ?? "gorsel").replace(/\.[^.]+$/, "")) || "gorsel";
|
|
5108
6115
|
const name = `${base}-${Date.now().toString(36)}.webp`;
|
|
5109
6116
|
const rel = `${contentDir}/media/${name}`;
|
|
5110
|
-
const abs =
|
|
5111
|
-
await
|
|
5112
|
-
await
|
|
6117
|
+
const abs = join6(root2, rel);
|
|
6118
|
+
await mkdir3(dirname3(abs), { recursive: true });
|
|
6119
|
+
await writeFile4(abs, Buffer.from(body.dataBase64, "base64"));
|
|
5113
6120
|
return c.json({ path: rel });
|
|
5114
6121
|
});
|
|
5115
6122
|
app.post("/api/_ai/models", async (c) => {
|
|
5116
6123
|
const body = await c.req.json();
|
|
5117
6124
|
const { provider, apiKey } = body;
|
|
5118
|
-
if (!provider) return c.json({ error: "provider
|
|
6125
|
+
if (!provider) return c.json({ error: "provider is required" }, 400);
|
|
5119
6126
|
try {
|
|
5120
6127
|
const models = provider === "gemini" ? await listGeminiModels(apiKey ?? "") : await listOpenAiCompatibleModels(
|
|
5121
6128
|
body.baseUrl || PROVIDER_BASE_URLS[provider] || "",
|
|
@@ -5130,7 +6137,7 @@ async function createServer(root2) {
|
|
|
5130
6137
|
const body = await c.req.json();
|
|
5131
6138
|
const { provider, apiKey, model, system, prompt } = body;
|
|
5132
6139
|
if (!provider || !apiKey || !model || !prompt) {
|
|
5133
|
-
return c.json({ error: "provider, model, apiKey
|
|
6140
|
+
return c.json({ error: "provider, model, apiKey and prompt are required" }, 400);
|
|
5134
6141
|
}
|
|
5135
6142
|
try {
|
|
5136
6143
|
const text = provider === "gemini" ? await callGemini(apiKey, model, system, prompt) : await callOpenAiCompatible(
|
|
@@ -5151,10 +6158,10 @@ async function createServer(root2) {
|
|
|
5151
6158
|
return c.text("forbidden", 400);
|
|
5152
6159
|
}
|
|
5153
6160
|
try {
|
|
5154
|
-
const bytes = await
|
|
6161
|
+
const bytes = await readFile3(join6(root2, contentDir, "media", file));
|
|
5155
6162
|
return new Response(bytes, { headers: { "content-type": "image/webp" } });
|
|
5156
6163
|
} catch {
|
|
5157
|
-
return c.text("
|
|
6164
|
+
return c.text("not found", 404);
|
|
5158
6165
|
}
|
|
5159
6166
|
});
|
|
5160
6167
|
app.get("/api/:collection", async (c) => {
|
|
@@ -5163,7 +6170,7 @@ async function createServer(root2) {
|
|
|
5163
6170
|
});
|
|
5164
6171
|
app.get("/api/:collection/:slug", async (c) => {
|
|
5165
6172
|
const data = await store.readEntry(c.req.param("collection"), c.req.param("slug"));
|
|
5166
|
-
return data ? c.json(data) : c.json({ error: "
|
|
6173
|
+
return data ? c.json(data) : c.json({ error: "not found" }, 404);
|
|
5167
6174
|
});
|
|
5168
6175
|
app.put("/api/:collection/:slug", async (c) => {
|
|
5169
6176
|
const slug = slugify(c.req.param("slug"));
|
|
@@ -5178,17 +6185,17 @@ async function createServer(root2) {
|
|
|
5178
6185
|
app.get("/*", async (c) => {
|
|
5179
6186
|
const urlPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
5180
6187
|
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
5181
|
-
const file =
|
|
6188
|
+
const file = join6(editorDir, rel);
|
|
5182
6189
|
if (!file.startsWith(editorDir)) return c.text("forbidden", 403);
|
|
5183
6190
|
const type = MIME[extname(file)] ?? "application/octet-stream";
|
|
5184
6191
|
try {
|
|
5185
|
-
return new Response(await
|
|
6192
|
+
return new Response(await readFile3(file), { headers: { "content-type": type } });
|
|
5186
6193
|
} catch {
|
|
5187
6194
|
try {
|
|
5188
|
-
const html = await
|
|
6195
|
+
const html = await readFile3(join6(editorDir, "index.html"));
|
|
5189
6196
|
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
5190
6197
|
} catch {
|
|
5191
|
-
return c.text("
|
|
6198
|
+
return c.text("Editor UI not found (run the justjson build first).", 404);
|
|
5192
6199
|
}
|
|
5193
6200
|
}
|
|
5194
6201
|
});
|
|
@@ -5203,27 +6210,51 @@ async function startServer(root2, port) {
|
|
|
5203
6210
|
const app = await createServer(root2);
|
|
5204
6211
|
serve({ fetch: app.fetch, port });
|
|
5205
6212
|
const url = `http://localhost:${port}`;
|
|
5206
|
-
console.log(`JustJSON
|
|
6213
|
+
console.log(`JustJSON is running at ${url}`);
|
|
5207
6214
|
openBrowser(url);
|
|
5208
6215
|
}
|
|
5209
6216
|
|
|
6217
|
+
// src/version.ts
|
|
6218
|
+
import { readFileSync } from "fs";
|
|
6219
|
+
function readVersion() {
|
|
6220
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
6221
|
+
return JSON.parse(raw).version;
|
|
6222
|
+
}
|
|
6223
|
+
|
|
5210
6224
|
// src/cli.ts
|
|
5211
6225
|
var program = new Command();
|
|
5212
6226
|
var root = process.cwd();
|
|
5213
|
-
program.name("justjson").description("
|
|
5214
|
-
program.command("init").description("
|
|
6227
|
+
program.name("justjson").description("A tiny local-first CMS that keeps your content as plain JSON").version(readVersion());
|
|
6228
|
+
program.command("init").description("Scaffold a project from a template").argument("[template]", `template name (${listTemplates().join(", ")})`, "blog").option("--astro", "also generate a working Astro site around the content").action(async (template, opts) => {
|
|
5215
6229
|
await initProject(root, template);
|
|
5216
|
-
console.log(`'${template}' template
|
|
6230
|
+
console.log(`Scaffolded from the '${template}' template.`);
|
|
6231
|
+
if (!opts.astro) return;
|
|
6232
|
+
const schema = await loadSchema(new FsAdapter(root), await resolveContentDir(root));
|
|
6233
|
+
if (!schema) return;
|
|
6234
|
+
const { written, skipped } = await writeAstroSite(root, schema, basename3(root) || "my-site");
|
|
6235
|
+
console.log(`Wrote ${written.length} site file(s).`);
|
|
6236
|
+
if (skipped.length > 0) console.log(`Kept your existing: ${skipped.join(", ")}`);
|
|
6237
|
+
console.log("\nNext:\n npm install\n npm run dev");
|
|
5217
6238
|
});
|
|
5218
|
-
program.command("types").description("
|
|
6239
|
+
program.command("types").description("Generate types.ts and a typed content.ts loader from your schema").action(async () => {
|
|
5219
6240
|
const out = await generateTypesFile(root);
|
|
5220
|
-
console.log(`
|
|
6241
|
+
console.log(`Wrote: ${out}`);
|
|
5221
6242
|
});
|
|
5222
|
-
program.command("export").description("
|
|
6243
|
+
program.command("export").description("Export schema, content and types as a ZIP").action(async () => {
|
|
5223
6244
|
const out = await exportZip(root);
|
|
5224
|
-
console.log(`
|
|
6245
|
+
console.log(`Wrote: ${out}`);
|
|
6246
|
+
});
|
|
6247
|
+
program.command("validate").description("Check your content against the schema (great for CI)").option("--json", "machine-readable JSON output").option("--strict", "treat warnings as errors too").action(async (opts) => {
|
|
6248
|
+
const issues = await validateProjectAt(root);
|
|
6249
|
+
if (issues === null) {
|
|
6250
|
+
console.error("No schema found. Run `justjson init` first.");
|
|
6251
|
+
process.exitCode = 1;
|
|
6252
|
+
return;
|
|
6253
|
+
}
|
|
6254
|
+
console.log(opts.json ? formatJson(issues) : formatText(issues));
|
|
6255
|
+
if (shouldFail(issues, Boolean(opts.strict))) process.exitCode = 1;
|
|
5225
6256
|
});
|
|
5226
|
-
program.command("serve", { isDefault: true }).description("
|
|
6257
|
+
program.command("serve", { isDefault: true }).description("Start the local editor server").option("-p, --port <port>", "port", "5180").action(async (opts) => {
|
|
5227
6258
|
await startServer(root, Number(opts.port));
|
|
5228
6259
|
});
|
|
5229
6260
|
program.parseAsync();
|