@cogenta/schema 0.1.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.
Files changed (169) hide show
  1. package/dist/define-collection.d.ts +8 -0
  2. package/dist/define-collection.d.ts.map +1 -0
  3. package/dist/define-collection.js +311 -0
  4. package/dist/define-collection.js.map +1 -0
  5. package/dist/errors.d.ts +16 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +18 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/fields.d.ts +117 -0
  10. package/dist/fields.d.ts.map +1 -0
  11. package/dist/fields.js +107 -0
  12. package/dist/fields.js.map +1 -0
  13. package/dist/generate-schema-json.d.ts +61 -0
  14. package/dist/generate-schema-json.d.ts.map +1 -0
  15. package/dist/generate-schema-json.js +128 -0
  16. package/dist/generate-schema-json.js.map +1 -0
  17. package/dist/generate-types.d.ts +5 -0
  18. package/dist/generate-types.d.ts.map +1 -0
  19. package/dist/generate-types.js +220 -0
  20. package/dist/generate-types.js.map +1 -0
  21. package/dist/id.d.ts +8 -0
  22. package/dist/id.d.ts.map +1 -0
  23. package/dist/id.js +8 -0
  24. package/dist/id.js.map +1 -0
  25. package/dist/index.d.ts +28 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/rich-text.d.ts +164 -0
  30. package/dist/rich-text.d.ts.map +1 -0
  31. package/dist/rich-text.js +97 -0
  32. package/dist/rich-text.js.map +1 -0
  33. package/dist/routing/ids.d.ts +18 -0
  34. package/dist/routing/ids.d.ts.map +1 -0
  35. package/dist/routing/ids.js +41 -0
  36. package/dist/routing/ids.js.map +1 -0
  37. package/dist/routing/index.d.ts +13 -0
  38. package/dist/routing/index.d.ts.map +1 -0
  39. package/dist/routing/index.js +7 -0
  40. package/dist/routing/index.js.map +1 -0
  41. package/dist/routing/redirects.d.ts +76 -0
  42. package/dist/routing/redirects.d.ts.map +1 -0
  43. package/dist/routing/redirects.js +237 -0
  44. package/dist/routing/redirects.js.map +1 -0
  45. package/dist/routing/resolve.d.ts +38 -0
  46. package/dist/routing/resolve.d.ts.map +1 -0
  47. package/dist/routing/resolve.js +23 -0
  48. package/dist/routing/resolve.js.map +1 -0
  49. package/dist/routing/router.d.ts +51 -0
  50. package/dist/routing/router.d.ts.map +1 -0
  51. package/dist/routing/router.js +132 -0
  52. package/dist/routing/router.js.map +1 -0
  53. package/dist/routing/slug-change.d.ts +39 -0
  54. package/dist/routing/slug-change.d.ts.map +1 -0
  55. package/dist/routing/slug-change.js +44 -0
  56. package/dist/routing/slug-change.js.map +1 -0
  57. package/dist/routing/slug.d.ts +82 -0
  58. package/dist/routing/slug.d.ts.map +1 -0
  59. package/dist/routing/slug.js +124 -0
  60. package/dist/routing/slug.js.map +1 -0
  61. package/dist/routing/slugify.d.ts +29 -0
  62. package/dist/routing/slugify.d.ts.map +1 -0
  63. package/dist/routing/slugify.js +104 -0
  64. package/dist/routing/slugify.js.map +1 -0
  65. package/dist/scheduling/index.d.ts +3 -0
  66. package/dist/scheduling/index.d.ts.map +1 -0
  67. package/dist/scheduling/index.js +2 -0
  68. package/dist/scheduling/index.js.map +1 -0
  69. package/dist/scheduling/publish.d.ts +71 -0
  70. package/dist/scheduling/publish.d.ts.map +1 -0
  71. package/dist/scheduling/publish.js +122 -0
  72. package/dist/scheduling/publish.js.map +1 -0
  73. package/dist/search/extract.d.ts +25 -0
  74. package/dist/search/extract.d.ts.map +1 -0
  75. package/dist/search/extract.js +188 -0
  76. package/dist/search/extract.js.map +1 -0
  77. package/dist/search/index.d.ts +36 -0
  78. package/dist/search/index.d.ts.map +1 -0
  79. package/dist/search/index.js +44 -0
  80. package/dist/search/index.js.map +1 -0
  81. package/dist/search/mysql.d.ts +7 -0
  82. package/dist/search/mysql.d.ts.map +1 -0
  83. package/dist/search/mysql.js +121 -0
  84. package/dist/search/mysql.js.map +1 -0
  85. package/dist/search/postgres.d.ts +9 -0
  86. package/dist/search/postgres.d.ts.map +1 -0
  87. package/dist/search/postgres.js +167 -0
  88. package/dist/search/postgres.js.map +1 -0
  89. package/dist/search/query.d.ts +35 -0
  90. package/dist/search/query.d.ts.map +1 -0
  91. package/dist/search/query.js +116 -0
  92. package/dist/search/query.js.map +1 -0
  93. package/dist/search/sqlite.d.ts +16 -0
  94. package/dist/search/sqlite.d.ts.map +1 -0
  95. package/dist/search/sqlite.js +166 -0
  96. package/dist/search/sqlite.js.map +1 -0
  97. package/dist/search/table.d.ts +49 -0
  98. package/dist/search/table.d.ts.map +1 -0
  99. package/dist/search/table.js +86 -0
  100. package/dist/search/table.js.map +1 -0
  101. package/dist/search/text.d.ts +42 -0
  102. package/dist/search/text.d.ts.map +1 -0
  103. package/dist/search/text.js +56 -0
  104. package/dist/search/text.js.map +1 -0
  105. package/dist/search/types.d.ts +98 -0
  106. package/dist/search/types.d.ts.map +1 -0
  107. package/dist/search/types.js +2 -0
  108. package/dist/search/types.js.map +1 -0
  109. package/dist/store/columns.d.ts +33 -0
  110. package/dist/store/columns.d.ts.map +1 -0
  111. package/dist/store/columns.js +113 -0
  112. package/dist/store/columns.js.map +1 -0
  113. package/dist/store/cursor.d.ts +23 -0
  114. package/dist/store/cursor.d.ts.map +1 -0
  115. package/dist/store/cursor.js +36 -0
  116. package/dist/store/cursor.js.map +1 -0
  117. package/dist/store/diff.d.ts +59 -0
  118. package/dist/store/diff.d.ts.map +1 -0
  119. package/dist/store/diff.js +124 -0
  120. package/dist/store/diff.js.map +1 -0
  121. package/dist/store/fragments.d.ts +13 -0
  122. package/dist/store/fragments.d.ts.map +1 -0
  123. package/dist/store/fragments.js +25 -0
  124. package/dist/store/fragments.js.map +1 -0
  125. package/dist/store/index.d.ts +22 -0
  126. package/dist/store/index.d.ts.map +1 -0
  127. package/dist/store/index.js +16 -0
  128. package/dist/store/index.js.map +1 -0
  129. package/dist/store/naming.d.ts +25 -0
  130. package/dist/store/naming.d.ts.map +1 -0
  131. package/dist/store/naming.js +80 -0
  132. package/dist/store/naming.js.map +1 -0
  133. package/dist/store/read-only.d.ts +16 -0
  134. package/dist/store/read-only.d.ts.map +1 -0
  135. package/dist/store/read-only.js +36 -0
  136. package/dist/store/read-only.js.map +1 -0
  137. package/dist/store/store.d.ts +52 -0
  138. package/dist/store/store.d.ts.map +1 -0
  139. package/dist/store/store.js +706 -0
  140. package/dist/store/store.js.map +1 -0
  141. package/dist/store/tables.d.ts +40 -0
  142. package/dist/store/tables.d.ts.map +1 -0
  143. package/dist/store/tables.js +267 -0
  144. package/dist/store/tables.js.map +1 -0
  145. package/dist/store/types.d.ts +105 -0
  146. package/dist/store/types.d.ts.map +1 -0
  147. package/dist/store/types.js +2 -0
  148. package/dist/store/types.js.map +1 -0
  149. package/dist/store/uuid.d.ts +6 -0
  150. package/dist/store/uuid.d.ts.map +1 -0
  151. package/dist/store/uuid.js +64 -0
  152. package/dist/store/uuid.js.map +1 -0
  153. package/dist/store/values.d.ts +40 -0
  154. package/dist/store/values.d.ts.map +1 -0
  155. package/dist/store/values.js +160 -0
  156. package/dist/store/values.js.map +1 -0
  157. package/dist/system-fields.d.ts +60 -0
  158. package/dist/system-fields.d.ts.map +1 -0
  159. package/dist/system-fields.js +73 -0
  160. package/dist/system-fields.js.map +1 -0
  161. package/dist/types.d.ts +101 -0
  162. package/dist/types.d.ts.map +1 -0
  163. package/dist/types.js +32 -0
  164. package/dist/types.js.map +1 -0
  165. package/dist/validation.d.ts +54 -0
  166. package/dist/validation.d.ts.map +1 -0
  167. package/dist/validation.js +183 -0
  168. package/dist/validation.js.map +1 -0
  169. package/package.json +43 -0
