@vectoriox/iox-ui 4.34.0 → 4.36.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.
@@ -971,6 +971,41 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
971
971
  type: Input
972
972
  }] } });
973
973
 
974
+ /**
975
+ * Phosphor's CSS needs TWO classes — a weight and a name (`.ph-thin.ph-house:before`). A class
976
+ * string carrying only the name matches nothing at all: no `font-family`, no glyph, so the element
977
+ * renders as an invisible, zero-glyph box.
978
+ *
979
+ * The icon picker used to emit exactly that (`ph-house`), so every icon chosen through it was blank
980
+ * on the canvas, in the picker grid and on the live site. Fixing the picker alone would leave every
981
+ * icon already saved broken, so the repair happens at RENDER time, here, and is shared by the
982
+ * builder's Icon component and the SSR engine's renderer.
983
+ *
984
+ * Everything that is not a weightless Phosphor class passes through untouched — Iconoir
985
+ * (`iconoir-home`), custom icon-set fonts, Font Awesome, and Phosphor strings that already carry a
986
+ * weight.
987
+ *
988
+ * See iox-ai-guidance `architecture/cms/custom-icon-fonts.md`.
989
+ */
990
+ /** The weight classes Phosphor ships. `ph` is the regular weight. */
991
+ const PHOSPHOR_WEIGHTS = new Set(['ph', 'ph-thin', 'ph-light', 'ph-regular', 'ph-bold', 'ph-fill', 'ph-duotone']);
992
+ /** The weight assumed when a Phosphor class has none — the weight this platform uses everywhere. */
993
+ const DEFAULT_PHOSPHOR_WEIGHT = 'ph-thin';
994
+ function normalizeIconClass(iconClass) {
995
+ const raw = (iconClass ?? '').trim();
996
+ if (!raw)
997
+ return '';
998
+ const tokens = raw.split(/\s+/);
999
+ const hasWeight = tokens.some(token => PHOSPHOR_WEIGHTS.has(token));
1000
+ if (hasWeight)
1001
+ return tokens.join(' ');
1002
+ // `ph-` prefixed but weightless — the broken shape. A token that is only `ph-` is not a name.
1003
+ const hasPhosphorName = tokens.some(token => token.startsWith('ph-') && token.length > 3);
1004
+ if (!hasPhosphorName)
1005
+ return tokens.join(' ');
1006
+ return [DEFAULT_PHOSPHOR_WEIGHT, ...tokens].join(' ');
1007
+ }
1008
+
974
1009
  /** Directive-host icon: the `<i>` IS the host — no wrapper. All styles on `.iox-node`. */
975
1010
  class BuilderIconComponent {
976
1011
  constructor() {
@@ -978,12 +1013,20 @@ class BuilderIconComponent {
978
1013
  this.iconClass = 'ph-thin ph-star';
979
1014
  this.nodeId = '';
980
1015
  }
1016
+ /**
1017
+ * Repaired at render time: a Phosphor class saved without its weight (`ph-house`) matches no CSS
1018
+ * rule and renders as an invisible box. The picker used to emit exactly that, so this keeps
1019
+ * every already-saved icon working. Shared with the SSR engine — see `normalizeIconClass`.
1020
+ */
1021
+ get normalizedIconClass() {
1022
+ return normalizeIconClass(this.iconClass);
1023
+ }
981
1024
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: BuilderIconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
982
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: BuilderIconComponent, isStandalone: false, selector: "i[iox-icon]", inputs: { iconClass: "iconClass", nodeId: "nodeId" }, host: { properties: { "class": "iconClass + ' iox-node-' + nodeId" } }, ngImport: i0, template: '', isInline: true, styles: [":host{display:inline-flex;align-items:center;justify-content:center;line-height:0}\n"] }); }
1025
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: BuilderIconComponent, isStandalone: false, selector: "i[iox-icon]", inputs: { iconClass: "iconClass", nodeId: "nodeId" }, host: { properties: { "class": "normalizedIconClass + ' iox-node-' + nodeId" } }, ngImport: i0, template: '', isInline: true, styles: [":host{display:inline-flex;align-items:center;justify-content:center;line-height:0}\n"] }); }
983
1026
  }
