@micacms/runtime 0.5.0-rc.2 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.d.ts +2 -0
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +9 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/http.d.ts +1 -3
  13. package/dist/http.d.ts.map +1 -1
  14. package/dist/http.js +2 -9
  15. package/dist/http.js.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -3
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/errors.d.ts +1 -2
  21. package/dist/lib/errors.d.ts.map +1 -1
  22. package/dist/lib/errors.js +0 -2
  23. package/dist/lib/errors.js.map +1 -1
  24. package/dist/lib/id.d.ts +0 -10
  25. package/dist/lib/id.d.ts.map +1 -1
  26. package/dist/lib/id.js +0 -50
  27. package/dist/lib/id.js.map +1 -1
  28. package/dist/lib/rate-limit.d.ts +0 -2
  29. package/dist/lib/rate-limit.d.ts.map +1 -1
  30. package/dist/lib/rate-limit.js +0 -3
  31. package/dist/lib/rate-limit.js.map +1 -1
  32. package/dist/middleware/index.d.ts +1 -1
  33. package/dist/middleware/index.js +2 -2
  34. package/dist/middleware/index.js.map +1 -1
  35. package/dist/routes/admin-content.d.ts.map +1 -1
  36. package/dist/routes/admin-content.js +4 -3
  37. package/dist/routes/admin-content.js.map +1 -1
  38. package/dist/routes/admin-media.d.ts.map +1 -1
  39. package/dist/routes/admin-media.js +6 -1
  40. package/dist/routes/admin-media.js.map +1 -1
  41. package/dist/routes/admin-system.d.ts.map +1 -1
  42. package/dist/routes/admin-system.js +6 -4
  43. package/dist/routes/admin-system.js.map +1 -1
  44. package/dist/schema.d.ts +15 -3
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +63 -10
  47. package/dist/schema.js.map +1 -1
  48. package/dist/services/changes.d.ts.map +1 -1
  49. package/dist/services/changes.js +89 -13
  50. package/dist/services/changes.js.map +1 -1
  51. package/dist/services/content-store.d.ts +6 -1
  52. package/dist/services/content-store.d.ts.map +1 -1
  53. package/dist/services/content-store.js +25 -15
  54. package/dist/services/content-store.js.map +1 -1
  55. package/dist/services/staging.d.ts +2 -0
  56. package/dist/services/staging.d.ts.map +1 -1
  57. package/dist/services/staging.js +15 -4
  58. package/dist/services/staging.js.map +1 -1
  59. package/dist/workspace/tree.d.ts +6 -0
  60. package/dist/workspace/tree.d.ts.map +1 -1
  61. package/dist/workspace/tree.js +28 -0
  62. package/dist/workspace/tree.js.map +1 -1
  63. package/package.json +12 -4
  64. package/src/app.ts +127 -0
  65. package/src/config.ts +113 -0
  66. package/src/context.ts +167 -0
  67. package/src/http.ts +46 -0
  68. package/src/index.ts +141 -0
  69. package/src/lib/errors.ts +84 -0
  70. package/src/lib/file-type.ts +108 -0
  71. package/src/lib/id.ts +6 -0
  72. package/src/lib/logger.ts +87 -0
  73. package/src/lib/ports.ts +17 -0
  74. package/src/lib/rate-limit.ts +72 -0
  75. package/src/lib/schema-stats.ts +183 -0
  76. package/src/lib/version.ts +29 -0
  77. package/src/middleware/index.ts +234 -0
  78. package/src/routes/admin-content.ts +197 -0
  79. package/src/routes/admin-media.ts +96 -0
  80. package/src/routes/admin-spa.ts +82 -0
  81. package/src/routes/admin-staging.ts +126 -0
  82. package/src/routes/admin-system.ts +146 -0
  83. package/src/routes/health.ts +67 -0
  84. package/src/schema.ts +217 -0
  85. package/src/server.ts +68 -0
  86. package/src/services/changes.ts +605 -0
  87. package/src/services/content-store.ts +492 -0
  88. package/src/services/media.ts +229 -0
  89. package/src/services/staging.ts +330 -0
  90. package/src/workspace/developer.ts +46 -0
  91. package/src/workspace/files.ts +243 -0
  92. package/src/workspace/git.ts +108 -0
  93. package/src/workspace/locks.ts +103 -0
  94. package/src/workspace/policy.ts +77 -0
  95. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Error taxonomy.
