@kdrgny/justjson 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/cli.js +1026 -125
- package/dist/editor/assets/index-B7tRIQcy.js +632 -0
- package/dist/editor/assets/index-GwsCiKhf.css +1 -0
- package/dist/editor/assets/public-sans-latin-ext-wght-normal-MQgHevqp.woff2 +0 -0
- package/dist/editor/assets/public-sans-latin-wght-normal-DdeTHZLK.woff2 +0 -0
- package/dist/editor/assets/public-sans-vietnamese-wght-normal-CtlIRbEm.woff2 +0 -0
- package/dist/editor/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/dist/editor/assets/space-mono-latin-400-normal-_3DlpgIW.woff +0 -0
- package/dist/editor/assets/space-mono-latin-700-normal-D7A851RN.woff +0 -0
- package/dist/editor/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/dist/editor/assets/space-mono-latin-ext-400-normal-D4cJI_B-.woff +0 -0
- package/dist/editor/assets/space-mono-latin-ext-400-normal-DTLbW2xa.woff2 +0 -0
- package/dist/editor/assets/space-mono-latin-ext-700-normal-B2s3bDs2.woff2 +0 -0
- package/dist/editor/assets/space-mono-latin-ext-700-normal-B_E7P90g.woff +0 -0
- package/dist/editor/assets/space-mono-vietnamese-400-normal-B0PMp_xB.woff +0 -0
- package/dist/editor/assets/space-mono-vietnamese-400-normal-BNOj0Qhp.woff2 +0 -0
- package/dist/editor/assets/space-mono-vietnamese-700-normal-D-KrLuLr.woff +0 -0
- package/dist/editor/assets/space-mono-vietnamese-700-normal-DWQgDHuA.woff2 +0 -0
- package/dist/editor/index.html +3 -3
- package/package.json +3 -14
- package/dist/editor/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/editor/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/editor/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/editor/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/editor/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/editor/assets/index-B-aBBk0p.css +0 -1
- package/dist/editor/assets/index-DhyPvr9S.js +0 -574
package/dist/cli.js
CHANGED
|
@@ -4054,6 +4054,20 @@ var coerce = {
|
|
|
4054
4054
|
var NEVER = INVALID;
|
|
4055
4055
|
|
|
4056
4056
|
// ../core/dist/index.js
|
|
4057
|
+
var JustJsonError = class extends Error {
|
|
4058
|
+
constructor(message) {
|
|
4059
|
+
super(message);
|
|
4060
|
+
this.name = new.target.name;
|
|
4061
|
+
}
|
|
4062
|
+
};
|
|
4063
|
+
var NotFoundError = class extends JustJsonError {
|
|
4064
|
+
};
|
|
4065
|
+
var UnsafeSlugError = class extends JustJsonError {
|
|
4066
|
+
};
|
|
4067
|
+
var PathEscapeError = class extends JustJsonError {
|
|
4068
|
+
};
|
|
4069
|
+
var SchemaError = class extends JustJsonError {
|
|
4070
|
+
};
|
|
4057
4071
|
var fieldTypes = [
|
|
4058
4072
|
"text",
|
|
4059
4073
|
"richtext",
|
|
@@ -4062,23 +4076,34 @@ var fieldTypes = [
|
|
|
4062
4076
|
"date",
|
|
4063
4077
|
"select",
|
|
4064
4078
|
"relation",
|
|
4065
|
-
"image"
|
|
4079
|
+
"image",
|
|
4080
|
+
"url",
|
|
4081
|
+
"email",
|
|
4082
|
+
"list",
|
|
4083
|
+
"color",
|
|
4084
|
+
"group"
|
|
4066
4085
|
];
|
|
4067
|
-
var zField = external_exports.
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
});
|
|
4086
|
+
var zField = external_exports.lazy(
|
|
4087
|
+
() => external_exports.object({
|
|
4088
|
+
key: external_exports.string().min(1),
|
|
4089
|
+
label: external_exports.string().optional(),
|
|
4090
|
+
type: external_exports.enum(fieldTypes),
|
|
4091
|
+
required: external_exports.boolean().optional(),
|
|
4092
|
+
options: external_exports.array(external_exports.string()).optional(),
|
|
4093
|
+
to: external_exports.string().optional(),
|
|
4094
|
+
fields: external_exports.array(zField).optional()
|
|
4095
|
+
}).superRefine((field, ctx) => {
|
|
4096
|
+
if (field.type === "select" && (!field.options || field.options.length === 0)) {
|
|
4097
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "a select field requires options" });
|
|
4098
|
+
}
|
|
4099
|
+
if (field.type === "relation" && !field.to) {
|
|
4100
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: 'a relation field requires "to"' });
|
|
4101
|
+
}
|
|
4102
|
+
if (field.type === "group" && (!field.fields || field.fields.length === 0)) {
|
|
4103
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: "a group field requires fields" });
|
|
4104
|
+
}
|
|
4105
|
+
})
|
|
4106
|
+
);
|
|
4082
4107
|
var zCollection = external_exports.object({
|
|
4083
4108
|
name: external_exports.string().min(1),
|
|
4084
4109
|
label: external_exports.string().optional(),
|
|
@@ -4102,30 +4127,38 @@ var zSchema = external_exports.object({
|
|
|
4102
4127
|
const containers = [...schema.collections, ...schema.singletons];
|
|
4103
4128
|
for (const c of containers) {
|
|
4104
4129
|
if (names.has(c.name)) {
|
|
4105
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4130
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate name: ${c.name}` });
|
|
4106
4131
|
}
|
|
4107
4132
|
names.add(c.name);
|
|
4108
4133
|
if (c.path.includes("..") || c.path.startsWith("/") || c.path.includes("\\")) {
|
|
4109
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4134
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `unsafe path: ${c.path}` });
|
|
4110
4135
|
}
|
|
4111
4136
|
if (paths.has(c.path)) {
|
|
4112
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4137
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate path: ${c.path}` });
|
|
4113
4138
|
}
|
|
4114
4139
|
paths.add(c.path);
|
|
4115
4140
|
const keys = /* @__PURE__ */ new Set();
|
|
4116
4141
|
for (const f of c.fields) {
|
|
4117
4142
|
if (keys.has(f.key)) {
|
|
4118
|
-
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `
|
|
4143
|
+
ctx.addIssue({ code: external_exports.ZodIssueCode.custom, message: `duplicate field key: ${f.key}` });
|
|
4119
4144
|
}
|
|
4120
4145
|
keys.add(f.key);
|
|
4121
4146
|
if (f.type === "relation" && f.to && !collectionNames.has(f.to)) {
|
|
4122
|
-
ctx.addIssue({
|
|
4147
|
+
ctx.addIssue({
|
|
4148
|
+
code: external_exports.ZodIssueCode.custom,
|
|
4149
|
+
message: `relation target does not exist: ${f.to}`
|
|
4150
|
+
});
|
|
4123
4151
|
}
|
|
4124
4152
|
}
|
|
4125
4153
|
}
|
|
4126
4154
|
});
|
|
4127
4155
|
function parseSchema(input) {
|
|
4128
|
-
|
|
4156
|
+
const result = zSchema.safeParse(input);
|
|
4157
|
+
if (result.success) return result.data;
|
|
4158
|
+
const lines = result.error.issues.map(
|
|
4159
|
+
(i) => i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message
|
|
4160
|
+
);
|
|
4161
|
+
throw new SchemaError(lines.join("\n"));
|
|
4129
4162
|
}
|
|
4130
4163
|
function serializeSchema(schema) {
|
|
4131
4164
|
return `${JSON.stringify(schema, null, 2)}
|
|
@@ -4149,7 +4182,7 @@ var TR = {
|
|
|
4149
4182
|
function slugify(input) {
|
|
4150
4183
|
const mapped = input.replace(/[çğıöşüÇĞİÖŞÜI]/g, (c) => TR[c] ?? c);
|
|
4151
4184
|
const slug = mapped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
4152
|
-
return slug || "
|
|
4185
|
+
return slug || "content";
|
|
4153
4186
|
}
|
|
4154
4187
|
var TITLE_KEYS = ["title", "name", "baslik", "ba\u015Fl\u0131k", "ad", "label", "heading", "isim"];
|
|
4155
4188
|
function entryTitle(fields, data) {
|
|
@@ -4171,7 +4204,7 @@ function schemaPath(contentDir) {
|
|
|
4171
4204
|
}
|
|
4172
4205
|
function assertSafeSlug(slug) {
|
|
4173
4206
|
if (slug.length === 0 || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
|
|
4174
|
-
throw new
|
|
4207
|
+
throw new UnsafeSlugError(`Unsafe slug: ${slug}`);
|
|
4175
4208
|
}
|
|
4176
4209
|
}
|
|
4177
4210
|
async function loadSchema(adapter, contentDir = "content") {
|
|
@@ -4193,12 +4226,12 @@ var ContentStore = class {
|
|
|
4193
4226
|
contentDir;
|
|
4194
4227
|
collection(name) {
|
|
4195
4228
|
const col = this.schema.collections.find((c) => c.name === name);
|
|
4196
|
-
if (!col) throw new
|
|
4229
|
+
if (!col) throw new NotFoundError(`Bilinmeyen koleksiyon: ${name}`);
|
|
4197
4230
|
return col;
|
|
4198
4231
|
}
|
|
4199
4232
|
singleton(name) {
|
|
4200
4233
|
const s = this.schema.singletons.find((x) => x.name === name);
|
|
4201
|
-
if (!s) throw new
|
|
4234
|
+
if (!s) throw new NotFoundError(`Bilinmeyen singleton: ${name}`);
|
|
4202
4235
|
return s;
|
|
4203
4236
|
}
|
|
4204
4237
|
entryPath(col, slug) {
|
|
@@ -4251,6 +4284,206 @@ var ContentStore = class {
|
|
|
4251
4284
|
`);
|
|
4252
4285
|
}
|
|
4253
4286
|
};
|
|
4287
|
+
function isEmpty(value) {
|
|
4288
|
+
return value === void 0 || value === null || value === "";
|
|
4289
|
+
}
|
|
4290
|
+
var URL_RE = /^[a-z][a-z0-9+.-]*:\/\/.+/i;
|
|
4291
|
+
var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
4292
|
+
var COLOR_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
4293
|
+
function typeError(field, value) {
|
|
4294
|
+
switch (field.type) {
|
|
4295
|
+
case "text":
|
|
4296
|
+
case "richtext":
|
|
4297
|
+
case "date":
|
|
4298
|
+
case "image":
|
|
4299
|
+
return typeof value === "string" ? null : "expected text";
|
|
4300
|
+
case "url":
|
|
4301
|
+
if (typeof value !== "string") return "expected text";
|
|
4302
|
+
return URL_RE.test(value) ? null : "expected a valid URL";
|
|
4303
|
+
case "email":
|
|
4304
|
+
if (typeof value !== "string") return "expected text";
|
|
4305
|
+
return EMAIL_RE.test(value) ? null : "expected a valid email address";
|
|
4306
|
+
case "color":
|
|
4307
|
+
if (typeof value !== "string") return "expected text";
|
|
4308
|
+
return COLOR_RE.test(value) ? null : "expected a hex color (e.g. #ff0000)";
|
|
4309
|
+
case "list":
|
|
4310
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
4311
|
+
return "expected a list of text";
|
|
4312
|
+
}
|
|
4313
|
+
return null;
|
|
4314
|
+
case "relation":
|
|
4315
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
4316
|
+
return "expected a list of slugs";
|
|
4317
|
+
}
|
|
4318
|
+
return null;
|
|
4319
|
+
case "group":
|
|
4320
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
4321
|
+
return "expected an object";
|
|
4322
|
+
}
|
|
4323
|
+
return null;
|
|
4324
|
+
case "number":
|
|
4325
|
+
return typeof value === "number" ? null : "expected a number";
|
|
4326
|
+
case "boolean":
|
|
4327
|
+
return typeof value === "boolean" ? null : "expected true or false";
|
|
4328
|
+
case "select":
|
|
4329
|
+
if (typeof value !== "string") return "expected text";
|
|
4330
|
+
return field.options?.includes(value) ? null : "not one of the options";
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
function validateEntry(fields, data) {
|
|
4334
|
+
const issues = [];
|
|
4335
|
+
const known = new Set(fields.map((f) => f.key));
|
|
4336
|
+
for (const field of fields) {
|
|
4337
|
+
const value = data[field.key];
|
|
4338
|
+
if (isEmpty(value)) {
|
|
4339
|
+
if (field.required) {
|
|
4340
|
+
issues.push({
|
|
4341
|
+
key: field.key,
|
|
4342
|
+
level: "warning",
|
|
4343
|
+
kind: "required",
|
|
4344
|
+
message: "required field is empty"
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
continue;
|
|
4348
|
+
}
|
|
4349
|
+
const err = typeError(field, value);
|
|
4350
|
+
if (err) issues.push({ key: field.key, level: "error", kind: "type", message: err });
|
|
4351
|
+
}
|
|
4352
|
+
for (const key of Object.keys(data)) {
|
|
4353
|
+
if (key.startsWith("_")) continue;
|
|
4354
|
+
if (!known.has(key)) {
|
|
4355
|
+
issues.push({
|
|
4356
|
+
key,
|
|
4357
|
+
level: "warning",
|
|
4358
|
+
kind: "unknown-key",
|
|
4359
|
+
message: "key is not in the schema"
|
|
4360
|
+
});
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
return { ok: issues.every((i) => i.level !== "error"), issues };
|
|
4364
|
+
}
|
|
4365
|
+
function basename(path) {
|
|
4366
|
+
return path.split(/[/\\]/).pop() ?? path;
|
|
4367
|
+
}
|
|
4368
|
+
function imageFields(fields) {
|
|
4369
|
+
return fields.filter((f) => f.type === "image");
|
|
4370
|
+
}
|
|
4371
|
+
function relationFields(fields) {
|
|
4372
|
+
return fields.filter((f) => f.type === "relation");
|
|
4373
|
+
}
|
|
4374
|
+
function validateProject(schema, content) {
|
|
4375
|
+
const issues = [];
|
|
4376
|
+
const mediaNames = content.media ? new Set(content.media.map(basename)) : null;
|
|
4377
|
+
const schemaCollectionNames = new Set(schema.collections.map((c) => c.name));
|
|
4378
|
+
for (const name of Object.keys(content.collections)) {
|
|
4379
|
+
if (!schemaCollectionNames.has(name)) {
|
|
4380
|
+
issues.push({
|
|
4381
|
+
level: "warning",
|
|
4382
|
+
kind: "schema-content-mismatch",
|
|
4383
|
+
collection: name,
|
|
4384
|
+
message: `Collection folder '${name}' is not in the schema`
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
const slugsByCollection = {};
|
|
4389
|
+
for (const [name, entries] of Object.entries(content.collections)) {
|
|
4390
|
+
slugsByCollection[name] = new Set(entries.map((e) => e.slug));
|
|
4391
|
+
}
|
|
4392
|
+
for (const col of schema.collections) {
|
|
4393
|
+
const entries = content.collections[col.name] ?? [];
|
|
4394
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4395
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4396
|
+
for (const { slug } of entries) {
|
|
4397
|
+
if (seen.has(slug) && !reported.has(slug)) {
|
|
4398
|
+
reported.add(slug);
|
|
4399
|
+
issues.push({
|
|
4400
|
+
level: "error",
|
|
4401
|
+
kind: "duplicate-slug",
|
|
4402
|
+
collection: col.name,
|
|
4403
|
+
slug,
|
|
4404
|
+
message: `Duplicate slug in '${col.name}': ${slug}`
|
|
4405
|
+
});
|
|
4406
|
+
}
|
|
4407
|
+
seen.add(slug);
|
|
4408
|
+
}
|
|
4409
|
+
const rels = relationFields(col.fields);
|
|
4410
|
+
const imgs = imageFields(col.fields);
|
|
4411
|
+
for (const { slug, data } of entries) {
|
|
4412
|
+
for (const issue of validateEntry(col.fields, data).issues) {
|
|
4413
|
+
issues.push({
|
|
4414
|
+
level: issue.level,
|
|
4415
|
+
kind: issue.kind,
|
|
4416
|
+
collection: col.name,
|
|
4417
|
+
slug,
|
|
4418
|
+
field: issue.key,
|
|
4419
|
+
message: issue.message
|
|
4420
|
+
});
|
|
4421
|
+
}
|
|
4422
|
+
for (const field of rels) {
|
|
4423
|
+
const value = data[field.key];
|
|
4424
|
+
if (!Array.isArray(value)) continue;
|
|
4425
|
+
const target = field.to ? slugsByCollection[field.to] : void 0;
|
|
4426
|
+
for (const ref of value) {
|
|
4427
|
+
if (typeof ref !== "string") continue;
|
|
4428
|
+
if (!target || !target.has(ref)) {
|
|
4429
|
+
issues.push({
|
|
4430
|
+
level: "error",
|
|
4431
|
+
kind: "broken-relation",
|
|
4432
|
+
collection: col.name,
|
|
4433
|
+
slug,
|
|
4434
|
+
field: field.key,
|
|
4435
|
+
message: field.to ? `Broken relation: '${ref}' does not exist in '${field.to}'` : `Relation field '${field.key}' has no target collection`
|
|
4436
|
+
});
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
if (mediaNames) {
|
|
4441
|
+
for (const field of imgs) {
|
|
4442
|
+
const value = data[field.key];
|
|
4443
|
+
if (typeof value !== "string" || value === "") continue;
|
|
4444
|
+
if (!mediaNames.has(basename(value))) {
|
|
4445
|
+
issues.push({
|
|
4446
|
+
level: "error",
|
|
4447
|
+
kind: "missing-media",
|
|
4448
|
+
collection: col.name,
|
|
4449
|
+
slug,
|
|
4450
|
+
field: field.key,
|
|
4451
|
+
message: `Missing media: ${value}`
|
|
4452
|
+
});
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
for (const s of schema.singletons) {
|
|
4459
|
+
const data = content.singletons[s.name] ?? {};
|
|
4460
|
+
for (const issue of validateEntry(s.fields, data).issues) {
|
|
4461
|
+
issues.push({
|
|
4462
|
+
level: issue.level,
|
|
4463
|
+
kind: issue.kind,
|
|
4464
|
+
singleton: s.name,
|
|
4465
|
+
field: issue.key,
|
|
4466
|
+
message: issue.message
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4469
|
+
if (mediaNames) {
|
|
4470
|
+
for (const field of imageFields(s.fields)) {
|
|
4471
|
+
const value = data[field.key];
|
|
4472
|
+
if (typeof value !== "string" || value === "") continue;
|
|
4473
|
+
if (!mediaNames.has(basename(value))) {
|
|
4474
|
+
issues.push({
|
|
4475
|
+
level: "error",
|
|
4476
|
+
kind: "missing-media",
|
|
4477
|
+
singleton: s.name,
|
|
4478
|
+
field: field.key,
|
|
4479
|
+
message: `Missing media: ${value}`
|
|
4480
|
+
});
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
return issues;
|
|
4486
|
+
}
|
|
4254
4487
|
function pascalCase(name) {
|
|
4255
4488
|
return name.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
4256
4489
|
}
|
|
@@ -4263,7 +4496,14 @@ function fieldTsType(field) {
|
|
|
4263
4496
|
case "select":
|
|
4264
4497
|
return (field.options ?? []).map((o) => `'${o}'`).join(" | ") || "string";
|
|
4265
4498
|
case "relation":
|
|
4499
|
+
case "list":
|
|
4266
4500
|
return "string[]";
|
|
4501
|
+
case "group": {
|
|
4502
|
+
const subs = field.fields ?? [];
|
|
4503
|
+
if (subs.length === 0) return "Record<string, unknown>";
|
|
4504
|
+
const inner = subs.map((f) => `${f.key}${f.required ? "" : "?"}: ${fieldTsType(f)}`).join("; ");
|
|
4505
|
+
return `{ ${inner} }`;
|
|
4506
|
+
}
|
|
4267
4507
|
default:
|
|
4268
4508
|
return "string";
|
|
4269
4509
|
}
|
|
@@ -4278,7 +4518,7 @@ ${lines.join("\n")}
|
|
|
4278
4518
|
}`;
|
|
4279
4519
|
}
|
|
4280
4520
|
function generateTypes(schema) {
|
|
4281
|
-
const blocks = ["//
|
|
4521
|
+
const blocks = ["// Generated by JustJSON \u2014 do not edit by hand."];
|
|
4282
4522
|
for (const col of schema.collections) {
|
|
4283
4523
|
const name = pascalCase(col.name);
|
|
4284
4524
|
blocks.push(emitInterface(name, col.fields));
|
|
@@ -4290,6 +4530,71 @@ function generateTypes(schema) {
|
|
|
4290
4530
|
return `${blocks.join("\n\n")}
|
|
4291
4531
|
`;
|
|
4292
4532
|
}
|
|
4533
|
+
function pascalCase2(name) {
|
|
4534
|
+
return name.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
4535
|
+
}
|
|
4536
|
+
var IMPORTS = `import { readFile, readdir } from 'node:fs/promises'
|
|
4537
|
+
import { dirname, join } from 'node:path'
|
|
4538
|
+
import { fileURLToPath } from 'node:url'`;
|
|
4539
|
+
var READERS = `export type WithSlug<T> = T & { slug: string }
|
|
4540
|
+
|
|
4541
|
+
export interface LoadOptions {
|
|
4542
|
+
/** true ise _status: 'draft' olan kay\u0131tlar da dahil edilir (varsay\u0131lan: yaln\u0131zca yay\u0131nlananlar). */
|
|
4543
|
+
drafts?: boolean
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
async function readCollection<T>(dir: string, opts?: LoadOptions): Promise<WithSlug<T>[]> {
|
|
4547
|
+
let files: string[]
|
|
4548
|
+
try {
|
|
4549
|
+
files = (await readdir(join(contentDir, dir))).filter((f) => f.endsWith('.json'))
|
|
4550
|
+
} catch {
|
|
4551
|
+
return []
|
|
4552
|
+
}
|
|
4553
|
+
const out: WithSlug<T>[] = []
|
|
4554
|
+
for (const file of files.sort()) {
|
|
4555
|
+
const raw = await readFile(join(contentDir, dir, file), 'utf8')
|
|
4556
|
+
const data = JSON.parse(raw) as T & { _status?: string }
|
|
4557
|
+
if (!opts?.drafts && data._status === 'draft') continue
|
|
4558
|
+
out.push({ ...data, slug: file.slice(0, -'.json'.length) })
|
|
4559
|
+
}
|
|
4560
|
+
return out
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
async function readSingleton<T>(file: string): Promise<T | null> {
|
|
4564
|
+
try {
|
|
4565
|
+
return JSON.parse(await readFile(join(contentDir, file), 'utf8')) as T
|
|
4566
|
+
} catch {
|
|
4567
|
+
return null
|
|
4568
|
+
}
|
|
4569
|
+
}`;
|
|
4570
|
+
function generateLoader(schema, contentDir) {
|
|
4571
|
+
const typeNames = [
|
|
4572
|
+
...schema.collections.map((c) => pascalCase2(c.name)),
|
|
4573
|
+
...schema.singletons.map((s) => pascalCase2(s.name))
|
|
4574
|
+
];
|
|
4575
|
+
const header = [
|
|
4576
|
+
"// Generated by JustJSON \u2014 do not edit by hand.",
|
|
4577
|
+
IMPORTS,
|
|
4578
|
+
`import type { ${typeNames.join(", ")} } from './types'`
|
|
4579
|
+
].join("\n");
|
|
4580
|
+
const q = (s) => `'${s.replace(/'/g, "\\'")}'`;
|
|
4581
|
+
const contentDirLine = `const contentDir = join(dirname(fileURLToPath(import.meta.url)), ${q(contentDir)})`;
|
|
4582
|
+
const loaders = [];
|
|
4583
|
+
for (const col of schema.collections) {
|
|
4584
|
+
const name = pascalCase2(col.name);
|
|
4585
|
+
loaders.push(
|
|
4586
|
+
`export const load${name} = (opts?: LoadOptions): Promise<WithSlug<${name}>[]> => readCollection<${name}>(${q(col.path)}, opts)`
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
for (const s of schema.singletons) {
|
|
4590
|
+
const name = pascalCase2(s.name);
|
|
4591
|
+
loaders.push(
|
|
4592
|
+
`export const load${name} = (): Promise<${name} | null> => readSingleton<${name}>(${q(s.path)})`
|
|
4593
|
+
);
|
|
4594
|
+
}
|
|
4595
|
+
return `${[header, contentDirLine, READERS, loaders.join("\n")].join("\n\n")}
|
|
4596
|
+
`;
|
|
4597
|
+
}
|
|
4293
4598
|
function json(data) {
|
|
4294
4599
|
return `${JSON.stringify(data, null, 2)}
|
|
4295
4600
|
`;
|
|
@@ -4342,7 +4647,7 @@ function fieldsFromRows(rows) {
|
|
|
4342
4647
|
return order.map((key) => ({ key, label: key, type: types.get(key) }));
|
|
4343
4648
|
}
|
|
4344
4649
|
function inferProject(data) {
|
|
4345
|
-
if (!isPlainObject(data)) throw new Error("
|
|
4650
|
+
if (!isPlainObject(data)) throw new Error("The imported JSON must be an object.");
|
|
4346
4651
|
const collections = [];
|
|
4347
4652
|
const singletons = [];
|
|
4348
4653
|
const entries = {};
|
|
@@ -4351,7 +4656,7 @@ function inferProject(data) {
|
|
|
4351
4656
|
const generalData = {};
|
|
4352
4657
|
const usedNames = /* @__PURE__ */ new Set();
|
|
4353
4658
|
const uniqueName = (base) => {
|
|
4354
|
-
let name = base || "
|
|
4659
|
+
let name = base || "field";
|
|
4355
4660
|
let n = 2;
|
|
4356
4661
|
while (usedNames.has(name)) name = `${base}-${n++}`;
|
|
4357
4662
|
usedNames.add(name);
|
|
@@ -4400,8 +4705,8 @@ function inferProject(data) {
|
|
|
4400
4705
|
}
|
|
4401
4706
|
}
|
|
4402
4707
|
if (generalFields.length > 0) {
|
|
4403
|
-
const name = uniqueName("
|
|
4404
|
-
singletons.push({ name, label: "
|
|
4708
|
+
const name = uniqueName("general");
|
|
4709
|
+
singletons.push({ name, label: "General", path: `${name}.json`, fields: generalFields });
|
|
4405
4710
|
singletonData[name] = generalData;
|
|
4406
4711
|
}
|
|
4407
4712
|
return { schema: { version: 1, collections, singletons }, entries, singletons: singletonData };
|
|
@@ -4422,7 +4727,7 @@ var FsAdapter = class {
|
|
|
4422
4727
|
const rootAbs = resolve(this.root);
|
|
4423
4728
|
const full = resolve(rootAbs, path);
|
|
4424
4729
|
if (full !== rootAbs && !full.startsWith(rootAbs + sep)) {
|
|
4425
|
-
throw new
|
|
4730
|
+
throw new PathEscapeError(`Path escapes the project root: ${path}`);
|
|
4426
4731
|
}
|
|
4427
4732
|
return full;
|
|
4428
4733
|
}
|
|
@@ -4481,7 +4786,7 @@ async function resolveContentDir(root2) {
|
|
|
4481
4786
|
// src/commands/export.ts
|
|
4482
4787
|
async function collectExportZip(adapter, contentDir) {
|
|
4483
4788
|
const schema = await loadSchema(adapter, contentDir);
|
|
4484
|
-
if (!schema) throw new Error("
|
|
4789
|
+
if (!schema) throw new Error("No schema found. Run `justjson init` first.");
|
|
4485
4790
|
const store = new ContentStore(adapter, schema, contentDir);
|
|
4486
4791
|
const entries = {};
|
|
4487
4792
|
for (const col of schema.collections) {
|
|
@@ -4518,58 +4823,187 @@ async function exportZip(root2, outFile = "justjson-export.zip") {
|
|
|
4518
4823
|
// src/templates/blog.json
|
|
4519
4824
|
var blog_default = {
|
|
4520
4825
|
title: "Blog",
|
|
4521
|
-
description: "
|
|
4826
|
+
description: "Posts with rich-text content, plus a Settings singleton for the site name.",
|
|
4522
4827
|
schema: {
|
|
4523
4828
|
version: 1,
|
|
4524
4829
|
collections: [
|
|
4525
4830
|
{
|
|
4526
4831
|
name: "posts",
|
|
4527
|
-
label: "
|
|
4832
|
+
label: "Posts",
|
|
4528
4833
|
path: "posts",
|
|
4529
4834
|
fields: [
|
|
4530
|
-
{
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4835
|
+
{
|
|
4836
|
+
key: "title",
|
|
4837
|
+
label: "Title",
|
|
4838
|
+
type: "text",
|
|
4839
|
+
required: true
|
|
4840
|
+
},
|
|
4841
|
+
{
|
|
4842
|
+
key: "slug",
|
|
4843
|
+
label: "Slug",
|
|
4844
|
+
type: "text",
|
|
4845
|
+
required: true
|
|
4846
|
+
},
|
|
4847
|
+
{
|
|
4848
|
+
key: "date",
|
|
4849
|
+
label: "Date",
|
|
4850
|
+
type: "date"
|
|
4851
|
+
},
|
|
4852
|
+
{
|
|
4853
|
+
key: "body",
|
|
4854
|
+
label: "Body",
|
|
4855
|
+
type: "richtext"
|
|
4856
|
+
}
|
|
4534
4857
|
]
|
|
4535
4858
|
}
|
|
4536
4859
|
],
|
|
4537
4860
|
singletons: [
|
|
4538
4861
|
{
|
|
4539
4862
|
name: "settings",
|
|
4540
|
-
label: "
|
|
4863
|
+
label: "Settings",
|
|
4541
4864
|
path: "settings.json",
|
|
4542
|
-
fields: [
|
|
4865
|
+
fields: [
|
|
4866
|
+
{
|
|
4867
|
+
key: "title",
|
|
4868
|
+
label: "Site name",
|
|
4869
|
+
type: "text"
|
|
4870
|
+
}
|
|
4871
|
+
]
|
|
4872
|
+
}
|
|
4873
|
+
]
|
|
4874
|
+
},
|
|
4875
|
+
samples: {
|
|
4876
|
+
posts: [
|
|
4877
|
+
{
|
|
4878
|
+
title: "First post",
|
|
4879
|
+
slug: "first-post",
|
|
4880
|
+
body: "Hello."
|
|
4881
|
+
}
|
|
4882
|
+
]
|
|
4883
|
+
}
|
|
4884
|
+
};
|
|
4885
|
+
|
|
4886
|
+
// src/templates/catalog.json
|
|
4887
|
+
var catalog_default = {
|
|
4888
|
+
title: "Product catalog",
|
|
4889
|
+
description: "Products with price, category and an image, plus Store settings.",
|
|
4890
|
+
schema: {
|
|
4891
|
+
version: 1,
|
|
4892
|
+
collections: [
|
|
4893
|
+
{
|
|
4894
|
+
name: "products",
|
|
4895
|
+
label: "Products",
|
|
4896
|
+
path: "products",
|
|
4897
|
+
fields: [
|
|
4898
|
+
{
|
|
4899
|
+
key: "title",
|
|
4900
|
+
label: "Title",
|
|
4901
|
+
type: "text",
|
|
4902
|
+
required: true
|
|
4903
|
+
},
|
|
4904
|
+
{
|
|
4905
|
+
key: "slug",
|
|
4906
|
+
label: "Slug",
|
|
4907
|
+
type: "text",
|
|
4908
|
+
required: true
|
|
4909
|
+
},
|
|
4910
|
+
{
|
|
4911
|
+
key: "price",
|
|
4912
|
+
label: "Price",
|
|
4913
|
+
type: "number"
|
|
4914
|
+
},
|
|
4915
|
+
{
|
|
4916
|
+
key: "category",
|
|
4917
|
+
label: "Category",
|
|
4918
|
+
type: "select",
|
|
4919
|
+
options: ["Home", "Clothing", "Accessories"]
|
|
4920
|
+
},
|
|
4921
|
+
{
|
|
4922
|
+
key: "cover",
|
|
4923
|
+
label: "Image",
|
|
4924
|
+
type: "image"
|
|
4925
|
+
},
|
|
4926
|
+
{
|
|
4927
|
+
key: "description",
|
|
4928
|
+
label: "Description",
|
|
4929
|
+
type: "richtext"
|
|
4930
|
+
}
|
|
4931
|
+
]
|
|
4932
|
+
}
|
|
4933
|
+
],
|
|
4934
|
+
singletons: [
|
|
4935
|
+
{
|
|
4936
|
+
name: "store",
|
|
4937
|
+
label: "Store",
|
|
4938
|
+
path: "store.json",
|
|
4939
|
+
fields: [
|
|
4940
|
+
{
|
|
4941
|
+
key: "title",
|
|
4942
|
+
label: "Store name",
|
|
4943
|
+
type: "text"
|
|
4944
|
+
},
|
|
4945
|
+
{
|
|
4946
|
+
key: "tagline",
|
|
4947
|
+
label: "Tagline",
|
|
4948
|
+
type: "text"
|
|
4949
|
+
}
|
|
4950
|
+
]
|
|
4543
4951
|
}
|
|
4544
4952
|
]
|
|
4545
4953
|
},
|
|
4546
4954
|
samples: {
|
|
4547
|
-
|
|
4955
|
+
products: [
|
|
4956
|
+
{
|
|
4957
|
+
title: "Ceramic mug",
|
|
4958
|
+
slug: "ceramic-mug",
|
|
4959
|
+
price: 18,
|
|
4960
|
+
category: "Home",
|
|
4961
|
+
description: "Hand-made ceramic mug."
|
|
4962
|
+
}
|
|
4963
|
+
]
|
|
4548
4964
|
}
|
|
4549
4965
|
};
|
|
4550
4966
|
|
|
4551
4967
|
// src/templates/changelog.json
|
|
4552
4968
|
var changelog_default = {
|
|
4553
4969
|
title: "Changelog",
|
|
4554
|
-
description: "
|
|
4970
|
+
description: "Release notes: version, date, type and a rich-text body.",
|
|
4555
4971
|
schema: {
|
|
4556
4972
|
version: 1,
|
|
4557
4973
|
collections: [
|
|
4558
4974
|
{
|
|
4559
4975
|
name: "releases",
|
|
4560
|
-
label: "
|
|
4976
|
+
label: "Releases",
|
|
4561
4977
|
path: "releases",
|
|
4562
4978
|
fields: [
|
|
4563
|
-
{
|
|
4564
|
-
|
|
4565
|
-
|
|
4979
|
+
{
|
|
4980
|
+
key: "version",
|
|
4981
|
+
label: "Version",
|
|
4982
|
+
type: "text",
|
|
4983
|
+
required: true
|
|
4984
|
+
},
|
|
4985
|
+
{
|
|
4986
|
+
key: "slug",
|
|
4987
|
+
label: "Slug",
|
|
4988
|
+
type: "text",
|
|
4989
|
+
required: true
|
|
4990
|
+
},
|
|
4991
|
+
{
|
|
4992
|
+
key: "date",
|
|
4993
|
+
label: "Date",
|
|
4994
|
+
type: "date"
|
|
4995
|
+
},
|
|
4566
4996
|
{
|
|
4567
4997
|
key: "type",
|
|
4568
|
-
label: "
|
|
4998
|
+
label: "Type",
|
|
4569
4999
|
type: "select",
|
|
4570
|
-
options: ["
|
|
5000
|
+
options: ["Added", "Changed", "Fixed"]
|
|
4571
5001
|
},
|
|
4572
|
-
{
|
|
5002
|
+
{
|
|
5003
|
+
key: "body",
|
|
5004
|
+
label: "Notes",
|
|
5005
|
+
type: "richtext"
|
|
5006
|
+
}
|
|
4573
5007
|
]
|
|
4574
5008
|
}
|
|
4575
5009
|
],
|
|
@@ -4580,8 +5014,8 @@ var changelog_default = {
|
|
|
4580
5014
|
{
|
|
4581
5015
|
version: "1.0.0",
|
|
4582
5016
|
slug: "1-0-0",
|
|
4583
|
-
type: "
|
|
4584
|
-
body: "
|
|
5017
|
+
type: "Added",
|
|
5018
|
+
body: "First release."
|
|
4585
5019
|
}
|
|
4586
5020
|
]
|
|
4587
5021
|
}
|
|
@@ -4590,113 +5024,302 @@ var changelog_default = {
|
|
|
4590
5024
|
// src/templates/cv.json
|
|
4591
5025
|
var cv_default = {
|
|
4592
5026
|
title: "CV",
|
|
4593
|
-
description: "
|
|
5027
|
+
description: "An Experience collection and a Profile singleton for your personal details.",
|
|
4594
5028
|
schema: {
|
|
4595
5029
|
version: 1,
|
|
4596
5030
|
collections: [
|
|
4597
5031
|
{
|
|
4598
5032
|
name: "experience",
|
|
4599
|
-
label: "
|
|
5033
|
+
label: "Experience",
|
|
4600
5034
|
path: "experience",
|
|
4601
5035
|
fields: [
|
|
4602
|
-
{
|
|
4603
|
-
|
|
4604
|
-
|
|
5036
|
+
{
|
|
5037
|
+
key: "role",
|
|
5038
|
+
label: "Role",
|
|
5039
|
+
type: "text",
|
|
5040
|
+
required: true
|
|
5041
|
+
},
|
|
5042
|
+
{
|
|
5043
|
+
key: "company",
|
|
5044
|
+
label: "Company",
|
|
5045
|
+
type: "text",
|
|
5046
|
+
required: true
|
|
5047
|
+
},
|
|
5048
|
+
{
|
|
5049
|
+
key: "summary",
|
|
5050
|
+
label: "Summary",
|
|
5051
|
+
type: "richtext"
|
|
5052
|
+
}
|
|
4605
5053
|
]
|
|
4606
5054
|
}
|
|
4607
5055
|
],
|
|
4608
5056
|
singletons: [
|
|
4609
5057
|
{
|
|
4610
5058
|
name: "profile",
|
|
4611
|
-
label: "
|
|
5059
|
+
label: "Profile",
|
|
4612
5060
|
path: "profile.json",
|
|
4613
5061
|
fields: [
|
|
4614
|
-
{
|
|
4615
|
-
|
|
5062
|
+
{
|
|
5063
|
+
key: "name",
|
|
5064
|
+
label: "Name",
|
|
5065
|
+
type: "text",
|
|
5066
|
+
required: true
|
|
5067
|
+
},
|
|
5068
|
+
{
|
|
5069
|
+
key: "headline",
|
|
5070
|
+
label: "Headline",
|
|
5071
|
+
type: "text"
|
|
5072
|
+
}
|
|
4616
5073
|
]
|
|
4617
5074
|
}
|
|
4618
5075
|
]
|
|
4619
5076
|
},
|
|
4620
5077
|
samples: {
|
|
4621
|
-
experience: [
|
|
5078
|
+
experience: [
|
|
5079
|
+
{
|
|
5080
|
+
role: "Developer",
|
|
5081
|
+
company: "Acme Inc.",
|
|
5082
|
+
summary: "..."
|
|
5083
|
+
}
|
|
5084
|
+
]
|
|
4622
5085
|
}
|
|
4623
5086
|
};
|
|
4624
5087
|
|
|
4625
5088
|
// src/templates/docs.json
|
|
4626
5089
|
var docs_default = {
|
|
4627
|
-
title: "
|
|
4628
|
-
description: "
|
|
5090
|
+
title: "Documentation",
|
|
5091
|
+
description: "Orderable pages with rich-text content, plus a Settings singleton for the site title.",
|
|
4629
5092
|
schema: {
|
|
4630
5093
|
version: 1,
|
|
4631
5094
|
collections: [
|
|
4632
5095
|
{
|
|
4633
5096
|
name: "pages",
|
|
4634
|
-
label: "
|
|
5097
|
+
label: "Pages",
|
|
4635
5098
|
path: "pages",
|
|
4636
5099
|
fields: [
|
|
4637
|
-
{
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
5100
|
+
{
|
|
5101
|
+
key: "title",
|
|
5102
|
+
label: "Title",
|
|
5103
|
+
type: "text",
|
|
5104
|
+
required: true
|
|
5105
|
+
},
|
|
5106
|
+
{
|
|
5107
|
+
key: "slug",
|
|
5108
|
+
label: "Slug",
|
|
5109
|
+
type: "text",
|
|
5110
|
+
required: true
|
|
5111
|
+
},
|
|
5112
|
+
{
|
|
5113
|
+
key: "order",
|
|
5114
|
+
label: "Order",
|
|
5115
|
+
type: "number"
|
|
5116
|
+
},
|
|
5117
|
+
{
|
|
5118
|
+
key: "body",
|
|
5119
|
+
label: "Body",
|
|
5120
|
+
type: "richtext"
|
|
5121
|
+
}
|
|
4641
5122
|
]
|
|
4642
5123
|
}
|
|
4643
5124
|
],
|
|
4644
5125
|
singletons: [
|
|
4645
5126
|
{
|
|
4646
5127
|
name: "settings",
|
|
4647
|
-
label: "
|
|
5128
|
+
label: "Settings",
|
|
4648
5129
|
path: "settings.json",
|
|
4649
5130
|
fields: [
|
|
4650
|
-
{
|
|
4651
|
-
|
|
5131
|
+
{
|
|
5132
|
+
key: "title",
|
|
5133
|
+
label: "Site name",
|
|
5134
|
+
type: "text"
|
|
5135
|
+
},
|
|
5136
|
+
{
|
|
5137
|
+
key: "tagline",
|
|
5138
|
+
label: "Tagline",
|
|
5139
|
+
type: "text"
|
|
5140
|
+
}
|
|
4652
5141
|
]
|
|
4653
5142
|
}
|
|
4654
5143
|
]
|
|
4655
5144
|
},
|
|
4656
5145
|
samples: {
|
|
4657
5146
|
pages: [
|
|
4658
|
-
{
|
|
5147
|
+
{
|
|
5148
|
+
title: "Getting started",
|
|
5149
|
+
slug: "getting-started",
|
|
5150
|
+
order: 1,
|
|
5151
|
+
body: "Welcome aboard."
|
|
5152
|
+
}
|
|
5153
|
+
]
|
|
5154
|
+
}
|
|
5155
|
+
};
|
|
5156
|
+
|
|
5157
|
+
// src/templates/event.json
|
|
5158
|
+
var event_default = {
|
|
5159
|
+
title: "Event schedule",
|
|
5160
|
+
description: "An event agenda of sessions with date, time and speaker.",
|
|
5161
|
+
schema: {
|
|
5162
|
+
version: 1,
|
|
5163
|
+
collections: [
|
|
5164
|
+
{
|
|
5165
|
+
name: "sessions",
|
|
5166
|
+
label: "Sessions",
|
|
5167
|
+
path: "sessions",
|
|
5168
|
+
fields: [
|
|
5169
|
+
{
|
|
5170
|
+
key: "title",
|
|
5171
|
+
label: "Title",
|
|
5172
|
+
type: "text",
|
|
5173
|
+
required: true
|
|
5174
|
+
},
|
|
5175
|
+
{
|
|
5176
|
+
key: "slug",
|
|
5177
|
+
label: "Slug",
|
|
5178
|
+
type: "text",
|
|
5179
|
+
required: true
|
|
5180
|
+
},
|
|
5181
|
+
{
|
|
5182
|
+
key: "date",
|
|
5183
|
+
label: "Date",
|
|
5184
|
+
type: "date"
|
|
5185
|
+
},
|
|
5186
|
+
{
|
|
5187
|
+
key: "time",
|
|
5188
|
+
label: "Time",
|
|
5189
|
+
type: "text"
|
|
5190
|
+
},
|
|
5191
|
+
{
|
|
5192
|
+
key: "speaker",
|
|
5193
|
+
label: "Speaker",
|
|
5194
|
+
type: "text"
|
|
5195
|
+
},
|
|
5196
|
+
{
|
|
5197
|
+
key: "room",
|
|
5198
|
+
label: "Room",
|
|
5199
|
+
type: "text"
|
|
5200
|
+
},
|
|
5201
|
+
{
|
|
5202
|
+
key: "description",
|
|
5203
|
+
label: "Description",
|
|
5204
|
+
type: "richtext"
|
|
5205
|
+
}
|
|
5206
|
+
]
|
|
5207
|
+
}
|
|
5208
|
+
],
|
|
5209
|
+
singletons: [
|
|
5210
|
+
{
|
|
5211
|
+
name: "event",
|
|
5212
|
+
label: "Event",
|
|
5213
|
+
path: "event.json",
|
|
5214
|
+
fields: [
|
|
5215
|
+
{
|
|
5216
|
+
key: "title",
|
|
5217
|
+
label: "Event name",
|
|
5218
|
+
type: "text"
|
|
5219
|
+
},
|
|
5220
|
+
{
|
|
5221
|
+
key: "tagline",
|
|
5222
|
+
label: "Tagline",
|
|
5223
|
+
type: "text"
|
|
5224
|
+
}
|
|
5225
|
+
]
|
|
5226
|
+
}
|
|
5227
|
+
]
|
|
5228
|
+
},
|
|
5229
|
+
samples: {
|
|
5230
|
+
sessions: [
|
|
5231
|
+
{
|
|
5232
|
+
title: "Opening keynote",
|
|
5233
|
+
slug: "opening-keynote",
|
|
5234
|
+
time: "09:00",
|
|
5235
|
+
speaker: "Ada Lovelace",
|
|
5236
|
+
room: "Main hall",
|
|
5237
|
+
description: "Welcome to the event."
|
|
5238
|
+
}
|
|
4659
5239
|
]
|
|
4660
5240
|
}
|
|
4661
5241
|
};
|
|
4662
5242
|
|
|
4663
5243
|
// src/templates/portfolio.json
|
|
4664
5244
|
var portfolio_default = {
|
|
4665
|
-
title: "
|
|
4666
|
-
description: "
|
|
5245
|
+
title: "Portfolio",
|
|
5246
|
+
description: "Projects with a cover image, tag and rich-text body, plus an About page.",
|
|
4667
5247
|
schema: {
|
|
4668
5248
|
version: 1,
|
|
4669
5249
|
collections: [
|
|
4670
5250
|
{
|
|
4671
5251
|
name: "projects",
|
|
4672
|
-
label: "
|
|
5252
|
+
label: "Projects",
|
|
4673
5253
|
path: "projects",
|
|
4674
5254
|
fields: [
|
|
4675
|
-
{
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
5255
|
+
{
|
|
5256
|
+
key: "title",
|
|
5257
|
+
label: "Title",
|
|
5258
|
+
type: "text",
|
|
5259
|
+
required: true
|
|
5260
|
+
},
|
|
5261
|
+
{
|
|
5262
|
+
key: "slug",
|
|
5263
|
+
label: "Slug",
|
|
5264
|
+
type: "text",
|
|
5265
|
+
required: true
|
|
5266
|
+
},
|
|
5267
|
+
{
|
|
5268
|
+
key: "summary",
|
|
5269
|
+
label: "Summary",
|
|
5270
|
+
type: "text"
|
|
5271
|
+
},
|
|
5272
|
+
{
|
|
5273
|
+
key: "cover",
|
|
5274
|
+
label: "Cover",
|
|
5275
|
+
type: "image"
|
|
5276
|
+
},
|
|
5277
|
+
{
|
|
5278
|
+
key: "url",
|
|
5279
|
+
label: "Link",
|
|
5280
|
+
type: "url"
|
|
5281
|
+
},
|
|
4680
5282
|
{
|
|
4681
5283
|
key: "kind",
|
|
4682
|
-
label: "
|
|
5284
|
+
label: "Kind",
|
|
4683
5285
|
type: "select",
|
|
4684
|
-
options: ["Web", "
|
|
5286
|
+
options: ["Web", "Mobile", "Design"]
|
|
5287
|
+
},
|
|
5288
|
+
{
|
|
5289
|
+
key: "body",
|
|
5290
|
+
label: "Body",
|
|
5291
|
+
type: "richtext"
|
|
4685
5292
|
},
|
|
4686
|
-
{
|
|
4687
|
-
|
|
5293
|
+
{
|
|
5294
|
+
key: "date",
|
|
5295
|
+
label: "Date",
|
|
5296
|
+
type: "date"
|
|
5297
|
+
}
|
|
4688
5298
|
]
|
|
4689
5299
|
}
|
|
4690
5300
|
],
|
|
4691
5301
|
singletons: [
|
|
4692
5302
|
{
|
|
4693
5303
|
name: "about",
|
|
4694
|
-
label: "
|
|
5304
|
+
label: "About",
|
|
4695
5305
|
path: "about.json",
|
|
4696
5306
|
fields: [
|
|
4697
|
-
{
|
|
4698
|
-
|
|
4699
|
-
|
|
5307
|
+
{
|
|
5308
|
+
key: "name",
|
|
5309
|
+
label: "Name",
|
|
5310
|
+
type: "text",
|
|
5311
|
+
required: true
|
|
5312
|
+
},
|
|
5313
|
+
{
|
|
5314
|
+
key: "avatar",
|
|
5315
|
+
label: "Avatar",
|
|
5316
|
+
type: "image"
|
|
5317
|
+
},
|
|
5318
|
+
{
|
|
5319
|
+
key: "bio",
|
|
5320
|
+
label: "Bio",
|
|
5321
|
+
type: "richtext"
|
|
5322
|
+
}
|
|
4700
5323
|
]
|
|
4701
5324
|
}
|
|
4702
5325
|
]
|
|
@@ -4704,11 +5327,92 @@ var portfolio_default = {
|
|
|
4704
5327
|
samples: {
|
|
4705
5328
|
projects: [
|
|
4706
5329
|
{
|
|
4707
|
-
title: "
|
|
4708
|
-
slug: "
|
|
4709
|
-
summary: "
|
|
5330
|
+
title: "Sample project",
|
|
5331
|
+
slug: "sample-project",
|
|
5332
|
+
summary: "A short description.",
|
|
4710
5333
|
kind: "Web",
|
|
4711
|
-
body: "
|
|
5334
|
+
body: "Project details go here."
|
|
5335
|
+
}
|
|
5336
|
+
]
|
|
5337
|
+
}
|
|
5338
|
+
};
|
|
5339
|
+
|
|
5340
|
+
// src/templates/recipe.json
|
|
5341
|
+
var recipe_default = {
|
|
5342
|
+
title: "Recipe box",
|
|
5343
|
+
description: "Recipes with ingredients, steps and a cover image, plus Kitchen settings.",
|
|
5344
|
+
schema: {
|
|
5345
|
+
version: 1,
|
|
5346
|
+
collections: [
|
|
5347
|
+
{
|
|
5348
|
+
name: "recipes",
|
|
5349
|
+
label: "Recipes",
|
|
5350
|
+
path: "recipes",
|
|
5351
|
+
fields: [
|
|
5352
|
+
{
|
|
5353
|
+
key: "title",
|
|
5354
|
+
label: "Title",
|
|
5355
|
+
type: "text",
|
|
5356
|
+
required: true
|
|
5357
|
+
},
|
|
5358
|
+
{
|
|
5359
|
+
key: "slug",
|
|
5360
|
+
label: "Slug",
|
|
5361
|
+
type: "text",
|
|
5362
|
+
required: true
|
|
5363
|
+
},
|
|
5364
|
+
{
|
|
5365
|
+
key: "time",
|
|
5366
|
+
label: "Time",
|
|
5367
|
+
type: "text"
|
|
5368
|
+
},
|
|
5369
|
+
{
|
|
5370
|
+
key: "servings",
|
|
5371
|
+
label: "Servings",
|
|
5372
|
+
type: "number"
|
|
5373
|
+
},
|
|
5374
|
+
{
|
|
5375
|
+
key: "cover",
|
|
5376
|
+
label: "Cover",
|
|
5377
|
+
type: "image"
|
|
5378
|
+
},
|
|
5379
|
+
{
|
|
5380
|
+
key: "ingredients",
|
|
5381
|
+
label: "Ingredients",
|
|
5382
|
+
type: "richtext"
|
|
5383
|
+
},
|
|
5384
|
+
{
|
|
5385
|
+
key: "steps",
|
|
5386
|
+
label: "Steps",
|
|
5387
|
+
type: "richtext"
|
|
5388
|
+
}
|
|
5389
|
+
]
|
|
5390
|
+
}
|
|
5391
|
+
],
|
|
5392
|
+
singletons: [
|
|
5393
|
+
{
|
|
5394
|
+
name: "kitchen",
|
|
5395
|
+
label: "Kitchen",
|
|
5396
|
+
path: "kitchen.json",
|
|
5397
|
+
fields: [
|
|
5398
|
+
{
|
|
5399
|
+
key: "title",
|
|
5400
|
+
label: "Site name",
|
|
5401
|
+
type: "text"
|
|
5402
|
+
}
|
|
5403
|
+
]
|
|
5404
|
+
}
|
|
5405
|
+
]
|
|
5406
|
+
},
|
|
5407
|
+
samples: {
|
|
5408
|
+
recipes: [
|
|
5409
|
+
{
|
|
5410
|
+
title: "Weeknight pasta",
|
|
5411
|
+
slug: "weeknight-pasta",
|
|
5412
|
+
time: "20 min",
|
|
5413
|
+
servings: 2,
|
|
5414
|
+
ingredients: "- 200g pasta\n- 2 cloves garlic\n- Olive oil",
|
|
5415
|
+
steps: "Boil the pasta. Fry the garlic in olive oil. Toss together."
|
|
4712
5416
|
}
|
|
4713
5417
|
]
|
|
4714
5418
|
}
|
|
@@ -4720,7 +5424,10 @@ var templates = {
|
|
|
4720
5424
|
cv: cv_default,
|
|
4721
5425
|
portfolio: portfolio_default,
|
|
4722
5426
|
docs: docs_default,
|
|
4723
|
-
changelog: changelog_default
|
|
5427
|
+
changelog: changelog_default,
|
|
5428
|
+
recipe: recipe_default,
|
|
5429
|
+
event: event_default,
|
|
5430
|
+
catalog: catalog_default
|
|
4724
5431
|
};
|
|
4725
5432
|
function listTemplates() {
|
|
4726
5433
|
return Object.keys(templates);
|
|
@@ -4749,7 +5456,7 @@ async function applyTemplate(adapter, contentDir, template) {
|
|
|
4749
5456
|
const store = new ContentStore(adapter, schema, contentDir);
|
|
4750
5457
|
for (const [collection, rows] of Object.entries(template.samples)) {
|
|
4751
5458
|
for (const row of rows) {
|
|
4752
|
-
const slug = slugify(typeof row.slug === "string" ? row.slug : String(row.title ?? "
|
|
5459
|
+
const slug = slugify(typeof row.slug === "string" ? row.slug : String(row.title ?? "content"));
|
|
4753
5460
|
await store.writeEntry(collection, slug, row);
|
|
4754
5461
|
}
|
|
4755
5462
|
}
|
|
@@ -4761,30 +5468,173 @@ async function initProject(root2, templateName) {
|
|
|
4761
5468
|
const adapter = new FsAdapter(root2);
|
|
4762
5469
|
const contentDir = await resolveContentDir(root2);
|
|
4763
5470
|
if (await loadSchema(adapter, contentDir)) {
|
|
4764
|
-
throw new Error("
|
|
5471
|
+
throw new Error("This folder already has a schema (content/_schema.json).");
|
|
4765
5472
|
}
|
|
4766
5473
|
await applyTemplate(adapter, contentDir, template);
|
|
4767
5474
|
}
|
|
4768
5475
|
|
|
4769
5476
|
// src/commands/types.ts
|
|
4770
5477
|
import { join as join2 } from "path";
|
|
4771
|
-
async function generateTypesFile(root2, outPath = "types.ts") {
|
|
5478
|
+
async function generateTypesFile(root2, outPath = "types.ts", loaderPath = "content.ts") {
|
|
4772
5479
|
const adapter = new FsAdapter(root2);
|
|
4773
5480
|
const contentDir = await resolveContentDir(root2);
|
|
4774
5481
|
const schema = await loadSchema(adapter, contentDir);
|
|
4775
|
-
if (!schema) throw new Error("
|
|
5482
|
+
if (!schema) throw new Error("No schema found. Run `justjson init` first.");
|
|
4776
5483
|
await adapter.write(outPath, generateTypes(schema));
|
|
5484
|
+
await adapter.write(loaderPath, generateLoader(schema, contentDir));
|
|
4777
5485
|
return join2(root2, outPath);
|
|
4778
5486
|
}
|
|
4779
5487
|
|
|
5488
|
+
// src/commands/validate.ts
|
|
5489
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
5490
|
+
import { join as join3 } from "path";
|
|
5491
|
+
async function listDir(path) {
|
|
5492
|
+
try {
|
|
5493
|
+
const entries = await readdir2(path, { withFileTypes: true });
|
|
5494
|
+
return entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
|
|
5495
|
+
} catch {
|
|
5496
|
+
return [];
|
|
5497
|
+
}
|
|
5498
|
+
}
|
|
5499
|
+
async function loadProjectContent(root2) {
|
|
5500
|
+
const adapter = new FsAdapter(root2);
|
|
5501
|
+
const contentDir = await resolveContentDir(root2);
|
|
5502
|
+
const schema = await loadSchema(adapter, contentDir);
|
|
5503
|
+
if (!schema) return null;
|
|
5504
|
+
const store = new ContentStore(adapter, schema, contentDir);
|
|
5505
|
+
const collections = {};
|
|
5506
|
+
for (const col of schema.collections) {
|
|
5507
|
+
const slugs = await store.listEntries(col.name);
|
|
5508
|
+
const entries = [];
|
|
5509
|
+
for (const slug of slugs) {
|
|
5510
|
+
entries.push({ slug, data: await store.readEntry(col.name, slug) ?? {} });
|
|
5511
|
+
}
|
|
5512
|
+
collections[col.name] = entries;
|
|
5513
|
+
}
|
|
5514
|
+
const known = new Set(schema.collections.map((c) => c.name));
|
|
5515
|
+
for (const { name, isDir } of await listDir(join3(root2, contentDir))) {
|
|
5516
|
+
if (!isDir || name === "media" || known.has(name) || collections[name]) continue;
|
|
5517
|
+
const files = await listDir(join3(root2, contentDir, name));
|
|
5518
|
+
collections[name] = files.filter((f) => !f.isDir && f.name.endsWith(".json")).map((f) => ({ slug: f.name.slice(0, -".json".length), data: {} }));
|
|
5519
|
+
}
|
|
5520
|
+
const singletons = {};
|
|
5521
|
+
for (const s of schema.singletons) {
|
|
5522
|
+
singletons[s.name] = await store.readSingleton(s.name);
|
|
5523
|
+
}
|
|
5524
|
+
const media = (await listDir(join3(root2, contentDir, "media"))).filter((f) => !f.isDir).map((f) => `${contentDir}/media/${f.name}`);
|
|
5525
|
+
return { schema, content: { collections, singletons, media } };
|
|
5526
|
+
}
|
|
5527
|
+
async function validateProjectAt(root2) {
|
|
5528
|
+
const loaded = await loadProjectContent(root2);
|
|
5529
|
+
if (!loaded) return null;
|
|
5530
|
+
return validateProject(loaded.schema, loaded.content);
|
|
5531
|
+
}
|
|
5532
|
+
function summarize(issues) {
|
|
5533
|
+
let errors = 0;
|
|
5534
|
+
let warnings = 0;
|
|
5535
|
+
for (const i of issues) {
|
|
5536
|
+
if (i.level === "error") errors++;
|
|
5537
|
+
else warnings++;
|
|
5538
|
+
}
|
|
5539
|
+
return { errors, warnings };
|
|
5540
|
+
}
|
|
5541
|
+
function shouldFail(issues, strict) {
|
|
5542
|
+
const { errors, warnings } = summarize(issues);
|
|
5543
|
+
return errors > 0 || strict && warnings > 0;
|
|
5544
|
+
}
|
|
5545
|
+
function location(issue) {
|
|
5546
|
+
const parts = [];
|
|
5547
|
+
if (issue.collection) parts.push(issue.collection);
|
|
5548
|
+
if (issue.singleton) parts.push(issue.singleton);
|
|
5549
|
+
if (issue.slug) parts.push(issue.slug);
|
|
5550
|
+
if (issue.field) parts.push(issue.field);
|
|
5551
|
+
return parts.join(":") || "(project)";
|
|
5552
|
+
}
|
|
5553
|
+
function formatText(issues) {
|
|
5554
|
+
if (issues.length === 0) return "\u2713 No issues \u2014 content matches the schema.";
|
|
5555
|
+
const lines = issues.map((i) => {
|
|
5556
|
+
const tag = i.level === "error" ? "ERROR" : "WARN ";
|
|
5557
|
+
return ` ${tag} ${location(i)} ${i.message}`;
|
|
5558
|
+
});
|
|
5559
|
+
const { errors, warnings } = summarize(issues);
|
|
5560
|
+
lines.push("");
|
|
5561
|
+
lines.push(`${errors} error(s), ${warnings} warning(s)`);
|
|
5562
|
+
return lines.join("\n");
|
|
5563
|
+
}
|
|
5564
|
+
function formatJson(issues) {
|
|
5565
|
+
const { errors, warnings } = summarize(issues);
|
|
5566
|
+
return JSON.stringify({ ok: errors === 0, errors, warnings, issues }, null, 2);
|
|
5567
|
+
}
|
|
5568
|
+
|
|
4780
5569
|
// src/server.ts
|
|
4781
5570
|
import { exec } from "child_process";
|
|
4782
5571
|
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
|
|
4783
|
-
import { basename, dirname as dirname2, extname, join as
|
|
5572
|
+
import { basename as basename2, dirname as dirname2, extname, join as join4, normalize } from "path";
|
|
4784
5573
|
import { fileURLToPath } from "url";
|
|
4785
5574
|
import { serve } from "@hono/node-server";
|
|
4786
5575
|
import { Hono } from "hono";
|
|
4787
5576
|
var editorDir = fileURLToPath(new URL("./editor", import.meta.url));
|
|
5577
|
+
var PROVIDER_BASE_URLS = {
|
|
5578
|
+
groq: "https://api.groq.com/openai/v1",
|
|
5579
|
+
openrouter: "https://openrouter.ai/api/v1"
|
|
5580
|
+
};
|
|
5581
|
+
async function callGemini(apiKey, model, system, prompt) {
|
|
5582
|
+
const cleanModel = model.trim().replace(/^models\//, "");
|
|
5583
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cleanModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
5584
|
+
const res = await fetch(url, {
|
|
5585
|
+
method: "POST",
|
|
5586
|
+
headers: { "content-type": "application/json" },
|
|
5587
|
+
body: JSON.stringify({
|
|
5588
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
5589
|
+
...system ? { systemInstruction: { parts: [{ text: system }] } } : {}
|
|
5590
|
+
})
|
|
5591
|
+
});
|
|
5592
|
+
const data = await res.json();
|
|
5593
|
+
if (!res.ok) throw new Error(data.error?.message ?? `Gemini request failed (${res.status})`);
|
|
5594
|
+
const text = data.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
|
|
5595
|
+
if (!text) throw new Error("Gemini returned an empty response");
|
|
5596
|
+
return text;
|
|
5597
|
+
}
|
|
5598
|
+
async function listGeminiModels(apiKey) {
|
|
5599
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}&pageSize=1000`;
|
|
5600
|
+
const res = await fetch(url);
|
|
5601
|
+
const data = await res.json();
|
|
5602
|
+
if (!res.ok)
|
|
5603
|
+
throw new Error(data.error?.message ?? `Could not fetch the model list (${res.status})`);
|
|
5604
|
+
return (data.models ?? []).filter((m) => m.supportedGenerationMethods?.includes("generateContent")).map((m) => ({
|
|
5605
|
+
id: (m.name ?? "").replace(/^models\//, ""),
|
|
5606
|
+
label: m.displayName ?? (m.name ?? "").replace(/^models\//, "")
|
|
5607
|
+
})).filter(
|
|
5608
|
+
(m) => m.id && !/tts|image|embedding|robotics|computer-use|veo|lyria|imagen|aqa/i.test(m.id)
|
|
5609
|
+
);
|
|
5610
|
+
}
|
|
5611
|
+
async function listOpenAiCompatibleModels(baseUrl, apiKey) {
|
|
5612
|
+
if (!baseUrl) throw new Error("This provider needs a base URL");
|
|
5613
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
|
|
5614
|
+
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}
|
|
5615
|
+
});
|
|
5616
|
+
const data = await res.json();
|
|
5617
|
+
if (!res.ok)
|
|
5618
|
+
throw new Error(data.error?.message ?? `Could not fetch the model list (${res.status})`);
|
|
5619
|
+
return (data.data ?? []).map((m) => ({ id: m.id ?? "", label: m.id ?? "" })).filter((m) => m.id);
|
|
5620
|
+
}
|
|
5621
|
+
async function callOpenAiCompatible(baseUrl, apiKey, model, system, prompt) {
|
|
5622
|
+
if (!baseUrl) throw new Error("This provider needs a base URL");
|
|
5623
|
+
const messages = [
|
|
5624
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
5625
|
+
{ role: "user", content: prompt }
|
|
5626
|
+
];
|
|
5627
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
5628
|
+
method: "POST",
|
|
5629
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
5630
|
+
body: JSON.stringify({ model, messages })
|
|
5631
|
+
});
|
|
5632
|
+
const data = await res.json();
|
|
5633
|
+
if (!res.ok) throw new Error(data.error?.message ?? `Request failed (${res.status})`);
|
|
5634
|
+
const text = data.choices?.[0]?.message?.content ?? "";
|
|
5635
|
+
if (!text) throw new Error("The provider returned an empty response");
|
|
5636
|
+
return text;
|
|
5637
|
+
}
|
|
4788
5638
|
var MIME = {
|
|
4789
5639
|
".html": "text/html; charset=utf-8",
|
|
4790
5640
|
".js": "text/javascript",
|
|
@@ -4803,15 +5653,16 @@ async function createServer(root2) {
|
|
|
4803
5653
|
let store = new ContentStore(adapter, schema, contentDir);
|
|
4804
5654
|
const app = new Hono();
|
|
4805
5655
|
app.onError((err, c) => {
|
|
4806
|
-
|
|
4807
|
-
if (
|
|
4808
|
-
|
|
4809
|
-
|
|
5656
|
+
if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);
|
|
5657
|
+
if (err instanceof UnsafeSlugError || err instanceof PathEscapeError) {
|
|
5658
|
+
return c.json({ error: err.message }, 400);
|
|
5659
|
+
}
|
|
5660
|
+
return c.json({ error: "server error" }, 500);
|
|
4810
5661
|
});
|
|
4811
5662
|
app.get(
|
|
4812
5663
|
"/api/_project",
|
|
4813
5664
|
(c) => c.json({
|
|
4814
|
-
name:
|
|
5665
|
+
name: basename2(root2) || "project",
|
|
4815
5666
|
path: root2,
|
|
4816
5667
|
contentDir,
|
|
4817
5668
|
collections: schema.collections.length,
|
|
@@ -4822,9 +5673,9 @@ async function createServer(root2) {
|
|
|
4822
5673
|
app.post("/api/_init", async (c) => {
|
|
4823
5674
|
const { template: id } = await c.req.json();
|
|
4824
5675
|
const t = id ? getTemplate(id) : void 0;
|
|
4825
|
-
if (!t) return c.json({ error: "
|
|
5676
|
+
if (!t) return c.json({ error: "Unknown template" }, 404);
|
|
4826
5677
|
if (schema.collections.length > 0 || schema.singletons.length > 0) {
|
|
4827
|
-
return c.json({ error: "
|
|
5678
|
+
return c.json({ error: "This folder already has a schema." }, 400);
|
|
4828
5679
|
}
|
|
4829
5680
|
schema = await applyTemplate(adapter, contentDir, t);
|
|
4830
5681
|
store = new ContentStore(adapter, schema, contentDir);
|
|
@@ -4832,7 +5683,7 @@ async function createServer(root2) {
|
|
|
4832
5683
|
});
|
|
4833
5684
|
app.post("/api/_import", async (c) => {
|
|
4834
5685
|
if (schema.collections.length > 0 || schema.singletons.length > 0) {
|
|
4835
|
-
return c.json({ error: "
|
|
5686
|
+
return c.json({ error: "This folder already has a schema." }, 400);
|
|
4836
5687
|
}
|
|
4837
5688
|
const { raw } = await c.req.json();
|
|
4838
5689
|
let next;
|
|
@@ -4847,7 +5698,7 @@ async function createServer(root2) {
|
|
|
4847
5698
|
entries = inferred.entries;
|
|
4848
5699
|
singletonData = inferred.singletons;
|
|
4849
5700
|
} catch (e) {
|
|
4850
|
-
return c.json({ error:
|
|
5701
|
+
return c.json({ error: `Import failed: ${e.message}` }, 400);
|
|
4851
5702
|
}
|
|
4852
5703
|
}
|
|
4853
5704
|
await saveSchema(adapter, next, contentDir);
|
|
@@ -4855,7 +5706,7 @@ async function createServer(root2) {
|
|
|
4855
5706
|
store = new ContentStore(adapter, schema, contentDir);
|
|
4856
5707
|
for (const [collection, rows] of Object.entries(entries)) {
|
|
4857
5708
|
for (const row of rows) {
|
|
4858
|
-
const slug = slugify(String(row.slug ?? row.title ?? "
|
|
5709
|
+
const slug = slugify(String(row.slug ?? row.title ?? "content")) || "content";
|
|
4859
5710
|
await store.writeEntry(collection, slug, row);
|
|
4860
5711
|
}
|
|
4861
5712
|
}
|
|
@@ -4897,25 +5748,58 @@ async function createServer(root2) {
|
|
|
4897
5748
|
});
|
|
4898
5749
|
app.post("/api/_media", async (c) => {
|
|
4899
5750
|
const body = await c.req.json();
|
|
4900
|
-
if (!body.dataBase64) return c.json({ error: "
|
|
5751
|
+
if (!body.dataBase64) return c.json({ error: "no data" }, 400);
|
|
4901
5752
|
const base = slugify((body.filename ?? "gorsel").replace(/\.[^.]+$/, "")) || "gorsel";
|
|
4902
5753
|
const name = `${base}-${Date.now().toString(36)}.webp`;
|
|
4903
5754
|
const rel = `${contentDir}/media/${name}`;
|
|
4904
|
-
const abs =
|
|
5755
|
+
const abs = join4(root2, rel);
|
|
4905
5756
|
await mkdir2(dirname2(abs), { recursive: true });
|
|
4906
5757
|
await writeFile3(abs, Buffer.from(body.dataBase64, "base64"));
|
|
4907
5758
|
return c.json({ path: rel });
|
|
4908
5759
|
});
|
|
5760
|
+
app.post("/api/_ai/models", async (c) => {
|
|
5761
|
+
const body = await c.req.json();
|
|
5762
|
+
const { provider, apiKey } = body;
|
|
5763
|
+
if (!provider) return c.json({ error: "provider is required" }, 400);
|
|
5764
|
+
try {
|
|
5765
|
+
const models = provider === "gemini" ? await listGeminiModels(apiKey ?? "") : await listOpenAiCompatibleModels(
|
|
5766
|
+
body.baseUrl || PROVIDER_BASE_URLS[provider] || "",
|
|
5767
|
+
apiKey ?? ""
|
|
5768
|
+
);
|
|
5769
|
+
return c.json({ models });
|
|
5770
|
+
} catch (e) {
|
|
5771
|
+
return c.json({ error: e.message }, 502);
|
|
5772
|
+
}
|
|
5773
|
+
});
|
|
5774
|
+
app.post("/api/_ai/generate", async (c) => {
|
|
5775
|
+
const body = await c.req.json();
|
|
5776
|
+
const { provider, apiKey, model, system, prompt } = body;
|
|
5777
|
+
if (!provider || !apiKey || !model || !prompt) {
|
|
5778
|
+
return c.json({ error: "provider, model, apiKey and prompt are required" }, 400);
|
|
5779
|
+
}
|
|
5780
|
+
try {
|
|
5781
|
+
const text = provider === "gemini" ? await callGemini(apiKey, model, system, prompt) : await callOpenAiCompatible(
|
|
5782
|
+
body.baseUrl || PROVIDER_BASE_URLS[provider] || "",
|
|
5783
|
+
apiKey,
|
|
5784
|
+
model,
|
|
5785
|
+
system,
|
|
5786
|
+
prompt
|
|
5787
|
+
);
|
|
5788
|
+
return c.json({ text });
|
|
5789
|
+
} catch (e) {
|
|
5790
|
+
return c.json({ error: e.message }, 502);
|
|
5791
|
+
}
|
|
5792
|
+
});
|
|
4909
5793
|
app.get("/media/:file", async (c) => {
|
|
4910
5794
|
const file = c.req.param("file");
|
|
4911
5795
|
if (file.includes("/") || file.includes("\\") || file.includes("..")) {
|
|
4912
5796
|
return c.text("forbidden", 400);
|
|
4913
5797
|
}
|
|
4914
5798
|
try {
|
|
4915
|
-
const bytes = await readFile2(
|
|
5799
|
+
const bytes = await readFile2(join4(root2, contentDir, "media", file));
|
|
4916
5800
|
return new Response(bytes, { headers: { "content-type": "image/webp" } });
|
|
4917
5801
|
} catch {
|
|
4918
|
-
return c.text("
|
|
5802
|
+
return c.text("not found", 404);
|
|
4919
5803
|
}
|
|
4920
5804
|
});
|
|
4921
5805
|
app.get("/api/:collection", async (c) => {
|
|
@@ -4924,7 +5808,7 @@ async function createServer(root2) {
|
|
|
4924
5808
|
});
|
|
4925
5809
|
app.get("/api/:collection/:slug", async (c) => {
|
|
4926
5810
|
const data = await store.readEntry(c.req.param("collection"), c.req.param("slug"));
|
|
4927
|
-
return data ? c.json(data) : c.json({ error: "
|
|
5811
|
+
return data ? c.json(data) : c.json({ error: "not found" }, 404);
|
|
4928
5812
|
});
|
|
4929
5813
|
app.put("/api/:collection/:slug", async (c) => {
|
|
4930
5814
|
const slug = slugify(c.req.param("slug"));
|
|
@@ -4939,17 +5823,17 @@ async function createServer(root2) {
|
|
|
4939
5823
|
app.get("/*", async (c) => {
|
|
4940
5824
|
const urlPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
4941
5825
|
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
4942
|
-
const file =
|
|
5826
|
+
const file = join4(editorDir, rel);
|
|
4943
5827
|
if (!file.startsWith(editorDir)) return c.text("forbidden", 403);
|
|
4944
5828
|
const type = MIME[extname(file)] ?? "application/octet-stream";
|
|
4945
5829
|
try {
|
|
4946
5830
|
return new Response(await readFile2(file), { headers: { "content-type": type } });
|
|
4947
5831
|
} catch {
|
|
4948
5832
|
try {
|
|
4949
|
-
const html = await readFile2(
|
|
5833
|
+
const html = await readFile2(join4(editorDir, "index.html"));
|
|
4950
5834
|
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
4951
5835
|
} catch {
|
|
4952
|
-
return c.text("
|
|
5836
|
+
return c.text("Editor UI not found (run the justjson build first).", 404);
|
|
4953
5837
|
}
|
|
4954
5838
|
}
|
|
4955
5839
|
});
|
|
@@ -4964,27 +5848,44 @@ async function startServer(root2, port) {
|
|
|
4964
5848
|
const app = await createServer(root2);
|
|
4965
5849
|
serve({ fetch: app.fetch, port });
|
|
4966
5850
|
const url = `http://localhost:${port}`;
|
|
4967
|
-
console.log(`JustJSON
|
|
5851
|
+
console.log(`JustJSON is running at ${url}`);
|
|
4968
5852
|
openBrowser(url);
|
|
4969
5853
|
}
|
|
4970
5854
|
|
|
5855
|
+
// src/version.ts
|
|
5856
|
+
import { readFileSync } from "fs";
|
|
5857
|
+
function readVersion() {
|
|
5858
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
5859
|
+
return JSON.parse(raw).version;
|
|
5860
|
+
}
|
|
5861
|
+
|
|
4971
5862
|
// src/cli.ts
|
|
4972
5863
|
var program = new Command();
|
|
4973
5864
|
var root = process.cwd();
|
|
4974
|
-
program.name("justjson").description("
|
|
4975
|
-
program.command("init").description("
|
|
5865
|
+
program.name("justjson").description("A tiny local-first CMS that keeps your content as plain JSON").version(readVersion());
|
|
5866
|
+
program.command("init").description("Scaffold a project from a template").argument("[template]", `template name (${listTemplates().join(", ")})`, "blog").action(async (template) => {
|
|
4976
5867
|
await initProject(root, template);
|
|
4977
|
-
console.log(`'${template}' template
|
|
5868
|
+
console.log(`Scaffolded from the '${template}' template.`);
|
|
4978
5869
|
});
|
|
4979
|
-
program.command("types").description("
|
|
5870
|
+
program.command("types").description("Generate types.ts and a typed content.ts loader from your schema").action(async () => {
|
|
4980
5871
|
const out = await generateTypesFile(root);
|
|
4981
|
-
console.log(`
|
|
5872
|
+
console.log(`Wrote: ${out}`);
|
|
4982
5873
|
});
|
|
4983
|
-
program.command("export").description("
|
|
5874
|
+
program.command("export").description("Export schema, content and types as a ZIP").action(async () => {
|
|
4984
5875
|
const out = await exportZip(root);
|
|
4985
|
-
console.log(`
|
|
5876
|
+
console.log(`Wrote: ${out}`);
|
|
5877
|
+
});
|
|
5878
|
+
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) => {
|
|
5879
|
+
const issues = await validateProjectAt(root);
|
|
5880
|
+
if (issues === null) {
|
|
5881
|
+
console.error("No schema found. Run `justjson init` first.");
|
|
5882
|
+
process.exitCode = 1;
|
|
5883
|
+
return;
|
|
5884
|
+
}
|
|
5885
|
+
console.log(opts.json ? formatJson(issues) : formatText(issues));
|
|
5886
|
+
if (shouldFail(issues, Boolean(opts.strict))) process.exitCode = 1;
|
|
4986
5887
|
});
|
|
4987
|
-
program.command("serve", { isDefault: true }).description("
|
|
5888
|
+
program.command("serve", { isDefault: true }).description("Start the local editor server").option("-p, --port <port>", "port", "5180").action(async (opts) => {
|
|
4988
5889
|
await startServer(root, Number(opts.port));
|
|
4989
5890
|
});
|
|
4990
5891
|
program.parseAsync();
|