984
1027
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: BuilderIconComponent, decorators: [{
985
1028
  type: Component,
986
- args: [{ selector: 'i[iox-icon]', template: '', host: { '[class]': "iconClass + ' iox-node-' + nodeId" }, standalone: false, styles: [":host{display:inline-flex;align-items:center;justify-content:center;line-height:0}\n"] }]
1029
+ args: [{ selector: 'i[iox-icon]', template: '', host: { '[class]': "normalizedIconClass + ' iox-node-' + nodeId" }, standalone: false, styles: [":host{display:inline-flex;align-items:center;justify-content:center;line-height:0}\n"] }]
987
1030
  }], propDecorators: { iconClass: [{
988
1031
  type: Input
989
1032
  }], nodeId: [{
@@ -2090,7 +2133,7 @@ function buildSwiperBreakpoints(inputs, responsive) {
2090
2133
  let previous = null;
2091
2134
  // Narrowest → widest, so "same as the band below" is a comparison against what we just emitted.
2092
2135
  for (const band of [...bands].reverse()) {
2093
- const resolved = pick(resolveInputs(inputs, responsive, band.id));
2136
+ const resolved = pick$1(resolveInputs(inputs, responsive, band.id));
2094
2137
  if (previous && sameValues(previous, resolved))
2095
2138
  continue;
2096
2139
  map[band.minWidth] = resolved;
@@ -2133,7 +2176,7 @@ function bandsWidestFirst() {
2133
2176
  return { id: def.id, minWidth };
2134
2177
  });
2135
2178
  }
2136
- function pick(resolved) {
2179
+ function pick$1(resolved) {
2137
2180
  const out = {};
2138
2181
  for (const name of SWIPER_RESPONSIVE_TRAITS) {
2139
2182
  if (resolved[name] !== undefined)
@@ -5466,6 +5509,588 @@ function resolvePath(obj, path) {
5466
5509
  }, obj);
5467
5510
  }
5468
5511
 
5512
+ /**
5513
+ * Resolve one binding's value out of its already-selected source data: path lookup, then pipes.
5514
+ *
5515
+ * Trivial on its own, which is exactly why it had been retyped at three call sites — the engine's
5516
+ * string renderer, the engine's component renderer and the builder canvas — plus a fourth needed by
5517
+ * page SEO. `resolvePath` itself was forked five times before it was shared, and the copy that
5518
+ * drifted caused a repeater to render in the SSR HTML and then blank on hydration.
5519
+ *
5520
+ * What is NOT here: picking the source (`resolvedData[binding.source]`) and deciding what an empty
5521
+ * context slot means. Those are per-renderer policy — the string renderer clears the prop, the
5522
+ * builder skips it — so they stay with the caller.
5523
+ */
5524
+ function resolveBoundValue(sourceData, binding, ctx = {}) {
5525
+ const raw = binding.path ? resolvePath(sourceData, binding.path) : sourceData;
5526
+ return applyBindingPipes(raw, binding.pipes, ctx);
5527
+ }
5528
+
5529
+ /**
5530
+ * The canonical shape of a page's SEO metadata, plus the flat tag list both renderers emit.
5531
+ *
5532
+ * ⚠️ This file is the ONLY declaration of that shape. Before it existed, `iox-cms` stored
5533
+ * `seo.og.title` while the SSR engine read a flat `seo.ogTitle`, so no Open Graph tag had ever been
5534
+ * emitted, and `iox-cms-client` carried a third, hand-copied variant of the same schema. See
5535
+ * iox-ai-guidance `architecture/cms/seo-plan.md`.
5536
+ */
5537
+ /**
5538
+ * The text fields that can carry a binding, as dotted paths into the document.
5539
+ *
5540
+ * One list, used by the resolver and (phase 2) by the panel that draws the bind affordance — so a
5541
+ * field can never be bindable in the UI and ignored at render time. `robots`, `sitemap.*` and
5542
+ * `structuredData` are deliberately absent: they are pick-lists and blobs, not prose.
5543
+ */
5544
+ const SEO_BINDABLE_FIELDS = [
5545
+ 'title',
5546
+ 'description',
5547
+ 'canonicalUrl',
5548
+ 'og.title',
5549
+ 'og.description',
5550
+ 'og.image',
5551
+ 'twitter.title',
5552
+ 'twitter.description',
5553
+ 'twitter.image',
5554
+ ];
5555
+ /**
5556
+ * The bottom rung of the ladder, as a table rather than a chain of ifs.
5557
+ *
5558
+ * Deliberately short. `og:title` and `twitter:*` are NOT here — `seoToTags` already falls back to
5559
+ * the page title for them, and materialising a copy of a value we did not author would only make
5560
+ * later drift harder to see.
5561
+ */
5562
+ const SEO_AUTO_SOURCES = [
5563
+ { field: 'title', source: 'contentTitle' },
5564
+ { field: 'description', source: 'contentDescription' },
5565
+ { field: 'og.image', source: 'contentImage' },
5566
+ { field: 'canonicalUrl', source: 'pageUrl' },
5567
+ ];
5568
+ /** Recommended maximum before search results truncate the title. */
5569
+ const SEO_TITLE_MAX = 60;
5570
+ /** Recommended maximum before search results truncate the description. */
5571
+ const SEO_DESCRIPTION_MAX = 160;
5572
+ const SEO_ROBOTS_OPTIONS = [
5573
+ { label: 'Index, Follow', value: 'index, follow' },
5574
+ { label: 'Index, No Follow', value: 'index, nofollow' },
5575
+ { label: 'No Index, Follow', value: 'noindex, follow' },
5576
+ { label: 'No Index, No Follow', value: 'noindex, nofollow' },
5577
+ ];
5578
+ const SEO_TWITTER_CARD_OPTIONS = [
5579
+ { label: 'Summary', value: 'summary' },
5580
+ { label: 'Summary Large Image', value: 'summary_large_image' },
5581
+ { label: 'App', value: 'app' },
5582
+ { label: 'Player', value: 'player' },
5583
+ ];
5584
+ const SEO_CHANGE_FREQ_OPTIONS = [
5585
+ { label: 'Always', value: 'always' },
5586
+ { label: 'Hourly', value: 'hourly' },
5587
+ { label: 'Daily', value: 'daily' },
5588
+ { label: 'Weekly', value: 'weekly' },
5589
+ { label: 'Monthly', value: 'monthly' },
5590
+ { label: 'Yearly', value: 'yearly' },
5591
+ { label: 'Never', value: 'never' },
5592
+ ];
5593
+
5594
+ /**
5595
+ * Coerce whatever a page document happens to hold into a `SeoDoc`.
5596
+ *
5597
+ * Every consumer calls this before reading `seo`, because four shapes are in the wild:
5598
+ * • the current nested shape (`og.title`);
5599
+ * • the flat shape the SSR engine used to read (`ogTitle`) — never actually written, but a
5600
+ * document hand-edited against the old engine code could carry it;
5601
+ * • `""` for "not set", which must not become an empty `<meta>`;
5602
+ * • `{ override: '…' }`, the per-field value object that plan phase 2 introduces — tolerated here
5603
+ * so the ladder can land without a second normalizer.
5604
+ *
5605
+ * Empty branches are dropped rather than left as `{}`, so `if (seo.og)` is a meaningful test.
5606
+ */
5607
+ function normalizeSeoDoc(raw) {
5608
+ const src = asRecord(raw);
5609
+ if (!src)
5610
+ return {};
5611
+ const og = compact({
5612
+ title: seoString(pick(src, 'og', 'title') ?? src['ogTitle']),
5613
+ description: seoString(pick(src, 'og', 'description') ?? src['ogDescription']),
5614
+ image: seoString(pick(src, 'og', 'image') ?? src['ogImage']),
5615
+ });
5616
+ const twitter = compact({
5617
+ card: seoString(pick(src, 'twitter', 'card')),
5618
+ title: seoString(pick(src, 'twitter', 'title')),
5619
+ description: seoString(pick(src, 'twitter', 'description')),
5620
+ image: seoString(pick(src, 'twitter', 'image')),
5621
+ });
5622
+ const sitemap = compact({
5623
+ include: bool(pick(src, 'sitemap', 'include')),
5624
+ priority: num(pick(src, 'sitemap', 'priority')),
5625
+ changeFreq: seoString(pick(src, 'sitemap', 'changeFreq')),
5626
+ });
5627
+ const structuredData = structured(src['structuredData']);
5628
+ const doc = compact({
5629
+ title: seoString(src['title']),
5630
+ description: seoString(src['description']),
5631
+ keywords: seoString(src['keywords']),
5632
+ canonicalUrl: seoString(src['canonicalUrl']),
5633
+ robots: seoString(src['robots']),
5634
+ og,
5635
+ twitter,
5636
+ sitemap,
5637
+ structuredData,
5638
+ });
5639
+ return doc ?? {};
5640
+ }
5641
+ /** Read `src[branch][leaf]` without assuming the branch exists or is an object. */
5642
+ function pick(src, branch, leaf) {
5643
+ const nested = asRecord(src[branch]);
5644
+ return nested ? nested[leaf] : undefined;
5645
+ }
5646
+ /**
5647
+ * A field's value as a trimmed string, or `undefined`. Exported because the resolver needs the exact
5648
+ * same coercion for a value it has just pulled out of a data source.
5649
+ *
5650
+ * Unwraps the value object: a bound or auto field has no `override`, so it correctly resolves to
5651
+ * `undefined` here and `resolveSeo` fills it in from the lower rungs.
5652
+ */
5653
+ function seoString(value) {
5654
+ const wrapper = asRecord(value);
5655
+ const raw = wrapper ? wrapper['override'] : value;
5656
+ if (typeof raw === 'number')
5657
+ return String(raw);
5658
+ if (typeof raw !== 'string')
5659
+ return undefined;
5660
+ const trimmed = raw.trim();
5661
+ return trimmed.length ? trimmed : undefined;
5662
+ }
5663
+ /**
5664
+ * `UIFormToDTO` can hand back `"true"` / `"false"` for a switch (see
5665
+ * `architecture/cms/shop.md` — the boolean/number coercion pitfall), so accept both.
5666
+ */
5667
+ function bool(value) {
5668
+ if (typeof value === 'boolean')
5669
+ return value;
5670
+ if (value === 'true')
5671
+ return true;
5672
+ if (value === 'false')
5673
+ return false;
5674
+ return undefined;
5675
+ }
5676
+ function num(value) {
5677
+ if (typeof value === 'number')
5678
+ return Number.isFinite(value) ? value : undefined;
5679
+ if (typeof value !== 'string' || !value.trim().length)
5680
+ return undefined;
5681
+ const parsed = Number(value);
5682
+ return Number.isFinite(parsed) ? parsed : undefined;
5683
+ }
5684
+ /** JSON-LD is stored as an object or as a JSON string from the `json` controller. */
5685
+ function structured(value) {
5686
+ if (typeof value === 'string') {
5687
+ const trimmed = value.trim();
5688
+ if (!trimmed.length)
5689
+ return undefined;
5690
+ try {
5691
+ return JSON.parse(trimmed);
5692
+ }
5693
+ catch {
5694
+ return undefined;
5695
+ }
5696
+ }
5697
+ if (Array.isArray(value))
5698
+ return value.length ? value : undefined;
5699
+ const record = asRecord(value);
5700
+ return record && Object.keys(record).length ? record : undefined;
5701
+ }
5702
+ function asRecord(value) {
5703
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
5704
+ ? value
5705
+ : undefined;
5706
+ }
5707
+ /** Drop `undefined` values and empty nested objects, so absence is always `undefined`. */
5708
+ function compact(value) {
5709
+ const out = {};
5710
+ for (const [key, entry] of Object.entries(value)) {
5711
+ if (entry === undefined)
5712
+ continue;
5713
+ out[key] = entry;
5714
+ }
5715
+ return (Object.keys(out).length ? out : undefined);
5716
+ }
5717
+
5718
+ /**
5719
+ * Turn a page's SEO document into the flat list of head tags to emit.
5720
+ *
5721
+ * Both renderers call this, so a page cannot describe itself differently to a crawler than the
5722
+ * builder previews. The order is fixed and part of the contract: title, description, keywords,
5723
+ * canonical, Open Graph, Twitter, robots.
5724
+ *
5725
+ * Fallbacks are deliberately shallow — `og:title` reuses the page title, `twitter:*` reuses the
5726
+ * Open Graph values — because a social scraper that finds no `og:title` falls back to `<title>`
5727
+ * itself, and duplicating a value we did not author only makes the drift harder to see. The
5728
+ * "derive from the page content" half of the ladder (first h1, first paragraph, first image) is
5729
+ * `resolveSeo`, which runs BEFORE this and hands its output in as a filled-in `SeoDoc`.
5730
+ */
5731
+ function seoToTags(seo, ctx = {}) {
5732
+ const doc = normalizeSeoDoc(seo);
5733
+ const tags = [];
5734
+ const title = doc.title ?? trimmed(ctx.fallbackTitle);
5735
+ if (title)
5736
+ tags.push({ kind: 'title', content: title });
5737
+ if (doc.description)
5738
+ tags.push({ kind: 'meta', attr: 'name', key: 'description', content: doc.description });
5739
+ if (doc.keywords)
5740
+ tags.push({ kind: 'meta', attr: 'name', key: 'keywords', content: doc.keywords });
5741
+ if (doc.canonicalUrl)
5742
+ tags.push({ kind: 'link', rel: 'canonical', href: doc.canonicalUrl });
5743
+ // Open Graph. `og:url` reuses the canonical URL, which is what it means.
5744
+ const ogTitle = doc.og?.title ?? title;
5745
+ const ogDescription = doc.og?.description ?? doc.description;
5746
+ if (ogTitle)
5747
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:title', content: ogTitle });
5748
+ if (ogDescription)
5749
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:description', content: ogDescription });
5750
+ if (doc.og?.image)
5751
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:image', content: doc.og.image });
5752
+ if (doc.canonicalUrl)
5753
+ tags.push({ kind: 'meta', attr: 'property', key: 'og:url', content: doc.canonicalUrl });
5754
+ // Twitter. Emitted ONLY when a card type is chosen: without `twitter:card` the other tags are
5755
+ // ignored anyway, so writing them would be noise that looks like configuration.
5756
+ if (doc.twitter?.card) {
5757
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:card', content: doc.twitter.card });
5758
+ const twitterTitle = doc.twitter.title ?? ogTitle;
5759
+ const twitterDescription = doc.twitter.description ?? ogDescription;
5760
+ const twitterImage = doc.twitter.image ?? doc.og?.image;
5761
+ if (twitterTitle)
5762
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:title', content: twitterTitle });
5763
+ if (twitterDescription) {
5764
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:description', content: twitterDescription });
5765
+ }
5766
+ if (twitterImage)
5767
+ tags.push({ kind: 'meta', attr: 'name', key: 'twitter:image', content: twitterImage });
5768
+ }
5769
+ if (doc.robots)
5770
+ tags.push({ kind: 'meta', attr: 'name', key: 'robots', content: doc.robots });
5771
+ return tags;
5772
+ }
5773
+ function trimmed(value) {
5774
+ const out = value?.trim();
5775
+ return out?.length ? out : undefined;
5776
+ }
5777
+
5778
+ /**
5779
+ * The authoring schema for the `seo` object, in the platform's standard property dialect: `label`
5780
+ * at property level, rendering metadata under `controller`.
5781
+ *
5782
+ * ONE builder, two callers: `iox-cms` spreads it into `PagesSchema` (the stored schema) and
5783
+ * `iox-cms-client` feeds it to `<dynamic-form>`. Those used to be two hand-written copies that had
5784
+ * already drifted — different controller types for the same field, and the client's copy missing
5785
+ * `structuredData` entirely.
5786
+ *
5787
+ * `keywords` is deliberately absent: the tag is emitted when a document carries one, but there is
5788
+ * no reason to invite new ones.
5789
+ */
5790
+ function buildSeoSchemaProperties() {
5791
+ return {
5792
+ title: {
5793
+ type: 'string',
5794
+ label: 'Meta Title',
5795
+ controller: {
5796
+ type: 'text',
5797
+ placeholder: 'Page title for search engines',
5798
+ maxLength: SEO_TITLE_MAX,
5799
+ },
5800
+ },
5801
+ description: {
5802
+ type: 'string',
5803
+ label: 'Meta Description',
5804
+ controller: {
5805
+ type: 'textarea',
5806
+ placeholder: 'Brief description for search results',
5807
+ maxLength: SEO_DESCRIPTION_MAX,
5808
+ },
5809
+ },
5810
+ canonicalUrl: {
5811
+ type: 'string',
5812
+ label: 'Canonical URL',
5813
+ controller: {
5814
+ type: 'text',
5815
+ placeholder: 'https://example.com/page',
5816
+ },
5817
+ },
5818
+ robots: {
5819
+ type: 'string',
5820
+ label: 'Robots',
5821
+ controller: {
5822
+ type: 'select',
5823
+ options: SEO_ROBOTS_OPTIONS.map(option => ({ ...option })),
5824
+ },
5825
+ },
5826
+ og: {
5827
+ type: 'object',
5828
+ label: 'Open Graph',
5829
+ controller: { type: 'object' },
5830
+ properties: {
5831
+ title: {
5832
+ type: 'string',
5833
+ label: 'OG Title',
5834
+ controller: { type: 'text', placeholder: 'Title for social sharing' },
5835
+ },
5836
+ description: {
5837
+ type: 'string',
5838
+ label: 'OG Description',
5839
+ controller: { type: 'textarea', placeholder: 'Description for social sharing' },
5840
+ },
5841
+ image: {
5842
+ type: 'string',
5843
+ label: 'OG Image',
5844
+ controller: { type: 'image' },
5845
+ },
5846
+ },
5847
+ },
5848
+ twitter: {
5849
+ type: 'object',
5850
+ label: 'Twitter Card',
5851
+ controller: { type: 'object' },
5852
+ properties: {
5853
+ card: {
5854
+ type: 'string',
5855
+ label: 'Card Type',
5856
+ controller: {
5857
+ type: 'select',
5858
+ options: SEO_TWITTER_CARD_OPTIONS.map(option => ({ ...option })),
5859
+ },
5860
+ },
5861
+ title: {
5862
+ type: 'string',
5863
+ label: 'Twitter Title',
5864
+ controller: { type: 'text' },
5865
+ },
5866
+ description: {
5867
+ type: 'string',
5868
+ label: 'Twitter Description',
5869
+ controller: { type: 'textarea' },
5870
+ },
5871
+ image: {
5872
+ type: 'string',
5873
+ label: 'Twitter Image',
5874
+ controller: { type: 'image' },
5875
+ },
5876
+ },
5877
+ },
5878
+ structuredData: {
5879
+ type: 'object',
5880
+ label: 'Structured Data (JSON-LD)',
5881
+ controller: { type: 'json' },
5882
+ },
5883
+ sitemap: {
5884
+ type: 'object',
5885
+ label: 'Sitemap Settings',
5886
+ controller: { type: 'object' },
5887
+ properties: {
5888
+ include: {
5889
+ type: 'boolean',
5890
+ label: 'Include in Sitemap',
5891
+ controller: { type: 'switch' },
5892
+ },
5893
+ priority: {
5894
+ type: 'number',
5895
+ label: 'Priority',
5896
+ controller: { type: 'range', options: { min: 0, max: 1, step: 0.1 } },
5897
+ },
5898
+ changeFreq: {
5899
+ type: 'string',
5900
+ label: 'Change Frequency',
5901
+ controller: {
5902
+ type: 'select',
5903
+ options: SEO_CHANGE_FREQ_OPTIONS.map(option => ({ ...option })),
5904
+ },
5905
+ },
5906
+ },
5907
+ },
5908
+ };
5909
+ }
5910
+
5911
+ /*
5912
+ * @vectoriox/iox-ui/seo-core — the shared, FRAMEWORK-FREE core of page SEO.
5913
+ *
5914
+ * ⚠️ Nothing in this entry point may import Angular, RxJS, the DOM, or Node. It is consumed by
5915
+ * THREE runtimes: the builder panel (Angular), the SSR engine (Angular), and `iox-cms` (NestJS,
5916
+ * plain Node) — the same reason `forms-core` exists.
5917
+ *
5918
+ * A page describes itself to a crawler exactly once: `seoToTags` is the only place that decides
5919
+ * which head tags a page emits, and `buildSeoSchemaProperties` the only place that declares what an
5920
+ * author can fill in. See iox-ai-guidance `architecture/cms/seo-plan.md`.
5921
+ */
5922
+
5923
+ /** `SeoBinding.pipes` is typed loosely in the framework-free core; narrow it once, here. */
5924
+ function boundSpec(binding) {
5925
+ return { path: binding.path, pipes: binding.pipes };
5926
+ }
5927
+ function resolveSeo(seo, ctx = {}) {
5928
+ // Rung 1 is exactly what the normalizer already produces: overrides, plus every non-bindable
5929
+ // field (robots, sitemap, structuredData) carried through untouched.
5930
+ const out = normalizeSeoDoc(seo);
5931
+ const raw = (seo && typeof seo === 'object' && !Array.isArray(seo) ? seo : {});
5932
+ // Derivation walks the whole tree, so do it once and only if some field will actually ask.
5933
+ let derived;
5934
+ const content = () => (derived ??= deriveSeoFromContent(ctx.tree, ctx.resolvedData));
5935
+ for (const field of SEO_BINDABLE_FIELDS) {
5936
+ if (readField(out, field) !== undefined)
5937
+ continue; // an override won
5938
+ const stored = fieldValue(raw, field);
5939
+ // Rung 2 — the binding.
5940
+ if (stored?.binding) {
5941
+ const value = resolveSeoBinding(stored.binding, ctx.resolvedData);
5942
+ if (value !== undefined) {
5943
+ writeField(out, field, value);
5944
+ continue;
5945
+ }
5946
+ }
5947
+ // Rung 3 — derive it, unless this field opted out.
5948
+ if (stored?.auto === false)
5949
+ continue;
5950
+ const source = SEO_AUTO_SOURCES.find(entry => entry.field === field)?.source;
5951
+ const auto = source ? autoValue(source, content, ctx) : undefined;
5952
+ if (auto !== undefined)
5953
+ writeField(out, field, auto);
5954
+ }
5955
+ // One last pass so a resolved value gets the same trimming and empty-branch dropping as a stored
5956
+ // one — `if (seo.og)` must keep meaning something.
5957
+ return normalizeSeoDoc(out);
5958
+ }
5959
+ /** Resolve one SEO binding against the page's data. `undefined` when its source did not resolve. */
5960
+ function resolveSeoBinding(binding, resolvedData) {
5961
+ const sourceData = resolvedData?.[binding.source];
5962
+ if (sourceData == null)
5963
+ return undefined;
5964
+ return seoString(resolveBoundValue(sourceData, boundSpec(binding)));
5965
+ }
5966
+ function autoValue(source, content, ctx) {
5967
+ switch (source) {
5968
+ case 'contentTitle':
5969
+ return content().title ?? seoString(ctx.pageName);
5970
+ case 'contentDescription':
5971
+ return content().description;
5972
+ case 'contentImage':
5973
+ return content().image;
5974
+ case 'pageUrl':
5975
+ return seoString(ctx.pageUrl);
5976
+ }
5977
+ }
5978
+ /**
5979
+ * Derive a title, description and image from the page's own content, in document order.
5980
+ *
5981
+ * ⚠️ Node bindings are resolved here too. On a template page the `<h1>` is normally bound to
5982
+ * `$current.title`, so reading `props.content` alone would return the design-time placeholder — the
5983
+ * same trap that made context slots fall through to component defaults. An unresolved binding yields
5984
+ * nothing rather than the placeholder.
5985
+ *
5986
+ * Node shapes are the renderers' own: `Heading` has `level` + `content`, `Text` has `content` (rich
5987
+ * HTML from the editor) + `tag`, `Image` has `src`.
5988
+ */
5989
+ function deriveSeoFromContent(tree, resolvedData) {
5990
+ const out = {};
5991
+ /** A heading that is not an h1 — used only if the page has no h1 at all. */
5992
+ let fallbackHeading;
5993
+ const visit = (nodes) => {
5994
+ for (const node of nodes ?? []) {
5995
+ const current = node;
5996
+ if (!current)
5997
+ continue;
5998
+ const props = resolveNodeProps(current, resolvedData);
5999
+ switch (current['type']) {
6000
+ case 'Heading': {
6001
+ const text = plainText(props['content']);
6002
+ if (text) {
6003
+ if (props['level'] === 'h1')
6004
+ out.title ??= text;
6005
+ else
6006
+ fallbackHeading ??= text;
6007
+ }
6008
+ break;
6009
+ }
6010
+ case 'Text': {
6011
+ // `span` is inline decoration, not a paragraph.
6012
+ if (props['tag'] !== 'span')
6013
+ out.description ??= truncate(plainText(props['content']), SEO_DESCRIPTION_MAX);
6014
+ break;
6015
+ }
6016
+ case 'Image': {
6017
+ out.image ??= seoString(props['src']);
6018
+ break;
6019
+ }
6020
+ }
6021
+ visit(current['children']);
6022
+ }
6023
+ };
6024
+ visit(tree);
6025
+ out.title ??= fallbackHeading;
6026
+ return out;
6027
+ }
6028
+ /** A node's props with its bindings applied — only what derivation reads, never the whole node. */
6029
+ function resolveNodeProps(node, resolvedData) {
6030
+ const props = { ...(node['props'] ?? {}) };
6031
+ for (const binding of (node['bindings'] ?? [])) {
6032
+ if (!binding?.prop)
6033
+ continue;
6034
+ const sourceData = resolvedData?.[binding.source];
6035
+ // A bound prop whose source did not resolve must NOT fall through to the design-time default.
6036
+ props[binding.prop] = sourceData == null ? undefined : resolveBoundValue(sourceData, boundSpec(binding));
6037
+ }
6038
+ return props;
6039
+ }
6040
+ /**
6041
+ * Editor content is rich HTML. Strip it to prose: tags out, block boundaries become spaces so words
6042
+ * do not fuse, the handful of entities an editor emits decoded, whitespace collapsed.
6043
+ */
6044
+ function plainText(value) {
6045
+ const raw = seoString(value);
6046
+ if (!raw)
6047
+ return undefined;
6048
+ const text = raw
6049
+ .replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, ' ')
6050
+ .replace(/<[^>]*>/g, '')
6051
+ .replace(/&nbsp;/gi, ' ')
6052
+ .replace(/&amp;/gi, '&')
6053
+ .replace(/&lt;/gi, '<')
6054
+ .replace(/&gt;/gi, '>')
6055
+ .replace(/&quot;/gi, '"')
6056
+ .replace(/&#39;|&apos;/gi, "'")
6057
+ .replace(/\s+/g, ' ')
6058
+ .trim();
6059
+ return text.length ? text : undefined;
6060
+ }
6061
+ /** Cut to `max` characters on a word boundary, with an ellipsis only when something was cut. */
6062
+ function truncate(value, max) {
6063
+ if (!value || value.length <= max)
6064
+ return value;
6065
+ const cut = value.slice(0, max - 1);
6066
+ const lastSpace = cut.lastIndexOf(' ');
6067
+ return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…';
6068
+ }
6069
+ // ── dotted-path access over the document, so the field table stays data and not a switch ───────────
6070
+ function fieldValue(raw, field) {
6071
+ const value = readField(raw, field);
6072
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
6073
+ return undefined;
6074
+ return value;
6075
+ }
6076
+ function readField(doc, field) {
6077
+ const [branch, leaf] = field.split('.');
6078
+ if (!leaf)
6079
+ return doc[branch];
6080
+ const nested = doc[branch];
6081
+ return nested && typeof nested === 'object' ? nested[leaf] : undefined;
6082
+ }
6083
+ function writeField(doc, field, value) {
6084
+ const [branch, leaf] = field.split('.');
6085
+ if (!leaf) {
6086
+ doc[branch] = value;
6087
+ return;
6088
+ }
6089
+ const nested = (doc[branch] && typeof doc[branch] === 'object' ? doc[branch] : {});
6090
+ nested[leaf] = value;
6091
+ doc[branch] = nested;
6092
+ }
6093
+
5469
6094
  /**
5470
6095
  * Element context state — shared types.
5471
6096
  *
@@ -6153,5 +6778,5 @@ function effectiveTransitionId(anim) {
6153
6778
  * Generated bundle index. Do not edit.
6154
6779
  */
6155
6780
 
6156
- 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 };
6781
+ 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_PHOSPHOR_WEIGHT, 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, normalizeIconClass, 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 };
6157
6782
  //# sourceMappingURL=vectoriox-iox-ui.mjs.map