@bobbykim/manguito-cms-api 0.4.3 → 0.5.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/dist/index.js CHANGED
@@ -30,6 +30,93 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  ));
31
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
32
 
33
+ // src/field-keys.ts
34
+ function isColumnBacked(field) {
35
+ const col = field.db_column;
36
+ if (col === null) return false;
37
+ if (col.column_name === "") return false;
38
+ if (col.junction) return false;
39
+ return true;
40
+ }
41
+ function buildFieldKeyMap(pairs, allLabels, droppedKeys) {
42
+ const labelToColumn = /* @__PURE__ */ new Map();
43
+ const columnToLabel = /* @__PURE__ */ new Map();
44
+ for (const { label, column } of pairs) {
45
+ labelToColumn.set(label, column);
46
+ columnToLabel.set(column, label);
47
+ }
48
+ for (const label of allLabels) {
49
+ const columnOwner = columnToLabel.get(label);
50
+ if (columnOwner !== void 0 && columnOwner !== label) {
51
+ throw new Error(
52
+ `Fatal: field key map failed to build \u2014 field label "${label}" collides with the storage column of field "${columnOwner}". A field label may not reuse another field's column name. Rename the field, then run \`manguito validate\` to check your schema.`
53
+ );
54
+ }
55
+ }
56
+ for (const label of labelToColumn.keys()) {
57
+ if (droppedKeys.has(label)) labelToColumn.delete(label);
58
+ }
59
+ for (const column of columnToLabel.keys()) {
60
+ if (droppedKeys.has(column)) columnToLabel.delete(column);
61
+ }
62
+ const diverges = [...labelToColumn].some(([label, column]) => label !== column);
63
+ function remap(input, lookup) {
64
+ const out = {};
65
+ for (const key of Object.keys(input)) {
66
+ if (droppedKeys.has(key)) continue;
67
+ out[lookup.get(key) ?? key] = input[key];
68
+ }
69
+ return out;
70
+ }
71
+ return {
72
+ toStorage: (input) => remap(input, labelToColumn),
73
+ toLabels: (row) => remap(row, columnToLabel),
74
+ columnFor: (label) => labelToColumn.get(label),
75
+ labelFor: (column) => columnToLabel.get(column),
76
+ labels: [...labelToColumn.keys()],
77
+ diverges
78
+ };
79
+ }
80
+ function createFieldKeyMap(fields) {
81
+ const droppedKeys = /* @__PURE__ */ new Set();
82
+ const pairs = [];
83
+ for (const f of fields) {
84
+ if (!isColumnBacked(f)) continue;
85
+ pairs.push({ label: f.name, column: f.db_column.column_name });
86
+ if (f.removed === true) {
87
+ droppedKeys.add(f.name);
88
+ droppedKeys.add(f.db_column.column_name);
89
+ }
90
+ }
91
+ return buildFieldKeyMap(
92
+ pairs,
93
+ fields.map((f) => f.name),
94
+ droppedKeys
95
+ );
96
+ }
97
+ function createFieldKeyMapFromProjection(projectionType, allFields) {
98
+ const pairs = projectionType.fields.map((f) => ({ label: f.exposed_as, column: f.column_name }));
99
+ const projected = new Set(pairs.map((p) => p.label));
100
+ const dropped = /* @__PURE__ */ new Set();
101
+ for (const f of allFields) {
102
+ if (!isColumnBacked(f)) continue;
103
+ dropped.add(f.name);
104
+ dropped.add(f.db_column.column_name);
105
+ }
106
+ for (const p of pairs) dropped.delete(p.column);
107
+ for (const label of projected) dropped.delete(label);
108
+ return buildFieldKeyMap(
109
+ pairs,
110
+ allFields.map((f) => f.name),
111
+ dropped
112
+ );
113
+ }
114
+ var init_field_keys = __esm({
115
+ "src/field-keys.ts"() {
116
+ "use strict";
117
+ }
118
+ });
119
+
33
120
  // src/relations.ts
34
121
  function quoteIdent(name) {
35
122
  if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
@@ -333,65 +420,6 @@ var init_relations = __esm({
333
420
  }
334
421
  });
335
422
 
