@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.
- package/dist/define-collection.d.ts +8 -0
- package/dist/define-collection.d.ts.map +1 -0
- package/dist/define-collection.js +311 -0
- package/dist/define-collection.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/fields.d.ts +117 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +107 -0
- package/dist/fields.js.map +1 -0
- package/dist/generate-schema-json.d.ts +61 -0
- package/dist/generate-schema-json.d.ts.map +1 -0
- package/dist/generate-schema-json.js +128 -0
- package/dist/generate-schema-json.js.map +1 -0
- package/dist/generate-types.d.ts +5 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +220 -0
- package/dist/generate-types.js.map +1 -0
- package/dist/id.d.ts +8 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +8 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/rich-text.d.ts +164 -0
- package/dist/rich-text.d.ts.map +1 -0
- package/dist/rich-text.js +97 -0
- package/dist/rich-text.js.map +1 -0
- package/dist/routing/ids.d.ts +18 -0
- package/dist/routing/ids.d.ts.map +1 -0
- package/dist/routing/ids.js +41 -0
- package/dist/routing/ids.js.map +1 -0
- package/dist/routing/index.d.ts +13 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +7 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/routing/redirects.d.ts +76 -0
- package/dist/routing/redirects.d.ts.map +1 -0
- package/dist/routing/redirects.js +237 -0
- package/dist/routing/redirects.js.map +1 -0
- package/dist/routing/resolve.d.ts +38 -0
- package/dist/routing/resolve.d.ts.map +1 -0
- package/dist/routing/resolve.js +23 -0
- package/dist/routing/resolve.js.map +1 -0
- package/dist/routing/router.d.ts +51 -0
- package/dist/routing/router.d.ts.map +1 -0
- package/dist/routing/router.js +132 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/routing/slug-change.d.ts +39 -0
- package/dist/routing/slug-change.d.ts.map +1 -0
- package/dist/routing/slug-change.js +44 -0
- package/dist/routing/slug-change.js.map +1 -0
- package/dist/routing/slug.d.ts +82 -0
- package/dist/routing/slug.d.ts.map +1 -0
- package/dist/routing/slug.js +124 -0
- package/dist/routing/slug.js.map +1 -0
- package/dist/routing/slugify.d.ts +29 -0
- package/dist/routing/slugify.d.ts.map +1 -0
- package/dist/routing/slugify.js +104 -0
- package/dist/routing/slugify.js.map +1 -0
- package/dist/scheduling/index.d.ts +3 -0
- package/dist/scheduling/index.d.ts.map +1 -0
- package/dist/scheduling/index.js +2 -0
- package/dist/scheduling/index.js.map +1 -0
- package/dist/scheduling/publish.d.ts +71 -0
- package/dist/scheduling/publish.d.ts.map +1 -0
- package/dist/scheduling/publish.js +122 -0
- package/dist/scheduling/publish.js.map +1 -0
- package/dist/search/extract.d.ts +25 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +188 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +36 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +44 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/mysql.d.ts +7 -0
- package/dist/search/mysql.d.ts.map +1 -0
- package/dist/search/mysql.js +121 -0
- package/dist/search/mysql.js.map +1 -0
- package/dist/search/postgres.d.ts +9 -0
- package/dist/search/postgres.d.ts.map +1 -0
- package/dist/search/postgres.js +167 -0
- package/dist/search/postgres.js.map +1 -0
- package/dist/search/query.d.ts +35 -0
- package/dist/search/query.d.ts.map +1 -0
- package/dist/search/query.js +116 -0
- package/dist/search/query.js.map +1 -0
- package/dist/search/sqlite.d.ts +16 -0
- package/dist/search/sqlite.d.ts.map +1 -0
- package/dist/search/sqlite.js +166 -0
- package/dist/search/sqlite.js.map +1 -0
- package/dist/search/table.d.ts +49 -0
- package/dist/search/table.d.ts.map +1 -0
- package/dist/search/table.js +86 -0
- package/dist/search/table.js.map +1 -0
- package/dist/search/text.d.ts +42 -0
- package/dist/search/text.d.ts.map +1 -0
- package/dist/search/text.js +56 -0
- package/dist/search/text.js.map +1 -0
- package/dist/search/types.d.ts +98 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/search/types.js +2 -0
- package/dist/search/types.js.map +1 -0
- package/dist/store/columns.d.ts +33 -0
- package/dist/store/columns.d.ts.map +1 -0
- package/dist/store/columns.js +113 -0
- package/dist/store/columns.js.map +1 -0
- package/dist/store/cursor.d.ts +23 -0
- package/dist/store/cursor.d.ts.map +1 -0
- package/dist/store/cursor.js +36 -0
- package/dist/store/cursor.js.map +1 -0
- package/dist/store/diff.d.ts +59 -0
- package/dist/store/diff.d.ts.map +1 -0
- package/dist/store/diff.js +124 -0
- package/dist/store/diff.js.map +1 -0
- package/dist/store/fragments.d.ts +13 -0
- package/dist/store/fragments.d.ts.map +1 -0
- package/dist/store/fragments.js +25 -0
- package/dist/store/fragments.js.map +1 -0
- package/dist/store/index.d.ts +22 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +16 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/naming.d.ts +25 -0
- package/dist/store/naming.d.ts.map +1 -0
- package/dist/store/naming.js +80 -0
- package/dist/store/naming.js.map +1 -0
- package/dist/store/read-only.d.ts +16 -0
- package/dist/store/read-only.d.ts.map +1 -0
- package/dist/store/read-only.js +36 -0
- package/dist/store/read-only.js.map +1 -0
- package/dist/store/store.d.ts +52 -0
- package/dist/store/store.d.ts.map +1 -0
- package/dist/store/store.js +706 -0
- package/dist/store/store.js.map +1 -0
- package/dist/store/tables.d.ts +40 -0
- package/dist/store/tables.d.ts.map +1 -0
- package/dist/store/tables.js +267 -0
- package/dist/store/tables.js.map +1 -0
- package/dist/store/types.d.ts +105 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +2 -0
- package/dist/store/types.js.map +1 -0
- package/dist/store/uuid.d.ts +6 -0
- package/dist/store/uuid.d.ts.map +1 -0
- package/dist/store/uuid.js +64 -0
- package/dist/store/uuid.js.map +1 -0
- package/dist/store/values.d.ts +40 -0
- package/dist/store/values.d.ts.map +1 -0
- package/dist/store/values.js +160 -0
- package/dist/store/values.js.map +1 -0
- package/dist/system-fields.d.ts +60 -0
- package/dist/system-fields.d.ts.map +1 -0
- package/dist/system-fields.js +73 -0
- package/dist/system-fields.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +54 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +183 -0
- package/dist/validation.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type DatabaseHandle, type SqlExecutor } from '@cogenta/core';
|
|
2
|
+
import type { CollectionDefinition, FieldDefinition } from '../types.js';
|
|
3
|
+
import { type SlugifyOptions } from './slugify.js';
|
|
4
|
+
/**
|
|
5
|
+
* A slug is unique **per collection and per locale**, never globally.
|
|
6
|
+
*
|
|
7
|
+
* That falls straight out of ADR-0014: one entry per language means the French
|
|
8
|
+
* and the English article are two rows, and both are legitimately `/mon-article`
|
|
9
|
+
* under their own locale prefix. Scoping uniqueness globally would force the
|
|
10
|
+
* translation to invent a slug nobody asked for.
|
|
11
|
+
*/
|
|
12
|
+
export interface SlugScope {
|
|
13
|
+
readonly collection: string;
|
|
14
|
+
readonly locale: string;
|
|
15
|
+
/** The entry being saved, so its own slug does not count as a collision. */
|
|
16
|
+
readonly excludeId?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Answers "is this slug already used inside the scope?". */
|
|
19
|
+
export type SlugTakenCheck = (candidate: string) => boolean | Promise<boolean>;
|
|
20
|
+
export interface UniqueSlugOptions extends SlugifyOptions {
|
|
21
|
+
/**
|
|
22
|
+
* How many suffixes to try before giving up. A bound exists so a broken
|
|
23
|
+
* `isTaken` cannot spin forever; it is high enough that no real editorial
|
|
24
|
+
* workload reaches it.
|
|
25
|
+
*/
|
|
26
|
+
readonly maxAttempts?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The first free slug in the `base`, `base-2`, `base-3`… series.
|
|
30
|
+
*
|
|
31
|
+
* The suffix starts at 2 rather than 1 because the unsuffixed slug *is* the
|
|
32
|
+
* first one: "article", "article-2" reads as a numbered pair, "article",
|
|
33
|
+
* "article-1" reads as an off-by-one.
|
|
34
|
+
*/
|
|
35
|
+
export declare function uniqueSlug(base: string, isTaken: SlugTakenCheck, options?: UniqueSlugOptions): Promise<string>;
|
|
36
|
+
export interface SqlSlugScope extends SlugScope {
|
|
37
|
+
readonly db: DatabaseHandle | SqlExecutor;
|
|
38
|
+
/** The table holding the entries of this collection. */
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly slugColumn?: string;
|
|
41
|
+
readonly localeColumn?: string;
|
|
42
|
+
readonly idColumn?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Set when one table holds several collections. Left unset when the schema
|
|
45
|
+
* gives every collection its own table, where the table already is the scope.
|
|
46
|
+
*/
|
|
47
|
+
readonly collectionColumn?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A `SlugTakenCheck` backed by the database.
|
|
51
|
+
*
|
|
52
|
+
* Kept behind the same callback the pure helpers take, so slug generation can
|
|
53
|
+
* be unit-tested without a database and the storage layer stays free to change
|
|
54
|
+
* its table layout without touching this file.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sqlSlugTaken(scope: SqlSlugScope): SlugTakenCheck;
|
|
57
|
+
/**
|
|
58
|
+
* The source text a `f.slug({ from: 'title' })` field derives from.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the field declares no source: such a slug is typed by the
|
|
61
|
+
* editor, and inventing one would overwrite their choice.
|
|
62
|
+
*/
|
|
63
|
+
export declare function slugSourceField(field: FieldDefinition): string | null;
|
|
64
|
+
export interface DeriveSlugInput {
|
|
65
|
+
readonly collection: CollectionDefinition;
|
|
66
|
+
/** Name of the `slug` field inside the collection. */
|
|
67
|
+
readonly field: string;
|
|
68
|
+
/** The entry being saved, before slug resolution. */
|
|
69
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
70
|
+
/** Scoped by the caller: it already knows the collection, the locale and the entry. */
|
|
71
|
+
readonly isTaken: SlugTakenCheck;
|
|
72
|
+
readonly options?: UniqueSlugOptions;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The slug an entry should be saved with: derived, transliterated, made unique.
|
|
76
|
+
*
|
|
77
|
+
* An explicit slug already present on the entry wins over the source field —
|
|
78
|
+
* the editor overrode it on purpose — but is still normalised and still made
|
|
79
|
+
* unique, because "already typed" is not "already free".
|
|
80
|
+
*/
|
|
81
|
+
export declare function deriveSlug(input: DeriveSlugInput): Promise<string>;
|
|
82
|
+
//# sourceMappingURL=slug.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slug.d.ts","sourceRoot":"","sources":["../../src/routing/slug.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAGnB,KAAK,WAAW,EAEjB,MAAM,eAAe,CAAA;AACtB,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACxE,OAAO,EAAmC,KAAK,cAAc,EAAW,MAAM,cAAc,CAAA;AAE5F;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;AAE9E,MAAM,WAAW,iBAAkB,SAAQ,cAAc;IACvD;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B;AAID;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CA8BjB;AAED,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,QAAQ,CAAC,EAAE,EAAE,cAAc,GAAG,WAAW,CAAA;IACzC,wDAAwD;IACxD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACnC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,cAAc,CAwBhE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAGrE;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,UAAU,EAAE,oBAAoB,CAAA;IACzC,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,qDAAqD;IACrD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAClD,uFAAuF;IACvF,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;IAChC,QAAQ,CAAC,OAAO,CAAC,EAAE,iBAAiB,CAAA;CACrC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CA2CxE"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CogentaError, identifier, limit, sql, } from '@cogenta/core';
|
|
2
|
+
import { DEFAULT_SLUG_MAX_LENGTH, isSlug, slugify } from './slugify.js';
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 1000;
|
|
4
|
+
/**
|
|
5
|
+
* The first free slug in the `base`, `base-2`, `base-3`… series.
|
|
6
|
+
*
|
|
7
|
+
* The suffix starts at 2 rather than 1 because the unsuffixed slug *is* the
|
|
8
|
+
* first one: "article", "article-2" reads as a numbered pair, "article",
|
|
9
|
+
* "article-1" reads as an off-by-one.
|
|
10
|
+
*/
|
|
11
|
+
export async function uniqueSlug(base, isTaken, options = {}) {
|
|
12
|
+
const separator = options.separator ?? '-';
|
|
13
|
+
const maxLength = options.maxLength ?? DEFAULT_SLUG_MAX_LENGTH;
|
|
14
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
15
|
+
if (base.length === 0) {
|
|
16
|
+
throw new CogentaError({
|
|
17
|
+
code: 'CONTENT_SLUG_INVALID',
|
|
18
|
+
message: 'An empty slug cannot be made unique.',
|
|
19
|
+
hint: 'Derive the slug from a non-empty source field, or supply one by hand.',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (!(await isTaken(base)))
|
|
23
|
+
return base;
|
|
24
|
+
for (let suffix = 2; suffix <= maxAttempts; suffix += 1) {
|
|
25
|
+
// The suffix is part of the budget, not an extension of it: a slug that
|
|
26
|
+
// grew past `maxLength` would be silently truncated by the column and two
|
|
27
|
+
// "unique" slugs would collide in storage.
|
|
28
|
+
const tail = `${separator}${suffix}`;
|
|
29
|
+
const candidate = `${base.slice(0, maxLength - tail.length)}${tail}`;
|
|
30
|
+
if (!(await isTaken(candidate)))
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
throw new CogentaError({
|
|
34
|
+
code: 'CONTENT_SLUG_TAKEN',
|
|
35
|
+
message: `No slug was free in the "${base}" series after ${maxAttempts} attempts.`,
|
|
36
|
+
hint: 'Choose a more specific title, or set the slug by hand.',
|
|
37
|
+
details: { base, maxAttempts },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A `SlugTakenCheck` backed by the database.
|
|
42
|
+
*
|
|
43
|
+
* Kept behind the same callback the pure helpers take, so slug generation can
|
|
44
|
+
* be unit-tested without a database and the storage layer stays free to change
|
|
45
|
+
* its table layout without touching this file.
|
|
46
|
+
*/
|
|
47
|
+
export function sqlSlugTaken(scope) {
|
|
48
|
+
const { db } = scope;
|
|
49
|
+
const table = identifier(scope.table, db.dialect);
|
|
50
|
+
const slugColumn = identifier(scope.slugColumn ?? 'slug', db.dialect);
|
|
51
|
+
const localeColumn = identifier(scope.localeColumn ?? 'locale', db.dialect);
|
|
52
|
+
const idColumn = identifier(scope.idColumn ?? 'id', db.dialect);
|
|
53
|
+
return async (candidate) => {
|
|
54
|
+
const collectionFilter = scope.collectionColumn === undefined
|
|
55
|
+
? sql ``
|
|
56
|
+
: sql ` and ${identifier(scope.collectionColumn, db.dialect)} = ${scope.collection}`;
|
|
57
|
+
const exclusion = scope.excludeId === undefined ? sql `` : sql ` and ${idColumn} <> ${scope.excludeId}`;
|
|
58
|
+
const found = await db.query(sql `
|
|
59
|
+
select ${idColumn} from ${table}
|
|
60
|
+
where ${slugColumn} = ${candidate}
|
|
61
|
+
and ${localeColumn} = ${scope.locale}${collectionFilter}${exclusion}
|
|
62
|
+
limit ${limit(1)}`);
|
|
63
|
+
return found.rows.length > 0;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The source text a `f.slug({ from: 'title' })` field derives from.
|
|
68
|
+
*
|
|
69
|
+
* Returns null when the field declares no source: such a slug is typed by the
|
|
70
|
+
* editor, and inventing one would overwrite their choice.
|
|
71
|
+
*/
|
|
72
|
+
export function slugSourceField(field) {
|
|
73
|
+
const from = field.options.from;
|
|
74
|
+
return typeof from === 'string' && from.length > 0 ? from : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The slug an entry should be saved with: derived, transliterated, made unique.
|
|
78
|
+
*
|
|
79
|
+
* An explicit slug already present on the entry wins over the source field —
|
|
80
|
+
* the editor overrode it on purpose — but is still normalised and still made
|
|
81
|
+
* unique, because "already typed" is not "already free".
|
|
82
|
+
*/
|
|
83
|
+
export async function deriveSlug(input) {
|
|
84
|
+
const field = input.collection.fields[input.field];
|
|
85
|
+
if (field === undefined || field.kind !== 'slug') {
|
|
86
|
+
throw new CogentaError({
|
|
87
|
+
code: 'CONTENT_SLUG_INVALID',
|
|
88
|
+
message: `Collection "${input.collection.name}" has no slug field named "${input.field}".`,
|
|
89
|
+
hint: 'Declare it with f.slug({ from: … }), or name an existing slug field.',
|
|
90
|
+
details: { collection: input.collection.name, field: input.field },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const explicit = input.values[input.field];
|
|
94
|
+
const source = slugSourceField(field);
|
|
95
|
+
const raw = typeof explicit === 'string' && explicit.length > 0
|
|
96
|
+
? explicit
|
|
97
|
+
: source === null
|
|
98
|
+
? ''
|
|
99
|
+
: stringOf(input.values[source]);
|
|
100
|
+
if (raw.length === 0) {
|
|
101
|
+
throw new CogentaError({
|
|
102
|
+
code: 'CONTENT_SLUG_INVALID',
|
|
103
|
+
message: `Nothing to build the "${input.field}" slug of "${input.collection.name}" from.`,
|
|
104
|
+
hint: source === null
|
|
105
|
+
? `Fill in "${input.field}".`
|
|
106
|
+
: `Fill in "${source}", or type "${input.field}" by hand.`,
|
|
107
|
+
details: { collection: input.collection.name, field: input.field, from: source },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const base = isSlug(raw, input.options) ? raw : slugify(raw, input.options);
|
|
111
|
+
if (base.length === 0) {
|
|
112
|
+
throw new CogentaError({
|
|
113
|
+
code: 'CONTENT_SLUG_INVALID',
|
|
114
|
+
message: `"${raw}" contains no character usable in a URL.`,
|
|
115
|
+
hint: `Type "${input.field}" by hand. A non-Latin script cannot be transliterated automatically.`,
|
|
116
|
+
details: { collection: input.collection.name, field: input.field },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return uniqueSlug(base, input.isTaken, input.options ?? {});
|
|
120
|
+
}
|
|
121
|
+
function stringOf(value) {
|
|
122
|
+
return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=slug.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slug.js","sourceRoot":"","sources":["../../src/routing/slug.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const DEFAULT_SLUG_MAX_LENGTH = 96;
|
|
2
|
+
export interface SlugifyOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Longest slug produced, in characters. A long title is truncated on a word
|
|
5
|
+
* boundary rather than mid-word, so the result still reads.
|
|
6
|
+
*/
|
|
7
|
+
readonly maxLength?: number;
|
|
8
|
+
/** The character between words. Anything but `-` is unusual; it is here for completeness. */
|
|
9
|
+
readonly separator?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A URL-safe, lowercase segment derived from arbitrary text.
|
|
13
|
+
*
|
|
14
|
+
* Returns an empty string when the input carries nothing transliterable — a
|
|
15
|
+
* title written entirely in Chinese, Arabic or Cyrillic. That is a real case,
|
|
16
|
+
* not an error: the caller decides whether to fall back to a manual slug or to
|
|
17
|
+
* refuse. `slugifyOrThrow` is the variant for callers that need a value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function slugify(input: string, options?: SlugifyOptions): string;
|
|
20
|
+
/** `slugify`, for callers that cannot proceed without a value. */
|
|
21
|
+
export declare function slugifyOrThrow(input: string, options?: SlugifyOptions): string;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a string is already a well-formed slug.
|
|
24
|
+
*
|
|
25
|
+
* Used to accept an editor-provided slug as it stands rather than mangling it:
|
|
26
|
+
* a human who typed "faq-2026" meant it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isSlug(value: string, options?: SlugifyOptions): boolean;
|
|
29
|
+
//# sourceMappingURL=slugify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slugify.d.ts","sourceRoot":"","sources":["../../src/routing/slugify.ts"],"names":[],"mappings":"AA6CA,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,6FAA6F;IAC7F,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CAgB3E;AAgBD,kEAAkE;AAClE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,MAAM,CAUlF;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAK3E"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CogentaError } from '@cogenta/core';
|
|
2
|
+
/**
|
|
3
|
+
* Turning a title into a URL segment.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately dependency-free (rule R9). Unicode already ships the hard part:
|
|
6
|
+
* `normalize('NFD')` splits "é" into "e" plus a combining acute, so dropping the
|
|
7
|
+
* combining range transliterates every accented Latin letter at once. What is
|
|
8
|
+
* left is the handful of letters Unicode does *not* decompose, because they are
|
|
9
|
+
* atomic characters rather than a base plus a mark — "ß", "æ", "ø" and friends.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The combining diacritical marks block, U+0300 to U+036F.
|
|
13
|
+
*
|
|
14
|
+
* NFD of Latin text produces marks from this block and no other, so the range
|
|
15
|
+
* is exact for what this function claims to handle. Written as code points
|
|
16
|
+
* rather than as a character class because the characters themselves are
|
|
17
|
+
* invisible in an editor, and an invisible character in a regex is a bug
|
|
18
|
+
* waiting to be introduced by the next person who touches the line.
|
|
19
|
+
*/
|
|
20
|
+
const COMBINING_FIRST = 0x0300;
|
|
21
|
+
const COMBINING_LAST = 0x036f;
|
|
22
|
+
/**
|
|
23
|
+
* Latin letters with no decomposition. Their transliteration is a convention,
|
|
24
|
+
* not an algorithm, so it has to be written down.
|
|
25
|
+
*/
|
|
26
|
+
const ATOMIC_LETTERS = {
|
|
27
|
+
ß: 'ss',
|
|
28
|
+
æ: 'ae',
|
|
29
|
+
œ: 'oe',
|
|
30
|
+
ø: 'o',
|
|
31
|
+
đ: 'd',
|
|
32
|
+
ð: 'd',
|
|
33
|
+
þ: 'th',
|
|
34
|
+
ł: 'l',
|
|
35
|
+
ı: 'i',
|
|
36
|
+
ħ: 'h',
|
|
37
|
+
ŧ: 't',
|
|
38
|
+
ŋ: 'n',
|
|
39
|
+
ĸ: 'k',
|
|
40
|
+
ſ: 's',
|
|
41
|
+
};
|
|
42
|
+
export const DEFAULT_SLUG_MAX_LENGTH = 96;
|
|
43
|
+
/**
|
|
44
|
+
* A URL-safe, lowercase segment derived from arbitrary text.
|
|
45
|
+
*
|
|
46
|
+
* Returns an empty string when the input carries nothing transliterable — a
|
|
47
|
+
* title written entirely in Chinese, Arabic or Cyrillic. That is a real case,
|
|
48
|
+
* not an error: the caller decides whether to fall back to a manual slug or to
|
|
49
|
+
* refuse. `slugifyOrThrow` is the variant for callers that need a value.
|
|
50
|
+
*/
|
|
51
|
+
export function slugify(input, options = {}) {
|
|
52
|
+
const separator = options.separator ?? '-';
|
|
53
|
+
const maxLength = options.maxLength ?? DEFAULT_SLUG_MAX_LENGTH;
|
|
54
|
+
let text = '';
|
|
55
|
+
for (const character of input.toLowerCase().normalize('NFD')) {
|
|
56
|
+
const code = character.codePointAt(0) ?? 0;
|
|
57
|
+
if (code >= COMBINING_FIRST && code <= COMBINING_LAST)
|
|
58
|
+
continue;
|
|
59
|
+
// Looked up after NFD, so a decomposed character is never taken for an atom.
|
|
60
|
+
text += ATOMIC_LETTERS[character] ?? character;
|
|
61
|
+
}
|
|
62
|
+
const words = text.split(/[^a-z0-9]+/u).filter((word) => word.length > 0);
|
|
63
|
+
if (words.length === 0)
|
|
64
|
+
return '';
|
|
65
|
+
return truncate(words, separator, maxLength);
|
|
66
|
+
}
|
|
67
|
+
/** Keeps whole words, so a truncated slug never ends on half a word. */
|
|
68
|
+
function truncate(words, separator, maxLength) {
|
|
69
|
+
let slug = '';
|
|
70
|
+
for (const word of words) {
|
|
71
|
+
const next = slug.length === 0 ? word : `${slug}${separator}${word}`;
|
|
72
|
+
if (next.length > maxLength)
|
|
73
|
+
break;
|
|
74
|
+
slug = next;
|
|
75
|
+
}
|
|
76
|
+
// A single first word longer than the budget still has to yield something.
|
|
77
|
+
return slug.length === 0 ? (words[0] ?? '').slice(0, maxLength) : slug;
|
|
78
|
+
}
|
|
79
|
+
/** `slugify`, for callers that cannot proceed without a value. */
|
|
80
|
+
export function slugifyOrThrow(input, options = {}) {
|
|
81
|
+
const slug = slugify(input, options);
|
|
82
|
+
if (slug.length > 0)
|
|
83
|
+
return slug;
|
|
84
|
+
throw new CogentaError({
|
|
85
|
+
code: 'CONTENT_SLUG_INVALID',
|
|
86
|
+
message: `"${input}" contains no character usable in a URL.`,
|
|
87
|
+
hint: 'Enter the slug by hand. A title written in a non-Latin script cannot be transliterated automatically.',
|
|
88
|
+
details: { input },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether a string is already a well-formed slug.
|
|
93
|
+
*
|
|
94
|
+
* Used to accept an editor-provided slug as it stands rather than mangling it:
|
|
95
|
+
* a human who typed "faq-2026" meant it.
|
|
96
|
+
*/
|
|
97
|
+
export function isSlug(value, options = {}) {
|
|
98
|
+
if (value.length === 0)
|
|
99
|
+
return false;
|
|
100
|
+
if (value.length > (options.maxLength ?? DEFAULT_SLUG_MAX_LENGTH))
|
|
101
|
+
return false;
|
|
102
|
+
return value === slugify(value, options);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=slugify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slugify.js","sourceRoot":"","sources":["../../src/routing/slugify.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { PublishHandler, ScheduledPublication, ScheduledPublishingOptions, SchedulePublicationInput, } from './publish.js';
|
|
2
|
+
export { cancelPublication, parsePayload, registerScheduledPublishing, reschedulePublication, SCHEDULED_PUBLISH_JOB, schedulePublication, } from './publish.js';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scheduling/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,2BAA2B,EAC3B,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scheduling/index.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,2BAA2B,EAC3B,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type JobId, type Logger, type QueueDriver } from '@cogenta/core';
|
|
2
|
+
/**
|
|
3
|
+
* Scheduled publication.
|
|
4
|
+
*
|
|
5
|
+
* An entry in `status: 'scheduled'` with a future `publishedAt` becomes a job in
|
|
6
|
+
* the L0 queue. The acceptance criterion of L1 is that this works **with the
|
|
7
|
+
* `database` queue too** — the degraded driver that has no worker of its own and
|
|
8
|
+
* is drained by a cron calling `tick()`.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is therefore written against `QueueDriver` and nothing else.
|
|
11
|
+
* No timer, no `setTimeout`, no assumption that a process is still alive at the
|
|
12
|
+
* publication time: the job carries its own due date, and whichever tick happens
|
|
13
|
+
* next after it runs it. On a cron every five minutes, a page scheduled for
|
|
14
|
+
* 09:00 is published between 09:00 and 09:05 — which is the honest promise of a
|
|
15
|
+
* host with no persistent worker, and it is the one to document.
|
|
16
|
+
*/
|
|
17
|
+
/** The job name. Public because a caller may need to inspect or cancel by name. */
|
|
18
|
+
export declare const SCHEDULED_PUBLISH_JOB = "cogenta.content.publish";
|
|
19
|
+
export interface ScheduledPublication {
|
|
20
|
+
readonly collection: string;
|
|
21
|
+
readonly entryId: string;
|
|
22
|
+
readonly locale: string;
|
|
23
|
+
/** Epoch milliseconds: when the entry becomes public. */
|
|
24
|
+
readonly publishAt: number;
|
|
25
|
+
}
|
|
26
|
+
/** Does the actual state change. Owned by the persistence layer (task 5), not by this file. */
|
|
27
|
+
export type PublishHandler = (publication: ScheduledPublication) => Promise<void>;
|
|
28
|
+
export interface SchedulePublicationInput {
|
|
29
|
+
readonly collection: string;
|
|
30
|
+
readonly entryId: string;
|
|
31
|
+
readonly locale: string;
|
|
32
|
+
/** A `Date`, an ISO 8601 string, or epoch milliseconds. */
|
|
33
|
+
readonly publishAt: Date | string | number;
|
|
34
|
+
readonly maxAttempts?: number;
|
|
35
|
+
readonly priority?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Queues the publication of one entry and returns the job to keep alongside it.
|
|
39
|
+
*
|
|
40
|
+
* The identifier has to be stored on the entry: it is the only handle on the
|
|
41
|
+
* job, and rescheduling without it leaves two jobs racing to publish the same
|
|
42
|
+
* entry at two different times.
|
|
43
|
+
*/
|
|
44
|
+
export declare function schedulePublication(queue: QueueDriver, input: SchedulePublicationInput): Promise<JobId>;
|
|
45
|
+
/** Cancels a pending publication. Safe to call on a job that already ran. */
|
|
46
|
+
export declare function cancelPublication(queue: QueueDriver, jobId: JobId): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Moves a publication to a new date.
|
|
49
|
+
*
|
|
50
|
+
* Cancel then enqueue, rather than an in-place update: the queue interface has
|
|
51
|
+
* no "reschedule", and adding one for this would push a content concern into
|
|
52
|
+
* every driver, Redis included.
|
|
53
|
+
*/
|
|
54
|
+
export declare function reschedulePublication(queue: QueueDriver, jobId: JobId, input: SchedulePublicationInput): Promise<JobId>;
|
|
55
|
+
export interface ScheduledPublishingOptions {
|
|
56
|
+
readonly logger?: Logger;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Registers the handler that publishes due entries.
|
|
60
|
+
*
|
|
61
|
+
* Called once at boot by whatever owns the queue — the worker process, or the
|
|
62
|
+
* cron entry point on a host that has none.
|
|
63
|
+
*/
|
|
64
|
+
export declare function registerScheduledPublishing(queue: QueueDriver, publish: PublishHandler, options?: ScheduledPublishingOptions): void;
|
|
65
|
+
/**
|
|
66
|
+
* A payload comes back from the queue as JSON, so it is `unknown` and is
|
|
67
|
+
* validated rather than cast. A job row can be edited by hand, restored from a
|
|
68
|
+
* backup, or left over from an older release.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parsePayload(payload: unknown): ScheduledPublication;
|
|
71
|
+
//# sourceMappingURL=publish.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../src/scheduling/publish.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,KAAK,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,eAAe,CAAA;AAEvF;;;;;;;;;;;;;;GAcG;AAEH,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,4BAA4B,CAAA;AAE9D,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,+FAA+F;AAC/F,MAAM,MAAM,cAAc,GAAG,CAAC,WAAW,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;AAEjF,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,CAAA;IAC1C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,KAAK,CAAC,CAiBhB;AAED,6EAA6E;AAC7E,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvF;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,KAAK,CAAC,CAGhB;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,0BAA+B,GACvC,IAAI,CAiBN;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,oBAAoB,CAsBnE"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { CogentaError } from '@cogenta/core';
|
|
2
|
+
/**
|
|
3
|
+
* Scheduled publication.
|
|
4
|
+
*
|
|
5
|
+
* An entry in `status: 'scheduled'` with a future `publishedAt` becomes a job in
|
|
6
|
+
* the L0 queue. The acceptance criterion of L1 is that this works **with the
|
|
7
|
+
* `database` queue too** — the degraded driver that has no worker of its own and
|
|
8
|
+
* is drained by a cron calling `tick()`.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is therefore written against `QueueDriver` and nothing else.
|
|
11
|
+
* No timer, no `setTimeout`, no assumption that a process is still alive at the
|
|
12
|
+
* publication time: the job carries its own due date, and whichever tick happens
|
|
13
|
+
* next after it runs it. On a cron every five minutes, a page scheduled for
|
|
14
|
+
* 09:00 is published between 09:00 and 09:05 — which is the honest promise of a
|
|
15
|
+
* host with no persistent worker, and it is the one to document.
|
|
16
|
+
*/
|
|
17
|
+
/** The job name. Public because a caller may need to inspect or cancel by name. */
|
|
18
|
+
export const SCHEDULED_PUBLISH_JOB = 'cogenta.content.publish';
|
|
19
|
+
/**
|
|
20
|
+
* Queues the publication of one entry and returns the job to keep alongside it.
|
|
21
|
+
*
|
|
22
|
+
* The identifier has to be stored on the entry: it is the only handle on the
|
|
23
|
+
* job, and rescheduling without it leaves two jobs racing to publish the same
|
|
24
|
+
* entry at two different times.
|
|
25
|
+
*/
|
|
26
|
+
export async function schedulePublication(queue, input) {
|
|
27
|
+
const publishAt = toEpochMs(input.publishAt, input);
|
|
28
|
+
const payload = {
|
|
29
|
+
collection: input.collection,
|
|
30
|
+
entryId: input.entryId,
|
|
31
|
+
locale: input.locale,
|
|
32
|
+
publishAt,
|
|
33
|
+
};
|
|
34
|
+
return queue.enqueue({
|
|
35
|
+
name: SCHEDULED_PUBLISH_JOB,
|
|
36
|
+
payload,
|
|
37
|
+
runAt: publishAt,
|
|
38
|
+
maxAttempts: input.maxAttempts ?? 3,
|
|
39
|
+
...(input.priority === undefined ? {} : { priority: input.priority }),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Cancels a pending publication. Safe to call on a job that already ran. */
|
|
43
|
+
export async function cancelPublication(queue, jobId) {
|
|
44
|
+
await queue.cancel(jobId);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Moves a publication to a new date.
|
|
48
|
+
*
|
|
49
|
+
* Cancel then enqueue, rather than an in-place update: the queue interface has
|
|
50
|
+
* no "reschedule", and adding one for this would push a content concern into
|
|
51
|
+
* every driver, Redis included.
|
|
52
|
+
*/
|
|
53
|
+
export async function reschedulePublication(queue, jobId, input) {
|
|
54
|
+
await queue.cancel(jobId);
|
|
55
|
+
return schedulePublication(queue, input);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Registers the handler that publishes due entries.
|
|
59
|
+
*
|
|
60
|
+
* Called once at boot by whatever owns the queue — the worker process, or the
|
|
61
|
+
* cron entry point on a host that has none.
|
|
62
|
+
*/
|
|
63
|
+
export function registerScheduledPublishing(queue, publish, options = {}) {
|
|
64
|
+
const logger = options.logger?.child({ component: 'scheduling' });
|
|
65
|
+
queue.process(SCHEDULED_PUBLISH_JOB, async (job) => {
|
|
66
|
+
const publication = parsePayload(job.payload);
|
|
67
|
+
await publish(publication);
|
|
68
|
+
logger?.info('scheduled entry published', {
|
|
69
|
+
collection: publication.collection,
|
|
70
|
+
entryId: publication.entryId,
|
|
71
|
+
locale: publication.locale,
|
|
72
|
+
// How late the tick was. On the database queue this is the cron interval,
|
|
73
|
+
// and it is the number to look at when someone asks why a page went live
|
|
74
|
+
// four minutes after the hour.
|
|
75
|
+
latenessMs: Date.now() - publication.publishAt,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A payload comes back from the queue as JSON, so it is `unknown` and is
|
|
81
|
+
* validated rather than cast. A job row can be edited by hand, restored from a
|
|
82
|
+
* backup, or left over from an older release.
|
|
83
|
+
*/
|
|
84
|
+
export function parsePayload(payload) {
|
|
85
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
86
|
+
throw invalidPayload(payload);
|
|
87
|
+
}
|
|
88
|
+
const record = payload;
|
|
89
|
+
const { collection, entryId, locale, publishAt } = record;
|
|
90
|
+
if (typeof collection !== 'string' ||
|
|
91
|
+
collection.length === 0 ||
|
|
92
|
+
typeof entryId !== 'string' ||
|
|
93
|
+
entryId.length === 0 ||
|
|
94
|
+
typeof locale !== 'string' ||
|
|
95
|
+
locale.length === 0 ||
|
|
96
|
+
typeof publishAt !== 'number' ||
|
|
97
|
+
!Number.isFinite(publishAt)) {
|
|
98
|
+
throw invalidPayload(payload);
|
|
99
|
+
}
|
|
100
|
+
return { collection, entryId, locale, publishAt };
|
|
101
|
+
}
|
|
102
|
+
function invalidPayload(payload) {
|
|
103
|
+
return new CogentaError({
|
|
104
|
+
code: 'CONTENT_SCHEDULE_INVALID',
|
|
105
|
+
message: `A "${SCHEDULED_PUBLISH_JOB}" job carries a payload it cannot be run from.`,
|
|
106
|
+
hint: 'Cancel the job and schedule the entry again. Its payload names the collection, the entry, the locale and the date.',
|
|
107
|
+
details: { received: typeof payload },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function toEpochMs(value, context) {
|
|
111
|
+
const milliseconds = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);
|
|
112
|
+
if (!Number.isFinite(milliseconds)) {
|
|
113
|
+
throw new CogentaError({
|
|
114
|
+
code: 'CONTENT_SCHEDULE_INVALID',
|
|
115
|
+
message: `"${String(value)}" is not a date to publish "${context.entryId}" on.`,
|
|
116
|
+
hint: 'Pass a Date, an ISO 8601 timestamp such as 2026-09-01T09:00:00Z, or epoch milliseconds.',
|
|
117
|
+
details: { collection: context.collection, entryId: context.entryId },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return milliseconds;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=publish.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish.js","sourceRoot":"","sources":["../../src/scheduling/publish.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ContentBlock, ContentEntry } from '../store/types.js';
|
|
2
|
+
import type { CollectionDefinition } from '../types.js';
|
|
3
|
+
import type { SearchDocument } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* The prose of a rich text document: the `text` of every span, plus the
|
|
6
|
+
* captions of the media nodes.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately not `JSON.stringify`: the raw document is roughly half syntax,
|
|
9
|
+
* and indexing it would let a search for `span` return every article on the
|
|
10
|
+
* site while a search for a genuinely rare word would be diluted by it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function extractRichText(value: unknown): string;
|
|
13
|
+
/** The prose of one block. Its `data` is contract B, so its shape is open. */
|
|
14
|
+
export declare function extractBlockText(block: ContentBlock): string;
|
|
15
|
+
/**
|
|
16
|
+
* The indexable document for an entry.
|
|
17
|
+
*
|
|
18
|
+
* The title is repeated inside the body on purpose: none of the three engines
|
|
19
|
+
* offers per-column weighting in a form the other two can match — Postgres has
|
|
20
|
+
* `setweight`, MySQL has nothing, FTS5 has a per-column BM25 weight vector — so
|
|
21
|
+
* the one portable way to make a title term count for more is to let it appear
|
|
22
|
+
* twice in the text every engine ranks.
|
|
23
|
+
*/
|
|
24
|
+
export declare function searchDocumentFor(collection: CollectionDefinition, entry: ContentEntry): SearchDocument;
|
|
25
|
+
//# sourceMappingURL=extract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../src/search/extract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAC/E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAEvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AA0EhD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CA6BtD;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAI5D;AAoCD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,oBAAoB,EAChC,KAAK,EAAE,YAAY,GAClB,cAAc,CA8BhB"}
|