@vectoriox/iox-ui 4.33.1 → 4.35.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/fesm2022/vectoriox-iox-ui-seo-core.mjs +400 -0
- package/fesm2022/vectoriox-iox-ui-seo-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +585 -3
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +111 -2
- package/package.json +5 -1
- package/seo-core/index.d.ts +189 -0
- package/seo-core/package.json +3 -0
- package/src/seo-core/normalize.ts +126 -0
- package/src/seo-core/public-api.ts +16 -0
- package/src/seo-core/schema.ts +140 -0
- package/src/seo-core/seo.model.ts +175 -0
- package/src/seo-core/tags.ts +57 -0
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
/**
|
|
51
|
+
* A binding on an SEO field. Structurally the resolvable part of an `IoxBinding` — `source` picks an
|
|
52
|
+
* entry in the page's resolved data (`$current` on a template page), `path` walks into it, `pipes`
|
|
53
|
+
* are the render-time transforms. Typed loosely because the pipe catalog lives in the main
|
|
54
|
+
* `@vectoriox/iox-ui` entry point, which this framework-free one must not import.
|
|
55
|
+
*/
|
|
56
|
+
export interface SeoBinding {
|
|
57
|
+
source: string;
|
|
58
|
+
path?: string;
|
|
59
|
+
pipes?: readonly unknown[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One SEO field as stored once it carries more than a typed string.
|
|
64
|
+
*
|
|
65
|
+
* The ladder is `override` → `binding` → auto; `auto: false` opts that field out of the automatic
|
|
66
|
+
* value, leaving it genuinely empty. A bare `string` in a stored document means `{ override }`, so
|
|
67
|
+
* nothing has to be migrated.
|
|
68
|
+
*/
|
|
69
|
+
export interface SeoFieldValue {
|
|
70
|
+
override?: string;
|
|
71
|
+
binding?: SeoBinding;
|
|
72
|
+
auto?: false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A stored SEO document, before resolution: any text field may be a plain string or a value object. */
|
|
76
|
+
export type SeoRawDoc = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The text fields that can carry a binding, as dotted paths into the document.
|
|
80
|
+
*
|
|
81
|
+
* One list, used by the resolver and (phase 2) by the panel that draws the bind affordance — so a
|
|
82
|
+
* field can never be bindable in the UI and ignored at render time. `robots`, `sitemap.*` and
|
|
83
|
+
* `structuredData` are deliberately absent: they are pick-lists and blobs, not prose.
|
|
84
|
+
*/
|
|
85
|
+
export const SEO_BINDABLE_FIELDS: readonly string[] = [
|
|
86
|
+
'title',
|
|
87
|
+
'description',
|
|
88
|
+
'canonicalUrl',
|
|
89
|
+
'og.title',
|
|
90
|
+
'og.description',
|
|
91
|
+
'og.image',
|
|
92
|
+
'twitter.title',
|
|
93
|
+
'twitter.description',
|
|
94
|
+
'twitter.image',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Where an automatic value comes from when a field has neither an override nor a binding. */
|
|
98
|
+
export type SeoAutoSource = 'contentTitle' | 'contentDescription' | 'contentImage' | 'pageUrl';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The bottom rung of the ladder, as a table rather than a chain of ifs.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately short. `og:title` and `twitter:*` are NOT here — `seoToTags` already falls back to
|
|
104
|
+
* the page title for them, and materialising a copy of a value we did not author would only make
|
|
105
|
+
* later drift harder to see.
|
|
106
|
+
*/
|
|
107
|
+
export const SEO_AUTO_SOURCES: ReadonlyArray<{ field: string; source: SeoAutoSource }> = [
|
|
108
|
+
{ field: 'title', source: 'contentTitle' },
|
|
109
|
+
{ field: 'description', source: 'contentDescription' },
|
|
110
|
+
{ field: 'og.image', source: 'contentImage' },
|
|
111
|
+
{ field: 'canonicalUrl', source: 'pageUrl' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/** `<title>`. */
|
|
115
|
+
export interface SeoTitleTag {
|
|
116
|
+
kind: 'title';
|
|
117
|
+
content: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A `<meta>` tag. `attr` says which attribute identifies it — Open Graph is addressed by
|
|
122
|
+
* `property`, everything else by `name`. Both renderers need that distinction to update rather
|
|
123
|
+
* than duplicate a tag.
|
|
124
|
+
*/
|
|
125
|
+
export interface SeoMetaTag {
|
|
126
|
+
kind: 'meta';
|
|
127
|
+
attr: 'name' | 'property';
|
|
128
|
+
key: string;
|
|
129
|
+
content: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A `<link>`, currently only `rel="canonical"`. */
|
|
133
|
+
export interface SeoLinkTag {
|
|
134
|
+
kind: 'link';
|
|
135
|
+
rel: string;
|
|
136
|
+
href: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type SeoTag = SeoTitleTag | SeoMetaTag | SeoLinkTag;
|
|
140
|
+
|
|
141
|
+
/** Context that lets `seoToTags` fill gaps without reaching for the DOM or a data source. */
|
|
142
|
+
export interface SeoTagContext {
|
|
143
|
+
/** Used as `<title>` when the document has none — usually the page name. */
|
|
144
|
+
fallbackTitle?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Recommended maximum before search results truncate the title. */
|
|
148
|
+
export const SEO_TITLE_MAX = 60;
|
|
149
|
+
|
|
150
|
+
/** Recommended maximum before search results truncate the description. */
|
|
151
|
+
export const SEO_DESCRIPTION_MAX = 160;
|
|
152
|
+
|
|
153
|
+
export const SEO_ROBOTS_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
|
154
|
+
{ label: 'Index, Follow', value: 'index, follow' },
|
|
155
|
+
{ label: 'Index, No Follow', value: 'index, nofollow' },
|
|
156
|
+
{ label: 'No Index, Follow', value: 'noindex, follow' },
|
|
157
|
+
{ label: 'No Index, No Follow', value: 'noindex, nofollow' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
export const SEO_TWITTER_CARD_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
|
161
|
+
{ label: 'Summary', value: 'summary' },
|
|
162
|
+
{ label: 'Summary Large Image', value: 'summary_large_image' },
|
|
163
|
+
{ label: 'App', value: 'app' },
|
|
164
|
+
{ label: 'Player', value: 'player' },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export const SEO_CHANGE_FREQ_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
|
168
|
+
{ label: 'Always', value: 'always' },
|
|
169
|
+
{ label: 'Hourly', value: 'hourly' },
|
|
170
|
+
{ label: 'Daily', value: 'daily' },
|
|
171
|
+
{ label: 'Weekly', value: 'weekly' },
|
|
172
|
+
{ label: 'Monthly', value: 'monthly' },
|
|
173
|
+
{ label: 'Yearly', value: 'yearly' },
|
|
174
|
+
{ label: 'Never', value: 'never' },
|
|
175
|
+
];
|
|
@@ -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
|
+
}
|