@hortonstudio/main 1.9.10 → 1.9.20
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/.prettierrc +8 -0
- package/README.md +146 -0
- package/eslint.config.js +32 -0
- package/index.ts +275 -0
- package/package.json +19 -2
- package/public/bootstrap.js +16 -0
- package/src/animations/animations.ts +93 -0
- package/src/animations/functions/counter/counter.ts +137 -0
- package/src/config.json +570 -0
- package/src/config.ts +105 -0
- package/src/modules/default/README.md +167 -0
- package/src/modules/default/default.ts +71 -0
- package/{autoInit → src/modules/default/functions}/accessibility/README.md +44 -12
- package/src/modules/default/functions/accessibility/accessibility.ts +54 -0
- package/src/modules/default/functions/accordion/README.md +451 -0
- package/src/modules/default/functions/accordion/accordion.ts +189 -0
- package/src/modules/default/functions/comparison/comparison.ts +424 -0
- package/src/modules/default/functions/marquee/marquee.ts +206 -0
- package/src/modules/default/functions/navbar/README.md +393 -0
- package/src/modules/default/functions/navbar/functions/arrow-navigation/arrow-navigation.ts +183 -0
- package/src/modules/default/functions/navbar/functions/dropdown/dropdown.ts +313 -0
- package/src/modules/default/functions/navbar/functions/menu/menu.ts +315 -0
- package/src/modules/default/functions/navbar/navbar.ts +51 -0
- package/{autoInit → src/modules/default/functions}/smooth-scroll/README.md +45 -14
- package/{autoInit/smooth-scroll/smooth-scroll.js → src/modules/default/functions/smooth-scroll/smooth-scroll.ts} +33 -38
- package/{autoInit → src/modules/default/functions}/transition/README.md +59 -32
- package/src/modules/default/functions/transition/transition.ts +290 -0
- package/src/modules/normalize/README.md +172 -0
- package/src/modules/normalize/functions/clickable/README.md +84 -0
- package/src/modules/normalize/functions/clickable/clickable.ts +43 -0
- package/src/modules/normalize/functions/clickable/functions/normalize/README.md +213 -0
- package/src/modules/normalize/functions/clickable/functions/normalize/normalize.ts +68 -0
- package/src/modules/normalize/functions/dupe/README.md +405 -0
- package/src/modules/normalize/functions/dupe/dupe.ts +197 -0
- package/src/modules/normalize/functions/sync/sync.ts +378 -0
- package/src/modules/normalize/normalize.ts +58 -0
- package/src/modules/structure/README.md +190 -0
- package/src/modules/structure/functions/form/README.md +94 -0
- package/src/modules/structure/functions/form/form.ts +54 -0
- package/src/modules/structure/functions/form/functions/honeypot/README.md +77 -0
- package/src/modules/structure/functions/form/functions/honeypot/honeypot.ts +37 -0
- package/src/modules/structure/functions/form/functions/range/README.md +410 -0
- package/src/modules/structure/functions/form/functions/range/range.ts +92 -0
- package/src/modules/structure/functions/form/functions/select/README.md +393 -0
- package/src/modules/structure/functions/form/functions/select/functions/custom-select/custom-select.ts +637 -0
- package/src/modules/structure/functions/form/functions/select/functions/states/states.ts +118 -0
- package/src/modules/structure/functions/form/functions/select/select.ts +48 -0
- package/src/modules/structure/functions/form/functions/test/test.ts +132 -0
- package/src/modules/structure/functions/pagination/README.md +527 -0
- package/src/modules/structure/functions/pagination/pagination.ts +493 -0
- package/src/modules/structure/functions/site-settings/README.md +395 -0
- package/src/modules/structure/functions/site-settings/site-settings.ts +158 -0
- package/{autoInit/accessibility → src/modules/structure}/functions/toc/README.md +18 -15
- package/{autoInit/accessibility/functions/toc/toc.js → src/modules/structure/functions/toc/functions/heading-links/heading-links.ts} +43 -63
- package/src/modules/structure/functions/toc/functions/progress-bar/progress-bar.ts +101 -0
- package/src/modules/structure/functions/toc/toc.ts +35 -0
- package/{autoInit/accessibility → src/modules/structure}/functions/year-replacement/README.md +7 -6
- package/src/modules/structure/functions/year-replacement/year-replacement.ts +59 -0
- package/src/modules/structure/structure.ts +59 -0
- package/src/utils/attributeSelector.ts +78 -0
- package/src/utils/cssVariables.ts +24 -0
- package/src/utils/gsap.ts +198 -0
- package/src/utils/heightAnimator.ts +130 -0
- package/src/utils/modalManager.ts +150 -0
- package/src/utils.ts +54 -0
- package/tsconfig.json +24 -0
- package/vite.config.js +45 -0
- package/.claude/settings.local.json +0 -70
- package/archive/hero.js +0 -794
- package/archive/modal.js +0 -80
- package/archive/text.js +0 -628
- package/autoInit/accessibility/accessibility.js +0 -53
- package/autoInit/accessibility/functions/blog-remover/README.md +0 -61
- package/autoInit/accessibility/functions/blog-remover/blog-remover.js +0 -31
- package/autoInit/accessibility/functions/click-forwarding/README.md +0 -60
- package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +0 -82
- package/autoInit/accessibility/functions/dropdown/README.md +0 -212
- package/autoInit/accessibility/functions/dropdown/dropdown.js +0 -167
- package/autoInit/accessibility/functions/list-accessibility/README.md +0 -56
- package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +0 -23
- package/autoInit/accessibility/functions/pagination/README.md +0 -428
- package/autoInit/accessibility/functions/pagination/pagination.js +0 -359
- package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
- package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
- package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
- package/autoInit/button/README.md +0 -122
- package/autoInit/button/button.js +0 -51
- package/autoInit/counter/README.md +0 -274
- package/autoInit/counter/counter.js +0 -185
- package/autoInit/form/README.md +0 -338
- package/autoInit/form/form.js +0 -374
- package/autoInit/navbar/README.md +0 -366
- package/autoInit/navbar/navbar.js +0 -786
- package/autoInit/site-settings/README.md +0 -218
- package/autoInit/site-settings/site-settings.js +0 -134
- package/autoInit/transition/transition.js +0 -116
- package/index.js +0 -305
- package/utils/before-after/README.md +0 -520
- package/utils/before-after/before-after.js +0 -653
- package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
- package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
- package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
- package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
- package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
- package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
- package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
- package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
- package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
- package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
- package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
- package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
- package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
- package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
- package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
- package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
- package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
- package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
- package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
- package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
- package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
- package/utils/css-animations/cards/card-clickable.html +0 -11
- package/utils/css-animations/defaults.html +0 -69
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { querySelectorAll } from '@utils';
|
|
2
|
+
|
|
3
|
+
const MAX_DUPE_COUNT = 20;
|
|
4
|
+
const TRANSITION_CLASS = 'hs-dupe-no-transition';
|
|
5
|
+
|
|
6
|
+
// Inject CSS once
|
|
7
|
+
let cssInjected = false;
|
|
8
|
+
function injectCSS() {
|
|
9
|
+
if (cssInjected) return;
|
|
10
|
+
|
|
11
|
+
const style = document.createElement('style');
|
|
12
|
+
style.textContent = `
|
|
13
|
+
.${TRANSITION_CLASS},
|
|
14
|
+
.${TRANSITION_CLASS} * {
|
|
15
|
+
transition: none !important;
|
|
16
|
+
animation: none !important;
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
document.head.appendChild(style);
|
|
20
|
+
cssInjected = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function init(config) {
|
|
24
|
+
// Inject CSS on first init
|
|
25
|
+
injectCSS();
|
|
26
|
+
|
|
27
|
+
const cleanup = {
|
|
28
|
+
processedElements: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Find all elements with dupe attribute
|
|
32
|
+
const dupeElements = querySelectorAll(config, 'wrapper');
|
|
33
|
+
|
|
34
|
+
dupeElements.forEach((element) => {
|
|
35
|
+
// Get dupe mode (child or self)
|
|
36
|
+
const mode = element.getAttribute('data-hs-dupe');
|
|
37
|
+
if (!mode || (mode !== 'child' && mode !== 'self')) {
|
|
38
|
+
console.warn('[dupe] Invalid mode. Use "child" or "self":', element);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Get dupe count (default: 1)
|
|
43
|
+
const countAttr = element.getAttribute('data-hs-dupe-count');
|
|
44
|
+
let count = countAttr ? parseInt(countAttr, 10) : 1;
|
|
45
|
+
|
|
46
|
+
// Validate and cap count
|
|
47
|
+
if (isNaN(count) || count < 1) {
|
|
48
|
+
console.warn('[dupe] Invalid count, using default of 1:', element);
|
|
49
|
+
count = 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (count > MAX_DUPE_COUNT) {
|
|
53
|
+
console.warn(
|
|
54
|
+
`[dupe] Count of ${count} exceeds maximum of ${MAX_DUPE_COUNT}. Capping at ${MAX_DUPE_COUNT}.`
|
|
55
|
+
);
|
|
56
|
+
count = MAX_DUPE_COUNT;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get modifier options
|
|
60
|
+
const options = {
|
|
61
|
+
hidden: element.hasAttribute('data-hs-dupe-hidden'),
|
|
62
|
+
noSelect: element.hasAttribute('data-hs-dupe-no-select'),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Execute duplication based on mode
|
|
66
|
+
if (mode === 'child') {
|
|
67
|
+
dupeChild(element, count, options, cleanup);
|
|
68
|
+
} else if (mode === 'self') {
|
|
69
|
+
dupeSelf(element, count, options, cleanup);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
result: `dupe processed ${cleanup.processedElements.length} elements`,
|
|
75
|
+
destroy: () => {
|
|
76
|
+
// Remove all cloned elements
|
|
77
|
+
cleanup.processedElements.forEach(({ clones }) => {
|
|
78
|
+
clones.forEach((clone) => {
|
|
79
|
+
if (clone.parentNode) {
|
|
80
|
+
clone.parentNode.removeChild(clone);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
cleanup.processedElements.length = 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function dupeChild(element, count, options, cleanup) {
|
|
90
|
+
// Get first direct child
|
|
91
|
+
const firstChild = element.children[0];
|
|
92
|
+
|
|
93
|
+
// Silently skip if no child exists (e.g., empty slot waiting for CMS content)
|
|
94
|
+
if (!firstChild) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const clones = [];
|
|
99
|
+
|
|
100
|
+
// Create clones and append as siblings
|
|
101
|
+
for (let i = 0; i < count; i++) {
|
|
102
|
+
const clone = firstChild.cloneNode(true);
|
|
103
|
+
|
|
104
|
+
// Disable transitions during insertion
|
|
105
|
+
clone.classList.add(TRANSITION_CLASS);
|
|
106
|
+
|
|
107
|
+
// Apply modifiers to clone
|
|
108
|
+
applyModifiers(clone, options);
|
|
109
|
+
|
|
110
|
+
// Append to DOM
|
|
111
|
+
element.appendChild(clone);
|
|
112
|
+
clones.push(clone);
|
|
113
|
+
|
|
114
|
+
// Force reflow
|
|
115
|
+
clone.offsetHeight;
|
|
116
|
+
|
|
117
|
+
// Re-enable transitions on next frame
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
clone.classList.remove(TRANSITION_CLASS);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Track for cleanup
|
|
124
|
+
cleanup.processedElements.push({
|
|
125
|
+
element,
|
|
126
|
+
mode: 'child',
|
|
127
|
+
clones,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dupeSelf(element, count, options, cleanup) {
|
|
132
|
+
const parent = element.parentNode;
|
|
133
|
+
|
|
134
|
+
if (!parent) {
|
|
135
|
+
console.warn('[dupe] Element has no parent, cannot duplicate self:', element);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const clones = [];
|
|
140
|
+
|
|
141
|
+
// Create clones and insert as next siblings
|
|
142
|
+
let referenceNode = element;
|
|
143
|
+
for (let i = 0; i < count; i++) {
|
|
144
|
+
const clone = element.cloneNode(true);
|
|
145
|
+
|
|
146
|
+
// Disable transitions during insertion
|
|
147
|
+
clone.classList.add(TRANSITION_CLASS);
|
|
148
|
+
|
|
149
|
+
// Remove dupe attributes from clone
|
|
150
|
+
clone.removeAttribute('data-hs-dupe');
|
|
151
|
+
clone.removeAttribute('data-hs-dupe-count');
|
|
152
|
+
clone.removeAttribute('data-hs-dupe-hidden');
|
|
153
|
+
clone.removeAttribute('data-hs-dupe-no-select');
|
|
154
|
+
|
|
155
|
+
// Apply modifiers to clone
|
|
156
|
+
applyModifiers(clone, options);
|
|
157
|
+
|
|
158
|
+
// Insert after reference node
|
|
159
|
+
if (referenceNode.nextSibling) {
|
|
160
|
+
parent.insertBefore(clone, referenceNode.nextSibling);
|
|
161
|
+
} else {
|
|
162
|
+
parent.appendChild(clone);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
clones.push(clone);
|
|
166
|
+
referenceNode = clone; // Next clone goes after this one
|
|
167
|
+
|
|
168
|
+
// Force reflow
|
|
169
|
+
clone.offsetHeight;
|
|
170
|
+
|
|
171
|
+
// Re-enable transitions on next frame
|
|
172
|
+
requestAnimationFrame(() => {
|
|
173
|
+
clone.classList.remove(TRANSITION_CLASS);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Track for cleanup
|
|
178
|
+
cleanup.processedElements.push({
|
|
179
|
+
element,
|
|
180
|
+
mode: 'self',
|
|
181
|
+
clones,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function applyModifiers(clone, options) {
|
|
186
|
+
// Add aria-hidden if requested
|
|
187
|
+
if (options.hidden) {
|
|
188
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add user-select: none if requested
|
|
192
|
+
if (options.noSelect) {
|
|
193
|
+
clone.style.userSelect = 'none';
|
|
194
|
+
clone.style.webkitUserSelect = 'none'; // Safari
|
|
195
|
+
clone.style.msUserSelect = 'none'; // IE/Edge
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Sync Module
|
|
3
|
+
*
|
|
4
|
+
* Syncs collection list data to static lists with matching structure.
|
|
5
|
+
* Supports two modes: simple syncing and field-based syncing.
|
|
6
|
+
*
|
|
7
|
+
* SIMPLE SYNCING:
|
|
8
|
+
* Extracts first text/href from source, applies to ALL matching elements in target.
|
|
9
|
+
* Use case: Footer service links.
|
|
10
|
+
*
|
|
11
|
+
* Source (collection list):
|
|
12
|
+
* <div data-hs-sync="source" data-hs-sync-id="services">
|
|
13
|
+
* <a data-hs-sync="item" href="/service-1">Service 1</a>
|
|
14
|
+
* <a data-hs-sync="item" href="/service-2">Service 2</a>
|
|
15
|
+
* </div>
|
|
16
|
+
*
|
|
17
|
+
* Target (footer):
|
|
18
|
+
* <div data-hs-sync="target" data-hs-sync-id="services">
|
|
19
|
+
* <a data-hs-sync="item" href="#">Placeholder</a>
|
|
20
|
+
* </div>
|
|
21
|
+
*
|
|
22
|
+
* FIELD-BASED SYNCING:
|
|
23
|
+
* Maps specific fields between source and target by field name.
|
|
24
|
+
* Use case: Before/after sliders, complex components with multiple images/text.
|
|
25
|
+
*
|
|
26
|
+
* Source (collection list):
|
|
27
|
+
* <div data-hs-sync="source" data-hs-sync-id="projects">
|
|
28
|
+
* <div data-hs-sync="item">
|
|
29
|
+
* <img data-hs-sync-field="before" src="/before1.jpg">
|
|
30
|
+
* <img data-hs-sync-field="after" src="/after1.jpg">
|
|
31
|
+
* <h3 data-hs-sync-field="title">Project 1</h3>
|
|
32
|
+
* <a data-hs-sync-field="link" href="/project-1">View</a>
|
|
33
|
+
* </div>
|
|
34
|
+
* </div>
|
|
35
|
+
*
|
|
36
|
+
* Target (before-after component):
|
|
37
|
+
* <div data-hs-sync="target" data-hs-sync-id="projects">
|
|
38
|
+
* <div data-hs-sync="item">
|
|
39
|
+
* <img data-hs-sync-field="before" src="/placeholder-before.jpg">
|
|
40
|
+
* <img data-hs-sync-field="after" src="/placeholder-after.jpg">
|
|
41
|
+
* <h3 data-hs-sync-field="title">Placeholder</h3>
|
|
42
|
+
* <a data-hs-sync-field="link" href="#">View</a>
|
|
43
|
+
* </div>
|
|
44
|
+
* </div>
|
|
45
|
+
*
|
|
46
|
+
* CHILD MODE SYNCING:
|
|
47
|
+
* Use when the item element is a wrapper and its child should be the template.
|
|
48
|
+
* Use case: Components where data-hs-sync="item" is on a parent wrapper.
|
|
49
|
+
*
|
|
50
|
+
* Target (marquee with child mode):
|
|
51
|
+
* <div data-hs-sync="target" data-hs-sync-id="marquee-logos">
|
|
52
|
+
* <div data-hs-sync="item" data-hs-sync-mode="child">
|
|
53
|
+
* <div class="marquee_list">
|
|
54
|
+
* <img data-hs-sync-field="image" src="/placeholder.jpg">
|
|
55
|
+
* </div>
|
|
56
|
+
* </div>
|
|
57
|
+
* </div>
|
|
58
|
+
*
|
|
59
|
+
* Result: The .marquee_list element (child) becomes the template, not the wrapper.
|
|
60
|
+
*
|
|
61
|
+
* IGNORE ELEMENTS:
|
|
62
|
+
* Preserve specific elements during sync (e.g., "All Services" button).
|
|
63
|
+
*
|
|
64
|
+
* <div data-hs-sync="target" data-hs-sync-id="services">
|
|
65
|
+
* <a data-hs-sync="ignore" href="/services">All Services</a>
|
|
66
|
+
* <a data-hs-sync="item" href="#">Placeholder</a>
|
|
67
|
+
* </div>
|
|
68
|
+
*
|
|
69
|
+
* Process:
|
|
70
|
+
* 1. Find source by sync-id
|
|
71
|
+
* 2. Find matching target by sync-id (skip if no match)
|
|
72
|
+
* 3. Clone target item template (or child if sync-mode="child")
|
|
73
|
+
* 4. Detect ignore element and its position relative to template
|
|
74
|
+
* 5. Clear target list (preserve ignore element)
|
|
75
|
+
* 6. For each source item: clone template, sync data (field-based or simple)
|
|
76
|
+
* 7. Insert synced items before or after ignore based on template position
|
|
77
|
+
*
|
|
78
|
+
* @version 2.0.0
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import { querySelectorAll, querySelector } from '@utils';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get all text nodes within an element
|
|
85
|
+
*/
|
|
86
|
+
function getTextNodes(element) {
|
|
87
|
+
const textNodes = [];
|
|
88
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
|
|
89
|
+
|
|
90
|
+
let node;
|
|
91
|
+
while ((node = walker.nextNode())) {
|
|
92
|
+
// Skip empty/whitespace-only nodes
|
|
93
|
+
if (node.nodeValue.trim()) {
|
|
94
|
+
textNodes.push(node);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return textNodes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all elements with href attribute (including the element itself)
|
|
103
|
+
*/
|
|
104
|
+
function getHrefElements(element) {
|
|
105
|
+
const hrefs = [];
|
|
106
|
+
|
|
107
|
+
// Check if element itself has href
|
|
108
|
+
if (element.hasAttribute('href')) {
|
|
109
|
+
hrefs.push(element);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check descendants
|
|
113
|
+
hrefs.push(...Array.from(element.querySelectorAll('[href]')));
|
|
114
|
+
|
|
115
|
+
return hrefs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if item uses field-based syncing
|
|
120
|
+
*/
|
|
121
|
+
function usesFieldBasedSync(item, config) {
|
|
122
|
+
const fieldAttr = config.attributes.properties.syncField;
|
|
123
|
+
return item.querySelector(`[${fieldAttr}]`) !== null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find element with src attribute (check element itself, then descendants)
|
|
128
|
+
*/
|
|
129
|
+
function findElementWithSrc(element) {
|
|
130
|
+
// Check element itself
|
|
131
|
+
if (element.hasAttribute('src')) {
|
|
132
|
+
const src = element.getAttribute('src');
|
|
133
|
+
if (src) return element;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check descendants
|
|
137
|
+
const descendant = element.querySelector('[src]');
|
|
138
|
+
if (descendant) {
|
|
139
|
+
const src = descendant.getAttribute('src');
|
|
140
|
+
if (src) return descendant;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sync data using field-based mapping
|
|
148
|
+
* Maps fields by name between source and target
|
|
149
|
+
*/
|
|
150
|
+
function syncFieldBasedData(sourceItem, targetClone, config) {
|
|
151
|
+
const fieldAttr = config.attributes.properties.syncField;
|
|
152
|
+
|
|
153
|
+
// Get all fields in source
|
|
154
|
+
const sourceFields = sourceItem.querySelectorAll(`[${fieldAttr}]`);
|
|
155
|
+
|
|
156
|
+
sourceFields.forEach((sourceField) => {
|
|
157
|
+
const fieldName = sourceField.getAttribute(fieldAttr);
|
|
158
|
+
|
|
159
|
+
// Find matching field in target
|
|
160
|
+
const targetField = targetClone.querySelector(`[${fieldAttr}="${fieldName}"]`);
|
|
161
|
+
if (!targetField) return;
|
|
162
|
+
|
|
163
|
+
// Try to find elements with src (for images)
|
|
164
|
+
const sourceImgElement = findElementWithSrc(sourceField);
|
|
165
|
+
const targetImgElement = findElementWithSrc(targetField);
|
|
166
|
+
|
|
167
|
+
if (sourceImgElement && targetImgElement) {
|
|
168
|
+
// Sync src attribute
|
|
169
|
+
const src = sourceImgElement.getAttribute('src');
|
|
170
|
+
if (src) {
|
|
171
|
+
targetImgElement.setAttribute('src', src);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Link elements: sync href
|
|
175
|
+
else if (sourceField.hasAttribute('href') && targetField.hasAttribute('href')) {
|
|
176
|
+
const href = sourceField.getAttribute('href');
|
|
177
|
+
if (href) {
|
|
178
|
+
targetField.setAttribute('href', href);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Text elements: sync text content
|
|
182
|
+
else {
|
|
183
|
+
const sourceTextNodes = getTextNodes(sourceField);
|
|
184
|
+
const sourceText =
|
|
185
|
+
sourceTextNodes.length > 0 ? sourceTextNodes[0].nodeValue : sourceField.textContent.trim();
|
|
186
|
+
|
|
187
|
+
if (sourceText) {
|
|
188
|
+
const targetTextNodes = getTextNodes(targetField);
|
|
189
|
+
if (targetTextNodes.length > 0) {
|
|
190
|
+
targetTextNodes[0].nodeValue = sourceText;
|
|
191
|
+
} else {
|
|
192
|
+
targetField.textContent = sourceText;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Sync data from source item to target clone
|
|
201
|
+
* Extracts text and href from source, applies to ALL matching elements in target
|
|
202
|
+
*/
|
|
203
|
+
function syncItemData(sourceItem, targetClone, config) {
|
|
204
|
+
// Check if using field-based syncing
|
|
205
|
+
if (usesFieldBasedSync(targetClone, config)) {
|
|
206
|
+
syncFieldBasedData(sourceItem, targetClone, config);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fallback to simple syncing (existing behavior)
|
|
211
|
+
// Get text content from source (first non-empty text node)
|
|
212
|
+
const sourceTextNodes = getTextNodes(sourceItem);
|
|
213
|
+
const sourceText = sourceTextNodes.length > 0 ? sourceTextNodes[0].nodeValue : '';
|
|
214
|
+
|
|
215
|
+
// Get href from source (first href element)
|
|
216
|
+
const sourceHrefs = getHrefElements(sourceItem);
|
|
217
|
+
const sourceHref = sourceHrefs.length > 0 ? sourceHrefs[0].getAttribute('href') : '';
|
|
218
|
+
|
|
219
|
+
// Apply source text to ALL text nodes in target
|
|
220
|
+
if (sourceText) {
|
|
221
|
+
const targetTextNodes = getTextNodes(targetClone);
|
|
222
|
+
targetTextNodes.forEach((node) => {
|
|
223
|
+
node.nodeValue = sourceText;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Apply source href to ALL href elements in target
|
|
228
|
+
if (sourceHref) {
|
|
229
|
+
const targetHrefs = getHrefElements(targetClone);
|
|
230
|
+
targetHrefs.forEach((el) => {
|
|
231
|
+
el.setAttribute('href', sourceHref);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function init(config) {
|
|
237
|
+
// Find all source lists and build map by sync-id
|
|
238
|
+
const sourceLists = querySelectorAll(config, 'source');
|
|
239
|
+
const sourceMap = new Map();
|
|
240
|
+
|
|
241
|
+
sourceLists.forEach((source) => {
|
|
242
|
+
const syncId = source.getAttribute(config.attributes.properties.syncId);
|
|
243
|
+
if (syncId) {
|
|
244
|
+
sourceMap.set(syncId, source);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Find all target lists
|
|
249
|
+
const targetLists = querySelectorAll(config, 'target');
|
|
250
|
+
let syncedCount = 0;
|
|
251
|
+
let skippedCount = 0;
|
|
252
|
+
|
|
253
|
+
targetLists.forEach((target) => {
|
|
254
|
+
const syncId = target.getAttribute(config.attributes.properties.syncId);
|
|
255
|
+
|
|
256
|
+
// Skip if no sync-id or no matching source
|
|
257
|
+
if (!syncId || !sourceMap.has(syncId)) {
|
|
258
|
+
skippedCount++;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const source = sourceMap.get(syncId);
|
|
263
|
+
|
|
264
|
+
// Find item template in target (element to clone)
|
|
265
|
+
const targetTemplateWrapper = querySelector(config, 'item', target);
|
|
266
|
+
if (!targetTemplateWrapper) {
|
|
267
|
+
console.warn(`[sync] Target list "${syncId}" missing item template`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check sync mode - determines which element to use as template
|
|
272
|
+
const syncMode = targetTemplateWrapper.getAttribute(config.attributes.properties.syncMode);
|
|
273
|
+
const isChildMode = syncMode === 'child';
|
|
274
|
+
|
|
275
|
+
// Find ignore element in target (if exists)
|
|
276
|
+
const ignoreElement = querySelector(config, 'ignore', target);
|
|
277
|
+
|
|
278
|
+
// Find all source items
|
|
279
|
+
const sourceItems = querySelectorAll(config, 'item', source);
|
|
280
|
+
if (sourceItems.length === 0) {
|
|
281
|
+
console.warn(`[sync] Source list "${syncId}" has no items`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Determine template based on mode
|
|
286
|
+
let template: Element;
|
|
287
|
+
if (isChildMode) {
|
|
288
|
+
// Child mode: clone the wrapper, we'll replace its child content
|
|
289
|
+
const firstChild = targetTemplateWrapper.firstElementChild;
|
|
290
|
+
if (!firstChild) {
|
|
291
|
+
console.warn(
|
|
292
|
+
`[sync] Target list "${syncId}" has sync-mode="child" but item wrapper has no children`
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// Clone the child element to use for syncing data
|
|
297
|
+
template = firstChild.cloneNode(true) as Element;
|
|
298
|
+
} else {
|
|
299
|
+
// Default mode: clone the item element itself
|
|
300
|
+
template = targetTemplateWrapper.cloneNode(true) as Element;
|
|
301
|
+
template.removeAttribute(config.attributes.elements.item.primary.split('=')[0]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Determine insertion strategy if ignore exists
|
|
305
|
+
let insertAfterIgnore = false;
|
|
306
|
+
if (ignoreElement) {
|
|
307
|
+
// Check if template wrapper comes after ignore element
|
|
308
|
+
let currentElement = ignoreElement.nextElementSibling;
|
|
309
|
+
while (currentElement) {
|
|
310
|
+
if (currentElement === targetTemplateWrapper) {
|
|
311
|
+
insertAfterIgnore = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
currentElement = currentElement.nextElementSibling;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (isChildMode) {
|
|
319
|
+
// CHILD MODE: Keep wrapper as single element, duplicate children inside it
|
|
320
|
+
// Clear the wrapper's children
|
|
321
|
+
while (targetTemplateWrapper.firstChild) {
|
|
322
|
+
targetTemplateWrapper.removeChild(targetTemplateWrapper.firstChild);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Create synced children and append to the wrapper
|
|
326
|
+
sourceItems.forEach((sourceItem) => {
|
|
327
|
+
const childClone = template.cloneNode(true) as Element;
|
|
328
|
+
syncItemData(sourceItem, childClone, config);
|
|
329
|
+
targetTemplateWrapper.appendChild(childClone);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Remove data-hs-sync="item" attribute from wrapper
|
|
333
|
+
targetTemplateWrapper.removeAttribute(config.attributes.elements.item.primary.split('=')[0]);
|
|
334
|
+
} else {
|
|
335
|
+
// DEFAULT MODE: Duplicate the item element itself
|
|
336
|
+
// Clear target list, but preserve ignore element
|
|
337
|
+
const children = Array.from(target.children);
|
|
338
|
+
children.forEach((child) => {
|
|
339
|
+
if (child !== ignoreElement) {
|
|
340
|
+
child.remove();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Create synced items
|
|
345
|
+
const syncedItems = Array.from(sourceItems).map((sourceItem) => {
|
|
346
|
+
const clone = template.cloneNode(true);
|
|
347
|
+
syncItemData(sourceItem, clone, config);
|
|
348
|
+
return clone;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Insert synced items based on strategy
|
|
352
|
+
if (ignoreElement) {
|
|
353
|
+
if (insertAfterIgnore) {
|
|
354
|
+
// Insert all items after ignore
|
|
355
|
+
syncedItems.forEach((item) => {
|
|
356
|
+
target.appendChild(item);
|
|
357
|
+
});
|
|
358
|
+
} else {
|
|
359
|
+
// Insert all items before ignore
|
|
360
|
+
syncedItems.forEach((item) => {
|
|
361
|
+
target.insertBefore(item, ignoreElement);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
// No ignore element, just append all
|
|
366
|
+
syncedItems.forEach((item) => {
|
|
367
|
+
target.appendChild(item);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
syncedCount++;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
result: `sync initialized (${syncedCount} synced, ${skippedCount} skipped)`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize Orchestrator
|
|
3
|
+
* Manages DOM normalization functions in sequence
|
|
4
|
+
*
|
|
5
|
+
* Uses static imports and passes config down to functions
|
|
6
|
+
* @version 2.0.0
|
|
7
|
+
*/
|
|
8
|
+
import config from '@config';
|
|
9
|
+
import { init as syncInit } from './functions/sync/sync.ts';
|
|
10
|
+
import { init as clickableInit } from './functions/clickable/clickable.ts';
|
|
11
|
+
import { init as dupeInit } from './functions/dupe/dupe.ts';
|
|
12
|
+
|
|
13
|
+
const CONFIG_ROOT = 'normalize';
|
|
14
|
+
|
|
15
|
+
export async function init() {
|
|
16
|
+
const cleanup = { destroyFunctions: [] };
|
|
17
|
+
const moduleConfig = config[CONFIG_ROOT];
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// Phase 1a: Sync (populates lists with collection data) - must complete first
|
|
21
|
+
const syncResult = await syncInit(moduleConfig.sync);
|
|
22
|
+
if (syncResult?.destroy) cleanup.destroyFunctions.push(syncResult.destroy);
|
|
23
|
+
|
|
24
|
+
// Phase 1b: Clickable (normalizes button/link structure) - runs after sync creates content
|
|
25
|
+
const clickableResult = await clickableInit(moduleConfig.clickable);
|
|
26
|
+
if (clickableResult?.destroy) cleanup.destroyFunctions.push(clickableResult.destroy);
|
|
27
|
+
|
|
28
|
+
// Phase 1c: Dupe (duplicates normalized elements)
|
|
29
|
+
const dupeResult = await dupeInit(moduleConfig.dupe);
|
|
30
|
+
if (dupeResult?.destroy) cleanup.destroyFunctions.push(dupeResult.destroy);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
result: 'normalize initialized',
|
|
34
|
+
destroy: () => {
|
|
35
|
+
// Call all destroy functions in reverse order
|
|
36
|
+
cleanup.destroyFunctions.reverse().forEach((destroyFn) => {
|
|
37
|
+
try {
|
|
38
|
+
destroyFn();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('[normalize] Error during cleanup:', error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
cleanup.destroyFunctions.length = 0;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('[normalize] Initialization failed:', error);
|
|
48
|
+
// Cleanup any partial initialization
|
|
49
|
+
cleanup.destroyFunctions.reverse().forEach((fn) => {
|
|
50
|
+
try {
|
|
51
|
+
fn();
|
|
52
|
+
} catch (cleanupError) {
|
|
53
|
+
console.error('[normalize] Error during error cleanup:', cleanupError);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|