@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
package/index.d.ts
CHANGED
|
@@ -1941,6 +1941,115 @@ declare function safeSetInput(ref: ComponentRef<unknown>, name: string, value: u
|
|
|
1941
1941
|
*/
|
|
1942
1942
|
declare function resolvePath(obj: any, path: string): any;
|
|
1943
1943
|
|
|
1944
|
+
/** The part of an `IoxBinding` that turns source data into a value. */
|
|
1945
|
+
interface BoundValueSpec {
|
|
1946
|
+
/** Dot/bracket path into the source data. Empty or absent means the source value itself. */
|
|
1947
|
+
path?: string;
|
|
1948
|
+
/** Render-time transforms (UI label "Filters"), applied after the path lookup. */
|
|
1949
|
+
pipes?: IoxPipe[];
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Resolve one binding's value out of its already-selected source data: path lookup, then pipes.
|
|
1953
|
+
*
|
|
1954
|
+
* Trivial on its own, which is exactly why it had been retyped at three call sites — the engine's
|
|
1955
|
+
* string renderer, the engine's component renderer and the builder canvas — plus a fourth needed by
|
|
1956
|
+
* page SEO. `resolvePath` itself was forked five times before it was shared, and the copy that
|
|
1957
|
+
* drifted caused a repeater to render in the SSR HTML and then blank on hydration.
|
|
1958
|
+
*
|
|
1959
|
+
* What is NOT here: picking the source (`resolvedData[binding.source]`) and deciding what an empty
|
|
1960
|
+
* context slot means. Those are per-renderer policy — the string renderer clears the prop, the
|
|
1961
|
+
* builder skips it — so they stay with the caller.
|
|
1962
|
+
*/
|
|
1963
|
+
declare function resolveBoundValue(sourceData: unknown, binding: BoundValueSpec, ctx?: PipeContext): unknown;
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.
|
|
1967
|
+
*
|
|
1968
|
+
* ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored
|
|
1969
|
+
* `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been
|
|
1970
|
+
* emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See
|
|
1971
|
+
* iox-ai-guidance `architecture/cms/seo-plan.md`.
|
|
1972
|
+
*/
|
|
1973
|
+
/** Open Graph subset we author and emit. */
|
|
1974
|
+
interface SeoOg {
|
|
1975
|
+
title?: string;
|
|
1976
|
+
description?: string;
|
|
1977
|
+
image?: string;
|
|
1978
|
+
}
|
|
1979
|
+
/** Twitter-card subset. Emitted only when `card` is set — see `seoToTags`. */
|
|
1980
|
+
interface SeoTwitter {
|
|
1981
|
+
card?: string;
|
|
1982
|
+
title?: string;
|
|
1983
|
+
description?: string;
|
|
1984
|
+
image?: string;
|
|
1985
|
+
}
|
|
1986
|
+
/** Sitemap participation for this page. Consumed by the sitemap route (plan phase 3). */
|
|
1987
|
+
interface SeoSitemap {
|
|
1988
|
+
include?: boolean;
|
|
1989
|
+
priority?: number;
|
|
1990
|
+
changeFreq?: string;
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* A page's stored SEO metadata, normalized.
|
|
1994
|
+
*
|
|
1995
|
+
* `keywords` has no authoring field — search engines have ignored the tag for over a decade — but
|
|
1996
|
+
* it is read and emitted when a document already carries one, because the engine used to read it.
|
|
1997
|
+
*/
|
|
1998
|
+
interface SeoDoc {
|
|
1999
|
+
title?: string;
|
|
2000
|
+
description?: string;
|
|
2001
|
+
keywords?: string;
|
|
2002
|
+
canonicalUrl?: string;
|
|
2003
|
+
robots?: string;
|
|
2004
|
+
og?: SeoOg;
|
|
2005
|
+
twitter?: SeoTwitter;
|
|
2006
|
+
structuredData?: unknown;
|
|
2007
|
+
sitemap?: SeoSitemap;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* Turn a page's STORED SEO document into the resolved one a crawler should see.
|
|
2012
|
+
*
|
|
2013
|
+
* Runs the ladder, per field: `override` → `binding` → auto. It lives in the main entry point rather
|
|
2014
|
+
* than `seo-core` because the binding rung needs `resolvePath` + `applyBindingPipes` from here, and
|
|
2015
|
+
* `iox-cms` (which imports `seo-core` under plain Node) never resolves anything — it only stores and
|
|
2016
|
+
* declares. `seoToTags` then turns this output into head tags.
|
|
2017
|
+
*
|
|
2018
|
+
* The reason this exists: `applyPageSeo(page.seo)` used to hand the stored strings straight to the
|
|
2019
|
+
* head, never consulting the resolved route item, so all 24 pages rendered from one template shipped
|
|
2020
|
+
* the same `<title>`. See iox-ai-guidance `architecture/cms/seo-plan.md`.
|
|
2021
|
+
*/
|
|
2022
|
+
/** What the ladder's lower two rungs need. All optional — a static page supplies almost none of it. */
|
|
2023
|
+
interface SeoResolveContext {
|
|
2024
|
+
/** The page's resolved data sources, keyed by alias (`$current` on a template page). */
|
|
2025
|
+
resolvedData?: Record<string, unknown> | null;
|
|
2026
|
+
/** The page's layout tree, for the "derive from the content" rung. */
|
|
2027
|
+
tree?: readonly unknown[] | null;
|
|
2028
|
+
/** Title of last resort, when the page has no heading at all. */
|
|
2029
|
+
pageName?: string;
|
|
2030
|
+
/** Absolute URL of the page being rendered — the automatic canonical. */
|
|
2031
|
+
pageUrl?: string;
|
|
2032
|
+
}
|
|
2033
|
+
/** What the layout tree yields for the bottom rung. */
|
|
2034
|
+
interface SeoDerivedContent {
|
|
2035
|
+
title?: string;
|
|
2036
|
+
description?: string;
|
|
2037
|
+
image?: string;
|
|
2038
|
+
}
|
|
2039
|
+
declare function resolveSeo(seo: unknown, ctx?: SeoResolveContext): SeoDoc;
|
|
2040
|
+
/**
|
|
2041
|
+
* Derive a title, description and image from the page's own content, in document order.
|
|
2042
|
+
*
|
|
2043
|
+
* ⚠️ Node bindings are resolved here too. On a template page the `<h1>` is normally bound to
|
|
2044
|
+
* `$current.title`, so reading `props.content` alone would return the design-time placeholder — the
|
|
2045
|
+
* same trap that made context slots fall through to component defaults. An unresolved binding yields
|
|
2046
|
+
* nothing rather than the placeholder.
|
|
2047
|
+
*
|
|
2048
|
+
* Node shapes are the renderers' own: `Heading` has `level` + `content`, `Text` has `content` (rich
|
|
2049
|
+
* HTML from the editor) + `tag`, `Image` has `src`.
|
|
2050
|
+
*/
|
|
2051
|
+
declare function deriveSeoFromContent(tree: readonly unknown[] | null | undefined, resolvedData?: Record<string, unknown> | null): SeoDerivedContent;
|
|
2052
|
+
|
|
1944
2053
|
/**
|
|
1945
2054
|
* Element context state — shared types.
|
|
1946
2055
|
*
|
|
@@ -2441,5 +2550,5 @@ declare class GalleryLoop {
|
|
|
2441
2550
|
private emit;
|
|
2442
2551
|
}
|
|
2443
2552
|
|
|
2444
|
-
export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
|
|
2445
|
-
export type { BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
|
|
2553
|
+
export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, deriveSeoFromContent, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveBoundValue, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSeo, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
|
|
2554
|
+
export type { BoundValueSpec, BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SeoDerivedContent, SeoResolveContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectoriox/iox-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.35.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,189 @@
|
|
|
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
|
+
/**
|
|
46
|
+
* A binding on an SEO field. Structurally the resolvable part of an `IoxBinding` — `source` picks an
|
|
47
|
+
* entry in the page's resolved data (`$current` on a template page), `path` walks into it, `pipes`
|
|
48
|
+
* are the render-time transforms. Typed loosely because the pipe catalog lives in the main
|
|
49
|
+
* `@vectoriox/iox-ui` entry point, which this framework-free one must not import.
|
|
50
|
+
*/
|
|
51
|
+
interface SeoBinding {
|
|
52
|
+
source: string;
|
|
53
|
+
path?: string;
|
|
54
|
+
pipes?: readonly unknown[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One SEO field as stored once it carries more than a typed string.
|
|
58
|
+
*
|
|
59
|
+
* The ladder is `override` → `binding` → auto; `auto: false` opts that field out of the automatic
|
|
60
|
+
* value, leaving it genuinely empty. A bare `string` in a stored document means `{ override }`, so
|
|
61
|
+
* nothing has to be migrated.
|
|
62
|
+
*/
|
|
63
|
+
interface SeoFieldValue {
|
|
64
|
+
override?: string;
|
|
65
|
+
binding?: SeoBinding;
|
|
66
|
+
auto?: false;
|
|
67
|
+
}
|
|
68
|
+
/** A stored SEO document, before resolution: any text field may be a plain string or a value object. */
|
|
69
|
+
type SeoRawDoc = Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* The text fields that can carry a binding, as dotted paths into the document.
|
|
72
|
+
*
|
|
73
|
+
* One list, used by the resolver and (phase 2) by the panel that draws the bind affordance — so a
|
|
74
|
+
* field can never be bindable in the UI and ignored at render time. `robots`, `sitemap.*` and
|
|
75
|
+
* `structuredData` are deliberately absent: they are pick-lists and blobs, not prose.
|
|
76
|
+
*/
|
|
77
|
+
declare const SEO_BINDABLE_FIELDS: readonly string[];
|
|
78
|
+
/** Where an automatic value comes from when a field has neither an override nor a binding. */
|
|
79
|
+
type SeoAutoSource = 'contentTitle' | 'contentDescription' | 'contentImage' | 'pageUrl';
|
|
80
|
+
/**
|
|
81
|
+
* The bottom rung of the ladder, as a table rather than a chain of ifs.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately short. `og:title` and `twitter:*` are NOT here — `seoToTags` already falls back to
|
|
84
|
+
* the page title for them, and materialising a copy of a value we did not author would only make
|
|
85
|
+
* later drift harder to see.
|
|
86
|
+
*/
|
|
87
|
+
declare const SEO_AUTO_SOURCES: ReadonlyArray<{
|
|
88
|
+
field: string;
|
|
89
|
+
source: SeoAutoSource;
|
|
90
|
+
}>;
|
|
91
|
+
/** `<title>`. */
|
|
92
|
+
interface SeoTitleTag {
|
|
93
|
+
kind: 'title';
|
|
94
|
+
content: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A `<meta>` tag. `attr` says which attribute identifies it — Open Graph is addressed by
|
|
98
|
+
* `property`, everything else by `name`. Both renderers need that distinction to update rather
|
|
99
|
+
* than duplicate a tag.
|
|
100
|
+
*/
|
|
101
|
+
interface SeoMetaTag {
|
|
102
|
+
kind: 'meta';
|
|
103
|
+
attr: 'name' | 'property';
|
|
104
|
+
key: string;
|
|
105
|
+
content: string;
|
|
106
|
+
}
|
|
107
|
+
/** A `<link>`, currently only `rel="canonical"`. */
|
|
108
|
+
interface SeoLinkTag {
|
|
109
|
+
kind: 'link';
|
|
110
|
+
rel: string;
|
|
111
|
+
href: string;
|
|
112
|
+
}
|
|
113
|
+
type SeoTag = SeoTitleTag | SeoMetaTag | SeoLinkTag;
|
|
114
|
+
/** Context that lets `seoToTags` fill gaps without reaching for the DOM or a data source. */
|
|
115
|
+
interface SeoTagContext {
|
|
116
|
+
/** Used as `<title>` when the document has none — usually the page name. */
|
|
117
|
+
fallbackTitle?: string;
|
|
118
|
+
}
|
|
119
|
+
/** Recommended maximum before search results truncate the title. */
|
|
120
|
+
declare const SEO_TITLE_MAX = 60;
|
|
121
|
+
/** Recommended maximum before search results truncate the description. */
|
|
122
|
+
declare const SEO_DESCRIPTION_MAX = 160;
|
|
123
|
+
declare const SEO_ROBOTS_OPTIONS: ReadonlyArray<{
|
|
124
|
+
label: string;
|
|
125
|
+
value: string;
|
|
126
|
+
}>;
|
|
127
|
+
declare const SEO_TWITTER_CARD_OPTIONS: ReadonlyArray<{
|
|
128
|
+
label: string;
|
|
129
|
+
value: string;
|
|
130
|
+
}>;
|
|
131
|
+
declare const SEO_CHANGE_FREQ_OPTIONS: ReadonlyArray<{
|
|
132
|
+
label: string;
|
|
133
|
+
value: string;
|
|
134
|
+
}>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Coerce whatever a page document happens to hold into a `SeoDoc`.
|
|
138
|
+
*
|
|
139
|
+
* Every consumer calls this before reading `seo`, because four shapes are in the wild:
|
|
140
|
+
* • the current nested shape (`og.title`);
|
|
141
|
+
* • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a
|
|
142
|
+
* document hand-edited against the old engine code could carry it;
|
|
143
|
+
* • `""` for "not set", which must not become an empty `<meta>`;
|
|
144
|
+
* • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here
|
|
145
|
+
* so the ladder can land without a second normalizer.
|
|
146
|
+
*
|
|
147
|
+
* Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.
|
|
148
|
+
*/
|
|
149
|
+
declare function normalizeSeoDoc(raw: unknown): SeoDoc;
|
|
150
|
+
/**
|
|
151
|
+
* A field's value as a trimmed string, or `undefined`. Exported because the resolver needs the exact
|
|
152
|
+
* same coercion for a value it has just pulled out of a data source.
|
|
153
|
+
*
|
|
154
|
+
* Unwraps the value object: a bound or auto field has no `override`, so it correctly resolves to
|
|
155
|
+
* `undefined` here and `resolveSeo` fills it in from the lower rungs.
|
|
156
|
+
*/
|
|
157
|
+
declare function seoString(value: unknown): string | undefined;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Turn a page's SEO document into the flat list of head tags to emit.
|
|
161
|
+
*
|
|
162
|
+
* Both renderers call this, so a page cannot describe itself differently to a crawler than the
|
|
163
|
+
* builder previews. The order is fixed and part of the contract: title, description, keywords,
|
|
164
|
+
* canonical, Open Graph, Twitter, robots.
|
|
165
|
+
*
|
|
166
|
+
* Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the
|
|
167
|
+
* Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`
|
|
168
|
+
* itself, and duplicating a value we did not author only makes the drift harder to see. The
|
|
169
|
+
* "derive from the page content" half of the ladder (first h1, first paragraph, first image) is
|
|
170
|
+
* `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.
|
|
171
|
+
*/
|
|
172
|
+
declare function seoToTags(seo: SeoDoc | unknown, ctx?: SeoTagContext): SeoTag[];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The authoring schema for the `seo` object, in the platform's standard property dialect: `label`
|
|
176
|
+
* at property level, rendering metadata under `controller`.
|
|
177
|
+
*
|
|
178
|
+
* ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and
|
|
179
|
+
* `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had
|
|
180
|
+
* already drifted — different controller types for the same field, and the client's copy missing
|
|
181
|
+
* `structuredData` entirely.
|
|
182
|
+
*
|
|
183
|
+
* `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is
|
|
184
|
+
* no reason to invite new ones.
|
|
185
|
+
*/
|
|
186
|
+
declare function buildSeoSchemaProperties(): Record<string, unknown>;
|
|
187
|
+
|
|
188
|
+
export { SEO_AUTO_SOURCES, SEO_BINDABLE_FIELDS, SEO_CHANGE_FREQ_OPTIONS, SEO_DESCRIPTION_MAX, SEO_ROBOTS_OPTIONS, SEO_TITLE_MAX, SEO_TWITTER_CARD_OPTIONS, buildSeoSchemaProperties, normalizeSeoDoc, seoString, seoToTags };
|
|
189
|
+
export type { SeoAutoSource, SeoBinding, SeoDoc, SeoFieldValue, SeoLinkTag, SeoMetaTag, SeoOg, SeoRawDoc, SeoSitemap, SeoTag, SeoTagContext, SeoTitleTag, SeoTwitter };
|
|
@@ -0,0 +1,126 @@
|
|
|
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: seoString(pick(src, 'og', 'title') ?? src['ogTitle']),
|
|
22
|
+
description: seoString(pick(src, 'og', 'description') ?? src['ogDescription']),
|
|
23
|
+
image: seoString(pick(src, 'og', 'image') ?? src['ogImage']),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const twitter = compact<SeoTwitter>({
|
|
27
|
+
card: seoString(pick(src, 'twitter', 'card')),
|
|
28
|
+
title: seoString(pick(src, 'twitter', 'title')),
|
|
29
|
+
description: seoString(pick(src, 'twitter', 'description')),
|
|
30
|
+
image: seoString(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: seoString(pick(src, 'sitemap', 'changeFreq')),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const structuredData = structured(src['structuredData']);
|
|
40
|
+
|
|
41
|
+
const doc = compact<SeoDoc>({
|
|
42
|
+
title: seoString(src['title']),
|
|
43
|
+
description: seoString(src['description']),
|
|
44
|
+
keywords: seoString(src['keywords']),
|
|
45
|
+
canonicalUrl: seoString(src['canonicalUrl']),
|
|
46
|
+
robots: seoString(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`. Exported because the resolver needs the exact
|
|
64
|
+
* same coercion for a value it has just pulled out of a data source.
|
|
65
|
+
*
|
|
66
|
+
* Unwraps the value object: a bound or auto field has no `override`, so it correctly resolves to
|
|
67
|
+
* `undefined` here and `resolveSeo` fills it in from the lower rungs.
|
|
68
|
+
*/
|
|
69
|
+
export function seoString(value: unknown): string | undefined {
|
|
70
|
+
const wrapper = asRecord(value);
|
|
71
|
+
const raw = wrapper ? wrapper['override'] : value;
|
|
72
|
+
if (typeof raw === 'number') return String(raw);
|
|
73
|
+
if (typeof raw !== 'string') return undefined;
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
return trimmed.length ? trimmed : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `UIFormToDTO` can hand back `"true"` / `"false"` for a switch (see
|
|
80
|
+
* `architecture/cms/shop.md` — the boolean/number coercion pitfall), so accept both.
|
|
81
|
+
*/
|
|
82
|
+
function bool(value: unknown): boolean | undefined {
|
|
83
|
+
if (typeof value === 'boolean') return value;
|
|
84
|
+
if (value === 'true') return true;
|
|
85
|
+
if (value === 'false') return false;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function num(value: unknown): number | undefined {
|
|
90
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
91
|
+
if (typeof value !== 'string' || !value.trim().length) return undefined;
|
|
92
|
+
const parsed = Number(value);
|
|
93
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** JSON-LD is stored as an object or as a JSON string from the `json` controller. */
|
|
97
|
+
function structured(value: unknown): unknown {
|
|
98
|
+
if (typeof value === 'string') {
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (!trimmed.length) return undefined;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(trimmed);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value)) return value.length ? value : undefined;
|
|
108
|
+
const record = asRecord(value);
|
|
109
|
+
return record && Object.keys(record).length ? record : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
113
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
114
|
+
? (value as Record<string, unknown>)
|
|
115
|
+
: undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Drop `undefined` values and empty nested objects, so absence is always `undefined`. */
|
|
119
|
+
function compact<T extends object>(value: Record<string, unknown>): T | undefined {
|
|
120
|
+
const out: Record<string, unknown> = {};
|
|
121
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
122
|
+
if (entry === undefined) continue;
|
|
123
|
+
out[key] = entry;
|
|
124
|
+
}
|
|
125
|
+
return (Object.keys(out).length ? out : undefined) as T | undefined;
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|