@vectoriox/iox-ui 4.33.1 → 4.34.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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.
3
+ *
4
+ * ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored
5
+ * `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been
6
+ * emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See
7
+ * iox-ai-guidance `architecture/cms/seo-plan.md`.
8
+ */
9
+ /** Recommended maximum before search results truncate the title. */
10
+ const SEO_TITLE_MAX = 60;
11
+ /** Recommended maximum before search results truncate the description. */
12
+ const SEO_DESCRIPTION_MAX = 160;
13
+ const SEO_ROBOTS_OPTIONS = [
14
+ { label: 'Index, Follow', value: 'index, follow' },
15
+ { label: 'Index, No Follow', value: 'index, nofollow' },
16
+ { label: 'No Index, Follow', value: 'noindex, follow' },
17
+ { label: 'No Index, No Follow', value: 'noindex, nofollow' },
18
+ ];
19
+ const SEO_TWITTER_CARD_OPTIONS = [
20
+ { label: 'Summary', value: 'summary' },
21
+ { label: 'Summary Large Image', value: 'summary_large_image' },
22
+ { label: 'App', value: 'app' },
23
+ { label: 'Player', value: 'player' },
24
+ ];
25
+ const SEO_CHANGE_FREQ_OPTIONS = [
26
+ { label: 'Always', value: 'always' },
27
+ { label: 'Hourly', value: 'hourly' },
28
+ { label: 'Daily', value: 'daily' },
29
+ { label: 'Weekly', value: 'weekly' },
30
+ { label: 'Monthly', value: 'monthly' },
31
+ { label: 'Yearly', value: 'yearly' },
32
+ { label: 'Never', value: 'never' },
33
+ ];
34
+
35
+ /**
36
+ * Coerce whatever a page document happens to hold into a `SeoDoc`.
37
+ *
38
+ * Every consumer calls this before reading `seo`, because four shapes are in the wild:
39
+ * • the current nested shape (`og.title`);
40
+ * • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a
41
+ * document hand-edited against the old engine code could carry it;
42
+ * • `""` for "not set", which must not become an empty `<meta>`;
43
+ * • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here
44
+ * so the ladder can land without a second normalizer.
45
+ *
46
+ * Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.
47
+ */
48
+ function normalizeSeoDoc(raw) {
49
+ const src = asRecord(raw);
50
+ if (!src)
51
+ return {};
52
+ const og = compact({
53
+ title: str(pick(src, 'og', 'title') ?? src['ogTitle']),
54
+ description: str(pick(src, 'og', 'description') ?? src['ogDescription']),
55
+ image: str(pick(src, 'og', 'image') ?? src['ogImage']),
56
+ });
57
+ const twitter = compact({
58
+ card: str(pick(src, 'twitter', 'card')),
59
+ title: str(pick(src, 'twitter', 'title')),
60
+ description: str(pick(src, 'twitter', 'description')),
61
+ image: str(pick(src, 'twitter', 'image')),
62
+ });
63
+ const sitemap = compact({
64
+ include: bool(pick(src, 'sitemap', 'include')),
65
+ priority: num(pick(src, 'sitemap', 'priority')),
66
+ changeFreq: str(pick(src, 'sitemap', 'changeFreq')),
67
+ });
68
+ const structuredData = structured(src['structuredData']);
69
+ const doc = compact({
70
+ title: str(src['title']),
71
+ description: str(src['description']),
72
+ keywords: str(src['keywords']),
73
+ canonicalUrl: str(src['canonicalUrl']),
74
+ robots: str(src['robots']),
75
+ og,
76
+ twitter,
77
+ sitemap,
78
+ structuredData,
79
+ });
80
+ return doc ?? {};
81
+ }
82
+ /** Read `src[branch][leaf]` without assuming the branch exists or is an object. */
83
+ function pick(src, branch, leaf) {
84
+ const nested = asRecord(src[branch]);
85
+ return nested ? nested[leaf] : undefined;
86
+ }
87
+ /**
88
+ * A field's value as a trimmed string, or `undefined`.
89
+ *
90
+ * Unwraps the phase-2 value object: a bound or auto field has no `override`, so it correctly
91
+ * resolves to `undefined` here and the binding layer fills it in later.
92
+ */
93
+ function str(value) {
94
+ const wrapper = asRecord(value);
95
+ const raw = wrapper ? wrapper['override'] : value;
96
+ if (typeof raw === 'number')
97
+ return String(raw);
98
+ if (typeof raw !== 'string')
99
+ return undefined;
100
+ const trimmed = raw.trim();
101
+ return trimmed.length ? trimmed : undefined;
102
+ }
103
+ /**
104
+ * `UIFormToDTO` can hand back `"true"` / `"false"` for a switch (see
105
+ * `architecture/cms/shop.md` — the boolean/number coercion pitfall), so accept both.
106
+ */
107
+ function bool(value) {
108
+ if (typeof value === 'boolean')
109
+ return value;
110
+ if (value === 'true')
111
+ return true;
112
+ if (value === 'false')
113
+ return false;
114
+ return undefined;
115
+ }
116
+ function num(value) {
117
+ if (typeof value === 'number')
118
+ return Number.isFinite(value) ? value : undefined;
119
+ if (typeof value !== 'string' || !value.trim().length)
120
+ return undefined;
121
+ const parsed = Number(value);
122
+ return Number.isFinite(parsed) ? parsed : undefined;
123
+ }
124
+ /** JSON-LD is stored as an object or as a JSON string from the `json` controller. */
125
+ function structured(value) {
126
+ if (typeof value === 'string') {
127
+ const trimmed = value.trim();
128
+ if (!trimmed.length)
129
+ return undefined;
130
+ try {
131
+ return JSON.parse(trimmed);
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ if (Array.isArray(value))
138
+ return value.length ? value : undefined;
139
+ const record = asRecord(value);
140
+ return record && Object.keys(record).length ? record : undefined;
141
+ }
142
+ function asRecord(value) {
143
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
144
+ ? value
145
+ : undefined;
146
+ }
147
+ /** Drop `undefined` values and empty nested objects, so absence is always `undefined`. */
148
+ function compact(value) {
149
+ const out = {};
150
+ for (const [key, entry] of Object.entries(value)) {
151
+ if (entry === undefined)
152
+ continue;
153
+ out[key] = entry;
154
+ }
155
+ return (Object.keys(out).length ? out : undefined);
156
+ }
157
+
158
+ /**
159
+ * Turn a page's SEO document into the flat list of head tags to emit.
160
+ *
161
+ * Both renderers call this, so a page cannot describe itself differently to a crawler than the
162
+ * builder previews. The order is fixed and part of the contract: title, description, keywords,
163
+ * canonical, Open Graph, Twitter, robots.
164
+ *
165
+ * Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the
166
+ * Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`
167
+ * itself, and duplicating a value we did not author only makes the drift harder to see. The
168
+ * "derive from the page content" half of the ladder (first h1, first paragraph, first image) is
169
+ * `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.
170
+ */
171
+ function seoToTags(seo, ctx = {}) {
172
+ const doc = normalizeSeoDoc(seo);
173
+ const tags = [];
174
+ const title = doc.title ?? trimmed(ctx.fallbackTitle);
175
+ if (title)
176
+ tags.push({ kind: 'title', content: title });
177
+ if (doc.description)
178
+ tags.push({ kind: 'meta', attr: 'name', key: 'description', content: doc.description });
179
+ if (doc.keywords)
180
+ tags.push({ kind: 'meta', attr: 'name', key: 'keywords', content: doc.keywords });
181
+ if (doc.canonicalUrl)
182
+ tags.push({ kind: 'link', rel: 'canonical', href: doc.canonicalUrl });
183
+ // Open Graph. `og:url` reuses the canonical URL, which is what it means.
184
+ const ogTitle = doc.og?.title ?? title;
185
+ const ogDescription = doc.og?.description ?? doc.description;
186
+ if (ogTitle)
187
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:title', content: ogTitle });
188
+ if (ogDescription)
189
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:description', content: ogDescription });
190
+ if (doc.og?.image)
191
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:image', content: doc.og.image });
192
+ if (doc.canonicalUrl)
193
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:url', content: doc.canonicalUrl });
194
+ // Twitter. Emitted ONLY when a card type is chosen: without `twitter:card` the other tags are
195
+ // ignored anyway, so writing them would be noise that looks like configuration.
196
+ if (doc.twitter?.card) {
197
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:card', content: doc.twitter.card });
198
+ const twitterTitle = doc.twitter.title ?? ogTitle;
199
+ const twitterDescription = doc.twitter.description ?? ogDescription;
200
+ const twitterImage = doc.twitter.image ?? doc.og?.image;
201
+ if (twitterTitle)
202
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:title', content: twitterTitle });
203
+ if (twitterDescription) {
204
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:description', content: twitterDescription });
205
+ }
206
+ if (twitterImage)
207
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:image', content: twitterImage });
208
+ }
209
+ if (doc.robots)
210
+ tags.push({ kind: 'meta', attr: 'name', key: 'robots', content: doc.robots });
211
+ return tags;
212
+ }
213
+ function trimmed(value) {
214
+ const out = value?.trim();
215
+ return out?.length ? out : undefined;
216
+ }
217
+
218
+ /**
219
+ * The authoring schema for the `seo` object, in the platform's standard property dialect: `label`
220
+ * at property level, rendering metadata under `controller`.
221
+ *
222
+ * ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and
223
+ * `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had
224
+ * already drifted — different controller types for the same field, and the client's copy missing
225
+ * `structuredData` entirely.
226
+ *
227
+ * `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is
228
+ * no reason to invite new ones.
229
+ */
230
+ function buildSeoSchemaProperties() {
231
+ return {
232
+ title: {
233
+ type: 'string',
234
+ label: 'Meta Title',
235
+ controller: {
236
+ type: 'text',
237
+ placeholder: 'Page title for search engines',
238
+ maxLength: SEO_TITLE_MAX,
239
+ },
240
+ },
241
+ description: {
242
+ type: 'string',
243
+ label: 'Meta Description',
244
+ controller: {
245
+ type: 'textarea',
246
+ placeholder: 'Brief description for search results',
247
+ maxLength: SEO_DESCRIPTION_MAX,
248
+ },
249
+ },
250
+ canonicalUrl: {
251
+ type: 'string',
252
+ label: 'Canonical URL',
253
+ controller: {
254
+ type: 'text',
255
+ placeholder: 'https://example.com/page',
256
+ },
257
+ },
258
+ robots: {
259
+ type: 'string',
260
+ label: 'Robots',
261
+ controller: {
262
+ type: 'select',
263
+ options: SEO_ROBOTS_OPTIONS.map(option => ({ ...option })),
264
+ },
265
+ },
266
+ og: {
267
+ type: 'object',
268
+ label: 'Open Graph',
269
+ controller: { type: 'object' },
270
+ properties: {
271
+ title: {
272
+ type: 'string',
273
+ label: 'OG Title',
274
+ controller: { type: 'text', placeholder: 'Title for social sharing' },
275
+ },
276
+ description: {
277
+ type: 'string',
278
+ label: 'OG Description',
279
+ controller: { type: 'textarea', placeholder: 'Description for social sharing' },
280
+ },
281
+ image: {
282
+ type: 'string',
283
+ label: 'OG Image',
284
+ controller: { type: 'image' },
285
+ },
286
+ },
287
+ },
288
+ twitter: {
289
+ type: 'object',
290
+ label: 'Twitter Card',
291
+ controller: { type: 'object' },
292
+ properties: {
293
+ card: {
294
+ type: 'string',
295
+ label: 'Card Type',
296
+ controller: {
297
+ type: 'select',
298
+ options: SEO_TWITTER_CARD_OPTIONS.map(option => ({ ...option })),
299
+ },
300
+ },
301
+ title: {
302
+ type: 'string',
303
+ label: 'Twitter Title',
304
+ controller: { type: 'text' },
305
+ },
306
+ description: {
307
+ type: 'string',
308
+ label: 'Twitter Description',
309
+ controller: { type: 'textarea' },
310
+ },
311
+ image: {
312
+ type: 'string',
313
+ label: 'Twitter Image',
314
+ controller: { type: 'image' },
315
+ },
316
+ },
317
+ },
318
+ structuredData: {
319
+ type: 'object',
320
+ label: 'Structured Data (JSON-LD)',
321
+ controller: { type: 'json' },
322
+ },
323
+ sitemap: {
324
+ type: 'object',
325
+ label: 'Sitemap Settings',
326
+ controller: { type: 'object' },
327
+ properties: {
328
+ include: {
329
+ type: 'boolean',
330
+ label: 'Include in Sitemap',
331
+ controller: { type: 'switch' },
332
+ },
333
+ priority: {
334
+ type: 'number',
335
+ label: 'Priority',
336
+ controller: { type: 'range', options: { min: 0, max: 1, step: 0.1 } },
337
+ },
338
+ changeFreq: {
339
+ type: 'string',
340
+ label: 'Change Frequency',
341
+ controller: {
342
+ type: 'select',
343
+ options: SEO_CHANGE_FREQ_OPTIONS.map(option => ({ ...option })),
344
+ },
345
+ },
346
+ },
347
+ },
348
+ };
349
+ }
350
+
351
+ /*
352
+ * @vectoriox/iox-ui/seo-core — the shared, FRAMEWORK-FREE core of page SEO.
353
+ *
354
+ * ⚠️ Nothing in this entry point may import Angular, RxJS, the DOM, or Node. It is consumed by
355
+ * THREE runtimes: the builder panel (Angular), the SSR engine (Angular), and `iox-cms` (NestJS,
356
+ * plain Node) — the same reason `forms-core` exists.
357
+ *
358
+ * A page describes itself to a crawler exactly once: `seoToTags` is the only place that decides
359
+ * which head tags a page emits, and `buildSeoSchemaProperties` the only place that declares what an
360
+ * author can fill in. See iox-ai-guidance `architecture/cms/seo-plan.md`.
361
+ */
362
+
363
+ /**
364
+ * Generated bundle index. Do not edit.
365
+ */
366
+
367
+ export { SEO_CHANGE_FREQ_OPTIONS, SEO_DESCRIPTION_MAX, SEO_ROBOTS_OPTIONS, SEO_TITLE_MAX, SEO_TWITTER_CARD_OPTIONS, buildSeoSchemaProperties, normalizeSeoDoc, seoToTags };
368
+ //# sourceMappingURL=vectoriox-iox-ui-seo-core.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vectoriox-iox-ui-seo-core.mjs","sources":["../../../projects/iox-ui/src/seo-core/seo.model.ts","../../../projects/iox-ui/src/seo-core/normalize.ts","../../../projects/iox-ui/src/seo-core/tags.ts","../../../projects/iox-ui/src/seo-core/schema.ts","../../../projects/iox-ui/src/seo-core/public-api.ts","../../../projects/iox-ui/src/seo-core/vectoriox-iox-ui-seo-core.ts"],"sourcesContent":["/**\n * The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.\n *\n * ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored\n * `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been\n * emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See\n * iox-ai-guidance `architecture/cms/seo-plan.md`.\n */\n\n/** Open Graph subset we author and emit. */\nexport interface SeoOg {\n title?: string;\n description?: string;\n image?: string;\n}\n\n/** Twitter-card subset. Emitted only when `card` is set — see `seoToTags`. */\nexport interface SeoTwitter {\n card?: string;\n title?: string;\n description?: string;\n image?: string;\n}\n\n/** Sitemap participation for this page. Consumed by the sitemap route (plan phase 3). */\nexport interface SeoSitemap {\n include?: boolean;\n priority?: number;\n changeFreq?: string;\n}\n\n/**\n * A page's stored SEO metadata, normalized.\n *\n * `keywords` has no authoring field — search engines have ignored the tag for over a decade — but\n * it is read and emitted when a document already carries one, because the engine used to read it.\n */\nexport interface SeoDoc {\n title?: string;\n description?: string;\n keywords?: string;\n canonicalUrl?: string;\n robots?: string;\n og?: SeoOg;\n twitter?: SeoTwitter;\n structuredData?: unknown;\n sitemap?: SeoSitemap;\n}\n\n/** `<title>`. */\nexport interface SeoTitleTag {\n kind: 'title';\n content: string;\n}\n\n/**\n * A `<meta>` tag. `attr` says which attribute identifies it — Open Graph is addressed by\n * `property`, everything else by `name`. Both renderers need that distinction to update rather\n * than duplicate a tag.\n */\nexport interface SeoMetaTag {\n kind: 'meta';\n attr: 'name' | 'property';\n key: string;\n content: string;\n}\n\n/** A `<link>`, currently only `rel=\"canonical\"`. */\nexport interface SeoLinkTag {\n kind: 'link';\n rel: string;\n href: string;\n}\n\nexport type SeoTag = SeoTitleTag | SeoMetaTag | SeoLinkTag;\n\n/** Context that lets `seoToTags` fill gaps without reaching for the DOM or a data source. */\nexport interface SeoTagContext {\n /** Used as `<title>` when the document has none — usually the page name. */\n fallbackTitle?: string;\n}\n\n/** Recommended maximum before search results truncate the title. */\nexport const SEO_TITLE_MAX = 60;\n\n/** Recommended maximum before search results truncate the description. */\nexport const SEO_DESCRIPTION_MAX = 160;\n\nexport const SEO_ROBOTS_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [\n { label: 'Index, Follow', value: 'index, follow' },\n { label: 'Index, No Follow', value: 'index, nofollow' },\n { label: 'No Index, Follow', value: 'noindex, follow' },\n { label: 'No Index, No Follow', value: 'noindex, nofollow' },\n];\n\nexport const SEO_TWITTER_CARD_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [\n { label: 'Summary', value: 'summary' },\n { label: 'Summary Large Image', value: 'summary_large_image' },\n { label: 'App', value: 'app' },\n { label: 'Player', value: 'player' },\n];\n\nexport const SEO_CHANGE_FREQ_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [\n { label: 'Always', value: 'always' },\n { label: 'Hourly', value: 'hourly' },\n { label: 'Daily', value: 'daily' },\n { label: 'Weekly', value: 'weekly' },\n { label: 'Monthly', value: 'monthly' },\n { label: 'Yearly', value: 'yearly' },\n { label: 'Never', value: 'never' },\n];\n","import { SeoDoc, SeoOg, SeoSitemap, SeoTwitter } from './seo.model';\n\n/**\n * Coerce whatever a page document happens to hold into a `SeoDoc`.\n *\n * Every consumer calls this before reading `seo`, because four shapes are in the wild:\n * • the current nested shape (`og.title`);\n * • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a\n * document hand-edited against the old engine code could carry it;\n * • `\"\"` for \"not set\", which must not become an empty `<meta>`;\n * • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here\n * so the ladder can land without a second normalizer.\n *\n * Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.\n */\nexport function normalizeSeoDoc(raw: unknown): SeoDoc {\n const src = asRecord(raw);\n if (!src) return {};\n\n const og = compact<SeoOg>({\n title: str(pick(src, 'og', 'title') ?? src['ogTitle']),\n description: str(pick(src, 'og', 'description') ?? src['ogDescription']),\n image: str(pick(src, 'og', 'image') ?? src['ogImage']),\n });\n\n const twitter = compact<SeoTwitter>({\n card: str(pick(src, 'twitter', 'card')),\n title: str(pick(src, 'twitter', 'title')),\n description: str(pick(src, 'twitter', 'description')),\n image: str(pick(src, 'twitter', 'image')),\n });\n\n const sitemap = compact<SeoSitemap>({\n include: bool(pick(src, 'sitemap', 'include')),\n priority: num(pick(src, 'sitemap', 'priority')),\n changeFreq: str(pick(src, 'sitemap', 'changeFreq')),\n });\n\n const structuredData = structured(src['structuredData']);\n\n const doc = compact<SeoDoc>({\n title: str(src['title']),\n description: str(src['description']),\n keywords: str(src['keywords']),\n canonicalUrl: str(src['canonicalUrl']),\n robots: str(src['robots']),\n og,\n twitter,\n sitemap,\n structuredData,\n });\n\n return doc ?? {};\n}\n\n/** Read `src[branch][leaf]` without assuming the branch exists or is an object. */\nfunction pick(src: Record<string, unknown>, branch: string, leaf: string): unknown {\n const nested = asRecord(src[branch]);\n return nested ? nested[leaf] : undefined;\n}\n\n/**\n * A field's value as a trimmed string, or `undefined`.\n *\n * Unwraps the phase-2 value object: a bound or auto field has no `override`, so it correctly\n * resolves to `undefined` here and the binding layer fills it in later.\n */\nfunction str(value: unknown): string | undefined {\n const wrapper = asRecord(value);\n const raw = wrapper ? wrapper['override'] : value;\n if (typeof raw === 'number') return String(raw);\n if (typeof raw !== 'string') return undefined;\n const trimmed = raw.trim();\n return trimmed.length ? trimmed : undefined;\n}\n\n/**\n * `UIFormToDTO` can hand back `\"true\"` / `\"false\"` for a switch (see\n * `architecture/cms/shop.md` — the boolean/number coercion pitfall), so accept both.\n */\nfunction bool(value: unknown): boolean | undefined {\n if (typeof value === 'boolean') return value;\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n}\n\nfunction num(value: unknown): number | undefined {\n if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;\n if (typeof value !== 'string' || !value.trim().length) return undefined;\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : undefined;\n}\n\n/** JSON-LD is stored as an object or as a JSON string from the `json` controller. */\nfunction structured(value: unknown): unknown {\n if (typeof value === 'string') {\n const trimmed = value.trim();\n if (!trimmed.length) return undefined;\n try {\n return JSON.parse(trimmed);\n } catch {\n return undefined;\n }\n }\n if (Array.isArray(value)) return value.length ? value : undefined;\n const record = asRecord(value);\n return record && Object.keys(record).length ? record : undefined;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\n/** Drop `undefined` values and empty nested objects, so absence is always `undefined`. */\nfunction compact<T extends object>(value: Record<string, unknown>): T | undefined {\n const out: Record<string, unknown> = {};\n for (const [key, entry] of Object.entries(value)) {\n if (entry === undefined) continue;\n out[key] = entry;\n }\n return (Object.keys(out).length ? out : undefined) as T | undefined;\n}\n","import { SeoDoc, SeoTag, SeoTagContext } from './seo.model';\nimport { normalizeSeoDoc } from './normalize';\n\n/**\n * Turn a page's SEO document into the flat list of head tags to emit.\n *\n * Both renderers call this, so a page cannot describe itself differently to a crawler than the\n * builder previews. The order is fixed and part of the contract: title, description, keywords,\n * canonical, Open Graph, Twitter, robots.\n *\n * Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the\n * Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`\n * itself, and duplicating a value we did not author only makes the drift harder to see. The\n * \"derive from the page content\" half of the ladder (first h1, first paragraph, first image) is\n * `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.\n */\nexport function seoToTags(seo: SeoDoc | unknown, ctx: SeoTagContext = {}): SeoTag[] {\n const doc = normalizeSeoDoc(seo);\n const tags: SeoTag[] = [];\n\n const title = doc.title ?? trimmed(ctx.fallbackTitle);\n if (title) tags.push({ kind: 'title', content: title });\n if (doc.description) tags.push({ kind: 'meta', attr: 'name', key: 'description', content: doc.description });\n if (doc.keywords) tags.push({ kind: 'meta', attr: 'name', key: 'keywords', content: doc.keywords });\n if (doc.canonicalUrl) tags.push({ kind: 'link', rel: 'canonical', href: doc.canonicalUrl });\n\n // Open Graph. `og:url` reuses the canonical URL, which is what it means.\n const ogTitle = doc.og?.title ?? title;\n const ogDescription = doc.og?.description ?? doc.description;\n if (ogTitle) tags.push({ kind: 'meta', attr: 'property', key: 'og:title', content: ogTitle });\n if (ogDescription) tags.push({ kind: 'meta', attr: 'property', key: 'og:description', content: ogDescription });\n if (doc.og?.image) tags.push({ kind: 'meta', attr: 'property', key: 'og:image', content: doc.og.image });\n if (doc.canonicalUrl) tags.push({ kind: 'meta', attr: 'property', key: 'og:url', content: doc.canonicalUrl });\n\n // Twitter. Emitted ONLY when a card type is chosen: without `twitter:card` the other tags are\n // ignored anyway, so writing them would be noise that looks like configuration.\n if (doc.twitter?.card) {\n tags.push({ kind: 'meta', attr: 'name', key: 'twitter:card', content: doc.twitter.card });\n const twitterTitle = doc.twitter.title ?? ogTitle;\n const twitterDescription = doc.twitter.description ?? ogDescription;\n const twitterImage = doc.twitter.image ?? doc.og?.image;\n if (twitterTitle) tags.push({ kind: 'meta', attr: 'name', key: 'twitter:title', content: twitterTitle });\n if (twitterDescription) {\n tags.push({ kind: 'meta', attr: 'name', key: 'twitter:description', content: twitterDescription });\n }\n if (twitterImage) tags.push({ kind: 'meta', attr: 'name', key: 'twitter:image', content: twitterImage });\n }\n\n if (doc.robots) tags.push({ kind: 'meta', attr: 'name', key: 'robots', content: doc.robots });\n\n return tags;\n}\n\nfunction trimmed(value: string | undefined): string | undefined {\n const out = value?.trim();\n return out?.length ? out : undefined;\n}\n","import {\n SEO_CHANGE_FREQ_OPTIONS,\n SEO_DESCRIPTION_MAX,\n SEO_ROBOTS_OPTIONS,\n SEO_TITLE_MAX,\n SEO_TWITTER_CARD_OPTIONS,\n} from './seo.model';\n\n/**\n * The authoring schema for the `seo` object, in the platform's standard property dialect: `label`\n * at property level, rendering metadata under `controller`.\n *\n * ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and\n * `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had\n * already drifted — different controller types for the same field, and the client's copy missing\n * `structuredData` entirely.\n *\n * `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is\n * no reason to invite new ones.\n */\nexport function buildSeoSchemaProperties(): Record<string, unknown> {\n return {\n title: {\n type: 'string',\n label: 'Meta Title',\n controller: {\n type: 'text',\n placeholder: 'Page title for search engines',\n maxLength: SEO_TITLE_MAX,\n },\n },\n description: {\n type: 'string',\n label: 'Meta Description',\n controller: {\n type: 'textarea',\n placeholder: 'Brief description for search results',\n maxLength: SEO_DESCRIPTION_MAX,\n },\n },\n canonicalUrl: {\n type: 'string',\n label: 'Canonical URL',\n controller: {\n type: 'text',\n placeholder: 'https://example.com/page',\n },\n },\n robots: {\n type: 'string',\n label: 'Robots',\n controller: {\n type: 'select',\n options: SEO_ROBOTS_OPTIONS.map(option => ({ ...option })),\n },\n },\n og: {\n type: 'object',\n label: 'Open Graph',\n controller: { type: 'object' },\n properties: {\n title: {\n type: 'string',\n label: 'OG Title',\n controller: { type: 'text', placeholder: 'Title for social sharing' },\n },\n description: {\n type: 'string',\n label: 'OG Description',\n controller: { type: 'textarea', placeholder: 'Description for social sharing' },\n },\n image: {\n type: 'string',\n label: 'OG Image',\n controller: { type: 'image' },\n },\n },\n },\n twitter: {\n type: 'object',\n label: 'Twitter Card',\n controller: { type: 'object' },\n properties: {\n card: {\n type: 'string',\n label: 'Card Type',\n controller: {\n type: 'select',\n options: SEO_TWITTER_CARD_OPTIONS.map(option => ({ ...option })),\n },\n },\n title: {\n type: 'string',\n label: 'Twitter Title',\n controller: { type: 'text' },\n },\n description: {\n type: 'string',\n label: 'Twitter Description',\n controller: { type: 'textarea' },\n },\n image: {\n type: 'string',\n label: 'Twitter Image',\n controller: { type: 'image' },\n },\n },\n },\n structuredData: {\n type: 'object',\n label: 'Structured Data (JSON-LD)',\n controller: { type: 'json' },\n },\n sitemap: {\n type: 'object',\n label: 'Sitemap Settings',\n controller: { type: 'object' },\n properties: {\n include: {\n type: 'boolean',\n label: 'Include in Sitemap',\n controller: { type: 'switch' },\n },\n priority: {\n type: 'number',\n label: 'Priority',\n controller: { type: 'range', options: { min: 0, max: 1, step: 0.1 } },\n },\n changeFreq: {\n type: 'string',\n label: 'Change Frequency',\n controller: {\n type: 'select',\n options: SEO_CHANGE_FREQ_OPTIONS.map(option => ({ ...option })),\n },\n },\n },\n },\n };\n}\n","/*\n * @vectoriox/iox-ui/seo-core — the shared, FRAMEWORK-FREE core of page SEO.\n *\n * ⚠️ Nothing in this entry point may import Angular, RxJS, the DOM, or Node. It is consumed by\n * THREE runtimes: the builder panel (Angular), the SSR engine (Angular), and `iox-cms` (NestJS,\n * plain Node) — the same reason `forms-core` exists.\n *\n * A page describes itself to a crawler exactly once: `seoToTags` is the only place that decides\n * which head tags a page emits, and `buildSeoSchemaProperties` the only place that declares what an\n * author can fill in. See iox-ai-guidance `architecture/cms/seo-plan.md`.\n */\n\nexport * from './seo.model';\nexport * from './normalize';\nexport * from './tags';\nexport * from './schema';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "4.33.1",
3
+ "version": "4.34.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -22,6 +22,10 @@
22
22
  "./forms-core": {
23
23
  "types": "./forms-core/index.d.ts",
24
24
  "default": "./fesm2022/vectoriox-iox-ui-forms-core.mjs"
25
+ },
26
+ "./seo-core": {
27
+ "types": "./seo-core/index.d.ts",
28
+ "default": "./fesm2022/vectoriox-iox-ui-seo-core.mjs"
25
29
  }
26
30
  },
27
31
  "sideEffects": false
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.
3
+ *
4
+ * ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored
5
+ * `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been
6
+ * emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See
7
+ * iox-ai-guidance `architecture/cms/seo-plan.md`.
8
+ */
9
+ /** Open Graph subset we author and emit. */
10
+ interface SeoOg {
11
+ title?: string;
12
+ description?: string;
13
+ image?: string;
14
+ }
15
+ /** Twitter-card subset. Emitted only when `card` is set — see `seoToTags`. */
16
+ interface SeoTwitter {
17
+ card?: string;
18
+ title?: string;
19
+ description?: string;
20
+ image?: string;
21
+ }
22
+ /** Sitemap participation for this page. Consumed by the sitemap route (plan phase 3). */
23
+ interface SeoSitemap {
24
+ include?: boolean;
25
+ priority?: number;
26
+ changeFreq?: string;
27
+ }
28
+ /**
29
+ * A page's stored SEO metadata, normalized.
30
+ *
31
+ * `keywords` has no authoring field — search engines have ignored the tag for over a decade — but
32
+ * it is read and emitted when a document already carries one, because the engine used to read it.
33
+ */
34
+ interface SeoDoc {
35
+ title?: string;
36
+ description?: string;
37
+ keywords?: string;
38
+ canonicalUrl?: string;
39
+ robots?: string;
40
+ og?: SeoOg;
41
+ twitter?: SeoTwitter;
42
+ structuredData?: unknown;
43
+ sitemap?: SeoSitemap;
44
+ }
45
+ /** `<title>`. */
46
+ interface SeoTitleTag {
47
+ kind: 'title';
48
+ content: string;
49
+ }
50
+ /**
51
+ * A `<meta>` tag. `attr` says which attribute identifies it — Open Graph is addressed by
52
+ * `property`, everything else by `name`. Both renderers need that distinction to update rather
53
+ * than duplicate a tag.
54
+ */
55
+ interface SeoMetaTag {
56
+ kind: 'meta';
57
+ attr: 'name' | 'property';
58
+ key: string;
59
+ content: string;
60
+ }
61
+ /** A `<link>`, currently only `rel="canonical"`. */
62
+ interface SeoLinkTag {
63
+ kind: 'link';
64
+ rel: string;
65
+ href: string;
66
+ }
67
+ type SeoTag = SeoTitleTag | SeoMetaTag | SeoLinkTag;
68
+ /** Context that lets `seoToTags` fill gaps without reaching for the DOM or a data source. */
69
+ interface SeoTagContext {
70
+ /** Used as `<title>` when the document has none — usually the page name. */
71
+ fallbackTitle?: string;
72
+ }
73
+ /** Recommended maximum before search results truncate the title. */
74
+ declare const SEO_TITLE_MAX = 60;
75
+ /** Recommended maximum before search results truncate the description. */
76
+ declare const SEO_DESCRIPTION_MAX = 160;
77
+ declare const SEO_ROBOTS_OPTIONS: ReadonlyArray<{
78
+ label: string;
79
+ value: string;
80
+ }>;
81
+ declare const SEO_TWITTER_CARD_OPTIONS: ReadonlyArray<{
82
+ label: string;
83
+ value: string;
84
+ }>;
85
+ declare const SEO_CHANGE_FREQ_OPTIONS: ReadonlyArray<{
86
+ label: string;
87
+ value: string;
88
+ }>;
89
+
90
+ /**
91
+ * Coerce whatever a page document happens to hold into a `SeoDoc`.
92
+ *
93
+ * Every consumer calls this before reading `seo`, because four shapes are in the wild:
94
+ * • the current nested shape (`og.title`);
95
+ * • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a
96
+ * document hand-edited against the old engine code could carry it;
97
+ * • `""` for "not set", which must not become an empty `<meta>`;
98
+ * • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here
99
+ * so the ladder can land without a second normalizer.
100
+ *
101
+ * Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.
102
+ */
103
+ declare function normalizeSeoDoc(raw: unknown): SeoDoc;
104
+
105
+ /**
106
+ * Turn a page's SEO document into the flat list of head tags to emit.
107
+ *
108
+ * Both renderers call this, so a page cannot describe itself differently to a crawler than the
109
+ * builder previews. The order is fixed and part of the contract: title, description, keywords,
110
+ * canonical, Open Graph, Twitter, robots.
111
+ *
112
+ * Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the
113
+ * Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`
114
+ * itself, and duplicating a value we did not author only makes the drift harder to see. The
115
+ * "derive from the page content" half of the ladder (first h1, first paragraph, first image) is
116
+ * `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.
117
+ */
118
+ declare function seoToTags(seo: SeoDoc | unknown, ctx?: SeoTagContext): SeoTag[];
119
+
120
+ /**
121
+ * The authoring schema for the `seo` object, in the platform's standard property dialect: `label`
122
+ * at property level, rendering metadata under `controller`.
123
+ *
124
+ * ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and
125
+ * `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had
126
+ * already drifted — different controller types for the same field, and the client's copy missing
127
+ * `structuredData` entirely.
128
+ *
129
+ * `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is
130
+ * no reason to invite new ones.
131
+ */
132
+ declare function buildSeoSchemaProperties(): Record<string, unknown>;
133
+
134
+ export { SEO_CHANGE_FREQ_OPTIONS, SEO_DESCRIPTION_MAX, SEO_ROBOTS_OPTIONS, SEO_TITLE_MAX, SEO_TWITTER_CARD_OPTIONS, buildSeoSchemaProperties, normalizeSeoDoc, seoToTags };
135
+ export type { SeoDoc, SeoLinkTag, SeoMetaTag, SeoOg, SeoSitemap, SeoTag, SeoTagContext, SeoTitleTag, SeoTwitter };
@@ -0,0 +1,3 @@
1
+ {
2
+ "module": "../fesm2022/vectoriox-iox-ui-seo-core.mjs"
3
+ }
@@ -0,0 +1,125 @@
1
+ import { SeoDoc, SeoOg, SeoSitemap, SeoTwitter } from './seo.model';
2
+
3
+ /**
4
+ * Coerce whatever a page document happens to hold into a `SeoDoc`.
5
+ *
6
+ * Every consumer calls this before reading `seo`, because four shapes are in the wild:
7
+ * • the current nested shape (`og.title`);
8
+ * • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a
9
+ * document hand-edited against the old engine code could carry it;
10
+ * • `""` for "not set", which must not become an empty `<meta>`;
11
+ * • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here
12
+ * so the ladder can land without a second normalizer.
13
+ *
14
+ * Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.
15
+ */
16
+ export function normalizeSeoDoc(raw: unknown): SeoDoc {
17
+ const src = asRecord(raw);
18
+ if (!src) return {};
19
+
20
+ const og = compact<SeoOg>({
21
+ title: str(pick(src, 'og', 'title') ?? src['ogTitle']),
22
+ description: str(pick(src, 'og', 'description') ?? src['ogDescription']),
23
+ image: str(pick(src, 'og', 'image') ?? src['ogImage']),
24
+ });
25
+
26
+ const twitter = compact<SeoTwitter>({
27
+ card: str(pick(src, 'twitter', 'card')),
28
+ title: str(pick(src, 'twitter', 'title')),
29
+ description: str(pick(src, 'twitter', 'description')),
30
+ image: str(pick(src, 'twitter', 'image')),
31
+ });
32
+
33
+ const sitemap = compact<SeoSitemap>({
34
+ include: bool(pick(src, 'sitemap', 'include')),
35
+ priority: num(pick(src, 'sitemap', 'priority')),
36
+ changeFreq: str(pick(src, 'sitemap', 'changeFreq')),
37
+ });
38
+
39
+ const structuredData = structured(src['structuredData']);
40
+
41
+ const doc = compact<SeoDoc>({
42
+ title: str(src['title']),
43
+ description: str(src['description']),
44
+ keywords: str(src['keywords']),
45
+ canonicalUrl: str(src['canonicalUrl']),
46
+ robots: str(src['robots']),
47
+ og,
48
+ twitter,
49
+ sitemap,
50
+ structuredData,
51
+ });
52
+
53
+ return doc ?? {};
54
+ }
55
+
56
+ /** Read `src[branch][leaf]` without assuming the branch exists or is an object. */
57
+ function pick(src: Record<string, unknown>, branch: string, leaf: string): unknown {
58
+ const nested = asRecord(src[branch]);
59
+ return nested ? nested[leaf] : undefined;
60
+ }
61
+
62
+ /**
63
+ * A field's value as a trimmed string, or `undefined`.
64
+ *
65
+ * Unwraps the phase-2 value object: a bound or auto field has no `override`, so it correctly
66
+ * resolves to `undefined` here and the binding layer fills it in later.
67
+ */
68
+ function str(value: unknown): string | undefined {
69
+ const wrapper = asRecord(value);
70
+ const raw = wrapper ? wrapper['override'] : value;
71
+ if (typeof raw === 'number') return String(raw);
72
+ if (typeof raw !== 'string') return undefined;
73
+ const trimmed = raw.trim();
74
+ return trimmed.length ? trimmed : undefined;
75
+ }
76
+
77
+ /**
78
+ * `UIFormToDTO` can hand back `"true"` / `"false"` for a switch (see
79
+ * `architecture/cms/shop.md` — the boolean/number coercion pitfall), so accept both.
80
+ */
81
+ function bool(value: unknown): boolean | undefined {
82
+ if (typeof value === 'boolean') return value;
83
+ if (value === 'true') return true;
84
+ if (value === 'false') return false;
85
+ return undefined;
86
+ }
87
+
88
+ function num(value: unknown): number | undefined {
89
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
90
+ if (typeof value !== 'string' || !value.trim().length) return undefined;
91
+ const parsed = Number(value);
92
+ return Number.isFinite(parsed) ? parsed : undefined;
93
+ }
94
+
95
+ /** JSON-LD is stored as an object or as a JSON string from the `json` controller. */
96
+ function structured(value: unknown): unknown {
97
+ if (typeof value === 'string') {
98
+ const trimmed = value.trim();
99
+ if (!trimmed.length) return undefined;
100
+ try {
101
+ return JSON.parse(trimmed);
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ if (Array.isArray(value)) return value.length ? value : undefined;
107
+ const record = asRecord(value);
108
+ return record && Object.keys(record).length ? record : undefined;
109
+ }
110
+
111
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
112
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
113
+ ? (value as Record<string, unknown>)
114
+ : undefined;
115
+ }
116
+
117
+ /** Drop `undefined` values and empty nested objects, so absence is always `undefined`. */
118
+ function compact<T extends object>(value: Record<string, unknown>): T | undefined {
119
+ const out: Record<string, unknown> = {};
120
+ for (const [key, entry] of Object.entries(value)) {
121
+ if (entry === undefined) continue;
122
+ out[key] = entry;
123
+ }
124
+ return (Object.keys(out).length ? out : undefined) as T | undefined;
125
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ * @vectoriox/iox-ui/seo-core — the shared, FRAMEWORK-FREE core of page SEO.
3
+ *
4
+ * ⚠️ Nothing in this entry point may import Angular, RxJS, the DOM, or Node. It is consumed by
5
+ * THREE runtimes: the builder panel (Angular), the SSR engine (Angular), and `iox-cms` (NestJS,
6
+ * plain Node) — the same reason `forms-core` exists.
7
+ *
8
+ * A page describes itself to a crawler exactly once: `seoToTags` is the only place that decides
9
+ * which head tags a page emits, and `buildSeoSchemaProperties` the only place that declares what an
10
+ * author can fill in. See iox-ai-guidance `architecture/cms/seo-plan.md`.
11
+ */
12
+
13
+ export * from './seo.model';
14
+ export * from './normalize';
15
+ export * from './tags';
16
+ export * from './schema';
@@ -0,0 +1,140 @@
1
+ import {
2
+ SEO_CHANGE_FREQ_OPTIONS,
3
+ SEO_DESCRIPTION_MAX,
4
+ SEO_ROBOTS_OPTIONS,
5
+ SEO_TITLE_MAX,
6
+ SEO_TWITTER_CARD_OPTIONS,
7
+ } from './seo.model';
8
+
9
+ /**
10
+ * The authoring schema for the `seo` object, in the platform's standard property dialect: `label`
11
+ * at property level, rendering metadata under `controller`.
12
+ *
13
+ * ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and
14
+ * `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had
15
+ * already drifted — different controller types for the same field, and the client's copy missing
16
+ * `structuredData` entirely.
17
+ *
18
+ * `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is
19
+ * no reason to invite new ones.
20
+ */
21
+ export function buildSeoSchemaProperties(): Record<string, unknown> {
22
+ return {
23
+ title: {
24
+ type: 'string',
25
+ label: 'Meta Title',
26
+ controller: {
27
+ type: 'text',
28
+ placeholder: 'Page title for search engines',
29
+ maxLength: SEO_TITLE_MAX,
30
+ },
31
+ },
32
+ description: {
33
+ type: 'string',
34
+ label: 'Meta Description',
35
+ controller: {
36
+ type: 'textarea',
37
+ placeholder: 'Brief description for search results',
38
+ maxLength: SEO_DESCRIPTION_MAX,
39
+ },
40
+ },
41
+ canonicalUrl: {
42
+ type: 'string',
43
+ label: 'Canonical URL',
44
+ controller: {
45
+ type: 'text',
46
+ placeholder: 'https://example.com/page',
47
+ },
48
+ },
49
+ robots: {
50
+ type: 'string',
51
+ label: 'Robots',
52
+ controller: {
53
+ type: 'select',
54
+ options: SEO_ROBOTS_OPTIONS.map(option => ({ ...option })),
55
+ },
56
+ },
57
+ og: {
58
+ type: 'object',
59
+ label: 'Open Graph',
60
+ controller: { type: 'object' },
61
+ properties: {
62
+ title: {
63
+ type: 'string',
64
+ label: 'OG Title',
65
+ controller: { type: 'text', placeholder: 'Title for social sharing' },
66
+ },
67
+ description: {
68
+ type: 'string',
69
+ label: 'OG Description',
70
+ controller: { type: 'textarea', placeholder: 'Description for social sharing' },
71
+ },
72
+ image: {
73
+ type: 'string',
74
+ label: 'OG Image',
75
+ controller: { type: 'image' },
76
+ },
77
+ },
78
+ },
79
+ twitter: {
80
+ type: 'object',
81
+ label: 'Twitter Card',
82
+ controller: { type: 'object' },
83
+ properties: {
84
+ card: {
85
+ type: 'string',
86
+ label: 'Card Type',
87
+ controller: {
88
+ type: 'select',
89
+ options: SEO_TWITTER_CARD_OPTIONS.map(option => ({ ...option })),
90
+ },
91
+ },
92
+ title: {
93
+ type: 'string',
94
+ label: 'Twitter Title',
95
+ controller: { type: 'text' },
96
+ },
97
+ description: {
98
+ type: 'string',
99
+ label: 'Twitter Description',
100
+ controller: { type: 'textarea' },
101
+ },
102
+ image: {
103
+ type: 'string',
104
+ label: 'Twitter Image',
105
+ controller: { type: 'image' },
106
+ },
107
+ },
108
+ },
109
+ structuredData: {
110
+ type: 'object',
111
+ label: 'Structured Data (JSON-LD)',
112
+ controller: { type: 'json' },
113
+ },
114
+ sitemap: {
115
+ type: 'object',
116
+ label: 'Sitemap Settings',
117
+ controller: { type: 'object' },
118
+ properties: {
119
+ include: {
120
+ type: 'boolean',
121
+ label: 'Include in Sitemap',
122
+ controller: { type: 'switch' },
123
+ },
124
+ priority: {
125
+ type: 'number',
126
+ label: 'Priority',
127
+ controller: { type: 'range', options: { min: 0, max: 1, step: 0.1 } },
128
+ },
129
+ changeFreq: {
130
+ type: 'string',
131
+ label: 'Change Frequency',
132
+ controller: {
133
+ type: 'select',
134
+ options: SEO_CHANGE_FREQ_OPTIONS.map(option => ({ ...option })),
135
+ },
136
+ },
137
+ },
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.
3
+ *
4
+ * ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored
5
+ * `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been
6
+ * emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See
7
+ * iox-ai-guidance `architecture/cms/seo-plan.md`.
8
+ */
9
+
10
+ /** Open Graph subset we author and emit. */
11
+ export interface SeoOg {
12
+ title?: string;
13
+ description?: string;
14
+ image?: string;
15
+ }
16
+
17
+ /** Twitter-card subset. Emitted only when `card` is set — see `seoToTags`. */
18
+ export interface SeoTwitter {
19
+ card?: string;
20
+ title?: string;
21
+ description?: string;
22
+ image?: string;
23
+ }
24
+
25
+ /** Sitemap participation for this page. Consumed by the sitemap route (plan phase 3). */
26
+ export interface SeoSitemap {
27
+ include?: boolean;
28
+ priority?: number;
29
+ changeFreq?: string;
30
+ }
31
+
32
+ /**
33
+ * A page's stored SEO metadata, normalized.
34
+ *
35
+ * `keywords` has no authoring field — search engines have ignored the tag for over a decade — but
36
+ * it is read and emitted when a document already carries one, because the engine used to read it.
37
+ */
38
+ export interface SeoDoc {
39
+ title?: string;
40
+ description?: string;
41
+ keywords?: string;
42
+ canonicalUrl?: string;
43
+ robots?: string;
44
+ og?: SeoOg;
45
+ twitter?: SeoTwitter;
46
+ structuredData?: unknown;
47
+ sitemap?: SeoSitemap;
48
+ }
49
+
50
+ /** `<title>`. */
51
+ export interface SeoTitleTag {
52
+ kind: 'title';
53
+ content: string;
54
+ }
55
+
56
+ /**
57
+ * A `<meta>` tag. `attr` says which attribute identifies it — Open Graph is addressed by
58
+ * `property`, everything else by `name`. Both renderers need that distinction to update rather
59
+ * than duplicate a tag.
60
+ */
61
+ export interface SeoMetaTag {
62
+ kind: 'meta';
63
+ attr: 'name' | 'property';
64
+ key: string;
65
+ content: string;
66
+ }
67
+
68
+ /** A `<link>`, currently only `rel="canonical"`. */
69
+ export interface SeoLinkTag {
70
+ kind: 'link';
71
+ rel: string;
72
+ href: string;
73
+ }
74
+
75
+ export type SeoTag = SeoTitleTag | SeoMetaTag | SeoLinkTag;
76
+
77
+ /** Context that lets `seoToTags` fill gaps without reaching for the DOM or a data source. */
78
+ export interface SeoTagContext {
79
+ /** Used as `<title>` when the document has none — usually the page name. */
80
+ fallbackTitle?: string;
81
+ }
82
+
83
+ /** Recommended maximum before search results truncate the title. */
84
+ export const SEO_TITLE_MAX = 60;
85
+
86
+ /** Recommended maximum before search results truncate the description. */
87
+ export const SEO_DESCRIPTION_MAX = 160;
88
+
89
+ export const SEO_ROBOTS_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
90
+ { label: 'Index, Follow', value: 'index, follow' },
91
+ { label: 'Index, No Follow', value: 'index, nofollow' },
92
+ { label: 'No Index, Follow', value: 'noindex, follow' },
93
+ { label: 'No Index, No Follow', value: 'noindex, nofollow' },
94
+ ];
95
+
96
+ export const SEO_TWITTER_CARD_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
97
+ { label: 'Summary', value: 'summary' },
98
+ { label: 'Summary Large Image', value: 'summary_large_image' },
99
+ { label: 'App', value: 'app' },
100
+ { label: 'Player', value: 'player' },
101
+ ];
102
+
103
+ export const SEO_CHANGE_FREQ_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
104
+ { label: 'Always', value: 'always' },
105
+ { label: 'Hourly', value: 'hourly' },
106
+ { label: 'Daily', value: 'daily' },
107
+ { label: 'Weekly', value: 'weekly' },
108
+ { label: 'Monthly', value: 'monthly' },
109
+ { label: 'Yearly', value: 'yearly' },
110
+ { label: 'Never', value: 'never' },
111
+ ];
@@ -0,0 +1,57 @@
1
+ import { SeoDoc, SeoTag, SeoTagContext } from './seo.model';
2
+ import { normalizeSeoDoc } from './normalize';
3
+
4
+ /**
5
+ * Turn a page's SEO document into the flat list of head tags to emit.
6
+ *
7
+ * Both renderers call this, so a page cannot describe itself differently to a crawler than the
8
+ * builder previews. The order is fixed and part of the contract: title, description, keywords,
9
+ * canonical, Open Graph, Twitter, robots.
10
+ *
11
+ * Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the
12
+ * Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`
13
+ * itself, and duplicating a value we did not author only makes the drift harder to see. The
14
+ * "derive from the page content" half of the ladder (first h1, first paragraph, first image) is
15
+ * `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.
16
+ */
17
+ export function seoToTags(seo: SeoDoc | unknown, ctx: SeoTagContext = {}): SeoTag[] {
18
+ const doc = normalizeSeoDoc(seo);
19
+ const tags: SeoTag[] = [];
20
+
21
+ const title = doc.title ?? trimmed(ctx.fallbackTitle);
22
+ if (title) tags.push({ kind: 'title', content: title });
23
+ if (doc.description) tags.push({ kind: 'meta', attr: 'name', key: 'description', content: doc.description });
24
+ if (doc.keywords) tags.push({ kind: 'meta', attr: 'name', key: 'keywords', content: doc.keywords });
25
+ if (doc.canonicalUrl) tags.push({ kind: 'link', rel: 'canonical', href: doc.canonicalUrl });
26
+
27
+ // Open Graph. `og:url` reuses the canonical URL, which is what it means.
28
+ const ogTitle = doc.og?.title ?? title;
29
+ const ogDescription = doc.og?.description ?? doc.description;
30
+ if (ogTitle) tags.push({ kind: 'meta', attr: 'property', key: 'og:title', content: ogTitle });
31
+ if (ogDescription) tags.push({ kind: 'meta', attr: 'property', key: 'og:description', content: ogDescription });
32
+ if (doc.og?.image) tags.push({ kind: 'meta', attr: 'property', key: 'og:image', content: doc.og.image });
33
+ if (doc.canonicalUrl) tags.push({ kind: 'meta', attr: 'property', key: 'og:url', content: doc.canonicalUrl });
34
+
35
+ // Twitter. Emitted ONLY when a card type is chosen: without `twitter:card` the other tags are
36
+ // ignored anyway, so writing them would be noise that looks like configuration.
37
+ if (doc.twitter?.card) {
38
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:card', content: doc.twitter.card });
39
+ const twitterTitle = doc.twitter.title ?? ogTitle;
40
+ const twitterDescription = doc.twitter.description ?? ogDescription;
41
+ const twitterImage = doc.twitter.image ?? doc.og?.image;
42
+ if (twitterTitle) tags.push({ kind: 'meta', attr: 'name', key: 'twitter:title', content: twitterTitle });
43
+ if (twitterDescription) {
44
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:description', content: twitterDescription });
45
+ }
46
+ if (twitterImage) tags.push({ kind: 'meta', attr: 'name', key: 'twitter:image', content: twitterImage });
47
+ }
48
+
49
+ if (doc.robots) tags.push({ kind: 'meta', attr: 'name', key: 'robots', content: doc.robots });
50
+
51
+ return tags;
52
+ }
53
+
54
+ function trimmed(value: string | undefined): string | undefined {
55
+ const out = value?.trim();
56
+ return out?.length ? out : undefined;
57
+ }