336
- // src/field-keys.ts
337
- function isColumnBacked(field) {
338
- const col = field.db_column;
339
- if (col === null) return false;
340
- if (col.column_name === "") return false;
341
- if (col.junction) return false;
342
- return true;
343
- }
344
- function createFieldKeyMap(fields) {
345
- const labelToColumn = /* @__PURE__ */ new Map();
346
- const columnToLabel = /* @__PURE__ */ new Map();
347
- const droppedKeys = /* @__PURE__ */ new Set();
348
- for (const f of fields) {
349
- if (!isColumnBacked(f)) continue;
350
- columnToLabel.set(f.db_column.column_name, f.name);
351
- if (f.removed === true) {
352
- droppedKeys.add(f.name);
353
- droppedKeys.add(f.db_column.column_name);
354
- continue;
355
- }
356
- labelToColumn.set(f.name, f.db_column.column_name);
357
- }
358
- for (const f of fields) {
359
- const columnOwner = columnToLabel.get(f.name);
360
- if (columnOwner !== void 0 && columnOwner !== f.name) {
361
- throw new Error(
362
- `Fatal: field key map failed to build \u2014 field label "${f.name}" collides with the storage column of field "${columnOwner}". A field label may not reuse another field's column name. Rename the field, then run \`manguito validate\` to check your schema.`
363
- );
364
- }
365
- }
366
- for (const f of fields) {
367
- if (isColumnBacked(f) && f.removed === true) {
368
- columnToLabel.delete(f.db_column.column_name);
369
- }
370
- }
371
- const diverges = [...labelToColumn].some(([label, column]) => label !== column);
372
- function remap(input, lookup) {
373
- const out = {};
374
- for (const key of Object.keys(input)) {
375
- if (droppedKeys.has(key)) continue;
376
- out[lookup.get(key) ?? key] = input[key];
377
- }
378
- return out;
379
- }
380
- return {
381
- toStorage: (input) => remap(input, labelToColumn),
382
- toLabels: (row) => remap(row, columnToLabel),
383
- columnFor: (label) => labelToColumn.get(label),
384
- labelFor: (column) => columnToLabel.get(column),
385
- labels: [...labelToColumn.keys()],
386
- diverges
387
- };
388
- }
389
- var init_field_keys = __esm({
390
- "src/field-keys.ts"() {
391
- "use strict";
392
- }
393
- });
394
-
395
423
  // src/graphql/scalars.ts
396
424
  var import_graphql, DateTimeScalar, JSONScalar;
