@vectoriox/iox-ui 4.15.0 → 4.16.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.
|
@@ -1788,6 +1788,608 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
|
|
|
1788
1788
|
args: ['childrenHost', { read: ViewContainerRef, static: true }]
|
|
1789
1789
|
}] } });
|
|
1790
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
|
|
1793
|
+
* becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
|
|
1794
|
+
* the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
|
|
1795
|
+
* copy of this decision logic; only the transport differs between the two:
|
|
1796
|
+
*
|
|
1797
|
+
* builder → authenticated org-scoped controllers (admin session):
|
|
1798
|
+
* single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
|
|
1799
|
+
* collection → GET admin.../resources/:type (ContentService.getContent)
|
|
1800
|
+
* engine → @Public() controllers (no auth, isPublished filter applied server-side):
|
|
1801
|
+
* single by id → GET api/:type/:id (UserResourcesController)
|
|
1802
|
+
* collection → GET api/:type?filters (UserResourcesController)
|
|
1803
|
+
*
|
|
1804
|
+
* The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
|
|
1805
|
+
* identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
|
|
1806
|
+
* onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
|
|
1807
|
+
* `?_id=` query — to match the builder's getContentById.
|
|
1808
|
+
*/
|
|
1809
|
+
/**
|
|
1810
|
+
* Match a page route template against an actual path and extract named params.
|
|
1811
|
+
* matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
|
|
1812
|
+
* Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
|
|
1813
|
+
*/
|
|
1814
|
+
function matchRouteParams(template, actual) {
|
|
1815
|
+
const t = (template ?? '').replace(/^\/+/, '').split('/');
|
|
1816
|
+
const a = (actual ?? '').replace(/^\/+/, '').split('/');
|
|
1817
|
+
const params = {};
|
|
1818
|
+
for (let i = 0; i < t.length; i++) {
|
|
1819
|
+
const seg = t[i];
|
|
1820
|
+
if (seg && seg.startsWith(':'))
|
|
1821
|
+
params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
|
|
1822
|
+
}
|
|
1823
|
+
return params;
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
|
|
1827
|
+
* config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
|
|
1828
|
+
* map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
|
|
1829
|
+
* `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
|
|
1830
|
+
* accidentally filtered to nothing.
|
|
1831
|
+
*/
|
|
1832
|
+
function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
|
|
1833
|
+
const params = {};
|
|
1834
|
+
const byRoute = req?.filterByRouteParam;
|
|
1835
|
+
if (byRoute?.field && byRoute?.routeParam) {
|
|
1836
|
+
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
1837
|
+
if (value !== undefined && value !== null && value !== '')
|
|
1838
|
+
params[byRoute.field] = value;
|
|
1839
|
+
}
|
|
1840
|
+
for (const q of req?.filterByQueryParams ?? []) {
|
|
1841
|
+
if (!q?.field)
|
|
1842
|
+
continue;
|
|
1843
|
+
// No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
|
|
1844
|
+
// on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
|
|
1845
|
+
const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
|
|
1846
|
+
if (value !== undefined && value !== null && value !== '')
|
|
1847
|
+
params[q.field] = value;
|
|
1848
|
+
}
|
|
1849
|
+
return params;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
|
|
1853
|
+
* (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
|
|
1854
|
+
* Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
|
|
1855
|
+
* single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
|
|
1856
|
+
* path-based form only applies to the id field; a single source filtered by any other field still
|
|
1857
|
+
* goes through the collection endpoint.
|
|
1858
|
+
*/
|
|
1859
|
+
function resolveSingleItemId(req, routeParams = {}) {
|
|
1860
|
+
const byRoute = req?.filterByRouteParam;
|
|
1861
|
+
if (!byRoute?.routeParam)
|
|
1862
|
+
return null;
|
|
1863
|
+
if (byRoute.field && byRoute.field !== '_id')
|
|
1864
|
+
return null;
|
|
1865
|
+
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
1866
|
+
return value !== undefined && value !== null && value !== '' ? String(value) : null;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
|
|
1870
|
+
* decision both the builder and the SSR engine run; each then executes the plan with its own
|
|
1871
|
+
* HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
|
|
1872
|
+
* the builder's getContentById.
|
|
1873
|
+
*/
|
|
1874
|
+
function planDataSource(ds, routeParams = {}, queryParams = {}) {
|
|
1875
|
+
const mode = ds?.mode === 'single' ? 'single' : 'list';
|
|
1876
|
+
switch (ds?.type) {
|
|
1877
|
+
case 'cms':
|
|
1878
|
+
case 'shop': {
|
|
1879
|
+
const domain = ds.type;
|
|
1880
|
+
const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
|
|
1881
|
+
const resource = req?.contentType;
|
|
1882
|
+
if (!resource)
|
|
1883
|
+
return { kind: 'none' };
|
|
1884
|
+
const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
|
|
1885
|
+
if (id)
|
|
1886
|
+
return { kind: 'by-id', domain, resource, id, mode };
|
|
1887
|
+
const filter = buildDataSourceFilter(req, routeParams, queryParams);
|
|
1888
|
+
const plan = { kind: 'collection', domain, resource, filter, mode };
|
|
1889
|
+
if (req?.limit)
|
|
1890
|
+
plan.limit = req.limit;
|
|
1891
|
+
return plan;
|
|
1892
|
+
}
|
|
1893
|
+
case 'external-api': {
|
|
1894
|
+
const endpoint = ds.request?.endpoint;
|
|
1895
|
+
return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
|
|
1896
|
+
}
|
|
1897
|
+
case 'static':
|
|
1898
|
+
return { kind: 'static', data: ds.request?.data ?? null, mode };
|
|
1899
|
+
case 'derived': {
|
|
1900
|
+
const d = ds.request?.derived;
|
|
1901
|
+
return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
|
|
1902
|
+
}
|
|
1903
|
+
default:
|
|
1904
|
+
return { kind: 'none' };
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Normalise a collection response into the shape a source expects: unwraps `{items}`/`{data}`
|
|
1909
|
+
* envelopes, then returns the first item for a `single` source or the whole array for a `list`.
|
|
1910
|
+
* A `by-id` response is already the item, so callers pass it through unchanged.
|
|
1911
|
+
*/
|
|
1912
|
+
function normalizeCollectionResult(mode, data) {
|
|
1913
|
+
const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
|
|
1914
|
+
return mode === 'single' ? (items[0] ?? null) : items;
|
|
1915
|
+
}
|
|
1916
|
+
/** Navigate a dot/bracket path into an object, e.g. `gallery`, `details.images`, `variants[0].skus`.
|
|
1917
|
+
* Returns undefined if any segment is missing. Kept minimal + self-contained so it is shareable by
|
|
1918
|
+
* both the builder and the SSR engine (the two have divergent local `resolvePath` copies). */
|
|
1919
|
+
function getByPath(obj, path) {
|
|
1920
|
+
if (obj == null || !path)
|
|
1921
|
+
return obj;
|
|
1922
|
+
// Turn `a[0].b` into ['a','0','b'].
|
|
1923
|
+
const segments = path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
|
|
1924
|
+
let cur = obj;
|
|
1925
|
+
for (const seg of segments) {
|
|
1926
|
+
if (cur == null)
|
|
1927
|
+
return undefined;
|
|
1928
|
+
cur = cur[seg];
|
|
1929
|
+
}
|
|
1930
|
+
return cur;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* The array a Repeater (or a repeating Slider) iterates over — the SHARED decision so the builder
|
|
1934
|
+
* canvas and the SSR engine agree (see data-source-resolving.md).
|
|
1935
|
+
* - No `sourcePath`: the resolved source IS the list. An array passes through; a lone object degrades
|
|
1936
|
+
* to a single-row list (back-compat with the old `fetchItems`); null/undefined → `[]`.
|
|
1937
|
+
* - With `sourcePath`: the source resolved to a SINGLE resource (e.g. a product); dive into the
|
|
1938
|
+
* nested array property (e.g. `gallery`). Non-array / missing → `[]`.
|
|
1939
|
+
*/
|
|
1940
|
+
/**
|
|
1941
|
+
* Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
|
|
1942
|
+
* NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
|
|
1943
|
+
* dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
|
|
1944
|
+
* SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
|
|
1945
|
+
*/
|
|
1946
|
+
function resolveDerivedInto(dataSources, resolved) {
|
|
1947
|
+
const derived = (dataSources ?? []).filter(d => d?.type === 'derived' && !!d.alias);
|
|
1948
|
+
let changed = true, guard = 0;
|
|
1949
|
+
while (changed && guard++ < 25) {
|
|
1950
|
+
changed = false;
|
|
1951
|
+
for (const d of derived) {
|
|
1952
|
+
if (d.alias in resolved)
|
|
1953
|
+
continue;
|
|
1954
|
+
const from = d.request?.derived?.from;
|
|
1955
|
+
if (from && from in resolved) {
|
|
1956
|
+
resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
|
|
1957
|
+
changed = true;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return resolved;
|
|
1962
|
+
}
|
|
1963
|
+
function resolveRepeaterItems(resolved, sourcePath) {
|
|
1964
|
+
if (sourcePath) {
|
|
1965
|
+
const nested = getByPath(resolved, sourcePath);
|
|
1966
|
+
return Array.isArray(nested) ? nested : [];
|
|
1967
|
+
}
|
|
1968
|
+
if (Array.isArray(resolved))
|
|
1969
|
+
return resolved;
|
|
1970
|
+
return resolved == null ? [] : [resolved];
|
|
1971
|
+
}
|
|
1972
|
+
// ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
|
|
1973
|
+
// Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
|
|
1974
|
+
// (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
|
|
1975
|
+
// (resolve the referenced subset per page). See org-data-sources-plan.md.
|
|
1976
|
+
/** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
|
|
1977
|
+
* `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
|
|
1978
|
+
function collectReferencedAliases(nodes, acc = new Set()) {
|
|
1979
|
+
for (const n of nodes ?? []) {
|
|
1980
|
+
for (const b of n?.bindings ?? []) {
|
|
1981
|
+
if (b?.source)
|
|
1982
|
+
acc.add(b.source);
|
|
1983
|
+
}
|
|
1984
|
+
if (n?.props?.source)
|
|
1985
|
+
acc.add(n.props.source);
|
|
1986
|
+
if (n?.children?.length)
|
|
1987
|
+
collectReferencedAliases(n.children, acc);
|
|
1988
|
+
}
|
|
1989
|
+
return acc;
|
|
1990
|
+
}
|
|
1991
|
+
/** The subset of `all` whose `alias` is referenced by `nodes` (shallow — direct references only). */
|
|
1992
|
+
function referencedDataSources(nodes, all) {
|
|
1993
|
+
const aliases = collectReferencedAliases(nodes);
|
|
1994
|
+
return (all ?? []).filter(ds => aliases.has(ds?.alias));
|
|
1995
|
+
}
|
|
1996
|
+
/** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
|
|
1997
|
+
* none of which the layout names directly:
|
|
1998
|
+
* - a `derived` alias needs its `request.derived.from` parent;
|
|
1999
|
+
* - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
|
|
2000
|
+
* and `request.relative.from` (the collection it navigates).
|
|
2001
|
+
* Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
|
|
2002
|
+
function referencedDataSourcesDeep(nodes, all) {
|
|
2003
|
+
const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
|
|
2004
|
+
const wanted = collectReferencedAliases(nodes);
|
|
2005
|
+
const queue = [...wanted];
|
|
2006
|
+
const enqueue = (alias) => {
|
|
2007
|
+
if (alias && !wanted.has(alias)) {
|
|
2008
|
+
wanted.add(alias);
|
|
2009
|
+
queue.push(alias);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
while (queue.length) {
|
|
2013
|
+
const ds = byAlias.get(queue.pop());
|
|
2014
|
+
enqueue(ds?.request?.derived?.from);
|
|
2015
|
+
enqueue(ds?.request?.relative?.subjectFrom);
|
|
2016
|
+
enqueue(ds?.request?.relative?.from);
|
|
2017
|
+
}
|
|
2018
|
+
return (all ?? []).filter(d => wanted.has(d?.alias));
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Turn a resolved {@link DataSourcePlan} into the concrete public HTTP request (url + params) — the
|
|
2022
|
+
* SINGLE place that decides the URL shape, shared by the builder binding preview and the SSR engine
|
|
2023
|
+
* so both hit the SAME public endpoints (no admin, no duplication). Returns `null` for `static` /
|
|
2024
|
+
* `none` plans (no HTTP call). A single item is a PATH segment (`/:resource/:id`), never `?_id=`.
|
|
2025
|
+
*/
|
|
2026
|
+
function dataSourcePlanToRequest(plan, endpoints) {
|
|
2027
|
+
const baseFor = (domain) => (domain === 'shop' ? endpoints.shopBase : endpoints.cmsBase);
|
|
2028
|
+
switch (plan.kind) {
|
|
2029
|
+
case 'by-id':
|
|
2030
|
+
return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
|
|
2031
|
+
case 'collection': {
|
|
2032
|
+
const params = { ...plan.filter };
|
|
2033
|
+
if (plan.limit)
|
|
2034
|
+
params['limit'] = plan.limit;
|
|
2035
|
+
return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
|
|
2036
|
+
}
|
|
2037
|
+
case 'external':
|
|
2038
|
+
return { url: plan.endpoint, params: {} };
|
|
2039
|
+
default:
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* Gallery — data model + the SHARED "what are the image URLs" resolver.
|
|
2046
|
+
*
|
|
2047
|
+
* A Gallery is populated one of two ways (see gallery-component-plan.md):
|
|
2048
|
+
* - **Manual**: an ordered `GalleryImage[]` trait. Each item is a raw/filebox URL OR a per-item
|
|
2049
|
+
* binding path resolved against the item/route context.
|
|
2050
|
+
* - **Bound**: the whole gallery repeats over a data source (a collection, or an array property
|
|
2051
|
+
* inside one resource via `sourcePath`) — the items come from the shared `resolveRepeaterItems`,
|
|
2052
|
+
* and one field of each record (`imageField`) is the URL.
|
|
2053
|
+
*
|
|
2054
|
+
* `resolveGalleryUrls` is the SINGLE place both the builder canvas and the SSR/runtime engine turn a
|
|
2055
|
+
* node's config into the final `string[]` the crossfade loop consumes — so both sides show the same
|
|
2056
|
+
* images. It is pure and transport-agnostic; never fork it. It reuses `getByPath` (the same path
|
|
2057
|
+
* reader used by data-source resolving) so binding paths behave identically everywhere.
|
|
2058
|
+
*/
|
|
2059
|
+
/**
|
|
2060
|
+
* Resolve a gallery's configured images to the flat, display-ready `string[]` the loop renders.
|
|
2061
|
+
* Pure. Empty/whitespace URLs are dropped so a half-filled manual list never yields blank frames.
|
|
2062
|
+
*
|
|
2063
|
+
* @param images manual `GalleryImage[]` (manual mode). Ignored when `bound` is provided.
|
|
2064
|
+
* @param bound bound-mode records + image field. When present, takes precedence over `images`.
|
|
2065
|
+
* @param ctx render context for per-item bindings (item/route data). Defaults to `{}`.
|
|
2066
|
+
*/
|
|
2067
|
+
function resolveGalleryUrls(images, bound, ctx = {}) {
|
|
2068
|
+
if (bound) {
|
|
2069
|
+
const field = bound.imageField;
|
|
2070
|
+
return (bound.items ?? [])
|
|
2071
|
+
.map(item => (field ? getByPath(item, field) : item))
|
|
2072
|
+
.map(coerceUrl)
|
|
2073
|
+
.filter((u) => !!u);
|
|
2074
|
+
}
|
|
2075
|
+
return (images ?? [])
|
|
2076
|
+
.map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
|
|
2077
|
+
.map(coerceUrl)
|
|
2078
|
+
.filter((u) => !!u);
|
|
2079
|
+
}
|
|
2080
|
+
/** A resolved value becomes a URL only if it's a non-empty string; everything else drops out. */
|
|
2081
|
+
function coerceUrl(value) {
|
|
2082
|
+
if (typeof value !== 'string')
|
|
2083
|
+
return null;
|
|
2084
|
+
const trimmed = value.trim();
|
|
2085
|
+
return trimmed ? trimmed : null;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Parse a pasted block of URLs (newline- and/or comma-separated) into `GalleryImage[]`. Used by the
|
|
2089
|
+
* MediaList trait editor's "paste URL list" affordance. Pure so it can be unit-tested + reused.
|
|
2090
|
+
*/
|
|
2091
|
+
function parseUrlList(text) {
|
|
2092
|
+
if (!text)
|
|
2093
|
+
return [];
|
|
2094
|
+
return text
|
|
2095
|
+
.split(/[\n,]+/)
|
|
2096
|
+
.map(s => s.trim())
|
|
2097
|
+
.filter(Boolean)
|
|
2098
|
+
.map(url => ({ url }));
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Gallery effect registry — the SHARED, effect-agnostic description of how each gallery slide looks
|
|
2103
|
+
* and animates in a given crossfade state. Modeled on `binding-pipes.ts` / `PAGE_TRANSITION_PRESETS`:
|
|
2104
|
+
* a descriptor map + a resolve fn, all pure, consumed by BOTH the builder twin and the engine twin so
|
|
2105
|
+
* the fade looks identical on the canvas and the live site.
|
|
2106
|
+
*
|
|
2107
|
+
* v1 ships only `fade` (the look ported from `sites/interior-design` fade-gallery). Adding `slide`,
|
|
2108
|
+
* `zoom`, `kenburns`, … later is a new entry here + its CSS — NO component refactor, because the
|
|
2109
|
+
* component only ever asks `resolveGalleryEffect(id).buildSlideStyle(state)` for per-slide inline CSS.
|
|
2110
|
+
*
|
|
2111
|
+
* See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2112
|
+
*/
|
|
2113
|
+
/** The ken-burns zoom baked into the reference fade look (scale 1 → 1.05 over show+fade). */
|
|
2114
|
+
const FADE_ZOOM_EXTRA_MS = 1000;
|
|
2115
|
+
const fadeEffect = {
|
|
2116
|
+
id: 'fade',
|
|
2117
|
+
label: 'Fade',
|
|
2118
|
+
defaults: { showMs: 4000, fadeMs: 1000 },
|
|
2119
|
+
buildSlideStyle(state) {
|
|
2120
|
+
const { active, fadingOut, firstCycleActive, durations } = state;
|
|
2121
|
+
const zoomMs = durations.showMs + durations.fadeMs + FADE_ZOOM_EXTRA_MS;
|
|
2122
|
+
// Before the first cycle activates, everything is flat + instant so there is no entry flash.
|
|
2123
|
+
if (!firstCycleActive) {
|
|
2124
|
+
return { opacity: '0', transform: 'scale(1)', transition: 'opacity 0ms, transform 0ms' };
|
|
2125
|
+
}
|
|
2126
|
+
const visible = active && !fadingOut;
|
|
2127
|
+
const engaged = active || fadingOut; // current or outgoing slide is zoomed-in
|
|
2128
|
+
return {
|
|
2129
|
+
opacity: visible ? '1' : '0',
|
|
2130
|
+
transform: engaged ? 'scale(1.05)' : 'scale(1)',
|
|
2131
|
+
transition: `opacity ${durations.fadeMs}ms cubic-bezier(0.77, 0, 0.175, 1), ` +
|
|
2132
|
+
`transform ${zoomMs}ms linear`,
|
|
2133
|
+
};
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
/** The registry — one entry per effect. Keyed by id; unknown ids fall back to `fade`. */
|
|
2137
|
+
const GALLERY_EFFECTS = {
|
|
2138
|
+
fade: fadeEffect,
|
|
2139
|
+
};
|
|
2140
|
+
/** Resolve an effect id to its descriptor, defaulting to `fade` (forward-compatible with new ids). */
|
|
2141
|
+
function resolveGalleryEffect(id) {
|
|
2142
|
+
return (id && GALLERY_EFFECTS[id]) || GALLERY_EFFECTS['fade'];
|
|
2143
|
+
}
|
|
2144
|
+
const GALLERY_EFFECT_DESCRIPTORS = Object.values(GALLERY_EFFECTS).map(e => ({ id: e.id, label: e.label }));
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Gallery crossfade loop — the SHARED, framework-free state machine that drives which image is
|
|
2148
|
+
* visible over time. Ported from the reference fade-gallery's `scheduleCycle` / `tryStartLoop` /
|
|
2149
|
+
* first-cycle-activation logic, with Angular specifics removed so BOTH the builder twin and the
|
|
2150
|
+
* engine/runtime twin (`iox-gallery`) run ONE implementation — never a forked copy that drifts.
|
|
2151
|
+
*
|
|
2152
|
+
* The loop owns only *timing + index* state and emits it through `onChange`; the component maps that
|
|
2153
|
+
* state to CSS via the effect registry (`gallery-effects.ts`). Timers are injected so the loop is
|
|
2154
|
+
* deterministic under fake timers in tests and platform-guardable in the engine.
|
|
2155
|
+
*
|
|
2156
|
+
* See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2157
|
+
*/
|
|
2158
|
+
/** Default scheduler over the platform's `setTimeout` / `requestAnimationFrame` (with a timer fallback). */
|
|
2159
|
+
const DEFAULT_GALLERY_SCHEDULER = {
|
|
2160
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
2161
|
+
clearTimer: h => clearTimeout(h),
|
|
2162
|
+
nextFrame: fn => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16),
|
|
2163
|
+
cancelFrame: h => {
|
|
2164
|
+
if (typeof cancelAnimationFrame === 'function')
|
|
2165
|
+
cancelAnimationFrame(h);
|
|
2166
|
+
else
|
|
2167
|
+
clearTimeout(h);
|
|
2168
|
+
},
|
|
2169
|
+
};
|
|
2170
|
+
class GalleryLoop {
|
|
2171
|
+
constructor(opts) {
|
|
2172
|
+
this.count = 0;
|
|
2173
|
+
this.currentIndex = 0;
|
|
2174
|
+
this.fadingOutIndex = null;
|
|
2175
|
+
this.firstCycleActive = false;
|
|
2176
|
+
this.cycleTimer = null;
|
|
2177
|
+
this.fadeTimer = null;
|
|
2178
|
+
this.frameHandle = null;
|
|
2179
|
+
/** Bumped on every (re)start so a stale timer callback from a previous run is ignored. */
|
|
2180
|
+
this.runToken = 0;
|
|
2181
|
+
this.running = false;
|
|
2182
|
+
this.scheduler = opts.scheduler ?? DEFAULT_GALLERY_SCHEDULER;
|
|
2183
|
+
this.showMs = opts.showMs;
|
|
2184
|
+
this.fadeMs = opts.fadeMs;
|
|
2185
|
+
this.onChange = opts.onChange;
|
|
2186
|
+
}
|
|
2187
|
+
get state() {
|
|
2188
|
+
return {
|
|
2189
|
+
currentIndex: this.currentIndex,
|
|
2190
|
+
fadingOutIndex: this.fadingOutIndex,
|
|
2191
|
+
firstCycleActive: this.firstCycleActive,
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
/** Start (or restart) the loop for `count` images. A count < 2 shows the single image without cycling. */
|
|
2195
|
+
start(count) {
|
|
2196
|
+
this.stop();
|
|
2197
|
+
this.count = Math.max(0, count | 0);
|
|
2198
|
+
this.currentIndex = 0;
|
|
2199
|
+
this.fadingOutIndex = null;
|
|
2200
|
+
this.firstCycleActive = false;
|
|
2201
|
+
if (this.count === 0) {
|
|
2202
|
+
this.emit();
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
this.running = true;
|
|
2206
|
+
const token = ++this.runToken;
|
|
2207
|
+
// Activate on the next frame so the first paint is flat (no entry-transition flash), then
|
|
2208
|
+
// begin cycling only when there is more than one image to advance through.
|
|
2209
|
+
this.frameHandle = this.scheduler.nextFrame(() => {
|
|
2210
|
+
if (token !== this.runToken || !this.running)
|
|
2211
|
+
return;
|
|
2212
|
+
this.firstCycleActive = true;
|
|
2213
|
+
this.emit();
|
|
2214
|
+
if (this.count > 1)
|
|
2215
|
+
this.scheduleCycle(token);
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
/** Update timing live. Takes effect on the next scheduled cycle; does not restart the loop. */
|
|
2219
|
+
setDurations(showMs, fadeMs) {
|
|
2220
|
+
this.showMs = showMs;
|
|
2221
|
+
this.fadeMs = fadeMs;
|
|
2222
|
+
}
|
|
2223
|
+
/** Stop and clear all timers. Idempotent. State is retained (call `start` to reset). */
|
|
2224
|
+
stop() {
|
|
2225
|
+
this.running = false;
|
|
2226
|
+
this.runToken++;
|
|
2227
|
+
if (this.cycleTimer != null) {
|
|
2228
|
+
this.scheduler.clearTimer(this.cycleTimer);
|
|
2229
|
+
this.cycleTimer = null;
|
|
2230
|
+
}
|
|
2231
|
+
if (this.fadeTimer != null) {
|
|
2232
|
+
this.scheduler.clearTimer(this.fadeTimer);
|
|
2233
|
+
this.fadeTimer = null;
|
|
2234
|
+
}
|
|
2235
|
+
if (this.frameHandle != null) {
|
|
2236
|
+
this.scheduler.cancelFrame(this.frameHandle);
|
|
2237
|
+
this.frameHandle = null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
scheduleCycle(token) {
|
|
2241
|
+
if (token !== this.runToken || !this.running || this.count < 2)
|
|
2242
|
+
return;
|
|
2243
|
+
this.cycleTimer = this.scheduler.setTimer(() => {
|
|
2244
|
+
if (token !== this.runToken || !this.running)
|
|
2245
|
+
return;
|
|
2246
|
+
this.fadingOutIndex = this.currentIndex;
|
|
2247
|
+
this.currentIndex = (this.currentIndex + 1) % this.count;
|
|
2248
|
+
this.emit();
|
|
2249
|
+
this.fadeTimer = this.scheduler.setTimer(() => {
|
|
2250
|
+
if (token !== this.runToken || !this.running)
|
|
2251
|
+
return;
|
|
2252
|
+
this.fadingOutIndex = null;
|
|
2253
|
+
this.emit();
|
|
2254
|
+
this.scheduleCycle(token);
|
|
2255
|
+
}, this.fadeMs);
|
|
2256
|
+
}, this.showMs);
|
|
2257
|
+
}
|
|
2258
|
+
emit() {
|
|
2259
|
+
this.onChange(this.state);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* `iox-gallery` — the engine/runtime twin of the Gallery builder component (the builder twin
|
|
2265
|
+
* `app-builder-gallery` arrives in Phase 2). It runs the SHARED crossfade loop + effect registry from
|
|
2266
|
+
* `@vectoriox/iox-ui`, so the fade behaves identically on the canvas and the live site — no forked
|
|
2267
|
+
* animation logic. See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2268
|
+
*
|
|
2269
|
+
* Population: v1 drives the loop from the `images` input (manual mode). Data-source binding
|
|
2270
|
+
* (`source`/`sourcePath`) is Phase 3 — this component already resolves through `resolveGalleryUrls`,
|
|
2271
|
+
* so that only adds a bound-source input, not new render logic.
|
|
2272
|
+
*
|
|
2273
|
+
* SSR / no-JS: on the server the loop never starts, so the **first image renders visible** as a static
|
|
2274
|
+
* frame (SEO + no-JS baseline). In the browser `ngAfterViewInit` starts the loop, which begins on the
|
|
2275
|
+
* same index 0 — so the static frame and the first live frame match and there is no hydration flash.
|
|
2276
|
+
*/
|
|
2277
|
+
class ClientGalleryComponent {
|
|
2278
|
+
constructor(platformId, cdr) {
|
|
2279
|
+
this.cdr = cdr;
|
|
2280
|
+
this.nodeId = '';
|
|
2281
|
+
/** Manual images — `GalleryImage[]` or a plain `string[]` of URLs (normalised on set). */
|
|
2282
|
+
this.images = [];
|
|
2283
|
+
this.effect = 'fade';
|
|
2284
|
+
this.showDuration = 4000;
|
|
2285
|
+
this.fadeDuration = 1000;
|
|
2286
|
+
this.autoStart = true;
|
|
2287
|
+
/** Layout mode — only `'stack'` is rendered in v1 (`'grid'` scaffolded for later). */
|
|
2288
|
+
this.layout = 'stack';
|
|
2289
|
+
/** The flat, display-ready URLs the loop cycles. Recomputed when `images` changes. */
|
|
2290
|
+
this.urls = [];
|
|
2291
|
+
this.currentIndex = 0;
|
|
2292
|
+
this.fadingOutIndex = null;
|
|
2293
|
+
this.started = false;
|
|
2294
|
+
this.loop = null;
|
|
2295
|
+
this.isBrowser = isPlatformBrowser(platformId);
|
|
2296
|
+
}
|
|
2297
|
+
ngOnChanges(changes) {
|
|
2298
|
+
if (changes['images']) {
|
|
2299
|
+
this.urls = resolveGalleryUrls(this.normalizeImages(), null);
|
|
2300
|
+
}
|
|
2301
|
+
if (this.loop && (changes['showDuration'] || changes['fadeDuration'])) {
|
|
2302
|
+
this.loop.setDurations(this.showDuration, this.fadeDuration);
|
|
2303
|
+
}
|
|
2304
|
+
// If the image set changed after the loop was already running, restart it on the new count.
|
|
2305
|
+
if (this.loop && changes['images'] && !changes['images'].firstChange) {
|
|
2306
|
+
this.startLoop();
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
ngAfterViewInit() {
|
|
2310
|
+
if (this.isBrowser && this.autoStart) {
|
|
2311
|
+
this.startLoop();
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
ngOnDestroy() {
|
|
2315
|
+
this.loop?.stop();
|
|
2316
|
+
this.loop = null;
|
|
2317
|
+
}
|
|
2318
|
+
/** Per-slide inline style. Before the loop activates (server + no-JS + pre-first-frame) the first
|
|
2319
|
+
* image is the visible static frame; once running, the effect owns each slide's look. */
|
|
2320
|
+
slideStyle(i) {
|
|
2321
|
+
if (!this.started) {
|
|
2322
|
+
return i === 0
|
|
2323
|
+
? { opacity: '1', transform: 'scale(1)', transition: 'none' }
|
|
2324
|
+
: { opacity: '0', transform: 'scale(1)', transition: 'none' };
|
|
2325
|
+
}
|
|
2326
|
+
return resolveGalleryEffect(this.effect).buildSlideStyle({
|
|
2327
|
+
active: i === this.currentIndex,
|
|
2328
|
+
fadingOut: i === this.fadingOutIndex,
|
|
2329
|
+
firstCycleActive: true,
|
|
2330
|
+
durations: { showMs: this.showDuration, fadeMs: this.fadeDuration },
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
startLoop() {
|
|
2334
|
+
if (!this.isBrowser)
|
|
2335
|
+
return;
|
|
2336
|
+
this.loop?.stop();
|
|
2337
|
+
this.loop = new GalleryLoop({
|
|
2338
|
+
showMs: this.showDuration,
|
|
2339
|
+
fadeMs: this.fadeDuration,
|
|
2340
|
+
onChange: state => {
|
|
2341
|
+
this.currentIndex = state.currentIndex;
|
|
2342
|
+
this.fadingOutIndex = state.fadingOutIndex;
|
|
2343
|
+
this.started = state.firstCycleActive;
|
|
2344
|
+
this.cdr.markForCheck();
|
|
2345
|
+
},
|
|
2346
|
+
});
|
|
2347
|
+
this.loop.start(this.urls.length);
|
|
2348
|
+
}
|
|
2349
|
+
normalizeImages() {
|
|
2350
|
+
return (this.images ?? []).map(img => (typeof img === 'string' ? { url: img } : img));
|
|
2351
|
+
}
|
|
2352
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ClientGalleryComponent, deps: [{ token: PLATFORM_ID }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2353
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: ClientGalleryComponent, isStandalone: false, selector: "iox-gallery", inputs: { nodeId: "nodeId", images: "images", effect: "effect", showDuration: "showDuration", fadeDuration: "fadeDuration", autoStart: "autoStart", layout: "layout" }, usesOnChanges: true, ngImport: i0, template: `
|
|
2354
|
+
<div class="iox-gallery" [class]="'iox-node-' + nodeId">
|
|
2355
|
+
<div *ngFor="let url of urls; index as i"
|
|
2356
|
+
class="iox-gallery__img"
|
|
2357
|
+
[style.background-image]="'url(' + url + ')'"
|
|
2358
|
+
[ngStyle]="slideStyle(i)"></div>
|
|
2359
|
+
<div class="iox-gallery__overlay"></div>
|
|
2360
|
+
</div>
|
|
2361
|
+
`, isInline: true, styles: [":host{display:block;width:100%;height:100%}.iox-gallery{position:relative;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); }
|
|
2362
|
+
}
|
|
2363
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ClientGalleryComponent, decorators: [{
|
|
2364
|
+
type: Component,
|
|
2365
|
+
args: [{ selector: 'iox-gallery', template: `
|
|
2366
|
+
<div class="iox-gallery" [class]="'iox-node-' + nodeId">
|
|
2367
|
+
<div *ngFor="let url of urls; index as i"
|
|
2368
|
+
class="iox-gallery__img"
|
|
2369
|
+
[style.background-image]="'url(' + url + ')'"
|
|
2370
|
+
[ngStyle]="slideStyle(i)"></div>
|
|
2371
|
+
<div class="iox-gallery__overlay"></div>
|
|
2372
|
+
</div>
|
|
2373
|
+
`, standalone: false, styles: [":host{display:block;width:100%;height:100%}.iox-gallery{position:relative;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"] }]
|
|
2374
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
2375
|
+
type: Inject,
|
|
2376
|
+
args: [PLATFORM_ID]
|
|
2377
|
+
}] }, { type: i0.ChangeDetectorRef }], propDecorators: { nodeId: [{
|
|
2378
|
+
type: Input
|
|
2379
|
+
}], images: [{
|
|
2380
|
+
type: Input
|
|
2381
|
+
}], effect: [{
|
|
2382
|
+
type: Input
|
|
2383
|
+
}], showDuration: [{
|
|
2384
|
+
type: Input
|
|
2385
|
+
}], fadeDuration: [{
|
|
2386
|
+
type: Input
|
|
2387
|
+
}], autoStart: [{
|
|
2388
|
+
type: Input
|
|
2389
|
+
}], layout: [{
|
|
2390
|
+
type: Input
|
|
2391
|
+
}] } });
|
|
2392
|
+
|
|
1791
2393
|
const COMPONENTS = [
|
|
1792
2394
|
BuilderImageComponent,
|
|
1793
2395
|
BuilderSpacerComponent,
|
|
@@ -1807,6 +2409,7 @@ const COMPONENTS = [
|
|
|
1807
2409
|
ClientSliderSlideComponent,
|
|
1808
2410
|
ClientListComponent,
|
|
1809
2411
|
ClientListItemComponent,
|
|
2412
|
+
ClientGalleryComponent,
|
|
1810
2413
|
];
|
|
1811
2414
|
class IoxBuilderComponentsModule {
|
|
1812
2415
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
|
|
@@ -1827,7 +2430,8 @@ class IoxBuilderComponentsModule {
|
|
|
1827
2430
|
ClientSliderContainerComponent,
|
|
1828
2431
|
ClientSliderSlideComponent,
|
|
1829
2432
|
ClientListComponent,
|
|
1830
|
-
ClientListItemComponent
|
|
2433
|
+
ClientListItemComponent,
|
|
2434
|
+
ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
|
|
1831
2435
|
BuilderSpacerComponent,
|
|
1832
2436
|
CardComponent,
|
|
1833
2437
|
BuilderDividerComponent,
|
|
@@ -1844,7 +2448,8 @@ class IoxBuilderComponentsModule {
|
|
|
1844
2448
|
ClientSliderContainerComponent,
|
|
1845
2449
|
ClientSliderSlideComponent,
|
|
1846
2450
|
ClientListComponent,
|
|
1847
|
-
ClientListItemComponent
|
|
2451
|
+
ClientListItemComponent,
|
|
2452
|
+
ClientGalleryComponent] }); }
|
|
1848
2453
|
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
|
|
1849
2454
|
}
|
|
1850
2455
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
|
|
@@ -1891,6 +2496,7 @@ class IoxComponentRegistryService {
|
|
|
1891
2496
|
['SliderSlide', ClientSliderSlideComponent],
|
|
1892
2497
|
['List', ClientListComponent],
|
|
1893
2498
|
['ListItem', ClientListItemComponent],
|
|
2499
|
+
['Gallery', ClientGalleryComponent],
|
|
1894
2500
|
]);
|
|
1895
2501
|
}
|
|
1896
2502
|
getComponent(type) {
|
|
@@ -2229,259 +2835,6 @@ function composeVirtualTraits(raw, base = raw) {
|
|
|
2229
2835
|
return result;
|
|
2230
2836
|
}
|
|
2231
2837
|
|
|
2232
|
-
/**
|
|
2233
|
-
* Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
|
|
2234
|
-
* becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
|
|
2235
|
-
* the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
|
|
2236
|
-
* copy of this decision logic; only the transport differs between the two:
|
|
2237
|
-
*
|
|
2238
|
-
* builder → authenticated org-scoped controllers (admin session):
|
|
2239
|
-
* single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
|
|
2240
|
-
* collection → GET admin.../resources/:type (ContentService.getContent)
|
|
2241
|
-
* engine → @Public() controllers (no auth, isPublished filter applied server-side):
|
|
2242
|
-
* single by id → GET api/:type/:id (UserResourcesController)
|
|
2243
|
-
* collection → GET api/:type?filters (UserResourcesController)
|
|
2244
|
-
*
|
|
2245
|
-
* The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
|
|
2246
|
-
* identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
|
|
2247
|
-
* onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
|
|
2248
|
-
* `?_id=` query — to match the builder's getContentById.
|
|
2249
|
-
*/
|
|
2250
|
-
/**
|
|
2251
|
-
* Match a page route template against an actual path and extract named params.
|
|
2252
|
-
* matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
|
|
2253
|
-
* Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
|
|
2254
|
-
*/
|
|
2255
|
-
function matchRouteParams(template, actual) {
|
|
2256
|
-
const t = (template ?? '').replace(/^\/+/, '').split('/');
|
|
2257
|
-
const a = (actual ?? '').replace(/^\/+/, '').split('/');
|
|
2258
|
-
const params = {};
|
|
2259
|
-
for (let i = 0; i < t.length; i++) {
|
|
2260
|
-
const seg = t[i];
|
|
2261
|
-
if (seg && seg.startsWith(':'))
|
|
2262
|
-
params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
|
|
2263
|
-
}
|
|
2264
|
-
return params;
|
|
2265
|
-
}
|
|
2266
|
-
/**
|
|
2267
|
-
* Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
|
|
2268
|
-
* config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
|
|
2269
|
-
* map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
|
|
2270
|
-
* `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
|
|
2271
|
-
* accidentally filtered to nothing.
|
|
2272
|
-
*/
|
|
2273
|
-
function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
|
|
2274
|
-
const params = {};
|
|
2275
|
-
const byRoute = req?.filterByRouteParam;
|
|
2276
|
-
if (byRoute?.field && byRoute?.routeParam) {
|
|
2277
|
-
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
2278
|
-
if (value !== undefined && value !== null && value !== '')
|
|
2279
|
-
params[byRoute.field] = value;
|
|
2280
|
-
}
|
|
2281
|
-
for (const q of req?.filterByQueryParams ?? []) {
|
|
2282
|
-
if (!q?.field)
|
|
2283
|
-
continue;
|
|
2284
|
-
// No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
|
|
2285
|
-
// on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
|
|
2286
|
-
const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
|
|
2287
|
-
if (value !== undefined && value !== null && value !== '')
|
|
2288
|
-
params[q.field] = value;
|
|
2289
|
-
}
|
|
2290
|
-
return params;
|
|
2291
|
-
}
|
|
2292
|
-
/**
|
|
2293
|
-
* Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
|
|
2294
|
-
* (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
|
|
2295
|
-
* Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
|
|
2296
|
-
* single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
|
|
2297
|
-
* path-based form only applies to the id field; a single source filtered by any other field still
|
|
2298
|
-
* goes through the collection endpoint.
|
|
2299
|
-
*/
|
|
2300
|
-
function resolveSingleItemId(req, routeParams = {}) {
|
|
2301
|
-
const byRoute = req?.filterByRouteParam;
|
|
2302
|
-
if (!byRoute?.routeParam)
|
|
2303
|
-
return null;
|
|
2304
|
-
if (byRoute.field && byRoute.field !== '_id')
|
|
2305
|
-
return null;
|
|
2306
|
-
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
2307
|
-
return value !== undefined && value !== null && value !== '' ? String(value) : null;
|
|
2308
|
-
}
|
|
2309
|
-
/**
|
|
2310
|
-
* Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
|
|
2311
|
-
* decision both the builder and the SSR engine run; each then executes the plan with its own
|
|
2312
|
-
* HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
|
|
2313
|
-
* the builder's getContentById.
|
|
2314
|
-
*/
|
|
2315
|
-
function planDataSource(ds, routeParams = {}, queryParams = {}) {
|
|
2316
|
-
const mode = ds?.mode === 'single' ? 'single' : 'list';
|
|
2317
|
-
switch (ds?.type) {
|
|
2318
|
-
case 'cms':
|
|
2319
|
-
case 'shop': {
|
|
2320
|
-
const domain = ds.type;
|
|
2321
|
-
const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
|
|
2322
|
-
const resource = req?.contentType;
|
|
2323
|
-
if (!resource)
|
|
2324
|
-
return { kind: 'none' };
|
|
2325
|
-
const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
|
|
2326
|
-
if (id)
|
|
2327
|
-
return { kind: 'by-id', domain, resource, id, mode };
|
|
2328
|
-
const filter = buildDataSourceFilter(req, routeParams, queryParams);
|
|
2329
|
-
const plan = { kind: 'collection', domain, resource, filter, mode };
|
|
2330
|
-
if (req?.limit)
|
|
2331
|
-
plan.limit = req.limit;
|
|
2332
|
-
return plan;
|
|
2333
|
-
}
|
|
2334
|
-
case 'external-api': {
|
|
2335
|
-
const endpoint = ds.request?.endpoint;
|
|
2336
|
-
return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
|
|
2337
|
-
}
|
|
2338
|
-
case 'static':
|
|
2339
|
-
return { kind: 'static', data: ds.request?.data ?? null, mode };
|
|
2340
|
-
case 'derived': {
|
|
2341
|
-
const d = ds.request?.derived;
|
|
2342
|
-
return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
|
|
2343
|
-
}
|
|
2344
|
-
default:
|
|
2345
|
-
return { kind: 'none' };
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
/**
|
|
2349
|
-
* Normalise a collection response into the shape a source expects: unwraps `{items}`/`{data}`
|
|
2350
|
-
* envelopes, then returns the first item for a `single` source or the whole array for a `list`.
|
|
2351
|
-
* A `by-id` response is already the item, so callers pass it through unchanged.
|
|
2352
|
-
*/
|
|
2353
|
-
function normalizeCollectionResult(mode, data) {
|
|
2354
|
-
const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
|
|
2355
|
-
return mode === 'single' ? (items[0] ?? null) : items;
|
|
2356
|
-
}
|
|
2357
|
-
/** Navigate a dot/bracket path into an object, e.g. `gallery`, `details.images`, `variants[0].skus`.
|
|
2358
|
-
* Returns undefined if any segment is missing. Kept minimal + self-contained so it is shareable by
|
|
2359
|
-
* both the builder and the SSR engine (the two have divergent local `resolvePath` copies). */
|
|
2360
|
-
function getByPath(obj, path) {
|
|
2361
|
-
if (obj == null || !path)
|
|
2362
|
-
return obj;
|
|
2363
|
-
// Turn `a[0].b` into ['a','0','b'].
|
|
2364
|
-
const segments = path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
|
|
2365
|
-
let cur = obj;
|
|
2366
|
-
for (const seg of segments) {
|
|
2367
|
-
if (cur == null)
|
|
2368
|
-
return undefined;
|
|
2369
|
-
cur = cur[seg];
|
|
2370
|
-
}
|
|
2371
|
-
return cur;
|
|
2372
|
-
}
|
|
2373
|
-
/**
|
|
2374
|
-
* The array a Repeater (or a repeating Slider) iterates over — the SHARED decision so the builder
|
|
2375
|
-
* canvas and the SSR engine agree (see data-source-resolving.md).
|
|
2376
|
-
* - No `sourcePath`: the resolved source IS the list. An array passes through; a lone object degrades
|
|
2377
|
-
* to a single-row list (back-compat with the old `fetchItems`); null/undefined → `[]`.
|
|
2378
|
-
* - With `sourcePath`: the source resolved to a SINGLE resource (e.g. a product); dive into the
|
|
2379
|
-
* nested array property (e.g. `gallery`). Non-array / missing → `[]`.
|
|
2380
|
-
*/
|
|
2381
|
-
/**
|
|
2382
|
-
* Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
|
|
2383
|
-
* NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
|
|
2384
|
-
* dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
|
|
2385
|
-
* SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
|
|
2386
|
-
*/
|
|
2387
|
-
function resolveDerivedInto(dataSources, resolved) {
|
|
2388
|
-
const derived = (dataSources ?? []).filter(d => d?.type === 'derived' && !!d.alias);
|
|
2389
|
-
let changed = true, guard = 0;
|
|
2390
|
-
while (changed && guard++ < 25) {
|
|
2391
|
-
changed = false;
|
|
2392
|
-
for (const d of derived) {
|
|
2393
|
-
if (d.alias in resolved)
|
|
2394
|
-
continue;
|
|
2395
|
-
const from = d.request?.derived?.from;
|
|
2396
|
-
if (from && from in resolved) {
|
|
2397
|
-
resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
|
|
2398
|
-
changed = true;
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
return resolved;
|
|
2403
|
-
}
|
|
2404
|
-
function resolveRepeaterItems(resolved, sourcePath) {
|
|
2405
|
-
if (sourcePath) {
|
|
2406
|
-
const nested = getByPath(resolved, sourcePath);
|
|
2407
|
-
return Array.isArray(nested) ? nested : [];
|
|
2408
|
-
}
|
|
2409
|
-
if (Array.isArray(resolved))
|
|
2410
|
-
return resolved;
|
|
2411
|
-
return resolved == null ? [] : [resolved];
|
|
2412
|
-
}
|
|
2413
|
-
// ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
|
|
2414
|
-
// Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
|
|
2415
|
-
// (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
|
|
2416
|
-
// (resolve the referenced subset per page). See org-data-sources-plan.md.
|
|
2417
|
-
/** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
|
|
2418
|
-
* `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
|
|
2419
|
-
function collectReferencedAliases(nodes, acc = new Set()) {
|
|
2420
|
-
for (const n of nodes ?? []) {
|
|
2421
|
-
for (const b of n?.bindings ?? []) {
|
|
2422
|
-
if (b?.source)
|
|
2423
|
-
acc.add(b.source);
|
|
2424
|
-
}
|
|
2425
|
-
if (n?.props?.source)
|
|
2426
|
-
acc.add(n.props.source);
|
|
2427
|
-
if (n?.children?.length)
|
|
2428
|
-
collectReferencedAliases(n.children, acc);
|
|
2429
|
-
}
|
|
2430
|
-
return acc;
|
|
2431
|
-
}
|
|
2432
|
-
/** The subset of `all` whose `alias` is referenced by `nodes` (shallow — direct references only). */
|
|
2433
|
-
function referencedDataSources(nodes, all) {
|
|
2434
|
-
const aliases = collectReferencedAliases(nodes);
|
|
2435
|
-
return (all ?? []).filter(ds => aliases.has(ds?.alias));
|
|
2436
|
-
}
|
|
2437
|
-
/** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
|
|
2438
|
-
* none of which the layout names directly:
|
|
2439
|
-
* - a `derived` alias needs its `request.derived.from` parent;
|
|
2440
|
-
* - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
|
|
2441
|
-
* and `request.relative.from` (the collection it navigates).
|
|
2442
|
-
* Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
|
|
2443
|
-
function referencedDataSourcesDeep(nodes, all) {
|
|
2444
|
-
const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
|
|
2445
|
-
const wanted = collectReferencedAliases(nodes);
|
|
2446
|
-
const queue = [...wanted];
|
|
2447
|
-
const enqueue = (alias) => {
|
|
2448
|
-
if (alias && !wanted.has(alias)) {
|
|
2449
|
-
wanted.add(alias);
|
|
2450
|
-
queue.push(alias);
|
|
2451
|
-
}
|
|
2452
|
-
};
|
|
2453
|
-
while (queue.length) {
|
|
2454
|
-
const ds = byAlias.get(queue.pop());
|
|
2455
|
-
enqueue(ds?.request?.derived?.from);
|
|
2456
|
-
enqueue(ds?.request?.relative?.subjectFrom);
|
|
2457
|
-
enqueue(ds?.request?.relative?.from);
|
|
2458
|
-
}
|
|
2459
|
-
return (all ?? []).filter(d => wanted.has(d?.alias));
|
|
2460
|
-
}
|
|
2461
|
-
/**
|
|
2462
|
-
* Turn a resolved {@link DataSourcePlan} into the concrete public HTTP request (url + params) — the
|
|
2463
|
-
* SINGLE place that decides the URL shape, shared by the builder binding preview and the SSR engine
|
|
2464
|
-
* so both hit the SAME public endpoints (no admin, no duplication). Returns `null` for `static` /
|
|
2465
|
-
* `none` plans (no HTTP call). A single item is a PATH segment (`/:resource/:id`), never `?_id=`.
|
|
2466
|
-
*/
|
|
2467
|
-
function dataSourcePlanToRequest(plan, endpoints) {
|
|
2468
|
-
const baseFor = (domain) => (domain === 'shop' ? endpoints.shopBase : endpoints.cmsBase);
|
|
2469
|
-
switch (plan.kind) {
|
|
2470
|
-
case 'by-id':
|
|
2471
|
-
return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
|
|
2472
|
-
case 'collection': {
|
|
2473
|
-
const params = { ...plan.filter };
|
|
2474
|
-
if (plan.limit)
|
|
2475
|
-
params['limit'] = plan.limit;
|
|
2476
|
-
return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
|
|
2477
|
-
}
|
|
2478
|
-
case 'external':
|
|
2479
|
-
return { url: plan.endpoint, params: {} };
|
|
2480
|
-
default:
|
|
2481
|
-
return null;
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
2838
|
/**
|
|
2486
2839
|
* Shared RELATIVE (route-context) data resolving — the SINGLE source of truth for how a detail page
|
|
2487
2840
|
* derives values FROM its current route item, consumed by BOTH the page builder (iox-cms-client) and
|
|
@@ -3187,5 +3540,5 @@ function effectiveTransitionId(anim) {
|
|
|
3187
3540
|
* Generated bundle index. Do not edit.
|
|
3188
3541
|
*/
|
|
3189
3542
|
|
|
3190
|
-
export { AOSService, AnalyticsService, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CardComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, ENVIRONMENT, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, applyBindingPipes, applyFeel, buildDataSourceFilter, buildRelatedFilter, buildTransitionTimeline, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, dataSourcePlanToRequest, effectiveTransitionId, excludeSelf, feelFor, getByPath, isReservedAlias, legacyEnterToTransition, matchRouteParams, normalizeCollectionResult, planDataSource, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveDerivedInto, resolvePageTransition, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, rewriteViewportUnits, stripSuppressedProps, styleKeyToKebab };
|
|
3543
|
+
export { AOSService, AnalyticsService, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, ENVIRONMENT, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, applyBindingPipes, applyFeel, buildDataSourceFilter, buildRelatedFilter, buildTransitionTimeline, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, dataSourcePlanToRequest, effectiveTransitionId, excludeSelf, feelFor, getByPath, isReservedAlias, legacyEnterToTransition, matchRouteParams, normalizeCollectionResult, parseUrlList, planDataSource, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolvePageTransition, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, rewriteViewportUnits, stripSuppressedProps, styleKeyToKebab };
|
|
3191
3544
|
//# sourceMappingURL=vectoriox-iox-ui.mjs.map
|