3
+ *
4
+ * Every failure the API reports on purpose is an `ApiError` carrying a stable
5
+ * machine-readable `code`. Anything else that reaches the error middleware is
6
+ * treated as a bug: logged with its stack, reported as a bare 500.
7
+ */
8
+
9
+ import type { FieldIssue } from "@micacms/core";
10
+
11
+ export type ErrorCode =
12
+ | "bad_request"
13
+ | "validation_failed"
14
+ | "unauthenticated"
15
+ | "forbidden"
16
+ | "not_found"
17
+ | "conflict"
18
+ | "payload_too_large"
19
+ | "unsupported_media_type"
20
+ | "rate_limited"
21
+ | "internal_error"
22
+ /** An identity provider Mica depends on could not be reached. */
23
+ | "service_unavailable";
24
+
25
+ const STATUS: Record<ErrorCode, number> = {
26
+ bad_request: 400,
27
+ validation_failed: 422,
28
+ unauthenticated: 401,
29
+ forbidden: 403,
30
+ not_found: 404,
31
+ conflict: 409,
32
+ payload_too_large: 413,
33
+ unsupported_media_type: 415,
34
+ rate_limited: 429,
35
+ internal_error: 500,
36
+ service_unavailable: 503,
37
+ };
38
+
39
+ export interface ApiErrorBody {
40
+ error: {
41
+ code: ErrorCode;
42
+ message: string;
43
+ issues?: FieldIssue[];
44
+ };
45
+ }
46
+
47
+ export class ApiError extends Error {
48
+ override name = "ApiError";
49
+ readonly code: ErrorCode;
50
+ readonly status: number;
51
+ readonly issues?: FieldIssue[];
52
+ /** Extra response headers, e.g. `Retry-After` on a rate limit. */
53
+ readonly headers?: Record<string, string>;
54
+
55
+ constructor(
56
+ code: ErrorCode,
57
+ message: string,
58
+ options: { issues?: FieldIssue[]; headers?: Record<string, string>; cause?: unknown } = {},
59
+ ) {
60
+ super(message, { cause: options.cause });
61
+ this.code = code;
62
+ this.status = STATUS[code];
63
+ this.issues = options.issues;
64
+ this.headers = options.headers;
65
+ }
66
+
67
+ toBody(): ApiErrorBody {
68
+ return {
69
+ error: {
70
+ code: this.code,
71
+ message: this.message,
72
+ ...(this.issues ? { issues: this.issues } : {}),
73
+ },
74
+ };
75
+ }
76
+ }
77
+
78
+ export const badRequest = (message: string) => new ApiError("bad_request", message);
79
+ export const unauthenticated = (message = "Authentication required.") =>
80
+ new ApiError("unauthenticated", message);
81
+ export const forbidden = (message = "You do not have access to this resource.") =>
82
+ new ApiError("forbidden", message);
83
+ export const notFound = (message = "Not found.") => new ApiError("not_found", message);
84
+ export const conflict = (message: string) => new ApiError("conflict", message);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Content sniffing for uploads.
3
+ *
4
+ * The browser-supplied MIME type is attacker-controlled and must not be
5
+ * trusted: an executable renamed to `.jpg` arrives as `image/jpeg`. The stored
6
+ * type is derived from the bytes instead, and the extension is regenerated
7
+ * from it rather than taken from the filename.
8
+ */
9
+
10
+ export interface DetectedType {
11
+ mimeType: string;
12
+ extension: string;
13
+ }
14
+
15
+ const startsWith = (bytes: Uint8Array, signature: number[], offset = 0): boolean =>
16
+ signature.every((byte, index) => bytes[offset + index] === byte);
17
+
18
+ const ascii = (bytes: Uint8Array, offset: number, length: number): string =>
19
+ Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
20
+
21
+ export function detectFileType(bytes: Uint8Array): DetectedType | null {
22
+ if (bytes.length < 12) return null;
23
+
24
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) {
25
+ return { mimeType: "image/jpeg", extension: "jpg" };
26
+ }
27
+
28
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
29
+ return { mimeType: "image/png", extension: "png" };
30
+ }
31
+
32
+ const header = ascii(bytes, 0, 6);
33
+ if (header === "GIF87a" || header === "GIF89a") {
34
+ return { mimeType: "image/gif", extension: "gif" };
35
+ }
36
+
37
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
38
+ return { mimeType: "image/webp", extension: "webp" };
39
+ }
40
+
41
+ // ISO base media formats declare their brand at offset 8, after the box size.
42
+ if (ascii(bytes, 4, 4) === "ftyp") {
43
+ const brand = ascii(bytes, 8, 4);
44
+ if (brand === "avif" || brand === "avis") {
45
+ return { mimeType: "image/avif", extension: "avif" };
46
+ }
47
+ if (brand === "heic" || brand === "heix" || brand === "mif1") {
48
+ return { mimeType: "image/heic", extension: "heic" };
49
+ }
50
+ if (brand === "isom" || brand === "mp42" || brand === "M4V ") {
51
+ return { mimeType: "video/mp4", extension: "mp4" };
52
+ }
53
+ }
54
+
55
+ if (ascii(bytes, 0, 5) === "%PDF-") {
56
+ return { mimeType: "application/pdf", extension: "pdf" };
57
+ }
58
+
59
+ if (isSvg(bytes)) {
60
+ return { mimeType: "image/svg+xml", extension: "svg" };
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /** SVG has no magic number, so look for a root `<svg>` in the leading text. */
67
+ function isSvg(bytes: Uint8Array): boolean {
68
+ const head = new TextDecoder("utf-8", { fatal: false })
69
+ .decode(bytes.slice(0, 1024))
70
+ .trimStart()
71
+ .toLowerCase();
72
+
73
+ if (head.startsWith("<svg")) return true;
74
+ if (head.startsWith("<?xml") || head.startsWith("<!doctype svg")) {
75
+ return head.includes("<svg");
76
+ }
77
+ return false;
78
+ }
79
+
80
+ const IMAGE_TYPES = new Set([
81
+ "image/jpeg",
82
+ "image/png",
83
+ "image/gif",
84
+ "image/webp",
85
+ "image/avif",
86
+ "image/heic",
87
+ ]);
88
+
89
+ const DOCUMENT_TYPES = new Set(["application/pdf"]);
90
+
91
+ export const isImageType = (mimeType: string): boolean => IMAGE_TYPES.has(mimeType);
92
+
93
+ /**
94
+ * Whether a detected type is acceptable for a field's `accept` setting.
95
+ * `any` deliberately does not mean "anything": only the types listed here can
96
+ * be stored at all.
97
+ */
98
+ export function isAccepted(mimeType: string, accept: "image" | "document" | "any"): boolean {
99
+ if (accept === "image") return IMAGE_TYPES.has(mimeType);
100
+ if (accept === "document") return DOCUMENT_TYPES.has(mimeType);
101
+ return IMAGE_TYPES.has(mimeType) || DOCUMENT_TYPES.has(mimeType) || mimeType === "video/mp4";
102
+ }
103
+
104
+ /**
105
+ * SVG can carry scripts, so Mica neither stores nor shows it (#86). It is still
106
+ * detected, so that the refusal can say why.
107
+ */
108
+ export const SVG_TYPE = "image/svg+xml";
package/src/lib/id.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /** URL-safe random token for sessions and one-time links. */
4
+ export function randomToken(bytes = 32): string {
5
+ return randomBytes(bytes).toString("base64url");
6
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Structured logging.
3
+ *
4
+ * JSON lines in production so a log shipper can parse them; a compact
5
+ * human-readable form in development.
6
+ */
7
+
8
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
9
+
10
+ const ORDER: Record<LogLevel, number> = {
11
+ debug: 10,
12
+ info: 20,
13
+ warn: 30,
14
+ error: 40,
15
+ silent: 100,
16
+ };
17
+
18
+ export interface Logger {
19
+ debug(message: string, fields?: Record<string, unknown>): void;
20
+ info(message: string, fields?: Record<string, unknown>): void;
21
+ warn(message: string, fields?: Record<string, unknown>): void;
22
+ error(message: string, fields?: Record<string, unknown>): void;
23
+ child(bindings: Record<string, unknown>): Logger;
24
+ }
25
+
26
+ const COLORS: Record<Exclude<LogLevel, "silent">, string> = {
27
+ debug: "",
28
+ info: "",
29
+ warn: "",
30
+ error: "",
31
+ };
32
+
33
+ export function createLogger(options: {
34
+ level: LogLevel;
35
+ pretty: boolean;
36
+ bindings?: Record<string, unknown>;
37
+ }): Logger {
38
+ const threshold = ORDER[options.level];
39
+ const bindings = options.bindings ?? {};
40
+
41
+ const write = (level: Exclude<LogLevel, "silent">, message: string, fields?: Record<string, unknown>) => {
42
+ if (ORDER[level] < threshold) return;
43
+
44
+ const payload = { ...bindings, ...fields };
45
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
46
+
47
+ if (options.pretty) {
48
+ const extra = Object.keys(payload).length > 0 ? ` ${formatFields(payload)}` : "";
49
+ stream.write(`${COLORS[level]}${level.padEnd(5)} ${message}${extra}\n`);
50
+ return;
51
+ }
52
+
53
+ stream.write(
54
+ `${JSON.stringify({
55
+ time: new Date().toISOString(),
56
+ level,
57
+ message,
58
+ ...payload,
59
+ })}\n`,
60
+ );
61
+ };
62
+
63
+ return {
64
+ debug: (message, fields) => write("debug", message, fields),
65
+ info: (message, fields) => write("info", message, fields),
66
+ warn: (message, fields) => write("warn", message, fields),
67
+ error: (message, fields) => write("error", message, fields),
68
+ child: (extra) =>
69
+ createLogger({ ...options, bindings: { ...bindings, ...extra } }),
70
+ };
71
+ }
72
+
73
+ function formatFields(fields: Record<string, unknown>): string {
74
+ return Object.entries(fields)
75
+ .map(([key, value]) => `${key}=${format(value)}`)
76
+ .join(" ");
77
+ }
78
+
79
+ function format(value: unknown): string {
80
+ if (typeof value === "string") return value;
81
+ if (value instanceof Error) return value.stack ?? value.message;
82
+ try {
83
+ return JSON.stringify(value) ?? String(value);
84
+ } catch {
85
+ return String(value);
86
+ }
87
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The port Mica listens on when nobody says otherwise.
3
+ *
4
+ * 6422 is `MICA` on a telephone keypad, which is the only reason anybody will
5
+ * remember it. What matters more is what it is not: 3000, 4000, 4173, 4200,
6
+ * 4321, 5000, 5173, 8000, 8080 and 1313 all belong to something a developer is
7
+ * likely to be running beside Mica — Astro's dev server most of all — and IANA
8
+ * has 6420 and 6421 assigned but not 6422, so nothing on the machine is
9
+ * expected to want it.
10
+ *
11
+ * A port collision is a bad first minute: the command fails, and the reason is
12
+ * somebody else's software. Picking an unfashionable number is the whole fix.
13
+ */
14
+ export const DEFAULT_PORT = 6422;
15
+
16
+ /** The address the CLI prints and the browser opens. */
17
+ export const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Fixed-window rate limiting, in memory.
3
+ *
4
+ * The CMS is a single process by design, so an in-memory counter is
5
+ * sufficient and avoids a Redis dependency. The trade-off is deliberate:
6
+ * counters reset on restart. Mica checks no passwords; the identity provider
7
+ * does, with its own limits, so a restart-and-retry loop gains little.
8
+ */
9
+
10
+ export interface RateLimitResult {
11
+ allowed: boolean;
12
+ remaining: number;
13
+ /** Seconds until the window resets. */
14
+ retryAfter: number;
15
+ }
16
+
17
+ export interface RateLimiter {
18
+ check(key: string): RateLimitResult;
19
+ size(): number;
20
+ stop(): void;
21
+ }
22
+
23
+ export interface RateLimiterOptions {
24
+ limit: number;
25
+ windowMs: number;
26
+ /** How often expired buckets are swept. */
27
+ sweepMs?: number;
28
+ }
29
+
30
+ export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
31
+ const buckets = new Map<string, { count: number; resetAt: number }>();
32
+
33
+ const sweep = setInterval(() => {
34
+ const now = Date.now();
35
+ for (const [key, bucket] of buckets) {
36
+ if (bucket.resetAt <= now) buckets.delete(key);
37
+ }
38
+ }, options.sweepMs ?? Math.max(options.windowMs, 60_000));
39
+
40
+ // A sweep timer must not keep the process alive on shutdown.
41
+ sweep.unref?.();
42
+
43
+ return {
44
+ check(key) {
45
+ const now = Date.now();
46
+ const bucket = buckets.get(key);
47
+
48
+ if (!bucket || bucket.resetAt <= now) {
49
+ buckets.set(key, { count: 1, resetAt: now + options.windowMs });
50
+ return { allowed: true, remaining: options.limit - 1, retryAfter: 0 };
51
+ }
52
+
53
+ bucket.count++;
54
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
55
+
56
+ if (bucket.count > options.limit) {
57
+ return { allowed: false, remaining: 0, retryAfter };
58
+ }
59
+
60
+ return { allowed: true, remaining: options.limit - bucket.count, retryAfter };
61
+ },
62
+
63
+ size() {
64
+ return buckets.size;
65
+ },
66
+
67
+ stop() {
68
+ clearInterval(sweep);
69
+ buckets.clear();
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Summarises a content model.
3
+ *
4
+ * The admin already receives the whole model and could count fields itself, but
5
+ * the interesting numbers are the recursive ones — objects and repeaters nest —
6
+ * and the relation graph, which is only visible after walking every branch.
7
+ * Doing it here keeps that walk in one place, next to the schema checks that
8
+ * use the same shapes.
9
+ *
10
+ * Pure: it reads a `Site` and nothing else, so it is the same answer for the
11
+ * same model whatever the state of the database.
12
+ */
13
+
14
+ import {
15
+ listedFields,
16
+ resolveSlugField,
17
+ resolveTitleField,
18
+ type AnyField,
19
+ type FieldKind,
20
+ type FieldMap,
21
+ type Site,
22
+ } from "@micacms/core";
23
+
24
+ export interface KindUsage {
25
+ kind: FieldKind;
26
+ count: number;
27
+ }
28
+
29
+ export interface RelationEdge {
30
+ /** Collection or singleton the field is declared in. */
31
+ from: string;
32
+ fromType: "collection" | "singleton";
33
+ /** Dotted path to the field, with `[]` marking a repeater. */
34
+ path: string;
35
+ /** Collection the relation points at. */
36
+ to: string;
37
+ multiple: boolean;
38
+ }
39
+
40
+ export interface FieldCounts {
41
+ /** Fields declared directly on the collection or singleton. */
42
+ top: number;
43
+ /** Every field, including those nested in objects and repeaters. */
44
+ total: number;
45
+ required: number;
46
+ }
47
+
48
+ export interface CollectionStats {
49
+ name: string;
50
+ label: string;
51
+ fields: FieldCounts;
52
+ titleField: string | null;
53
+ slugField: string | null;
54
+ listed: number;
55
+ sortable: boolean;
56
+ /** Deepest level of nesting; 1 means no objects or repeaters. */
57
+ depth: number;
58
+ }
59
+
60
+ export interface SingletonStats {
61
+ name: string;
62
+ label: string;
63
+ fields: FieldCounts;
64
+ depth: number;
65
+ }
66
+
67
+ export interface SchemaStats {
68
+ collections: CollectionStats[];
69
+ singletons: SingletonStats[];
70
+ totals: FieldCounts & { collections: number; singletons: number };
71
+ /** Field kinds in use, most used first. */
72
+ kinds: KindUsage[];
73
+ relations: RelationEdge[];
74
+ }
75
+
76
+ /** The two kinds that carry a nested `fields` map. */
77
+ function children(field: AnyField): FieldMap | null {
78
+ return field.kind === "object" || field.kind === "repeater" ? field.fields : null;
79
+ }
80
+
81
+ /**
82
+ * Depth-first walk over a field map, visiting nested fields too.
83
+ *
84
+ * `path` is what an editor would say out loud — `blocks[].heading` rather than
85
+ * `blocks.fields.heading` — so it can be printed as-is.
86
+ */
87
+ function walk(
88
+ fields: FieldMap,
89
+ visit: (key: string, field: AnyField, path: string, depth: number) => void,
90
+ prefix = "",
91
+ depth = 1,
92
+ ): void {
93
+ for (const [key, field] of Object.entries(fields)) {
94
+ const path = prefix ? `${prefix}.${key}` : key;
95
+ visit(key, field, path, depth);
96
+
97
+ const nested = children(field);
98
+ if (nested) {
99
+ walk(nested, visit, field.kind === "repeater" ? `${path}[]` : path, depth + 1);
100
+ }
101
+ }
102
+ }
103
+
104
+ function countFields(fields: FieldMap): { counts: FieldCounts; depth: number } {
105
+ const counts: FieldCounts = { top: Object.keys(fields).length, total: 0, required: 0 };
106
+ let deepest = 1;
107
+
108
+ walk(fields, (_key, field, _path, depth) => {
109
+ counts.total++;
110
+ if (field.required) counts.required++;
111
+ if (depth > deepest) deepest = depth;
112
+ });
113
+
114
+ return { counts, depth: deepest };
115
+ }
116
+
117
+ export function schemaStats(site: Site): SchemaStats {
118
+ const kinds = new Map<FieldKind, number>();
119
+ const relations: RelationEdge[] = [];
120
+
121
+ const totals: FieldCounts & { collections: number; singletons: number } = {
122
+ collections: 0,
123
+ singletons: 0,
124
+ top: 0,
125
+ total: 0,
126
+ required: 0,
127
+ };
128
+
129
+ const tally = (fields: FieldMap, from: string, fromType: RelationEdge["fromType"]): void => {
130
+ walk(fields, (_key, field, path) => {
131
+ kinds.set(field.kind, (kinds.get(field.kind) ?? 0) + 1);
132
+
133
+ if (field.kind === "relation") {
134
+ relations.push({ from, fromType, path, to: field.collection, multiple: field.multiple ?? false });
135
+ }
136
+ });
137
+ };
138
+
139
+ const collections = Object.values(site.collections).map((def): CollectionStats => {
140
+ const { counts, depth } = countFields(def.fields);
141
+ tally(def.fields, def.name, "collection");
142
+
143
+ totals.collections++;
144
+ totals.top += counts.top;
145
+ totals.total += counts.total;
146
+ totals.required += counts.required;
147
+
148
+ return {
149
+ name: def.name,
150
+ label: def.label,
151
+ fields: counts,
152
+ // The same resolution the editor sees, not the raw declaration: both keys
153
+ // are optional in the model and fall back to a convention.
154
+ titleField: resolveTitleField(def) ?? null,
155
+ slugField: resolveSlugField(def) ?? null,
156
+ listed: listedFields(def).length,
157
+ sortable: def.sortable ?? false,
158
+ depth,
159
+ };
160
+ });
161
+
162
+ const singletons = Object.values(site.singletons).map((def): SingletonStats => {
163
+ const { counts, depth } = countFields(def.fields);
164
+ tally(def.fields, def.name, "singleton");
165
+
166
+ totals.singletons++;
167
+ totals.top += counts.top;
168
+ totals.total += counts.total;
169
+ totals.required += counts.required;
170
+
171
+ return { name: def.name, label: def.label, fields: counts, depth };
172
+ });
173
+
174
+ return {
175
+ collections,
176
+ singletons,
177
+ totals,
178
+ kinds: [...kinds]
179
+ .map(([kind, count]) => ({ kind, count }))
180
+ .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind)),
181
+ relations,
182
+ };
183
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The version of this build, read from the package manifest.
3
+ *
4
+ * Read rather than imported: an import would need a JSON module attribute and
5
+ * would put a second copy of the manifest in `dist/`. The file is a fixed
6
+ * distance from this module in both layouts — `src/lib` and `dist/lib`
7
+ * are the same depth below the package root, and the Docker image copies
8
+ * `package.json` next to `dist/` — so one path serves both.
9
+ *
10
+ * A missing or unreadable manifest is not worth failing a request over; the
11
+ * system page shows "unknown" instead.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ function readVersion(): string {
18
+ try {
19
+ const manifest = fs.readFileSync(
20
+ path.resolve(import.meta.dirname, "../../package.json"),
21
+ "utf8",
22
+ );
23
+ return (JSON.parse(manifest) as { version?: string }).version ?? "unknown";
24
+ } catch {
25
+ return "unknown";
26
+ }
27
+ }
28
+
29
+ export const VERSION = readVersion();