@mosetta/content 0.0.0-stage → 0.0.10

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/src/i18n.ts ADDED
@@ -0,0 +1,177 @@
1
+ // LANGUAGE ADDRESSING and the TRANSLATOR PORT — the only part of localization the platform
2
+ // owns. Which languages a project's addresses serve is its content config (`config.json`:
3
+ // `languages`, `defaultLanguage`); the language of a request is the first path segment when it
4
+ // names one (`/ru/blog/x`), the default otherwise. The WORDS in each language are not here: they
5
+ // belong to a block (a reference provider) the page places like any other, which reads the
6
+ // request's language off the route — all but the page's words ABOUT itself (its meta's title and
7
+ // description), which the head and the article lists need before any block renders: the meta
8
+ // carries them per language. The translator is a host service behind a port.
9
+
10
+ export interface ILanguages {
11
+ /** ISO 639-1 codes, the default first */
12
+ readonly languages: readonly string[];
13
+ readonly defaultLanguage: string;
14
+ }
15
+
16
+ export const DEFAULT_LANGUAGES: ILanguages = { languages: ['en'], defaultLanguage: 'en' };
17
+
18
+ const CODE_RE = /^[a-z]{2}(-[a-z]{2})?$/i;
19
+
20
+ /** the content config's language block, validated; anything odd → the single default */
21
+ export function parseLanguages(config: unknown): ILanguages {
22
+ const raw = config as { languages?: unknown; defaultLanguage?: unknown } | null;
23
+ const list = Array.isArray(raw?.languages) ? raw.languages.filter((l): l is string => typeof l === 'string' && CODE_RE.test(l)).map((l) => l.toLowerCase()) : [];
24
+ if (!list.length) return DEFAULT_LANGUAGES;
25
+ const preferred = typeof raw?.defaultLanguage === 'string' ? raw.defaultLanguage.toLowerCase() : list[0];
26
+ const defaultLanguage = list.includes(preferred) ? preferred : list[0];
27
+ return { languages: [defaultLanguage, ...list.filter((l) => l !== defaultLanguage)], defaultLanguage };
28
+ }
29
+
30
+ export interface ILanguageOfPath {
31
+ readonly lang: string;
32
+ /** the path with the language prefix removed — what the route table is matched against */
33
+ readonly pathname: string;
34
+ /** the request named the language in the path (the default may be named too) */
35
+ readonly prefixed: boolean;
36
+ }
37
+
38
+ import { languageScheme } from '@mosetta/core';
39
+
40
+ export { isRightToLeft, pathInLanguage } from '@mosetta/core';
41
+
42
+ /** `/ru/blog/x` → ru + `/blog/x`; `/blog/x` → the default; an unknown first segment is not a language */
43
+ export function languageOfPath(pathname: string, languages: ILanguages): ILanguageOfPath {
44
+ const { lang, path, named } = languageScheme('prefix').read(pathname, languages);
45
+ return { lang, pathname: path, prefixed: named };
46
+ }
47
+
48
+ // ---- the page's words about itself, in a language ----
49
+
50
+ interface IMetaWithWords {
51
+ readonly title?: string;
52
+ readonly description?: string;
53
+ readonly i18n?: Readonly<Record<string, { readonly title?: string; readonly description?: string }>>;
54
+ }
55
+
56
+ /**
57
+ * The meta a reader of `lang` gets: the title and description in their language where the page
58
+ * has them, the original otherwise. The other languages are left out — a reader never needs them.
59
+ */
60
+ export function metaInLanguage<TMeta extends IMetaWithWords>(meta: TMeta | undefined, lang: string): Omit<TMeta, 'i18n'> | undefined {
61
+ if (!meta) return meta;
62
+ const { i18n, ...own } = meta;
63
+ const words = i18n && typeof i18n === 'object' ? i18n[lang] : undefined;
64
+ if (!words || typeof words !== 'object') return own;
65
+ const out: Record<string, unknown> = { ...own };
66
+ for (const key of ['title', 'description'] as const) {
67
+ const word = words[key];
68
+ if (typeof word === 'string' && word.trim()) out[key] = word;
69
+ }
70
+ return out as Omit<TMeta, 'i18n'>;
71
+ }
72
+
73
+ // ---- translation: the port, the wire protocol, the shared handler ----
74
+
75
+ /** one string to translate, and what it is on the page */
76
+ export interface ITranslateSegment {
77
+ readonly text: string;
78
+ /** what reads it — the block and its setting (`heading · text`, `button · text`), or `page title` */
79
+ readonly role?: string;
80
+ }
81
+
82
+ /** what a translator is told about the page as a whole */
83
+ export interface ITranslateContext {
84
+ /** what the page is: its title and description in the original */
85
+ readonly page?: string;
86
+ /** the author's notes: what to leave untranslated, the tone, the terms */
87
+ readonly instructions?: string;
88
+ }
89
+
90
+ /**
91
+ * The TRANSLATOR PORT: the strings of one page in, the same strings translated out, in the same
92
+ * order. The strings come in the page's order, each with what it is on the page, together with
93
+ * what the page is about and the author's notes — a heading, a button and a table cell are
94
+ * translated differently, and an English lesson keeps its English examples. Where the words come
95
+ * from is the host's business (a Workers AI model, a paid API, an echo in dev).
96
+ */
97
+ export interface ITranslator {
98
+ translate(segments: readonly ITranslateSegment[], from: string, to: string, context: ITranslateContext): Promise<readonly string[]>;
99
+ }
100
+
101
+ /** dev's translator: marks the string with the target language, so a "translation" is visibly one */
102
+ export const echoTranslator: ITranslator = {
103
+ translate: (segments, _from, to) => Promise.resolve(segments.map(({ text }) => `[${to}] ${text}`)),
104
+ };
105
+
106
+ /** the host's translation endpoint — POST an ITranslateRequest, receive an ITranslateResponse */
107
+ export const TRANSLATE_PATH = '/__mosetta__/translate';
108
+
109
+ export interface ITranslateItem {
110
+ readonly id: string;
111
+ readonly text: string;
112
+ readonly role?: string;
113
+ }
114
+
115
+ export interface ITranslateRequest {
116
+ readonly from: string;
117
+ readonly to: string;
118
+ /** in the page's order */
119
+ readonly items: readonly ITranslateItem[];
120
+ readonly context?: ITranslateContext;
121
+ }
122
+
123
+ export interface ITranslateResponse {
124
+ readonly items: readonly { readonly id: string; readonly text: string }[];
125
+ }
126
+
127
+ export const MAX_TRANSLATE_ITEMS = 200;
128
+ export const MAX_TRANSLATE_CHARS = 40_000;
129
+ /** the longest role, page summary and notes the endpoint accepts */
130
+ export const MAX_TRANSLATE_ROLE = 200;
131
+ export const MAX_TRANSLATE_CONTEXT = 4000;
132
+
133
+ const jsonResponse = (body: unknown, status: number): Response => new Response(JSON.stringify(body), {
134
+ status,
135
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
136
+ });
137
+
138
+ /**
139
+ * The endpoint every host shares: validates the request, asks the translator, answers item by
140
+ * item. The host decides WHO may call it (an editor's session on the platform, anyone in dev)
141
+ * and WHICH translator answers — `null` says the host has none (501).
142
+ */
143
+ export async function handleTranslate(request: Request, translator: ITranslator | null): Promise<Response> {
144
+ if (request.method !== 'POST') return jsonResponse({ error: { code: 'METHOD_NOT_ALLOWED' } }, 405);
145
+ if (!translator) return jsonResponse({ error: { code: 'NO_TRANSLATOR', message: 'this host has no translator configured' } }, 501);
146
+ let body: ITranslateRequest;
147
+ try {
148
+ body = (await request.json()) as ITranslateRequest;
149
+ } catch {
150
+ return jsonResponse({ error: { code: 'BAD_JSON' } }, 400);
151
+ }
152
+ const { from, to } = body ?? {};
153
+ if (typeof from !== 'string' || !CODE_RE.test(from) || typeof to !== 'string' || !CODE_RE.test(to) || from === to) {
154
+ return jsonResponse({ error: { code: 'BAD_LANGUAGES' } }, 400);
155
+ }
156
+ const items: readonly ITranslateItem[] = Array.isArray(body.items) ? (body.items as ITranslateItem[]) : [];
157
+ if (!items.length || items.length > MAX_TRANSLATE_ITEMS
158
+ || items.some((i) => !i || typeof i.id !== 'string' || typeof i.text !== 'string' || !i.text.trim())
159
+ || items.some((i) => i.role !== undefined && (typeof i.role !== 'string' || i.role.length > MAX_TRANSLATE_ROLE))
160
+ || items.reduce((n, i) => n + i.text.length, 0) > MAX_TRANSLATE_CHARS) {
161
+ return jsonResponse({ error: { code: 'BAD_ITEMS' } }, 400);
162
+ }
163
+ const raw = (body.context ?? {}) as Record<string, unknown>;
164
+ const contextText = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
165
+ const context: ITranslateContext = { page: contextText(raw.page), instructions: contextText(raw.instructions) };
166
+ if ((context.page?.length ?? 0) > MAX_TRANSLATE_CONTEXT || (context.instructions?.length ?? 0) > MAX_TRANSLATE_CONTEXT) {
167
+ return jsonResponse({ error: { code: 'BAD_CONTEXT' } }, 400);
168
+ }
169
+ try {
170
+ const texts = await translator.translate(items.map((i) => ({ text: i.text, ...(i.role ? { role: i.role } : {}) })), from, to, context);
171
+ if (texts.length !== items.length) throw new Error(`translator answered ${texts.length} of ${items.length}`);
172
+ const answer: ITranslateResponse = { items: items.map((item, i) => ({ id: item.id, text: texts[i] })) };
173
+ return jsonResponse(answer, 200);
174
+ } catch (e) {
175
+ return jsonResponse({ error: { code: 'TRANSLATION_FAILED', message: String((e as Error)?.message ?? e) } }, 502);
176
+ }
177
+ }
package/src/media.ts ADDED
@@ -0,0 +1,69 @@
1
+ // MEDIA: pictures and videos an author uploads from a settings form. They live in the project's
2
+ // PUBLIC media store (its CDN folder) under `media/…` — a public asset like a module's css. The
3
+ // browser puts the file there itself, through an address the Worker signed for exactly that key,
4
+ // type and size (a video does not travel through a server action). The rules are pure data: what
5
+ // may go up, how big, and under which key.
6
+ const IMAGE_TYPES: Record<string, string> = {
7
+ 'image/png': 'png',
8
+ 'image/jpeg': 'jpg',
9
+ 'image/gif': 'gif',
10
+ 'image/webp': 'webp',
11
+ 'image/avif': 'avif',
12
+ };
13
+
14
+ // played by the browser as uploaded: no transcoding, so only what every browser plays
15
+ const VIDEO_TYPES: Record<string, string> = {
16
+ 'video/mp4': 'mp4',
17
+ 'video/webm': 'webm',
18
+ };
19
+
20
+ export type TMediaKind = 'image' | 'video';
21
+
22
+ /** the largest picture accepted — bytes */
23
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
24
+
25
+ /** the largest video accepted — bytes */
26
+ export const MAX_VIDEO_BYTES = 100 * 1024 * 1024;
27
+
28
+ export const MEDIA_PREFIX = 'media/';
29
+
30
+ /** where the editor asks its host to sign an upload (the host answers it itself) */
31
+ export const MEDIA_UPLOAD_PATH = '/__mosetta__/media/upload';
32
+
33
+ /** raster images and web video only: an svg can carry script, and no page needs one uploaded */
34
+ export function mediaKindOf(type: string): TMediaKind | null {
35
+ if (Object.hasOwn(IMAGE_TYPES, type)) return 'image';
36
+ if (Object.hasOwn(VIDEO_TYPES, type)) return 'video';
37
+ return null;
38
+ }
39
+
40
+ export const isAllowedMediaType = (type: string): boolean => mediaKindOf(type) !== null;
41
+
42
+ /** what the browser offers in the file picker for a kind */
43
+ export const acceptedTypes = (kind: TMediaKind): string => Object.keys(kind === 'image' ? IMAGE_TYPES : VIDEO_TYPES).join(',');
44
+
45
+ const megabytes = (bytes: number): string => `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`;
46
+
47
+ /** why a file cannot go up, in words for its author — or null when it can */
48
+ export function mediaIssue(type: string, size: number): string | null {
49
+ const kind = mediaKindOf(type);
50
+ if (!kind) return `${type || 'this file'} is not a picture (png, jpeg, gif, webp, avif) or a video (mp4, webm)`;
51
+ if (!Number.isInteger(size) || size <= 0) return 'the file is empty';
52
+ const limit = kind === 'image' ? MAX_IMAGE_BYTES : MAX_VIDEO_BYTES;
53
+ return size > limit ? `the ${kind} is ${megabytes(size)} — the limit is ${megabytes(limit)}` : null;
54
+ }
55
+
56
+ /**
57
+ * The object key of an upload: `media/<yyyy>/<mm>/<stamp>-<slug>.<ext>` — the stamp makes it
58
+ * unique (so the CDN may cache it forever), the slug keeps the original name readable, the
59
+ * extension follows the declared type, never the name.
60
+ */
61
+ export function mediaObjectKey(name: string, type: string, now: Date = new Date()): string {
62
+ const ext = IMAGE_TYPES[type] ?? VIDEO_TYPES[type];
63
+ if (!ext) throw new Error(`media: unsupported type ${type}`);
64
+ const base = name.replace(/\.[^.]*$/, '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'media';
65
+ const stamp = now.getTime().toString(36);
66
+ const yyyy = now.getUTCFullYear();
67
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
68
+ return `${MEDIA_PREFIX}${yyyy}/${mm}/${stamp}-${base}.${ext}`;
69
+ }
package/src/model.ts ADDED
@@ -0,0 +1,300 @@
1
+ // Content domain model: pages and prefabs are revisioned entities.
2
+ //
3
+ // TWO separate axes (they used to share one):
4
+ // - VERSIONS — the publish history: an integer axis assigned at publish time (no semver — content
5
+ // has no API-compat semantics); exactly one version per entity is `published` (= live), the rest
6
+ // are `archived`, all of them immutable.
7
+ // - DRAFTS — a flat, unordered set of working copies, each under its own random id. Drafts are
8
+ // NOT in the meta: recording them there would make the meta a shared write hotspot between
9
+ // concurrent editors — the exact race the split exists to remove. They are a prefix LISTING.
10
+
11
+ export type ContentKind = 'pages' | 'prefabs';
12
+
13
+ export type RevisionStatus = 'published' | 'archived';
14
+
15
+ export type PrefabType = 'page' | 'unit';
16
+
17
+ // The per-entity meta document (`pages/<name>/page.json` / `prefabs/<name>/prefab.json`).
18
+ // Mutated ONLY at publish — a rare, owner-visible operation; draft churn never touches it.
19
+ export interface IContentMeta {
20
+ /** the published (live) version, or null while nothing is published yet */
21
+ readonly live: number | null;
22
+ /** every published-ever version (stringified int key) → its status; exactly one is 'published' */
23
+ readonly versions: Record<string, RevisionStatus>;
24
+ /** the names given at publish, by version — a publication may be called something */
25
+ readonly names?: Record<string, string>;
26
+ /** prefabs only: what the prefab is a template of (previously encoded in the file name) */
27
+ readonly type?: PrefabType;
28
+ /**
29
+ * FROZEN by the platform (the workbench page of a platform project): no new draft, no publish,
30
+ * no delete — and no other page may publish under its route. Its content is a live reference, so
31
+ * it never needs editing; the platform's deploy changes what it becomes.
32
+ */
33
+ readonly locked?: boolean;
34
+ }
35
+
36
+ // ---- drafts ----------------------------------------------------------------------------------
37
+
38
+ /**
39
+ * The WORKING fields a draft document carries on top of its content. They live inline in the doc
40
+ * (not in an envelope) so every reader of a draft — preview, ssr, the editor — sees the same shape
41
+ * a published document has; publish strips them the way it strips history/redo.
42
+ */
43
+ export interface IDraftFields {
44
+ /**
45
+ * The draft's write sequence — the optimistic-concurrency token. Every accepted save must carry
46
+ * exactly `stored.seq + 1`: a writer that fell behind (someone else saved meanwhile) breaks the
47
+ * chain and is refused at the trust boundary (412), which the editor answers with fork-or-rollback.
48
+ */
49
+ readonly seq: number;
50
+ /** ISO timestamp of the draft's creation */
51
+ readonly createdAt: string;
52
+ /** the published version this draft's lineage started from; absent when none was published */
53
+ readonly baseRev?: number;
54
+ /** an optional human label for pickers/tabs */
55
+ readonly title?: string;
56
+ }
57
+
58
+ /** what a draft listing returns per draft — the fields, never the content */
59
+ export interface IDraftSummary extends IDraftFields {
60
+ readonly id: string;
61
+ /** the route the draft would publish under — a page's; read with the summary, the doc is at hand */
62
+ readonly pattern?: string;
63
+ }
64
+
65
+ /** draft ids are random (no coordination on create); dot-free so a preview token can carry one */
66
+ export const DRAFT_ID_RE = /^[a-z0-9][a-z0-9-]{5,63}$/;
67
+
68
+ /** the published form of a draft document: every working field stripped */
69
+ export function publishedFormOf<T extends object>(doc: T): Omit<T, 'history' | 'redo' | keyof IDraftFields> {
70
+ const {
71
+ history: _history, redo: _redo, seq: _seq, createdAt: _createdAt, baseRev: _baseRev, title: _title, ...body
72
+ } = doc as T & Partial<IDraftFields> & { history?: unknown; redo?: unknown };
73
+ return body;
74
+ }
75
+
76
+ /** the slot a child belongs to when it names none — spelled out where the tree is manipulated */
77
+ export const DEFAULT_SLOT = 'default';
78
+
79
+ // A tree node as persisted in content documents. Deliberately self-contained (not imported from the
80
+ // legacy @mosetta/core): the JSON contract is owned by this package — includes the
81
+ // node-settings target model (`settings`) the runtime types will converge to.
82
+ export interface IBlueprintNodeDoc {
83
+ readonly id: string;
84
+ /** canonical full module name: _ns_<ns>_t_<type>_n_<name>_v_<version> */
85
+ readonly reference: string;
86
+ readonly children: IBlueprintNodeDoc[];
87
+ readonly slot?: string;
88
+ readonly settings?: Record<string, unknown>;
89
+ /** effective-ssr flag (SSR epic): true on the ROOT enables server rendering; false opts a
90
+ * branch out (not re-enablable deeper — no islands); absent → inherit */
91
+ readonly ssr?: boolean;
92
+ /**
93
+ * A PROVIDER that takes new blocks under its wing: a block placed in its reach (inserted,
94
+ * pasted) gets its bindable fields of the provider's type linked to it at once. The editor's
95
+ * business — no renderer reads it.
96
+ */
97
+ readonly autoBind?: boolean;
98
+ }
99
+
100
+ // What was done to a page tree — the unit the draft's history is made of. One entry per mutation,
101
+ // structured so a history UI can list it and an undo can act on it without parsing prose.
102
+ export interface IPageAction {
103
+ readonly type: 'insert' | 'remove' | 'settings' | 'ssr' | 'paste' | 'cut' | 'move';
104
+ /** for a paste — the first arriving block's fresh id; the whole paste is ONE step */
105
+ readonly nodeId: string;
106
+ /** insert only: the module that went in */
107
+ readonly reference?: string;
108
+ }
109
+
110
+ /** a page snapshot as stored inside history/redo entries: both stacks stripped — entries never
111
+ * nest, or the JSON would grow without bound */
112
+ export type TPageSnapshot = Omit<IPageDoc, 'history' | 'redo'>;
113
+
114
+ /** one step of a draft's history: the action, and the page as it was BEFORE it */
115
+ export interface IPageHistoryEntry {
116
+ readonly action: IPageAction;
117
+ readonly before: TPageSnapshot;
118
+ }
119
+
120
+ /** an undone step waiting on the redo list: the action, and the page as it was AFTER it */
121
+ export interface IPageRedoEntry {
122
+ readonly action: IPageAction;
123
+ readonly after: TPageSnapshot;
124
+ }
125
+
126
+ /** how many steps a draft remembers; the oldest falls off, keeping the JSON bounded */
127
+ export const PAGE_HISTORY_LIMIT = 20;
128
+
129
+ // A page document — the exact flat shape the render path consumes (public/pages/<name>.json).
130
+ // A DRAFT of it additionally carries the working fields (IDraftFields) and the undo stacks;
131
+ // publish strips them all — a published version is pure content.
132
+ /**
133
+ * What a page says ABOUT itself — the head tags, the entry in an article list, the share card.
134
+ * Versioned with the document like the pattern is. Every field optional: a page without meta is
135
+ * a page with an empty head.
136
+ */
137
+ export interface IPageMeta {
138
+ readonly title?: string;
139
+ readonly description?: string;
140
+ /** an absolute URL of the cover / share image */
141
+ readonly image?: string;
142
+ /** ISO date (YYYY-MM-DD) — what an article list sorts by */
143
+ readonly date?: string;
144
+ /** the category an article list filters by (`?category=<key>`) */
145
+ readonly category?: string;
146
+ /** kept out of search engines: `robots: noindex` in the head, left out of the sitemap */
147
+ readonly noindex?: boolean;
148
+ /**
149
+ * The words in the project's other languages, by language code: a reader of `/ru/…` gets the
150
+ * Russian title and description, a field left out reads the original
151
+ */
152
+ readonly i18n?: Readonly<Record<string, IPageMetaWords>>;
153
+ }
154
+
155
+ /** the part of a page's meta that is words — what a translation gives in another language */
156
+ export interface IPageMetaWords {
157
+ readonly title?: string;
158
+ readonly description?: string;
159
+ }
160
+
161
+ /** a setting of one of a prefab's blocks each instance sets for itself */
162
+ export interface IPrefabField {
163
+ /** the block's id as the prefab authored it */
164
+ readonly node: string;
165
+ readonly setting: string;
166
+ /** what the instance's form calls it */
167
+ readonly label: string;
168
+ }
169
+
170
+ export interface IPageDoc extends IBlueprintNodeDoc, Partial<IDraftFields> {
171
+ /** route pattern; versioned WITH the doc so publishing changes the route atomically */
172
+ readonly pattern: string;
173
+ /**
174
+ * A PREFAB's open fields — carried only by a prefab opened in the editor, which edits every tree
175
+ * in a page's shape (see prefabAsPage); a page has none
176
+ */
177
+ readonly fields?: readonly IPrefabField[];
178
+ readonly meta?: IPageMeta;
179
+ readonly fallback?: string;
180
+ readonly devOnly?: boolean;
181
+ /** the draft's working memory (undo); publish strips it — a published page carries none */
182
+ readonly history?: readonly IPageHistoryEntry[];
183
+ /** steps undone and waiting to be redone; in the document like history is — it would be
184
+ * surprising for one direction to survive a reload and the other not. A fresh edit clears it */
185
+ readonly redo?: readonly IPageRedoEntry[];
186
+ }
187
+
188
+ // A prefab document. `type` lives in the meta, not here; drafts carry the working fields too.
189
+ export interface IPrefabDoc extends Partial<IDraftFields> {
190
+ readonly root: IBlueprintNodeDoc;
191
+ /** the settings each instance sets for itself */
192
+ readonly fields?: readonly IPrefabField[];
193
+ readonly attributes?: Record<string, unknown>;
194
+ readonly devOnly?: boolean;
195
+ /** a draft's undo memory, in the page shape the editor edits it in */
196
+ readonly history?: readonly IPageHistoryEntry[];
197
+ readonly redo?: readonly IPageRedoEntry[];
198
+ }
199
+
200
+ export interface IContentEntity {
201
+ readonly name: string;
202
+ readonly meta: IContentMeta;
203
+ /** the entity's drafts (listing + working fields) — the meta knows nothing about them */
204
+ readonly drafts: readonly IDraftSummary[];
205
+ }
206
+
207
+ /** a loaded PUBLISHED (or archived) version */
208
+ export interface ILoadedContent<TDoc> {
209
+ readonly name: string;
210
+ readonly meta: IContentMeta;
211
+ readonly rev: number;
212
+ readonly doc: TDoc;
213
+ }
214
+
215
+ /** a loaded draft — identified by its id, no meta involved */
216
+ export interface ILoadedDraft<TDoc> {
217
+ readonly name: string;
218
+ readonly id: string;
219
+ readonly doc: TDoc;
220
+ }
221
+
222
+ // Catalog: built from the provider's `public/modules.json` — a single catalog of entities
223
+ // (own modules + packaged ones); a descriptor = {id, reference, assets, dependencies}.
224
+ export interface IModuleDescriptorReference {
225
+ readonly namespace: string;
226
+ readonly name: string;
227
+ readonly type: string;
228
+ readonly version: string;
229
+ }
230
+
231
+ export interface IModuleDescriptor {
232
+ readonly id: string;
233
+ readonly reference: IModuleDescriptorReference;
234
+ readonly assets?: readonly string[];
235
+ readonly dependencies?: readonly string[];
236
+ /**
237
+ * A PAGE ROOT: the module a page names as its own root, not a block to place inside one. Stamped
238
+ * by the build from the definer the facet imported; absent on everything else.
239
+ */
240
+ readonly isRoot?: boolean;
241
+ /**
242
+ * An INTERNAL block: a tool's own (an editor panel, the workbench shell, the editor's fallback),
243
+ * placed by the platform's pages and never offered to an author. Stamped by the build from the
244
+ * `markInternalTile` import the facet made; absent on everything else.
245
+ */
246
+ readonly internal?: boolean;
247
+ }
248
+
249
+ export interface ICatalogEntry extends IModuleDescriptor {
250
+ /** a paired `editor`-facet module (same namespace+name+version) exists → the editor has a settings form */
251
+ readonly hasEditor: boolean;
252
+ /** the block's icon (`<name>.icon.svg` beside its module), where it can be fetched; absent — none */
253
+ readonly iconUrl?: string;
254
+ }
255
+
256
+ /**
257
+ * A PREFAB a library brings (the catalog names it beside its distribution's blocks): placed on a
258
+ * page, it is LIVE — `reference` names no version, the page always takes its current form.
259
+ */
260
+ export interface ILibraryPrefabEntry {
261
+ readonly namespace: string;
262
+ readonly name: string;
263
+ /** the distribution version that brought it */
264
+ readonly version: string;
265
+ /** what a page names to place it: `_ns_<namespace>_t_prefab_n_<name>_v_live` */
266
+ readonly reference: string;
267
+ }
268
+
269
+ /**
270
+ * A DISTRIBUTION as its catalog entity describes it (`mosetta.json` of its package): what it is
271
+ * called, what it brings, its pictures, where its source lives. `internal` — a tool's own (the
272
+ * editor): no blocks for authors.
273
+ */
274
+ export interface IDistributionEntry {
275
+ readonly id: string;
276
+ readonly namespace: string;
277
+ readonly version: string;
278
+ readonly title: string;
279
+ readonly description?: string;
280
+ readonly source?: string;
281
+ readonly iconUrl?: string;
282
+ readonly coverUrl?: string;
283
+ readonly internal?: boolean;
284
+ /** the distribution others are built on (the UI kit): always connected */
285
+ readonly base?: boolean;
286
+ /** what this version says it changed — its section of the distribution's CHANGELOG.md */
287
+ readonly changes?: string;
288
+ }
289
+
290
+ // Validation primitives shared by the zod factories and any plain-JS consumer (the node provider).
291
+ // Mirrors the canonical encoding in cli/runtime/naming.js — validation-only, not a parser.
292
+ export const CANONICAL_REFERENCE_RE = /^_ns_[A-Za-z0-9._-]+_t_[a-z][A-Za-z0-9]*_n_[A-Za-z0-9._-]+_v_[A-Za-z0-9_]+$/;
293
+
294
+ // Entity names follow the module/prefab naming convention: lowerCamel, alphanumeric.
295
+ export const ENTITY_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
296
+
297
+ export function maxRevision(meta: IContentMeta): number | null {
298
+ const revs = Object.keys(meta.versions).map(Number).filter((n) => Number.isInteger(n) && n > 0);
299
+ return revs.length ? Math.max(...revs) : null;
300
+ }
package/src/names.ts ADDED
@@ -0,0 +1,50 @@
1
+ // A NAME for a draft: two friendly words, drawn from the draft's id — `brave-otter`. A draft
2
+ // used to be known by the first eight characters of its id, which nobody can tell apart or
3
+ // remember; a name of words is what services give a thing the author did not name yet. Drawn
4
+ // from the id so it never has to be stored to be stable (it is stored anyway, as the draft's
5
+ // title, where the author may change it later).
6
+
7
+ const ADJECTIVES = [
8
+ 'amber', 'bold', 'brave', 'breezy', 'bright', 'calm', 'cheery', 'clever', 'cosmic', 'crisp',
9
+ 'dapper', 'daring', 'dusty', 'eager', 'early', 'fancy', 'fresh', 'frosty', 'gentle', 'glad',
10
+ 'golden', 'happy', 'hearty', 'honest', 'humble', 'jolly', 'keen', 'kind', 'leafy', 'lively',
11
+ 'lucky', 'lunar', 'merry', 'mighty', 'misty', 'mossy', 'nimble', 'noble', 'plucky', 'polite',
12
+ 'proud', 'quick', 'quiet', 'rapid', 'rosy', 'royal', 'rusty', 'sandy', 'shiny', 'silent',
13
+ 'sleek', 'smart', 'snowy', 'solid', 'steady', 'stormy', 'sunny', 'swift', 'tender', 'tidy',
14
+ 'velvet', 'vivid', 'warm', 'wise', 'witty', 'young', 'zesty',
15
+ ];
16
+
17
+ const NOUNS = [
18
+ 'acorn', 'anchor', 'badger', 'beacon', 'bramble', 'canyon', 'cedar', 'clover', 'comet', 'delta',
19
+ 'dune', 'eagle', 'ember', 'falcon', 'fern', 'finch', 'fjord', 'garnet', 'glacier', 'grove',
20
+ 'harbor', 'hazel', 'heron', 'hollow', 'iris', 'island', 'ivory', 'jasper', 'juniper', 'kelp',
21
+ 'kestrel', 'lagoon', 'lantern', 'lotus', 'mango', 'maple', 'marble', 'meadow', 'nebula', 'nutmeg',
22
+ 'oak', 'orchid', 'otter', 'pebble', 'pine', 'prairie', 'quartz', 'quill', 'raven', 'reef',
23
+ 'river', 'saffron', 'sage', 'thistle', 'tulip', 'tundra', 'umber', 'valley', 'violet', 'walnut',
24
+ 'willow', 'wren', 'yarrow', 'zephyr',
25
+ ];
26
+
27
+ /** the longest name a publication may be given */
28
+ export const MAX_REVISION_TITLE = 80;
29
+
30
+ function digest(text: string): number {
31
+ let hash = 0x811c9dc5;
32
+ for (let i = 0; i < text.length; i += 1) {
33
+ hash ^= text.charCodeAt(i);
34
+ hash = Math.imul(hash, 0x01000193);
35
+ }
36
+ return hash >>> 0;
37
+ }
38
+
39
+ /** the two words a seed (a draft's id) names: the same seed, the same words */
40
+ export function draftTitleOf(seed: string): string {
41
+ const hash = digest(seed);
42
+ return `${ADJECTIVES[hash % ADJECTIVES.length]}-${NOUNS[(hash >>> 8) % NOUNS.length]}`;
43
+ }
44
+
45
+ /** a publication's name as stored: trimmed, bounded; empty — none */
46
+ export function revisionTitleOf(title: unknown): string | null {
47
+ if (typeof title !== 'string') return null;
48
+ const trimmed = title.trim().slice(0, MAX_REVISION_TITLE);
49
+ return trimmed || null;
50
+ }