@@ -0,0 +1,237 @@
1
+ import { CogentaError, identifier, limit, sql, } from '@cogenta/core';
2
+ import { newId } from '../id.js';
3
+ import { normalisePath } from './router.js';
4
+ /**
5
+ * The redirect table.
6
+ *
7
+ * L1 calls unresolved redirects "a daily nuisance that WordPress and Drupal do
8
+ * not solve" and asks that this work without anyone thinking about it. Two
9
+ * things make that true, and both are enforced here rather than left to callers:
10
+ *
11
+ * - **Chains are flattened.** When A already points at B and B starts pointing
12
+ * at C, the row for A is rewritten to C. Otherwise every rename adds a hop,
13
+ * and a page renamed five times costs a visitor five round trips.
14
+ * - **Loops are refused.** A→B→A serves nothing but an infinite redirect, so it
15
+ * is rejected at write time, where there is still a human to tell.
16
+ */
17
+ export const REDIRECTS_TABLE = 'cogenta_redirects';
18
+ /**
19
+ * Longest path stored, checked before insert.
20
+ *
21
+ * It must stay in step with the `varchar(512)` of the DDL below — a width
22
+ * cannot be a bound parameter, so the number is written twice on purpose rather
23
+ * than concatenated into the statement.
24
+ */
25
+ const MAX_PATH_LENGTH = 512;
26
+ /**
27
+ * Hops `resolve` will follow before giving up.
28
+ *
29
+ * Flattening means one hop is normally enough. The cap exists for a table
30
+ * edited by hand or migrated in from another CMS, where a cycle can exist that
31
+ * this code never wrote.
32
+ */
33
+ const MAX_HOPS = 10;
34
+ export const REDIRECT_REASONS = ['slug-change', 'manual', 'import'];
35
+ export function createRedirectStore(options) {
36
+ const { db } = options;
37
+ const now = options.now ?? Date.now;
38
+ const nextId = options.newId ?? newId;
39
+ const table = identifier(options.table ?? REDIRECTS_TABLE, db.dialect);
40
+ let ready = false;
41
+ async function ensureTable() {
42
+ if (ready)
43
+ return;
44
+ await db.query(sql `
45
+ create table if not exists ${table} (
46
+ id varchar(64) not null primary key,
47
+ from_path varchar(512) not null,
48
+ to_path varchar(512) not null,
49
+ status integer not null,
50
+ collection varchar(255),
51
+ entry_id varchar(64),
52
+ locale varchar(35),
53
+ reason varchar(32) not null,
54
+ created_at bigint not null
55
+ )`);
56
+ // Unique, not merely indexed: one source path has one destination, and the
57
+ // database is the only place that stays true under two concurrent renames.
58
+ await db
59
+ .query(sql `create unique index ${identifier(`${options.table ?? REDIRECTS_TABLE}_from`, db.dialect)}
60
+ on ${table} (from_path)`)
61
+ .catch(() => undefined); // already there — no dialect spells "if not exists" the same way
62
+ ready = true;
63
+ }
64
+ function checkPath(path, field) {
65
+ const normalised = normalisePath(path);
66
+ if (normalised === '/' && field === 'from') {
67
+ throw new CogentaError({
68
+ code: 'CONTENT_ROUTE_INVALID',
69
+ message: 'The site root cannot redirect.',
70
+ hint: 'Change the home page instead of redirecting away from "/".',
71
+ });
72
+ }
73
+ if (normalised.length > MAX_PATH_LENGTH) {
74
+ throw new CogentaError({
75
+ code: 'CONTENT_ROUTE_INVALID',
76
+ message: `A redirect path may be ${MAX_PATH_LENGTH} characters at most.`,
77
+ hint: 'Shorten the slug, or the pattern the collection is routed on.',
78
+ details: { field, length: normalised.length },
79
+ });
80
+ }
81
+ return normalised;
82
+ }
83
+ async function targetOf(tx, path) {
84
+ const found = await tx.query(sql `
85
+ select to_path from ${table} where from_path = ${path} limit ${limit(1)}`);
86
+ return found.rows[0]?.to_path;
87
+ }
88
+ /**
89
+ * Where `to` really leads, and proof that it does not lead back to `from`.
90
+ *
91
+ * Both jobs at once on purpose: they are the same walk, and doing them
92
+ * separately would walk the chain twice and let it change in between.
93
+ */
94
+ async function flattenTarget(tx, from, to) {
95
+ const seen = new Set([from]);
96
+ let target = to;
97
+ for (let hop = 0; hop <= MAX_HOPS; hop += 1) {
98
+ if (seen.has(target)) {
99
+ throw new CogentaError({
100
+ code: 'CONTENT_REDIRECT_LOOP',
101
+ message: `Redirecting ${from} to ${to} would send visitors round in a circle.`,
102
+ hint: `${target} already leads back to ${from}. Remove that redirect first, or point this one elsewhere.`,
103
+ details: { from, to, at: target },
104
+ });
105
+ }
106
+ seen.add(target);
107
+ const next = await targetOf(tx, target);
108
+ if (next === undefined)
109
+ return target;
110
+ target = next;
111
+ }
112
+ throw new CogentaError({
113
+ code: 'CONTENT_REDIRECT_LOOP',
114
+ message: `The redirect chain starting at ${to} is more than ${MAX_HOPS} hops long.`,
115
+ hint: 'The table holds a cycle it did not get from Cogenta. Inspect and prune it.',
116
+ details: { from, to },
117
+ });
118
+ }
119
+ async function deleteFrom(path) {
120
+ await ensureTable();
121
+ const result = await db.query(sql `
122
+ delete from ${table} where from_path = ${normalisePath(path)}`);
123
+ return result.rowsAffected > 0;
124
+ }
125
+ return {
126
+ ensureTable,
127
+ add: async (input) => {
128
+ await ensureTable();
129
+ const from = checkPath(input.from, 'from');
130
+ const to = checkPath(input.to, 'to');
131
+ const status = input.status ?? 301;
132
+ const reason = input.reason ?? 'manual';
133
+ const createdAt = now();
134
+ if (from === to) {
135
+ throw new CogentaError({
136
+ code: 'CONTENT_REDIRECT_LOOP',
137
+ message: `${from} cannot redirect to itself.`,
138
+ hint: 'Nothing needs a redirect here: the path did not change.',
139
+ details: { from },
140
+ });
141
+ }
142
+ // One transaction: the chain is read, rewritten and extended together, so
143
+ // a second rename running at the same time cannot slot a hop in between.
144
+ return db.transaction(async (tx) => {
145
+ const target = await flattenTarget(tx, from, to);
146
+ // Everything that pointed at the old path now points at the new one.
147
+ // This is what keeps the table one hop deep however often an entry is
148
+ // renamed.
149
+ await tx.query(sql `
150
+ update ${table} set to_path = ${target} where to_path = ${from}`);
151
+ // Delete-then-insert rather than an upsert: `ON CONFLICT`, `ON
152
+ // DUPLICATE KEY` and `INSERT OR REPLACE` are three different
153
+ // statements, and this layer owes callers one behaviour.
154
+ await tx.query(sql `delete from ${table} where from_path = ${from}`);
155
+ const record = {
156
+ id: nextId(),
157
+ from,
158
+ to: target,
159
+ status,
160
+ collection: input.collection ?? null,
161
+ entryId: input.entryId ?? null,
162
+ locale: input.locale ?? null,
163
+ reason,
164
+ createdAt,
165
+ };
166
+ await tx.query(sql `
167
+ insert into ${table}
168
+ (id, from_path, to_path, status, collection, entry_id, locale, reason, created_at)
169
+ values (${record.id}, ${record.from}, ${record.to}, ${record.status},
170
+ ${record.collection}, ${record.entryId}, ${record.locale},
171
+ ${record.reason}, ${record.createdAt})`);
172
+ return record;
173
+ }, { immediate: true });
174
+ },
175
+ resolve: async (path) => {
176
+ await ensureTable();
177
+ const start = normalisePath(path);
178
+ const seen = new Set([start]);
179
+ let current = start;
180
+ let status = 301;
181
+ for (let hop = 0; hop < MAX_HOPS; hop += 1) {
182
+ const found = await db.query(sql `
183
+ select to_path, status from ${table} where from_path = ${current} limit ${limit(1)}`);
184
+ const row = found.rows[0];
185
+ if (row === undefined)
186
+ break;
187
+ // A 302 anywhere in the chain wins: a temporary hop must not be cached
188
+ // as permanent by a browser that never sees the rest of the chain.
189
+ if (Number(row.status) === 302)
190
+ status = 302;
191
+ current = row.to_path;
192
+ // A cycle in imported data resolves to the last path before the loop
193
+ // closes rather than throwing: serving one redirect is better than
194
+ // serving a 500 on a URL a visitor actually asked for.
195
+ if (seen.has(current))
196
+ return { to: current, status };
197
+ seen.add(current);
198
+ }
199
+ return current === start ? null : { to: current, status };
200
+ },
201
+ list: async (listOptions = {}) => {
202
+ await ensureTable();
203
+ const byCollection = listOptions.collection === undefined
204
+ ? sql ``
205
+ : sql ` and collection = ${listOptions.collection}`;
206
+ const byLocale = listOptions.locale === undefined ? sql `` : sql ` and locale = ${listOptions.locale}`;
207
+ const found = await db.query(sql `
208
+ select id, from_path, to_path, status, collection, entry_id, locale, reason, created_at
209
+ from ${table}
210
+ where 1 = 1${byCollection}${byLocale}
211
+ order by created_at desc, from_path asc
212
+ limit ${limit(listOptions.limit ?? 1000)}`);
213
+ return found.rows.map(toRecord);
214
+ },
215
+ remove: deleteFrom,
216
+ // Same statement, different intent: `remove` is an editor deleting a rule,
217
+ // `release` is the engine noticing a path is served again. Kept as two
218
+ // names because the call sites read as two different decisions.
219
+ release: deleteFrom,
220
+ };
221
+ }
222
+ function toRecord(row) {
223
+ return {
224
+ id: row.id,
225
+ from: row.from_path,
226
+ to: row.to_path,
227
+ status: Number(row.status) === 302 ? 302 : 301,
228
+ collection: row.collection,
229
+ entryId: row.entry_id,
230
+ locale: row.locale,
231
+ reason: REDIRECT_REASONS.includes(row.reason)
232
+ ? row.reason
233
+ : 'manual',
234
+ createdAt: Number(row.created_at),
235
+ };
236
+ }
237
+ //# sourceMappingURL=redirects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirects.js","sourceRoot":"","sources":["../../src/routing/redirects.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ import type { CollectionDefinition } from '../types.js';
2
+ import type { RedirectStatus, RedirectStore } from './redirects.js';
3
+ import { type RouteMatch, type RouteOptions } from './router.js';
4
+ /**
5
+ * What a request for a URL resolves to.
6
+ *
7
+ * A discriminated union rather than a nullable entry, because "no entry" splits
8
+ * into two answers a site must treat differently: redirect, or 404. Collapsing
9
+ * them is how a rename ends up serving a 404 to a link that used to work.
10
+ */
11
+ export type UrlResolution = {
12
+ readonly kind: 'entry';
13
+ readonly match: RouteMatch;
14
+ readonly entry: unknown;
15
+ } | {
16
+ readonly kind: 'redirect';
17
+ readonly to: string;
18
+ readonly status: RedirectStatus;
19
+ } | {
20
+ readonly kind: 'notFound';
21
+ };
22
+ /** Looks the matched route up in storage. Owned by the persistence layer, not by routing. */
23
+ export type EntryLookup = (match: RouteMatch) => Promise<unknown | null>;
24
+ export interface ResolveUrlOptions extends RouteOptions {
25
+ readonly collections: readonly CollectionDefinition[];
26
+ readonly redirects: RedirectStore;
27
+ readonly lookup: EntryLookup;
28
+ }
29
+ /**
30
+ * The order matters: content first, redirects second.
31
+ *
32
+ * A path that a live entry answers must never be redirected, however stale the
33
+ * table is. Checking redirects first would let a leftover row shadow a page
34
+ * that has come back — and that failure is invisible in the admin, because the
35
+ * entry is right there, published.
36
+ */
37
+ export declare function resolveUrl(path: string, options: ResolveUrlOptions): Promise<UrlResolution>;
38
+ //# sourceMappingURL=resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/routing/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AACnE,OAAO,EAA4B,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1F;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC/E;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAEjC,6FAA6F;AAC7F,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;AAExE,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACrD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAC7B;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAajG"}
@@ -0,0 +1,23 @@
1
+ import { matchPath, normalisePath } from './router.js';
2
+ /**
3
+ * The order matters: content first, redirects second.
4
+ *
5
+ * A path that a live entry answers must never be redirected, however stale the
6
+ * table is. Checking redirects first would let a leftover row shadow a page
7
+ * that has come back — and that failure is invisible in the admin, because the
8
+ * entry is right there, published.
9
+ */
10
+ export async function resolveUrl(path, options) {
11
+ const normalised = normalisePath(path);
12
+ const match = matchPath(options.collections, normalised, options);
13
+ if (match !== null) {
14
+ const entry = await options.lookup(match);
15
+ if (entry !== null && entry !== undefined)
16
+ return { kind: 'entry', match, entry };
17
+ }
18
+ const redirect = await options.redirects.resolve(normalised);
19
+ if (redirect !== null)
20
+ return { kind: 'redirect', to: redirect.to, status: redirect.status };
21
+ return { kind: 'notFound' };
22
+ }
23
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/routing/resolve.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAsC,MAAM,aAAa,CAAA;AAuB1F;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,OAA0B;IACvE,MAAM,UAAU,GAAG,aAAa,CAAC,IAAI,CAAC,CAAA;IACtC,MAAM,KAAK,GAAG,SAAS,CAAC,OAAO,CAAC,WAAW,EAAE,UAAU,EAAE,OAAO,CAAC,CAAA;IAEjE,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;IACnF,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IAC5D,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAA;IAE5F,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAA;AAC7B,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { CollectionDefinition } from '../types.js';
2
+ /**
3
+ * Turning a URL into "which collection, which locale, which entry" and back.
4
+ *
5
+ * The pattern comes from `routing.pattern` in the collection (contract A):
6
+ * `/blog/:slug`, with the locale prefixed when `routing.locale` is true. Nothing
7
+ * here touches the database — a route match is a statement about the *shape* of
8
+ * a URL, and the lookup that follows belongs to the persistence layer.
9
+ */
10
+ export interface RouteMatch {
11
+ readonly collection: string;
12
+ /** Null when the collection is not localised. */
13
+ readonly locale: string | null;
14
+ readonly params: Readonly<Record<string, string>>;
15
+ }
16
+ export interface RouteOptions {
17
+ /**
18
+ * The locales the site serves.
19
+ *
20
+ * Without it, `/blog/fr` and `/fr/blog` are indistinguishable guesses. The
21
+ * list makes the answer a fact, so pass it whenever the site has one.
22
+ */
23
+ readonly locales?: readonly string[];
24
+ /**
25
+ * The locale a URL carries when it has no prefix.
26
+ *
27
+ * Sites usually serve the source language unprefixed — `/blog/hello` next to
28
+ * `/fr/blog/bonjour`. Leave it unset to require the prefix on every localised
29
+ * route.
30
+ */
31
+ readonly defaultLocale?: string;
32
+ }
33
+ /** `/Blog/Été/` and `/blog/été` are the same route. Case is left alone: paths are case-sensitive. */
34
+ export declare function normalisePath(path: string): string;
35
+ /**
36
+ * The path an entry is served at.
37
+ *
38
+ * Values are percent-encoded here rather than by the caller: a slug is normally
39
+ * URL-safe already, but a pattern can carry any field, and a category named
40
+ * "R&D" must not produce a broken link.
41
+ */
42
+ export declare function buildPath(collection: CollectionDefinition, params: Readonly<Record<string, string>>, locale?: string): string;
43
+ /**
44
+ * The collection and parameters a path resolves to, or null.
45
+ *
46
+ * Collections are tried in the order given. A more specific pattern must be
47
+ * declared before a more general one, exactly as in a router table — guessing
48
+ * specificity is how routers acquire behaviour nobody can predict.
49
+ */
50
+ export declare function matchPath(collections: readonly CollectionDefinition[], path: string, options?: RouteOptions): RouteMatch | null;
51
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/routing/router.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAEvD;;;;;;;GAOG;AAEH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iDAAiD;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAClD;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAChC;AAKD,qGAAqG;AACrG,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKlD;AAQD;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,UAAU,EAAE,oBAAoB,EAChC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EACxC,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CAwCR;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,WAAW,EAAE,SAAS,oBAAoB,EAAE,EAC5C,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,YAAiB,GACzB,UAAU,GAAG,IAAI,CASnB"}
@@ -0,0 +1,132 @@
1
+ import { CogentaError } from '@cogenta/core';
2
+ /** A BCP 47 language tag, loose enough for `fr`, `fr-FR` and `zh-Hant-TW`. */
3
+ const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
4
+ /** `/Blog/Été/` and `/blog/été` are the same route. Case is left alone: paths are case-sensitive. */
5
+ export function normalisePath(path) {
6
+ const withoutQuery = path.split(/[?#]/u)[0] ?? '';
7
+ const trimmed = withoutQuery.replace(/\/+$/u, '');
8
+ if (trimmed.length === 0)
9
+ return '/';
10
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
11
+ }
12
+ function segmentsOf(path) {
13
+ return normalisePath(path)
14
+ .split('/')
15
+ .filter((segment) => segment.length > 0);
16
+ }
17
+ /**
18
+ * The path an entry is served at.
19
+ *
20
+ * Values are percent-encoded here rather than by the caller: a slug is normally
21
+ * URL-safe already, but a pattern can carry any field, and a category named
22
+ * "R&D" must not produce a broken link.
23
+ */
24
+ export function buildPath(collection, params, locale) {
25
+ const routing = collection.routing;
26
+ if (routing === undefined) {
27
+ throw new CogentaError({
28
+ code: 'CONTENT_ROUTE_INVALID',
29
+ message: `Collection "${collection.name}" has no route.`,
30
+ hint: 'Give it routing: { pattern: "/…/:slug" } before asking for a URL.',
31
+ details: { collection: collection.name },
32
+ });
33
+ }
34
+ const rendered = segmentsOf(routing.pattern).map((segment) => {
35
+ if (!segment.startsWith(':'))
36
+ return segment;
37
+ const name = segment.slice(1);
38
+ const value = params[name];
39
+ if (value === undefined || value.length === 0) {
40
+ throw new CogentaError({
41
+ code: 'CONTENT_ROUTE_INVALID',
42
+ message: `The route of "${collection.name}" needs a value for ":${name}".`,
43
+ hint: `Pass "${name}" in the parameters. It matches the field of the same name.`,
44
+ details: { collection: collection.name, pattern: routing.pattern, missing: name },
45
+ });
46
+ }
47
+ return encodeURIComponent(value);
48
+ });
49
+ if (routing.locale === true) {
50
+ if (locale === undefined || locale.length === 0) {
51
+ throw new CogentaError({
52
+ code: 'CONTENT_ROUTE_INVALID',
53
+ message: `The route of "${collection.name}" is localised and needs a locale.`,
54
+ hint: 'Pass the locale of the entry. It is a system field, so every entry has one.',
55
+ details: { collection: collection.name, pattern: routing.pattern },
56
+ });
57
+ }
58
+ rendered.unshift(encodeURIComponent(locale));
59
+ }
60
+ return `/${rendered.join('/')}`;
61
+ }
62
+ /**
63
+ * The collection and parameters a path resolves to, or null.
64
+ *
65
+ * Collections are tried in the order given. A more specific pattern must be
66
+ * declared before a more general one, exactly as in a router table — guessing
67
+ * specificity is how routers acquire behaviour nobody can predict.
68
+ */
69
+ export function matchPath(collections, path, options = {}) {
70
+ const segments = segmentsOf(path).map(decodeSegment);
71
+ for (const collection of collections) {
72
+ const match = matchCollection(collection, segments, options);
73
+ if (match !== null)
74
+ return match;
75
+ }
76
+ return null;
77
+ }
78
+ function matchCollection(collection, segments, options) {
79
+ const routing = collection.routing;
80
+ if (routing === undefined)
81
+ return null;
82
+ const pattern = segmentsOf(routing.pattern);
83
+ if (routing.locale !== true) {
84
+ const params = matchSegments(pattern, segments);
85
+ return params === null ? null : { collection: collection.name, locale: null, params };
86
+ }
87
+ const prefix = segments[0];
88
+ if (prefix !== undefined && isLocale(prefix, options)) {
89
+ const params = matchSegments(pattern, segments.slice(1));
90
+ if (params !== null)
91
+ return { collection: collection.name, locale: prefix, params };
92
+ }
93
+ // No prefix: only a site that serves a default locale unprefixed can answer.
94
+ if (options.defaultLocale === undefined)
95
+ return null;
96
+ const params = matchSegments(pattern, segments);
97
+ return params === null
98
+ ? null
99
+ : { collection: collection.name, locale: options.defaultLocale, params };
100
+ }
101
+ function matchSegments(pattern, segments) {
102
+ if (pattern.length !== segments.length)
103
+ return null;
104
+ const params = {};
105
+ for (const [index, expected] of pattern.entries()) {
106
+ const actual = segments[index];
107
+ if (actual === undefined || actual.length === 0)
108
+ return null;
109
+ if (expected.startsWith(':')) {
110
+ params[expected.slice(1)] = actual;
111
+ continue;
112
+ }
113
+ if (expected !== actual)
114
+ return null;
115
+ }
116
+ return params;
117
+ }
118
+ function isLocale(segment, options) {
119
+ if (options.locales !== undefined)
120
+ return options.locales.includes(segment);
121
+ return LANGUAGE_TAG.test(segment);
122
+ }
123
+ /** A malformed escape is not a route; it must not take the process down. */
124
+ function decodeSegment(segment) {
125
+ try {
126
+ return decodeURIComponent(segment);
127
+ }
128
+ catch {
129
+ return segment;
130
+ }
131
+ }
132
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../../src/routing/router.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ import type { CollectionDefinition, ContentStatus } from '../types.js';
2
+ import type { RedirectRecord, RedirectStore } from './redirects.js';
3
+ /**
4
+ * The acceptance criterion of L1, in one function: *changing the slug of a
5
+ * published entry creates a 301 with no intervention.*
6
+ *
7
+ * It is a free function taking a store rather than a method on the store so the
8
+ * persistence layer can call it inside the same transaction as the save. A
9
+ * redirect written after the fact is a redirect that a crash can lose.
10
+ */
11
+ export interface SlugChange {
12
+ readonly collection: CollectionDefinition;
13
+ readonly entryId: string;
14
+ readonly locale?: string;
15
+ /** The status the entry has **after** the save. */
16
+ readonly status: ContentStatus;
17
+ readonly previousSlug: string;
18
+ readonly nextSlug: string;
19
+ /**
20
+ * Values for the other parameters of the pattern, when it has any —
21
+ * `/blog/:year/:slug`. The slug parameter is filled in from the two above.
22
+ */
23
+ readonly params?: Readonly<Record<string, string>>;
24
+ /** The pattern parameter carrying the slug. Rarely anything but `slug`. */
25
+ readonly slugParam?: string;
26
+ }
27
+ /**
28
+ * Records the redirect a slug change owes, or explains why it owes none.
29
+ *
30
+ * Returns null in the cases where a redirect would be noise rather than help:
31
+ *
32
+ * - the slug did not change;
33
+ * - the collection has no route, so there is no URL to redirect;
34
+ * - the entry is not published. A draft was never served under the old URL,
35
+ * and a redirect from a URL nobody could reach is a row that will only ever
36
+ * confuse whoever reads the table later.
37
+ */
38
+ export declare function recordSlugChange(store: RedirectStore, change: SlugChange): Promise<RedirectRecord | null>;
39
+ //# sourceMappingURL=slug-change.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug-change.d.ts","sourceRoot":"","sources":["../../src/routing/slug-change.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAGnE;;;;;;;GAOG;AAEH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,oBAAoB,CAAA;IACzC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,mDAAmD;IACnD,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAClD,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CA+BhC"}
@@ -0,0 +1,44 @@
1
+ import { buildPath } from './router.js';
2
+ /**
3
+ * Records the redirect a slug change owes, or explains why it owes none.
4
+ *
5
+ * Returns null in the cases where a redirect would be noise rather than help:
6
+ *
7
+ * - the slug did not change;
8
+ * - the collection has no route, so there is no URL to redirect;
9
+ * - the entry is not published. A draft was never served under the old URL,
10
+ * and a redirect from a URL nobody could reach is a row that will only ever
11
+ * confuse whoever reads the table later.
12
+ */
13
+ export async function recordSlugChange(store, change) {
14
+ if (change.previousSlug === change.nextSlug)
15
+ return null;
16
+ if (change.collection.routing === undefined)
17
+ return null;
18
+ if (!isServed(change.status))
19
+ return null;
20
+ const slugParam = change.slugParam ?? 'slug';
21
+ const base = change.params ?? {};
22
+ const from = buildPath(change.collection, { ...base, [slugParam]: change.previousSlug }, change.locale);
23
+ const to = buildPath(change.collection, { ...base, [slugParam]: change.nextSlug }, change.locale);
24
+ if (from === to)
25
+ return null;
26
+ // The new URL is served by this entry from now on, so any redirect leaving it
27
+ // is stale. This is what lets an editor undo a rename: without it, moving a
28
+ // page back to its old slug would be refused as a loop.
29
+ await store.release(to);
30
+ return store.add({
31
+ from,
32
+ to,
33
+ status: 301,
34
+ reason: 'slug-change',
35
+ collection: change.collection.name,
36
+ entryId: change.entryId,
37
+ ...(change.locale === undefined ? {} : { locale: change.locale }),
38
+ });
39
+ }
40
+ /** Only a published entry has ever been reachable by a visitor. */
41
+ function isServed(status) {
42
+ return status === 'published';
43
+ }
44
+ //# sourceMappingURL=slug-change.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug-change.js","sourceRoot":"","sources":["../../src/routing/slug-change.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA4BvC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,KAAoB,EACpB,MAAkB;IAElB,IAAI,MAAM,CAAC,YAAY,KAAK,MAAM,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IACxD,IAAI,MAAM,CAAC,UAAU,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACxD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAA;IAEzC,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,MAAM,CAAA;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,CAAA;IAEhC,MAAM,IAAI,GAAG,SAAS,CACpB,MAAM,CAAC,UAAU,EACjB,EAAE,GAAG,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,YAAY,EAAE,EAC7C,MAAM,CAAC,MAAM,CACd,CAAA;IACD,MAAM,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC,UAAU,EAAE,EAAE,GAAG,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,QAAQ,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IAEjG,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,IAAI,CAAA;IAE5B,8EAA8E;IAC9E,4EAA4E;IAC5E,wDAAwD;IACxD,MAAM,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IAEvB,OAAO,KAAK,CAAC,GAAG,CAAC;QACf,IAAI;QACJ,EAAE;QACF,MAAM,EAAE,GAAG;QACX,MAAM,EAAE,aAAa;QACrB,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC,IAAI;QAClC,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;KAClE,CAAC,CAAA;AACJ,CAAC;AAED,mEAAmE;AACnE,SAAS,QAAQ,CAAC,MAAqB;IACrC,OAAO,MAAM,KAAK,WAAW,CAAA;AAC/B,CAAC"}