397
425
  var init_scalars = __esm({
@@ -1069,15 +1097,20 @@ function normalizePrefix(prefix) {
1069
1097
  }
1070
1098
  function createPublicPaths(prefix) {
1071
1099
  return {
1072
- collection: (basePath) => `${prefix}/${basePath}`,
1073
- item: (basePath) => `${prefix}/${basePath}/:slug`,
1074
- taxonomyCollection: (typeName) => `${prefix}/taxonomy/${typeName}`,
1075
- taxonomyItem: (typeName) => `${prefix}/taxonomy/${typeName}/:id`,
1076
1100
  mediaCollection: () => `${prefix}/media`,
1077
1101
  mediaItem: () => `${prefix}/media/:id`,
1078
1102
  openapi: () => `${prefix}/openapi.json`
1079
1103
  };
1080
1104
  }
1105
+ function createVersionedPaths(prefix, version) {
1106
+ const root = version === null ? prefix : `${prefix}/${version}`;
1107
+ return {
1108
+ collection: (basePath) => `${root}/${basePath}`,
1109
+ item: (basePath) => `${root}/${basePath}/:slug`,
1110
+ taxonomyCollection: (typeName) => `${root}/taxonomy/${typeName}`,
1111
+ taxonomyItem: (typeName) => `${root}/taxonomy/${typeName}/:id`
1112
+ };
1113
+ }
1081
1114
 
1082
1115
  // src/app.ts
1083
1116
  var import_hono = require("hono");
@@ -1673,7 +1706,7 @@ function nestedTargets(fields) {
1673
1706
  }
1674
1707
  return out;
1675
1708
  }
1676
- function buildProjectors(registry, fieldKeyMaps) {
1709
+ function buildProjectors(registry, fieldKeyMaps, fallbacks) {
1677
1710
  const projectors = {};
1678
1711
  const sources = [
1679
1712
  registry.content_types,
@@ -1684,7 +1717,14 @@ function buildProjectors(registry, fieldKeyMaps) {
1684
1717
  for (const [typeName, type] of Object.entries(source)) {
1685
1718
  const map = fieldKeyMaps[typeName];
1686
1719
  if (!map) continue;
1687
- projectors[typeName] = { map, nested: nestedTargets(type.fields) };
1720
+ const typeFallbacks = fallbacks?.[typeName];
1721
+ projectors[typeName] = {
1722
+ map,
1723
+ nested: nestedTargets(type.fields),
1724
+ // Spread rather than assign: with exactOptionalPropertyTypes, an optional
1725
+ // property may be ABSENT but not present-and-undefined.
1726
+ ...typeFallbacks !== void 0 && { fallbacks: typeFallbacks }
1727
+ };
1688
1728
  }
1689
1729
  }
1690
1730
  return projectors;
@@ -1693,6 +1733,11 @@ function projectRow(row, typeName, projectors) {
1693
1733
  const p = projectors[typeName];
1694
1734
  if (!p) return row;
1695
1735
  const out = p.map.toLabels(row);
1736
+ if (p.fallbacks !== void 0) {
1737
+ for (const [label, value] of Object.entries(p.fallbacks)) {
1738
+ if (out[label] === null || out[label] === void 0) out[label] = value;
1739
+ }
1740
+ }
1696
1741
  for (const { label, target } of p.nested) {
1697
1742
  const v = out[label];
1698
1743
  if (v === null || v === void 0) continue;
@@ -1705,6 +1750,9 @@ function projectRow(row, typeName, projectors) {
1705
1750
  return out;
1706
1751
  }
1707
1752
 
1753
+ // src/routes/content.ts
1754
+ init_field_keys();
1755
+
1708
1756
  // src/routes/query-params.ts
1709
1757
  var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
1710
1758
  var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
@@ -1786,13 +1834,24 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
1786
1834
  const basePath = contentType.default_base_path;
1787
1835
  const repo = repos[typeName];
1788
1836
  if (!repo) continue;
1789
- const relationFieldNames = new Set(
1790
- contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1791
- );
1837
+ const fieldKeys = projectors[typeName].map;
1838
+ const relationFieldNames = /* @__PURE__ */ new Set();
1839
+ for (const f of contentType.fields) {
1840
+ if (!RELATION_FIELD_TYPES.has(f.field_type)) continue;
1841
+ if (isColumnBacked(f)) {
1842
+ const label = fieldKeys.labelFor(f.db_column.column_name);
1843
+ if (label !== void 0) relationFieldNames.add(label);
1844
+ } else {
1845
+ relationFieldNames.add(f.name);
1846
+ }
1847
+ }
1792
1848
  const filterableFieldNames = /* @__PURE__ */ new Set([
1793
- ...contentType.fields.filter((f) => f.field_type !== "programmatic").map((f) => f.name),
1849
+ ...fieldKeys.labels,
1794
1850
  ...contentType.system_fields.map((f) => f.name)
1795
1851
  ]);
1852
+ const sortableFieldNames = new Set(
1853
+ [...SORTABLE_FIELDS].filter((f) => filterableFieldNames.has(f))
1854
+ );
1796
1855
  if (contentType.only_one) {
1797
1856
  app.get(paths.collection(basePath), async (c) => {
1798
1857
  const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
@@ -1808,8 +1867,6 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
1808
1867
  });
1809
1868
  } else {
1810
1869
  registerListRoute(paths.collection(basePath), async (c) => {
1811
- const projector = projectors[typeName];
1812
- const fieldKeys = projector.map;
1813
1870
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
1814
1871
  if (!pagination.ok) {
1815
1872
  return c.json(
@@ -1824,13 +1881,13 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
1824
1881
  );
1825
1882
  }
1826
1883
  const sortBy = c.req.query("sort_by") ?? "created_at";
1827
- if (!SORTABLE_FIELDS.has(sortBy)) {
1884
+ if (!sortableFieldNames.has(sortBy)) {
1828
1885
  return c.json(
1829
1886
  {
1830
1887
  ok: false,
1831
1888
  error: {
1832
1889
  code: "INVALID_SORT_FIELD",
1833
- message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
1890
+ message: `'${sortBy}' is not sortable. Allowed: ${[...sortableFieldNames].join(", ")}`
1834
1891
  }
1835
1892
  },
1836
1893
  400
@@ -1877,7 +1934,7 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
1877
1934
  );
1878
1935
  }
1879
1936
  }
1880
- const sortColumn = fieldKeys.columnFor(sortBy) ?? sortBy;
1937
+ const sortColumn = fieldKeys.labels.includes(sortBy) ? fieldKeys.columnFor(sortBy) : sortBy;
1881
1938
  const result = await repo.findMany({
1882
1939
  published_only: true,
1883
1940
  page: pagination.page,
@@ -3759,7 +3816,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
3759
3816
  var import_drizzle_orm6 = require("drizzle-orm");
3760
3817
  var API_VERSION = (() => {
3761
3818
  try {
3762
- return "0.4.3";
3819
+ return "0.5.0";
3763
3820
  } catch {
3764
3821
  return "0.0.0";
3765
3822
  }
@@ -4166,6 +4223,95 @@ function createMediaRepository(db) {
4166
4223
 
4167
4224
  // src/app.ts
4168
4225
  init_field_keys();
4226
+
4227
+ // src/versions.ts
4228
+ init_field_keys();
4229
+ var VERSION_SEGMENT = /^v\d+$/;
4230
+ function versionNumber(version) {
4231
+ return Number.parseInt(version.slice(1), 10);
4232
+ }
4233
+ function classifyVersion(segment, model) {
4234
+ if (!VERSION_SEGMENT.test(segment)) return "not-a-version";
4235
+ if (model.live.includes(segment)) return "live";
4236
+ return versionNumber(segment) < versionNumber(model.current) ? "retired" : "unknown";
4237
+ }
4238
+ function fallbacksFor(projection) {
4239
+ const out = {};
4240
+ for (const [typeName, type] of Object.entries(projection.types)) {
4241
+ const perLabel = {};
4242
+ for (const f of type.fields) {
4243
+ if (f.fallback !== void 0) perLabel[f.exposed_as] = f.fallback;
4244
+ }
4245
+ if (Object.keys(perLabel).length > 0) out[typeName] = perLabel;
4246
+ }
4247
+ return out;
4248
+ }
4249
+ function buildVersionSurface(input) {
4250
+ const { version, projectionVersion, prefix, registry, model, makeRepo } = input;
4251
+ const paths = createVersionedPaths(prefix, version);
4252
+ const projection = model.projections[projectionVersion];
4253
+ const fieldKeyMaps = {};
4254
+ const sources = [
4255
+ registry.content_types,
4256
+ registry.taxonomy_types,
4257
+ registry.paragraph_types
4258
+ ];
4259
+ for (const source of sources) {
4260
+ for (const [typeName, type] of Object.entries(source)) {
4261
+ const projectionType = projection?.types[typeName];
4262
+ fieldKeyMaps[typeName] = projectionType ? createFieldKeyMapFromProjection(projectionType, type.fields) : createFieldKeyMap(type.fields);
4263
+ }
4264
+ }
4265
+ const fallbacks = projection ? fallbacksFor(projection) : void 0;
4266
+ const projectors = buildProjectors(registry, fieldKeyMaps, fallbacks);
4267
+ const sortableColumnsFor = (typeName) => {
4268
+ const map = fieldKeyMaps[typeName];
4269
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
4270
+ const systemFieldNames = new Set((type?.system_fields ?? []).map((f) => f.name));
4271
+ const out = /* @__PURE__ */ new Set();
4272
+ for (const label of SORTABLE_FIELDS) {
4273
+ const column = map.columnFor(label);
4274
+ if (column !== void 0) out.add(column);
4275
+ else if (systemFieldNames.has(label)) out.add(label);
4276
+ }
4277
+ return out;
4278
+ };
4279
+ const repos = {};
4280
+ for (const [typeName, ct] of Object.entries(registry.content_types)) {
4281
+ repos[typeName] = makeRepo(typeName, ct.db.table_name, sortableColumnsFor(typeName));
4282
+ }
4283
+ for (const [typeName, tt] of Object.entries(registry.taxonomy_types)) {
4284
+ repos[typeName] = makeRepo(typeName, tt.db.table_name, sortableColumnsFor(typeName));
4285
+ }
4286
+ return { paths, fieldKeyMaps, projectors, repos };
4287
+ }
4288
+ function deprecationHeaders(input) {
4289
+ const { requested, model, successor } = input;
4290
+ const link = `<${successor}>; rel="successor-version"`;
4291
+ if (requested === null) {
4292
+ if (model.live.length <= 1) return null;
4293
+ return {
4294
+ Deprecation: "true",
4295
+ Link: link,
4296
+ Warning: `299 - "Unversioned path resolves to the latest version (${model.current}) and will change when a new version is cut. Pin a version."`
4297
+ };
4298
+ }
4299
+ if (requested === model.current) return null;
4300
+ return { Deprecation: "true", Link: link };
4301
+ }
4302
+
4303
+ // src/app.ts
4304
+ function versionRoutePaths(registry, paths) {
4305
+ const out = [paths.collection("content"), paths.collection("taxonomy")];
4306
+ for (const ct of Object.values(registry.content_types)) {
4307
+ out.push(paths.collection(ct.default_base_path));
4308
+ if (!ct.only_one) out.push(paths.item(ct.default_base_path));
4309
+ }
4310
+ for (const tt of Object.values(registry.taxonomy_types)) {
4311
+ out.push(paths.taxonomyCollection(tt.name), paths.taxonomyItem(tt.name));
4312
+ }
4313
+ return out;
4314
+ }
4169
4315
  var MISSING_STORAGE_ERROR = `\u2717 api.storage is required but not configured.
4170
4316
  Add a storage adapter to your manguito.config.ts:
4171
4317
 
@@ -4181,6 +4327,7 @@ function createCmsApp(options) {
4181
4327
  }
4182
4328
  const prefix = normalizePrefix(options.prefix);
4183
4329
  const publicPaths = createPublicPaths(prefix);
4330
+ const versionedPaths = createVersionedPaths(prefix, null);
4184
4331
  const { storage, registry, db, rateLimit, media, cors } = options;
4185
4332
  const cmsName = options.name ?? "Manguito CMS";
4186
4333
  const maxFileSize = media?.max_file_size;
@@ -4229,9 +4376,18 @@ function createCmsApp(options) {
4229
4376
  ])
4230
4377
  ]);
4231
4378
  const projectors = buildProjectors(registry, fieldKeyMaps);
4232
- const sortableColumnsFor = (typeName) => new Set(
4233
- [...SORTABLE_FIELDS].map((label) => fieldKeyMaps[typeName].columnFor(label) ?? label)
4234
- );
4379
+ const sortableColumnsFor = (typeName) => {
4380
+ const map = fieldKeyMaps[typeName];
4381
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
4382
+ const systemFieldNames = new Set((type?.system_fields ?? []).map((f) => f.name));
4383
+ const out = /* @__PURE__ */ new Set();
4384
+ for (const label of SORTABLE_FIELDS) {
4385
+ const column = map.columnFor(label);
4386
+ if (column !== void 0) out.add(column);
4387
+ else if (systemFieldNames.has(label)) out.add(label);
4388
+ }
4389
+ return out;
4390
+ };
4235
4391
  const contentRepos = Object.fromEntries(
4236
4392
  Object.entries(registry.content_types).map(([typeName, ct]) => [
4237
4393
  typeName,
@@ -4250,27 +4406,14 @@ function createCmsApp(options) {
4250
4406
  );
4251
4407
  const repos = { ...contentRepos, ...taxonomyRepos };
4252
4408
  const mediaRepo = createMediaRepository(db);
4253
- const publicContentRepos = Object.fromEntries(
4254
- Object.entries(registry.content_types).map(([typeName, ct]) => [
4255
- typeName,
4256
- createDrizzleContentRepository(db, ct.db.table_name, {
4257
- relations: buildRelationsMap(ct.fields, registry),
4258
- publishedRelations: true,
4259
- sortableColumns: sortableColumnsFor(typeName)
4260
- })
4261
- ])
4262
- );
4263
- const publicTaxonomyRepos = Object.fromEntries(
4264
- Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
4265
- typeName,
4266
- createDrizzleContentRepository(db, tt.db.table_name, {
4267
- relations: buildRelationsMap(tt.fields, registry),
4268
- publishedRelations: true,
4269
- sortableColumns: sortableColumnsFor(typeName)
4270
- })
4271
- ])
4272
- );
4273
- const publicRepos = { ...publicContentRepos, ...publicTaxonomyRepos };
4409
+ const makeRepo = (typeName, tableName, sortableColumns) => {
4410
+ const type = registry.content_types[typeName] ?? registry.taxonomy_types[typeName];
4411
+ return createDrizzleContentRepository(db, tableName, {
4412
+ relations: buildRelationsMap(type.fields, registry),
4413
+ publishedRelations: true,
4414
+ sortableColumns
4415
+ });
4416
+ };
4274
4417
  const graphqlRepos = Object.fromEntries([
4275
4418
  ...Object.entries(registry.content_types).map(([typeName, ct]) => [
4276
4419
  typeName,
@@ -4293,17 +4436,17 @@ function createCmsApp(options) {
4293
4436
  for (const ct of Object.values(registry.content_types)) {
4294
4437
  const base = ct.default_base_path;
4295
4438
  if (ct.only_one) {
4296
- paths[publicPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
4439
+ paths[versionedPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
4297
4440
  } else {
4298
- paths[publicPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
4299
- paths[publicPaths.item(base).replace(":slug", "{slug}")] = {
4441
+ paths[versionedPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
4442
+ paths[versionedPaths.item(base).replace(":slug", "{slug}")] = {
4300
4443
  get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] }
4301
4444
  };
4302
4445
  }
4303
4446
  }
4304
4447
  for (const tt of Object.values(registry.taxonomy_types)) {
4305
- paths[publicPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
4306
- paths[publicPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
4448
+ paths[versionedPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
4449
+ paths[versionedPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
4307
4450
  get: { summary: `Get ${tt.label} by id`, tags: [tt.label] }
4308
4451
  };
4309
4452
  }
@@ -4323,7 +4466,62 @@ function createCmsApp(options) {
4323
4466
  paths: {}
4324
4467
  })
4325
4468
  );
4326
- registerPublicContentRoutes(app, registry, publicRepos, projectors, publicPaths, listRateLimit, programmaticResolver);
4469
+ const model = options.versions ?? {
4470
+ current: "v1",
4471
+ live: ["v1"],
4472
+ projections: {}
4473
+ };
4474
+ const passes = [
4475
+ ...options.versions !== void 0 ? model.live.map((v) => ({ version: v, projectionVersion: v })) : [],
4476
+ { version: null, projectionVersion: model.current }
4477
+ ];
4478
+ for (const pass of passes) {
4479
+ const surface = buildVersionSurface({ ...pass, prefix, registry, model, makeRepo });
4480
+ const headers = deprecationHeaders({
4481
+ requested: pass.version,
4482
+ model,
4483
+ // The version root, with no trailing slash — never
4484
+ // createVersionedPaths(prefix, model.current).collection(''), which
4485
+ // would yield `${prefix}/${model.current}/` in every Link header.
4486
+ successor: `${prefix}/${model.current}`
4487
+ });
4488
+ if (headers !== null) {
4489
+ const attachDeprecationHeaders = async (c, next) => {
4490
+ await next();
4491
+ for (const [key, value] of Object.entries(headers)) c.res.headers.set(key, value);
4492
+ };
4493
+ for (const path of versionRoutePaths(registry, surface.paths)) {
4494
+ app.use(path, attachDeprecationHeaders);
4495
+ }
4496
+ }
4497
+ registerPublicContentRoutes(app, registry, surface.repos, surface.projectors, surface.paths, listRateLimit, programmaticResolver);
4498
+ }
4499
+ if (options.versions !== void 0) {
4500
+ app.all(`${prefix}/:version/*`, async (c, next) => {
4501
+ const kind = classifyVersion(c.req.param("version"), model);
4502
+ if (kind === "not-a-version" || kind === "live") return next();
4503
+ const live = model.live.join(", ");
4504
+ return kind === "retired" ? c.json(
4505
+ {
4506
+ ok: false,
4507
+ error: {
4508
+ code: "VERSION_RETIRED",
4509
+ message: `Version ${c.req.param("version")} is no longer served. Live versions: ${live}.`
4510
+ }
4511
+ },
4512
+ 410
4513
+ ) : c.json(
4514
+ {
4515
+ ok: false,
4516
+ error: {
4517
+ code: "VERSION_NOT_FOUND",
4518
+ message: `No version ${c.req.param("version")} is served. Live versions: ${live}.`
4519
+ }
4520
+ },
4521
+ 404
4522
+ );
4523
+ });
4524
+ }
4327
4525
  registerPublicMediaRoutes(app, mediaRepo, publicPaths, listRateLimit);
4328
4526
  if (options.graphql?.enabled) {
4329
4527
  const gqlOptions = options.graphql;