mage-obsidian 1.1.2 → 2.0.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.
Files changed (111) hide show
  1. package/.github/workflows/ci.yml +38 -0
  2. package/.github/workflows/publish.yml +26 -25
  3. package/.oxlintrc.json +19 -0
  4. package/.prettierignore +7 -0
  5. package/.prettierrc.json +7 -0
  6. package/LICENSE +1 -1
  7. package/README.md +1 -0
  8. package/eslint.config.js +24 -0
  9. package/package.json +39 -13
  10. package/pnpm-workspace.yaml +4 -0
  11. package/src/cli/buildThemes.ts +263 -0
  12. package/src/config/default.ts +31 -0
  13. package/src/config/eslint.js +60 -0
  14. package/src/core/configResolver.ts +141 -0
  15. package/src/core/contractValidator.ts +56 -0
  16. package/src/core/cssResolver.ts +153 -0
  17. package/src/{service/generateInterceptors.js → core/generateInterceptors.ts} +79 -46
  18. package/src/core/generateJsconfig.ts +168 -0
  19. package/src/{service/moduleResolver.js → core/moduleResolver.ts} +79 -46
  20. package/src/core/preCompileFiles.ts +105 -0
  21. package/src/core/preCompileMagentoFiles.ts +34 -0
  22. package/src/core/themeResolverSync.ts +66 -0
  23. package/src/runtime/i18nCore.ts +113 -0
  24. package/src/runtime/interceptorManager.ts +186 -0
  25. package/src/runtime/islands.ts +84 -0
  26. package/src/runtime/sectionStoreCore.ts +259 -0
  27. package/src/utils/{findComponents.js → findComponents.ts} +29 -16
  28. package/src/utils/runWithConcurrency.ts +35 -0
  29. package/src/vite/defaultNodeResolver.ts +17 -0
  30. package/src/{service/inheritAssetsModuleResolver.js → vite/inheritAssetsModuleResolver.ts} +15 -11
  31. package/src/{service/inheritModuleResolver.js → vite/inheritModuleResolver.ts} +6 -10
  32. package/src/{service/interceptorsPlugin.js → vite/interceptorsPlugin.ts} +25 -19
  33. package/src/vite/magentoHrmRewrite.ts +44 -0
  34. package/src/vite/sharedPlugins.ts +54 -0
  35. package/src/vite/themeSourceWatcher.ts +96 -0
  36. package/src/vite/unresolvedModuleGuard.ts +87 -0
  37. package/tsconfig.json +34 -0
  38. package/vitest.config.js +18 -0
  39. package/jest.config.js +0 -5
  40. package/src/__tests__/__mocks__/configResolver.js +0 -35
  41. package/src/__tests__/fixtures/interceptors/emptyTarget.js +0 -2
  42. package/src/__tests__/fixtures/interceptors/pluginForNonFunction.js +0 -7
  43. package/src/__tests__/fixtures/interceptors/pluginModule.js +0 -13
  44. package/src/__tests__/fixtures/interceptors/targetModule.js +0 -12
  45. package/src/__tests__/fixtures/interceptors/targetNonFunction.js +0 -7
  46. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentA.vue +0 -0
  47. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentB.vue +0 -0
  48. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/css/module.extend.css +0 -0
  49. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/js/main.js +0 -0
  50. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/main.js +0 -0
  51. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/module.config.js +0 -9
  52. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentA.vue +0 -0
  53. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentB.vue +0 -0
  54. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/css/module.extend.css +0 -2
  55. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/js/main.js +0 -0
  56. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/module.config.js +0 -7
  57. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentA.vue +0 -0
  58. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentB.vue +0 -0
  59. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/css/module.extend.css +0 -2
  60. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/js/main.js +0 -0
  61. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/module.config.js +0 -7
  62. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentA.vue +0 -0
  63. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentB.vue +0 -0
  64. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/css/module.extend.css +0 -0
  65. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/js/main.js +0 -0
  66. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js +0 -2
  67. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
  68. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
  69. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
  70. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/js/main.js +0 -0
  71. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/module.config.js +0 -2
  72. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentA.vue +0 -0
  73. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentB.vue +0 -0
  74. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/css/module.extend.css +0 -0
  75. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/js/main.js +0 -0
  76. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/module.config.js +0 -2
  77. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentA.vue +0 -0
  78. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentB.vue +0 -0
  79. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/css/theme.source.css +0 -6
  80. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/js/main.js +0 -0
  81. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/theme.config.js +0 -5
  82. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/css/theme.source.css +0 -6
  83. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/theme.config.js +0 -5
  84. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
  85. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
  86. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
  87. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/js/main.js +0 -0
  88. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/module.config.js +0 -2
  89. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/css/theme.source.css +0 -6
  90. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/theme.config.js +0 -2
  91. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/css/theme.source.css +0 -6
  92. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/theme.config.js +0 -3
  93. package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_a.json +0 -53
  94. package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_b.json +0 -50
  95. package/src/__tests__/service/generateInterceptors.test.js +0 -196
  96. package/src/__tests__/service/inheritModuleResolver.test.js +0 -61
  97. package/src/__tests__/service/interceptorManager_magento_behavior.test.js +0 -137
  98. package/src/__tests__/service/moduleResolver.test.js +0 -194
  99. package/src/__tests__/service/themeResolverSync.test.js +0 -97
  100. package/src/__tests__/vite-plugins/interceptorsPlugin.test.js +0 -121
  101. package/src/config/default.js +0 -27
  102. package/src/scripts/buildThemes.js +0 -132
  103. package/src/service/configResolver.js +0 -63
  104. package/src/service/cssResolver.js +0 -73
  105. package/src/service/defaultNodeResolver.js +0 -16
  106. package/src/service/interceptorManager.js +0 -137
  107. package/src/service/magentoHrmRewrite.js +0 -43
  108. package/src/service/preCompileFiles.js +0 -37
  109. package/src/service/preCompileMagentoFiles.js +0 -16
  110. package/src/service/setupGlobals.js +0 -19
  111. package/src/service/themeResolverSync.js +0 -67
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Framework i18n core — Magento-parity phrase translation for the Vue/ESM side.
3
+ *
4
+ * Mirrors Magento's `$t` / `$.mage.__`: a phrase is looked up in the dictionary
5
+ * (the per-locale `js-translation.json` Magento already generates during static
6
+ * deploy) and falls back to the original phrase when absent; then `%1`, `%2`, …
7
+ * placeholders are substituted positionally.
8
+ *
9
+ * Kept free of Vue and the DOM so it is unit-testable in isolation — the Vue
10
+ * plugin (shipped as a module web asset) wraps these primitives.
11
+ */
12
+
13
+ export interface I18nConfig {
14
+ locale?: string;
15
+ dictionaryUrl?: string;
16
+ }
17
+
18
+ interface I18nScope {
19
+ __MAGE_OBSIDIAN_I18N__?: I18nConfig;
20
+ }
21
+
22
+ export type Dictionary = Record<string, string>;
23
+
24
+ // Published by PHP on the page before the ESM runtime loads.
25
+ declare global {
26
+ interface Window {
27
+ __MAGE_OBSIDIAN_I18N__?: I18nConfig;
28
+ }
29
+ }
30
+
31
+ const PLACEHOLDER = /%(\d+)/g;
32
+
33
+ /**
34
+ * Replace `%1`, `%2`, … with positional args. An out-of-range placeholder is
35
+ * left untouched so a malformed phrase never throws or yields "undefined".
36
+ */
37
+ export function interpolate(text: string, args: unknown[] = []): string {
38
+ if (typeof text !== "string" || !args || args.length === 0) {
39
+ return text;
40
+ }
41
+ return text.replace(PLACEHOLDER, (match, index) => {
42
+ const value = args[Number(index) - 1];
43
+ return value === undefined ? match : String(value);
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Translate a phrase against a dictionary, falling back to the phrase itself,
49
+ * then interpolate placeholders.
50
+ */
51
+ export function translatePhrase(
52
+ dictionary: Dictionary | null | undefined,
53
+ phrase: string,
54
+ args: unknown[] = [],
55
+ ): string {
56
+ const dict = dictionary && typeof dictionary === "object" ? dictionary : {};
57
+ const translated = Object.prototype.hasOwnProperty.call(dict, phrase) ? dict[phrase] : phrase;
58
+ return interpolate(translated, args);
59
+ }
60
+
61
+ /**
62
+ * Read the runtime i18n config published by PHP as `window.__MAGE_OBSIDIAN_I18N__`.
63
+ * Returns sane defaults when absent so the layer degrades to passthrough.
64
+ */
65
+ export function readI18nConfig(
66
+ scope: I18nScope | undefined = typeof window !== "undefined" ? window : undefined,
67
+ ): { locale: string; dictionaryUrl: string | null } {
68
+ const config = scope && scope.__MAGE_OBSIDIAN_I18N__;
69
+ return {
70
+ locale: (config && config.locale) || "en_US",
71
+ dictionaryUrl: (config && config.dictionaryUrl) || null,
72
+ };
73
+ }
74
+
75
+ const dictionaryCache = new Map<string, Promise<Dictionary>>();
76
+
77
+ /**
78
+ * Fetch and cache a dictionary by URL. The fetch happens at most once per URL
79
+ * regardless of how many Vue apps request it. Magento emits `[]` for an empty
80
+ * dictionary, which is normalized to `{}`; any failure degrades to `{}` so the
81
+ * UI keeps rendering original phrases.
82
+ */
83
+ export function loadDictionary(
84
+ url: string | null | undefined,
85
+ fetchImpl: typeof fetch | undefined = typeof fetch !== "undefined" ? fetch : undefined,
86
+ ): Promise<Dictionary> {
87
+ if (!url) {
88
+ return Promise.resolve({});
89
+ }
90
+ if (!dictionaryCache.has(url)) {
91
+ if (typeof fetchImpl !== "function") {
92
+ return Promise.resolve({});
93
+ }
94
+ const promise: Promise<Dictionary> = fetchImpl(url)
95
+ .then((res) => (res && res.ok ? res.json() : {}))
96
+ .then(
97
+ (data: unknown): Dictionary =>
98
+ data && typeof data === "object" && !Array.isArray(data)
99
+ ? (data as Dictionary)
100
+ : {},
101
+ )
102
+ .catch((): Dictionary => ({}));
103
+ dictionaryCache.set(url, promise);
104
+ }
105
+ return dictionaryCache.get(url)!;
106
+ }
107
+
108
+ /**
109
+ * Test-only: clear the dictionary cache between cases.
110
+ */
111
+ export function _resetDictionaryCache(): void {
112
+ dictionaryCache.clear();
113
+ }
@@ -0,0 +1,186 @@
1
+ export type InterceptorType = "before" | "around" | "after";
2
+
3
+ // Handlers receive the intercepted subject first (Magento plugin parity); the
4
+ // remaining shape varies per type, so the tail stays `any[]`.
5
+ export type InterceptorHandler = (subject: any, ...rest: any[]) => any;
6
+
7
+ export interface InterceptorEntry {
8
+ name: string;
9
+ handler: InterceptorHandler;
10
+ sortOrder: number;
11
+ }
12
+
13
+ type InterceptorBuckets = Record<InterceptorType, InterceptorEntry[]>;
14
+
15
+ const TYPES: readonly InterceptorType[] = ["before", "around", "after"];
16
+
17
+ function emptyBuckets(): InterceptorBuckets {
18
+ return { before: [], around: [], after: [] };
19
+ }
20
+
21
+ class InterceptorManager {
22
+ // `declare` keeps this type-only (erasable): the runtime field is created by
23
+ // the constructor assignment below, type-stripping emits nothing for this.
24
+ declare interceptors: Record<string, InterceptorBuckets>;
25
+
26
+ constructor() {
27
+ this.interceptors = {};
28
+ }
29
+
30
+ /**
31
+ * Register an interceptor. `target` is the function/method name to wrap;
32
+ * `name` is a unique id; `sortOrder` controls execution order.
33
+ */
34
+ addInterceptor(
35
+ target: string,
36
+ name: string,
37
+ type: InterceptorType,
38
+ handler: InterceptorHandler,
39
+ sortOrder: number = 10,
40
+ ) {
41
+ if (!this.interceptors[target]) {
42
+ this.interceptors[target] = emptyBuckets();
43
+ }
44
+ if (!TYPES.includes(type)) {
45
+ throw new Error(`Invalid interceptor type: ${type}`);
46
+ }
47
+ this.interceptors[target][type].push({ name, handler, sortOrder });
48
+ this.interceptors[target][type].sort((a, b) => a.sortOrder - b.sortOrder);
49
+ }
50
+
51
+ /**
52
+ * Execute the intercepted method chain synchronously.
53
+ *
54
+ * Handlers receive the intercepted module exports as an explicit `subject`
55
+ * first argument (Magento plugin parity): before(subject, ...args),
56
+ * around(subject, proceed, ...args), after(subject, result, ...args).
57
+ * `this` is still bound to the same object, so handlers written against the
58
+ * legacy `this`-based access keep working (arrow functions now have a path
59
+ * to the subject they could not reach via `this`).
60
+ */
61
+ executeSync(
62
+ target: string,
63
+ originalMethod: (...args: any[]) => any,
64
+ context: any,
65
+ ...args: any[]
66
+ ): any {
67
+ const interceptors = this.interceptors[target] || emptyBuckets();
68
+
69
+ // Execute 'before' interceptors
70
+ for (const interceptor of interceptors.before) {
71
+ const result = interceptor.handler.apply(context, [context, ...args]);
72
+ if (Array.isArray(result)) {
73
+ args = result;
74
+ }
75
+ }
76
+
77
+ // Execute 'around' interceptors
78
+ let methodToExecute = (...currentArgs: any[]) => {
79
+ return originalMethod.apply(context, currentArgs);
80
+ };
81
+
82
+ if (interceptors.around.length > 0) {
83
+ const aroundInterceptors = [...interceptors.around].reverse();
84
+ for (const interceptor of aroundInterceptors) {
85
+ const next = methodToExecute;
86
+ methodToExecute = (...currentArgs: any[]) => {
87
+ return interceptor.handler.apply(context, [context, next, ...currentArgs]);
88
+ };
89
+ }
90
+ }
91
+
92
+ let result = methodToExecute(...args);
93
+
94
+ // Execute 'after' interceptors
95
+ for (const interceptor of interceptors.after) {
96
+ result = interceptor.handler.apply(context, [context, result, ...args]);
97
+ }
98
+
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Execute the intercepted method chain.
104
+ *
105
+ * Same `subject`-first contract as {@link executeSync}: before(subject,
106
+ * ...args), around(subject, proceed, ...args), after(subject, result,
107
+ * ...args), with `this` still bound to the subject for backward compat.
108
+ */
109
+ async execute(
110
+ target: string,
111
+ originalMethod: (...args: any[]) => any,
112
+ context: any,
113
+ ...args: any[]
114
+ ): Promise<any> {
115
+ const interceptors = this.interceptors[target] || emptyBuckets();
116
+
117
+ // Execute 'before' interceptors
118
+ // Before interceptors receive (subject, ...args) and can modify args by
119
+ // returning an array of the (subject-less) args.
120
+ for (const interceptor of interceptors.before) {
121
+ const result = await interceptor.handler.apply(context, [context, ...args]);
122
+ if (Array.isArray(result)) {
123
+ args = result;
124
+ }
125
+ }
126
+
127
+ // Execute 'around' interceptors
128
+ // Around interceptors receive (subject, proceed, ...args)
129
+ let methodToExecute = async (...currentArgs: any[]) => {
130
+ return await originalMethod.apply(context, currentArgs);
131
+ };
132
+
133
+ // Wrap around interceptors: first registered is outer-most
134
+ if (interceptors.around.length > 0) {
135
+ const aroundInterceptors = [...interceptors.around].reverse();
136
+ for (const interceptor of aroundInterceptors) {
137
+ const next = methodToExecute;
138
+ methodToExecute = async (...currentArgs: any[]) => {
139
+ return await interceptor.handler.apply(context, [
140
+ context,
141
+ next,
142
+ ...currentArgs,
143
+ ]);
144
+ };
145
+ }
146
+ }
147
+
148
+ let result = await methodToExecute(...args);
149
+
150
+ // Execute 'after' interceptors
151
+ // After interceptors receive (subject, result, ...args) and must return result
152
+ for (const interceptor of interceptors.after) {
153
+ result = await interceptor.handler.apply(context, [context, result, ...args]);
154
+ }
155
+
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Create a proxy that intercepts method calls on an object (e.g. module
161
+ * exports). `namespace` scopes the interceptor keys; `useAsync` picks the
162
+ * async or sync chain.
163
+ */
164
+ intercept(target: object, namespace: string, useAsync: boolean = true) {
165
+ return new Proxy(target, {
166
+ get: (obj: any, prop) => {
167
+ const value = obj[prop];
168
+ if (typeof value === "function") {
169
+ return (...args: any[]) => {
170
+ const method = useAsync ? this.execute : this.executeSync;
171
+ return method.call(
172
+ this,
173
+ `${namespace}::${String(prop)}`,
174
+ value,
175
+ obj,
176
+ ...args,
177
+ );
178
+ };
179
+ }
180
+ return value;
181
+ },
182
+ });
183
+ }
184
+ }
185
+
186
+ export default new InterceptorManager();
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Framework island runtime — the browser side of `renderVueComponent`.
3
+ *
4
+ * PHP emits an inert marker per component
5
+ * (`<div data-mage-island data-component data-props data-strategy>`); this
6
+ * module turns each marker into a mounted Vue app. Every island is its own app
7
+ * (preserving isolation), but the Vue runtime and the i18n plugin are loaded
8
+ * once per page and shared, and "visible" islands hydrate only when they enter
9
+ * the viewport — so below-the-fold components cost nothing until scrolled to.
10
+ *
11
+ * All side effects (dynamic import, app creation, plugin wiring, viewport
12
+ * observation) are injected, so the discovery/hydration logic is unit-testable
13
+ * in Node without a DOM, a bundler, or Vue. The concrete wiring lives in the
14
+ * module's `web/js/islands.js`.
15
+ */
16
+
17
+ // Set synchronously before the async import so a second observer callback for
18
+ // the same element is a no-op (dataset key for `data-mage-island-mounted`).
19
+ const MOUNTED_FLAG = "mageIslandMounted";
20
+
21
+ interface IslandElement {
22
+ dataset: Record<string, string | undefined>;
23
+ }
24
+
25
+ interface AppLike {
26
+ mount(el: unknown): unknown;
27
+ }
28
+
29
+ interface HydrateDeps {
30
+ importComponent(source: string): Promise<{ default?: unknown }>;
31
+ createApp(component: unknown, props?: Record<string, unknown>): AppLike;
32
+ configureApp(app: AppLike): void;
33
+ }
34
+
35
+ interface DiscoverDeps extends HydrateDeps {
36
+ observe(element: IslandElement, onVisible: () => void): void;
37
+ }
38
+
39
+ /**
40
+ * Mount a single island. Idempotent: the first call claims the element and
41
+ * later calls return immediately, so re-observation never double-mounts.
42
+ *
43
+ * @throws Error When the marker has no `data-component`.
44
+ */
45
+ export async function hydrateIsland(
46
+ element: IslandElement,
47
+ deps: HydrateDeps,
48
+ ): Promise<AppLike | undefined> {
49
+ if (element.dataset[MOUNTED_FLAG]) {
50
+ return undefined;
51
+ }
52
+ element.dataset[MOUNTED_FLAG] = "1";
53
+
54
+ const source = element.dataset.component;
55
+ if (!source) {
56
+ throw new Error("Island marker is missing data-component.");
57
+ }
58
+
59
+ const module = await deps.importComponent(source);
60
+ const component = module.default ?? module;
61
+ const props = element.dataset.props ? JSON.parse(element.dataset.props) : {};
62
+
63
+ const app = deps.createApp(component, props);
64
+ deps.configureApp(app);
65
+ app.mount(element);
66
+ return app;
67
+ }
68
+
69
+ /**
70
+ * Hydrate every marker. `eager` markers mount now; the rest mount when the
71
+ * injected observer reports them visible.
72
+ */
73
+ export function hydrateAll(elements: Iterable<IslandElement>, deps: DiscoverDeps): void {
74
+ for (const element of elements) {
75
+ const strategy = element.dataset.strategy ?? "visible";
76
+ if (strategy === "eager") {
77
+ void hydrateIsland(element, deps);
78
+ } else {
79
+ deps.observe(element, () => {
80
+ void hydrateIsland(element, deps);
81
+ });
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Section store core — pure helpers for mirroring a set of server-pushed,
3
+ * versioned "sections" into a reactive store backed by localStorage.
4
+ *
5
+ * This is the GENERIC mechanism, deliberately domain-agnostic: it knows how to
6
+ * parse/merge/select sections, decide staleness, read a version cookie and
7
+ * decide when to hydrate — but nothing about WHICH endpoint, cookie or storage
8
+ * key a given integration uses. A binding (e.g. the Magento customer-data
9
+ * adapter) supplies those and wraps these primitives with Vue/Pinia reactivity.
10
+ *
11
+ * Kept free of Vue, Pinia and the DOM so the parsing/merge/staleness rules are
12
+ * unit-testable in isolation.
13
+ */
14
+
15
+ export type SectionData = Record<string, unknown>;
16
+ export type SectionMap = Record<string, SectionData>;
17
+
18
+ export interface SectionLoadOptions {
19
+ baseUrl?: string;
20
+ forceNewTimestamp?: boolean;
21
+ }
22
+
23
+ export interface SectionRuntimeConfig {
24
+ lifetimeSeconds: number;
25
+ expirableSections: string[];
26
+ }
27
+
28
+ interface SectionRuntimeScope {
29
+ __MAGE_OBSIDIAN_SECTIONS__?: { lifetime?: unknown; expirable?: unknown };
30
+ }
31
+
32
+ // Published by PHP on the page before the ESM runtime loads.
33
+ declare global {
34
+ interface Window {
35
+ __MAGE_OBSIDIAN_SECTIONS__?: { lifetime?: unknown; expirable?: unknown };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Parse the raw section-storage JSON into a map of section objects. Tolerates
41
+ * missing/corrupt input and drops non-object section values so the caller
42
+ * always gets a clean `{ [section]: object }` map.
43
+ */
44
+ export function parseSectionStorage(raw: string | null | undefined): SectionMap {
45
+ if (typeof raw !== "string" || raw === "") {
46
+ return {};
47
+ }
48
+
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ } catch {
53
+ return {};
54
+ }
55
+
56
+ return pickObjectEntries(parsed);
57
+ }
58
+
59
+ /**
60
+ * Return a single section, or null when it is absent or not an object.
61
+ */
62
+ export function selectSection(
63
+ sections: SectionMap | null | undefined,
64
+ name: string,
65
+ ): SectionData | null {
66
+ if (!sections || typeof sections !== "object") {
67
+ return null;
68
+ }
69
+ const value = sections[name];
70
+ return value && typeof value === "object" && !Array.isArray(value)
71
+ ? (value as SectionData)
72
+ : null;
73
+ }
74
+
75
+ /**
76
+ * Overlay freshly loaded sections onto the current map (a section-load response
77
+ * merges per section; non-object values are ignored).
78
+ */
79
+ export function mergeSections(
80
+ current: SectionMap | null | undefined,
81
+ incoming: SectionMap | null | undefined,
82
+ ): SectionMap {
83
+ return { ...pickObjectEntries(current), ...pickObjectEntries(incoming) };
84
+ }
85
+
86
+ /**
87
+ * Whether a section should be (re)fetched. A missing section is stale; a
88
+ * section without a positive `data_id` is a client-side section that never
89
+ * expires; with no positive lifetime nothing expires either. Otherwise it is
90
+ * stale once `data_id + lifetime` has passed.
91
+ */
92
+ export function isSectionStale(
93
+ section: SectionData | null | undefined,
94
+ lifetimeSeconds: number,
95
+ nowSeconds: number,
96
+ ): boolean {
97
+ if (!section || typeof section !== "object") {
98
+ return true;
99
+ }
100
+
101
+ const dataId = Number(section.data_id);
102
+ if (!Number.isFinite(dataId) || dataId <= 0) {
103
+ return false;
104
+ }
105
+ if (!Number.isFinite(lifetimeSeconds) || lifetimeSeconds <= 0) {
106
+ return false;
107
+ }
108
+
109
+ return dataId + lifetimeSeconds <= nowSeconds;
110
+ }
111
+
112
+ /**
113
+ * Build the section-load URL a binding uses to (re)load sections. `endpoint` is
114
+ * supplied by the binding (this core is endpoint-agnostic). An empty name list
115
+ * requests ALL sections by omitting the `sections` param — Magento's protocol
116
+ * returns every section then and rejects a literal `sections=*` with 400.
117
+ */
118
+ export function buildSectionLoadUrl(
119
+ endpoint: string,
120
+ sectionNames: string[],
121
+ options: SectionLoadOptions = {},
122
+ ): string {
123
+ const { baseUrl = "/", forceNewTimestamp = false } = options;
124
+ const names = Array.isArray(sectionNames) ? sectionNames.filter(Boolean) : [];
125
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
126
+ const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
127
+
128
+ const params: string[] = [];
129
+ if (names.length) {
130
+ params.push(`sections=${names.join(",")}`);
131
+ }
132
+ params.push(`force_new_section_timestamp=${forceNewTimestamp ? "true" : "false"}`);
133
+
134
+ return `${base}${path}?${params.join("&")}`;
135
+ }
136
+
137
+ /**
138
+ * Read a single cookie value from a `document.cookie` string. Returns "" when
139
+ * the cookie is absent. Kept pure (the binding passes `document.cookie` in) so
140
+ * the version lookup is testable without a DOM.
141
+ */
142
+ export function readCookie(cookieString: string | null | undefined, name: string): string {
143
+ if (typeof cookieString !== "string" || cookieString === "" || !name) {
144
+ return "";
145
+ }
146
+ for (const pair of cookieString.split(";")) {
147
+ const index = pair.indexOf("=");
148
+ if (index === -1) {
149
+ continue;
150
+ }
151
+ if (pair.slice(0, index).trim() === name) {
152
+ return decodeURIComponent(pair.slice(index + 1).trim());
153
+ }
154
+ }
155
+ return "";
156
+ }
157
+
158
+ /**
159
+ * Whether the store must hydrate sections from the server on init.
160
+ *
161
+ * A binding bumps a version (e.g. Magento's `private_content_version` cookie)
162
+ * whenever the server's pushed content changes. When nothing is cached yet, or
163
+ * the current version has moved past the last synced version, the cached
164
+ * sections are stale.
165
+ */
166
+ export function needsHydration(
167
+ sections: SectionMap | null | undefined,
168
+ syncedVersion: string,
169
+ currentVersion: string,
170
+ ): boolean {
171
+ if (!sections || typeof sections !== "object" || Object.keys(sections).length === 0) {
172
+ return true;
173
+ }
174
+ return currentVersion !== "" && currentVersion !== syncedVersion;
175
+ }
176
+
177
+ /**
178
+ * True when the session marker cookie is gone. Magento's login/logout controllers
179
+ * delete it server-side, so its absence means cached sections are stale (the
180
+ * version cookie doesn't move on logout). False when no marker is configured.
181
+ */
182
+ export function sessionInvalidated(
183
+ cookieString: string | null | undefined,
184
+ sessionCookieName: string,
185
+ ): boolean {
186
+ if (!sessionCookieName) {
187
+ return false;
188
+ }
189
+ return readCookie(cookieString, sessionCookieName) === "";
190
+ }
191
+
192
+ /**
193
+ * The expirable sections that have aged out and must be re-fetched, mirroring the
194
+ * lifetime branch of Magento's native `getExpiredSectionNames`: a section listed
195
+ * in `expirableNames` expires once `data_id + lifetimeSeconds <= now`.
196
+ *
197
+ * This is the backstop the version cookie cannot provide: `private_content_version`
198
+ * only moves on POST, so a section whose server data changed through a non-POST
199
+ * path the browser took — most commonly the PHP session/quote expiring while
200
+ * localStorage lives on — is otherwise never re-fetched, leaving a stale snapshot
201
+ * (e.g. a cart badge that outlives its quote). Absent sections are skipped here,
202
+ * exactly like native (they reload via the version/empty-cache path instead).
203
+ */
204
+ export function expiredSectionNames(
205
+ sections: SectionMap | null | undefined,
206
+ lifetimeSeconds: number,
207
+ expirableNames: string[],
208
+ nowSeconds: number,
209
+ ): string[] {
210
+ if (!Array.isArray(expirableNames) || expirableNames.length === 0) {
211
+ return [];
212
+ }
213
+ const map = sections && typeof sections === "object" ? sections : {};
214
+ const expired: string[] = [];
215
+ for (const name of expirableNames) {
216
+ const section = map[name];
217
+ if (
218
+ section &&
219
+ typeof section === "object" &&
220
+ isSectionStale(section, lifetimeSeconds, nowSeconds)
221
+ ) {
222
+ expired.push(name);
223
+ }
224
+ }
225
+ return expired;
226
+ }
227
+
228
+ /**
229
+ * Read the runtime section config a binding's host page publishes (lifetime +
230
+ * which sections expire by lifetime). Kept pure — the binding passes the scope
231
+ * (e.g. `window`) — so it is testable without a DOM, like `readI18nConfig`.
232
+ */
233
+ export function readSectionRuntimeConfig(
234
+ scope: SectionRuntimeScope | undefined = typeof window !== "undefined" ? window : undefined,
235
+ ): SectionRuntimeConfig {
236
+ const config = scope && scope.__MAGE_OBSIDIAN_SECTIONS__;
237
+ const lifetime = Number(config && config.lifetime);
238
+ const expirable = config && config.expirable;
239
+ return {
240
+ lifetimeSeconds: Number.isFinite(lifetime) && lifetime > 0 ? lifetime : 0,
241
+ expirableSections: Array.isArray(expirable)
242
+ ? expirable.filter((name): name is string => typeof name === "string")
243
+ : [],
244
+ };
245
+ }
246
+
247
+ function pickObjectEntries(value: unknown): SectionMap {
248
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
249
+ return {};
250
+ }
251
+
252
+ const result: SectionMap = {};
253
+ for (const [key, entry] of Object.entries(value)) {
254
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
255
+ result[key] = entry as SectionData;
256
+ }
257
+ }
258
+ return result;
259
+ }