svelte-intlayer 9.0.0-canary.9 → 9.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.
- package/dist/IntlayerNodeWrapper.svelte +30 -5
- package/dist/analytics/exposureSink.d.ts +28 -0
- package/dist/analytics/exposureSink.js +19 -0
- package/dist/analytics/index.d.ts +3 -0
- package/dist/analytics/index.js +3 -0
- package/dist/analytics/useAnalytics.d.ts +12 -0
- package/dist/analytics/useAnalytics.js +66 -0
- package/dist/analytics/useConversion.d.ts +29 -0
- package/dist/analytics/useConversion.js +28 -0
- package/dist/analytics/useExperiment.d.ts +40 -0
- package/dist/analytics/useExperiment.js +68 -0
- package/dist/client/setupIntlayer.svelte.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/markdownLazyRenderer.test.d.ts +1 -0
- package/dist/markdownLazyRenderer.test.js +52 -0
- package/dist/plugins.js +75 -29
- package/package.json +18 -15
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
// `Renderer` is either a component or — when it comes from a code-split chunk
|
|
3
|
+
// that had not landed at interpretation time — a promise for one.
|
|
2
4
|
export let Renderer: any = undefined;
|
|
3
5
|
export let rendererProps: Record<string, any> = {};
|
|
4
6
|
export let value: any = undefined;
|
|
7
|
+
|
|
8
|
+
// Capitalised so Svelte resolves it as a component rather than an HTML element.
|
|
9
|
+
let ResolvedRenderer: any;
|
|
10
|
+
let isAwaitingRenderer = false;
|
|
11
|
+
|
|
12
|
+
// Already-available renderers resolve synchronously, so SSR and the first paint
|
|
13
|
+
// are never deferred; only a genuinely pending chunk is awaited.
|
|
14
|
+
$: {
|
|
15
|
+
if (typeof Renderer?.then === 'function') {
|
|
16
|
+
isAwaitingRenderer = true;
|
|
17
|
+
|
|
18
|
+
Renderer.then((component: any) => {
|
|
19
|
+
ResolvedRenderer = component;
|
|
20
|
+
isAwaitingRenderer = false;
|
|
21
|
+
});
|
|
22
|
+
} else {
|
|
23
|
+
ResolvedRenderer = Renderer;
|
|
24
|
+
isAwaitingRenderer = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
</script>
|
|
6
28
|
|
|
7
|
-
{#if
|
|
8
|
-
|
|
29
|
+
{#if isAwaitingRenderer}
|
|
30
|
+
<!-- Render nothing while the chunk loads: falling through to `value` would
|
|
31
|
+
flash the raw source (e.g. `**bold**`) before the renderer arrives. -->
|
|
32
|
+
{:else if typeof ResolvedRenderer === 'string'}
|
|
33
|
+
<svelte:element this={ResolvedRenderer} {...rendererProps}>
|
|
9
34
|
{value}
|
|
10
35
|
</svelte:element>
|
|
11
|
-
{:else if typeof
|
|
12
|
-
<
|
|
36
|
+
{:else if typeof ResolvedRenderer === 'function'}
|
|
37
|
+
<ResolvedRenderer {...rendererProps}>
|
|
13
38
|
{value}
|
|
14
|
-
</
|
|
39
|
+
</ResolvedRenderer>
|
|
15
40
|
{:else}
|
|
16
41
|
{value}
|
|
17
42
|
{/if}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { KeyPath } from '@intlayer/types/keyPath';
|
|
2
|
+
/**
|
|
3
|
+
* Node metadata reported for a content exposure. Kept dependency-free (types
|
|
4
|
+
* only) so the interpreter hot path in `plugins.ts` never statically imports
|
|
5
|
+
* `@intlayer/analytics` — the package stays optional and tree-shakeable.
|
|
6
|
+
*/
|
|
7
|
+
export type ExposureInput = {
|
|
8
|
+
dictionaryKey: string;
|
|
9
|
+
keyPath: KeyPath[];
|
|
10
|
+
locale?: string;
|
|
11
|
+
nodeType?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Consumer of exposure reports, installed by `useAnalytics` after init. */
|
|
14
|
+
export type ExposureSink = (input: ExposureInput) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Installs (or clears) the exposure sink. `useAnalytics` sets this once the
|
|
17
|
+
* analytics client has loaded, and clears it on teardown.
|
|
18
|
+
*
|
|
19
|
+
* @param next - The sink to install, or `null` to clear.
|
|
20
|
+
*/
|
|
21
|
+
export declare const setExposureSink: (next: ExposureSink | null) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Forwards a content exposure to the installed sink. A cheap null-check when
|
|
24
|
+
* analytics is not running, so it is safe to call from every node resolution.
|
|
25
|
+
*
|
|
26
|
+
* @param input - The node metadata to report.
|
|
27
|
+
*/
|
|
28
|
+
export declare const reportExposure: (input: ExposureInput) => void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let sink = null;
|
|
2
|
+
/**
|
|
3
|
+
* Installs (or clears) the exposure sink. `useAnalytics` sets this once the
|
|
4
|
+
* analytics client has loaded, and clears it on teardown.
|
|
5
|
+
*
|
|
6
|
+
* @param next - The sink to install, or `null` to clear.
|
|
7
|
+
*/
|
|
8
|
+
export const setExposureSink = (next) => {
|
|
9
|
+
sink = next;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Forwards a content exposure to the installed sink. A cheap null-check when
|
|
13
|
+
* analytics is not running, so it is safe to call from every node resolution.
|
|
14
|
+
*
|
|
15
|
+
* @param input - The node metadata to report.
|
|
16
|
+
*/
|
|
17
|
+
export const reportExposure = (input) => {
|
|
18
|
+
sink?.(input);
|
|
19
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initializes the Intlayer analytics client singleton when analytics is
|
|
3
|
+
* enabled (mirrors {@link useEditor}). Records an initial page view, keeps the
|
|
4
|
+
* client aware of the current locale via `intlayerStore`, and wires node-level
|
|
5
|
+
* content exposures.
|
|
6
|
+
*
|
|
7
|
+
* The module is loaded via a dynamic `import()` so it ships as its own async
|
|
8
|
+
* chunk, off the critical rendering path. Apps that don't opt in
|
|
9
|
+
* (`analytics.enabled !== true`) pay nothing — the whole body is guarded by
|
|
10
|
+
* `INTLAYER_ANALYTICS_ENABLED` and dead-code-eliminated at build time.
|
|
11
|
+
*/
|
|
12
|
+
export declare const useAnalytics: () => void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isEnabled } from '@intlayer/analytics/isEnabled';
|
|
2
|
+
import { onDestroy, onMount } from 'svelte';
|
|
3
|
+
import { intlayerStore } from '../client/intlayerStore';
|
|
4
|
+
import { setExposureSink } from './exposureSink';
|
|
5
|
+
/**
|
|
6
|
+
* Initializes the Intlayer analytics client singleton when analytics is
|
|
7
|
+
* enabled (mirrors {@link useEditor}). Records an initial page view, keeps the
|
|
8
|
+
* client aware of the current locale via `intlayerStore`, and wires node-level
|
|
9
|
+
* content exposures.
|
|
10
|
+
*
|
|
11
|
+
* The module is loaded via a dynamic `import()` so it ships as its own async
|
|
12
|
+
* chunk, off the critical rendering path. Apps that don't opt in
|
|
13
|
+
* (`analytics.enabled !== true`) pay nothing — the whole body is guarded by
|
|
14
|
+
* `INTLAYER_ANALYTICS_ENABLED` and dead-code-eliminated at build time.
|
|
15
|
+
*/
|
|
16
|
+
export const useAnalytics = () => {
|
|
17
|
+
if (process.env.INTLAYER_ANALYTICS_ENABLED === 'false' || !isEnabled)
|
|
18
|
+
return;
|
|
19
|
+
let unsubscribeLocale = null;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
// Tracks whether initAnalyticsClient actually ran, so cleanup never calls
|
|
22
|
+
// stopAnalyticsClient for an init that was cancelled — that would decrement
|
|
23
|
+
// the shared reference count once too many.
|
|
24
|
+
let initialized = false;
|
|
25
|
+
onMount(() => {
|
|
26
|
+
import('@intlayer/analytics')
|
|
27
|
+
.then(({ initAnalyticsClient, buildContentExposure }) => {
|
|
28
|
+
if (cancelled)
|
|
29
|
+
return;
|
|
30
|
+
const client = initAnalyticsClient();
|
|
31
|
+
initialized = true;
|
|
32
|
+
// Svelte stores call the subscriber with the current value on
|
|
33
|
+
// subscription — that first call seeds the locale; later calls are
|
|
34
|
+
// locale switches, which are a real signal worth a page view.
|
|
35
|
+
let isInitialStoreValue = true;
|
|
36
|
+
unsubscribeLocale = intlayerStore.subscribe(({ locale }) => {
|
|
37
|
+
if (!locale)
|
|
38
|
+
return;
|
|
39
|
+
client.setLocale(locale);
|
|
40
|
+
if (isInitialStoreValue) {
|
|
41
|
+
isInitialStoreValue = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
client.trackPageView({ reason: 'locale_change' });
|
|
45
|
+
});
|
|
46
|
+
client.trackPageView({ reason: 'initial' });
|
|
47
|
+
// Route node-level exposures (from the interpreter plugins) into the
|
|
48
|
+
// client. Serialization happens here, off the hot render path.
|
|
49
|
+
setExposureSink((input) => client.trackContentExposure(buildContentExposure(input)));
|
|
50
|
+
})
|
|
51
|
+
.catch(() => {
|
|
52
|
+
/* chunk failed to load — analytics stays a no-op */
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
onDestroy(() => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
unsubscribeLocale?.();
|
|
58
|
+
setExposureSink(null);
|
|
59
|
+
import('@intlayer/analytics')
|
|
60
|
+
.then(({ stopAnalyticsClient }) => {
|
|
61
|
+
if (initialized)
|
|
62
|
+
stopAnalyticsClient();
|
|
63
|
+
})
|
|
64
|
+
.catch(() => { });
|
|
65
|
+
});
|
|
66
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameters describing a conversion to record.
|
|
3
|
+
*/
|
|
4
|
+
export type ConversionParams = {
|
|
5
|
+
/** The experiment this conversion is attributed to. */
|
|
6
|
+
experimentKey: string;
|
|
7
|
+
/** The variant the session was assigned. */
|
|
8
|
+
variant: string;
|
|
9
|
+
/** The goal reached (e.g. `"signup"`, `"cta_click"`). */
|
|
10
|
+
goal: string;
|
|
11
|
+
/** Optional numeric value (e.g. revenue). */
|
|
12
|
+
value?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Returns a `trackConversion` callback for content A/B testing. Safe to call
|
|
16
|
+
* from any component: it resolves to a no-op when analytics is disabled or the
|
|
17
|
+
* client has not been initialized yet.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```svelte
|
|
21
|
+
* <script>
|
|
22
|
+
* const trackConversion = useConversion();
|
|
23
|
+
* </script>
|
|
24
|
+
* <button on:click={() => trackConversion({
|
|
25
|
+
* experimentKey: 'homepage-hero', variant: 'b', goal: 'cta_click',
|
|
26
|
+
* })}>Get started</button>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare const useConversion: () => (params: ConversionParams) => void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isEnabled } from '@intlayer/analytics/isEnabled';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a `trackConversion` callback for content A/B testing. Safe to call
|
|
4
|
+
* from any component: it resolves to a no-op when analytics is disabled or the
|
|
5
|
+
* client has not been initialized yet.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script>
|
|
10
|
+
* const trackConversion = useConversion();
|
|
11
|
+
* </script>
|
|
12
|
+
* <button on:click={() => trackConversion({
|
|
13
|
+
* experimentKey: 'homepage-hero', variant: 'b', goal: 'cta_click',
|
|
14
|
+
* })}>Get started</button>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export const useConversion = () => {
|
|
18
|
+
return (params) => {
|
|
19
|
+
if (process.env.INTLAYER_ANALYTICS_ENABLED === 'false' || !isEnabled) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
import('@intlayer/analytics')
|
|
23
|
+
.then(({ getGlobalAnalyticsClient }) => {
|
|
24
|
+
getGlobalAnalyticsClient()?.trackConversion(params);
|
|
25
|
+
})
|
|
26
|
+
.catch(() => { });
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* The state of an A/B experiment for the current session.
|
|
4
|
+
*/
|
|
5
|
+
export type ExperimentState = {
|
|
6
|
+
/**
|
|
7
|
+
* The variant assigned to this session. Until assignment resolves (and on
|
|
8
|
+
* the server, or when analytics is disabled), this is the first variant —
|
|
9
|
+
* treat the first variant as the control.
|
|
10
|
+
*/
|
|
11
|
+
variant: Readable<string>;
|
|
12
|
+
/**
|
|
13
|
+
* `true` once the variant has been deterministically assigned and its
|
|
14
|
+
* exposure recorded. Useful to defer rendering when a flash of the control
|
|
15
|
+
* variant is not acceptable.
|
|
16
|
+
*/
|
|
17
|
+
isAssigned: Readable<boolean>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Assigns this session to a variant of an A/B experiment and records the
|
|
21
|
+
* exposure, scoped to `experimentKey`, so the backend can compute per-variant
|
|
22
|
+
* conversion rates against `useConversion` events.
|
|
23
|
+
*
|
|
24
|
+
* Assignment is deterministic per session (no server round-trip, stable across
|
|
25
|
+
* re-mounts). The exposure is recorded once per mount.
|
|
26
|
+
*
|
|
27
|
+
* @param experimentKey - Unique key identifying the experiment.
|
|
28
|
+
* @param variants - The candidate variant names; the first one is the control.
|
|
29
|
+
* @param weights - Optional relative weights, one per variant (e.g. `[9, 1]`).
|
|
30
|
+
* @returns The {@link ExperimentState} for this session.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```svelte
|
|
34
|
+
* <script>
|
|
35
|
+
* const { variant } = useExperiment('homepage-hero', ['a', 'b']);
|
|
36
|
+
* const content = useIntlayer($variant === 'b' ? 'hero-b' : 'hero');
|
|
37
|
+
* </script>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const useExperiment: (experimentKey: string, variants: string[], weights?: number[]) => ExperimentState;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isEnabled } from '@intlayer/analytics/isEnabled';
|
|
2
|
+
import { onDestroy, onMount } from 'svelte';
|
|
3
|
+
import { readonly, writable } from 'svelte/store';
|
|
4
|
+
/**
|
|
5
|
+
* Assigns this session to a variant of an A/B experiment and records the
|
|
6
|
+
* exposure, scoped to `experimentKey`, so the backend can compute per-variant
|
|
7
|
+
* conversion rates against `useConversion` events.
|
|
8
|
+
*
|
|
9
|
+
* Assignment is deterministic per session (no server round-trip, stable across
|
|
10
|
+
* re-mounts). The exposure is recorded once per mount.
|
|
11
|
+
*
|
|
12
|
+
* @param experimentKey - Unique key identifying the experiment.
|
|
13
|
+
* @param variants - The candidate variant names; the first one is the control.
|
|
14
|
+
* @param weights - Optional relative weights, one per variant (e.g. `[9, 1]`).
|
|
15
|
+
* @returns The {@link ExperimentState} for this session.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```svelte
|
|
19
|
+
* <script>
|
|
20
|
+
* const { variant } = useExperiment('homepage-hero', ['a', 'b']);
|
|
21
|
+
* const content = useIntlayer($variant === 'b' ? 'hero-b' : 'hero');
|
|
22
|
+
* </script>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const useExperiment = (experimentKey, variants, weights) => {
|
|
26
|
+
const variant = writable(variants[0] ?? '');
|
|
27
|
+
const isAssigned = writable(false);
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
let initialized = false;
|
|
30
|
+
onMount(() => {
|
|
31
|
+
if (process.env.INTLAYER_ANALYTICS_ENABLED === 'false' || !isEnabled) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
import('@intlayer/analytics')
|
|
35
|
+
.then(({ initAnalyticsClient }) => {
|
|
36
|
+
if (cancelled)
|
|
37
|
+
return;
|
|
38
|
+
// Ref-counted: guarantees a live client even when this hook's import
|
|
39
|
+
// resolves before the provider's. Balanced by stop on destroy.
|
|
40
|
+
const client = initAnalyticsClient();
|
|
41
|
+
initialized = true;
|
|
42
|
+
const assigned = client.getVariant(experimentKey, variants, weights);
|
|
43
|
+
variant.set(assigned);
|
|
44
|
+
isAssigned.set(true);
|
|
45
|
+
// Experiment-level exposure — the denominator of the conversion rate.
|
|
46
|
+
client.trackContentExposure({
|
|
47
|
+
dictionaryKey: experimentKey,
|
|
48
|
+
keyPath: '',
|
|
49
|
+
nodeType: 'experiment',
|
|
50
|
+
experimentKey,
|
|
51
|
+
variant: assigned,
|
|
52
|
+
});
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {
|
|
55
|
+
/* chunk failed to load — control variant stays in place */
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
import('@intlayer/analytics')
|
|
61
|
+
.then(({ stopAnalyticsClient }) => {
|
|
62
|
+
if (initialized)
|
|
63
|
+
stopAnalyticsClient();
|
|
64
|
+
})
|
|
65
|
+
.catch(() => { });
|
|
66
|
+
});
|
|
67
|
+
return { variant: readonly(variant), isAssigned: readonly(isAssigned) };
|
|
68
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { internationalization } from '@intlayer/config/built';
|
|
2
2
|
import { setIntlayerIdentifier } from '@intlayer/config/client';
|
|
3
|
+
import { useAnalytics } from '../analytics/useAnalytics';
|
|
3
4
|
import { useEditor } from '../editor/useEditor';
|
|
4
5
|
import { setIntlayerContext } from './intlayerContext';
|
|
5
6
|
import { intlayerStore } from './intlayerStore';
|
|
@@ -23,6 +24,7 @@ import { intlayerStore } from './intlayerStore';
|
|
|
23
24
|
export const setupIntlayer = (initialLocale) => {
|
|
24
25
|
setIntlayerIdentifier();
|
|
25
26
|
useEditor();
|
|
27
|
+
useAnalytics();
|
|
26
28
|
// Create Reactive State (Svelte 5)
|
|
27
29
|
// We make the locale a "rune" so updates propagate
|
|
28
30
|
let locale = $state(initialLocale);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ declare module '@intlayer/core/interpreter' {
|
|
|
4
4
|
interface IInterpreterPlugin<T, S, L extends LocalesValues> extends IInterpreterPluginSvelte<T, S, L> {
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
export * from './analytics';
|
|
7
8
|
export * from './client';
|
|
8
9
|
export * from './getDictionary';
|
|
9
10
|
export * from './getIntlayer';
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mockConfig = vi.hoisted(() => ({
|
|
3
|
+
editor: { enabled: false },
|
|
4
|
+
internationalization: { defaultLocale: 'en', locales: ['en'] },
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('@intlayer/config/built', () => ({
|
|
7
|
+
default: mockConfig,
|
|
8
|
+
editor: mockConfig.editor,
|
|
9
|
+
internationalization: mockConfig.internationalization,
|
|
10
|
+
}));
|
|
11
|
+
/**
|
|
12
|
+
* `renderIntlayerNode` is where the plugin hands off the Svelte component that
|
|
13
|
+
* will actually render the markdown. Capturing its arguments shows whether a
|
|
14
|
+
* renderer was resolved, or whether the node fell back to raw text because the
|
|
15
|
+
* lazily imported chunk had not landed yet.
|
|
16
|
+
*/
|
|
17
|
+
const capturedRenderArguments = vi.hoisted(() => []);
|
|
18
|
+
vi.mock('./renderIntlayerNode', () => ({
|
|
19
|
+
renderIntlayerNode: (args) => {
|
|
20
|
+
capturedRenderArguments.push(args);
|
|
21
|
+
const node = () => undefined;
|
|
22
|
+
Object.defineProperty(node, 'value', { value: args.value });
|
|
23
|
+
return node;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
/**
|
|
27
|
+
* Runs the markdown string plugin against a raw markdown source and reports the
|
|
28
|
+
* component it selected for rendering.
|
|
29
|
+
*/
|
|
30
|
+
const resolveMarkdownRenderer = async () => {
|
|
31
|
+
const { markdownStringPlugin } = await import('./plugins');
|
|
32
|
+
capturedRenderArguments.length = 0;
|
|
33
|
+
markdownStringPlugin.transform('Some **bold** text', { dictionaryKey: 'markdown-lazy', keyPath: [] }, (() => ({})));
|
|
34
|
+
// The plugin hands over a promise so a not-yet-loaded chunk is awaited rather
|
|
35
|
+
// than snapshotted as `null`.
|
|
36
|
+
return await Promise.resolve(capturedRenderArguments.at(-1)?.component);
|
|
37
|
+
};
|
|
38
|
+
describe('markdown renderer lazy-loading', () => {
|
|
39
|
+
it('resolves a renderer once the lazily imported chunk has landed', async () => {
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
await import('./plugins');
|
|
42
|
+
// Let the module-scope dynamic imports settle before interpreting.
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
44
|
+
expect(await resolveMarkdownRenderer()).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
it('resolves a renderer even when interpreted before the chunk lands', async () => {
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
// No await here: this mirrors a dictionary read during the first synchronous
|
|
49
|
+
// render pass, while the markdown chunk is still in flight.
|
|
50
|
+
expect(await resolveMarkdownRenderer()).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/plugins.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { editor, internationalization } from '@intlayer/config/built';
|
|
2
|
-
import { conditionPlugin, enumerationPlugin, fallbackPlugin, filePlugin, genderPlugin, nestedPlugin, pluralPlugin, translationPlugin, } from '@intlayer/core/interpreter';
|
|
2
|
+
import { conditionPlugin, enumerationPlugin, fallbackPlugin, filePlugin, genderPlugin, isInterpolableWrapperNode, nestedPlugin, pluralPlugin, transformInterpolableNode, translationPlugin, } from '@intlayer/core/interpreter';
|
|
3
3
|
import * as NodeTypes from '@intlayer/types/nodeType';
|
|
4
|
+
import { reportExposure } from './analytics/exposureSink';
|
|
4
5
|
import { default as ContentSelector } from './editor/ContentSelector.svelte';
|
|
5
6
|
import { HTMLRenderer } from './html/index';
|
|
6
7
|
import { renderIntlayerNode } from './renderIntlayerNode';
|
|
@@ -9,28 +10,62 @@ import { renderIntlayerNode } from './renderIntlayerNode';
|
|
|
9
10
|
// entirely when the feature is disabled at build time.
|
|
10
11
|
let _getMarkdownMetadata = null;
|
|
11
12
|
let _compile = null;
|
|
12
|
-
let _MarkdownMetadataRenderer = null;
|
|
13
|
-
let _MarkdownMetadataWithSelector = null;
|
|
14
|
-
let _MarkdownRenderer = null;
|
|
15
|
-
let _MarkdownWithSelector = null;
|
|
16
13
|
let _svelteHtmlRuntime = null;
|
|
17
14
|
let _HTMLWithSelector = null;
|
|
15
|
+
/**
|
|
16
|
+
* Kept as a promise rather than being unwrapped into module-level variables:
|
|
17
|
+
* a dictionary interpreted during the first synchronous render would otherwise
|
|
18
|
+
* snapshot a still-`null` renderer, and `IntlayerNodeWrapper` would silently
|
|
19
|
+
* fall back to printing the raw markdown source (`**bold**`) forever.
|
|
20
|
+
*/
|
|
21
|
+
let markdownRendererModulesPromise = null;
|
|
18
22
|
if (process.env.INTLAYER_NODE_TYPE_MARKDOWN !== 'false') {
|
|
19
23
|
void import('@intlayer/core/markdown').then((m) => {
|
|
20
24
|
_getMarkdownMetadata = m.getMarkdownMetadata;
|
|
21
25
|
_compile = m.compile;
|
|
22
26
|
});
|
|
23
|
-
|
|
24
|
-
import('./markdown/MarkdownMetadataRenderer.svelte')
|
|
25
|
-
import('./markdown/MarkdownMetadataWithSelector.svelte')
|
|
26
|
-
import('./markdown/MarkdownRenderer.svelte')
|
|
27
|
-
import('./markdown/MarkdownWithSelector.svelte')
|
|
28
|
-
import('./markdown/runtime')
|
|
29
|
-
])
|
|
27
|
+
markdownRendererModulesPromise = Promise.all([
|
|
28
|
+
import('./markdown/MarkdownMetadataRenderer.svelte'),
|
|
29
|
+
import('./markdown/MarkdownMetadataWithSelector.svelte'),
|
|
30
|
+
import('./markdown/MarkdownRenderer.svelte'),
|
|
31
|
+
import('./markdown/MarkdownWithSelector.svelte'),
|
|
32
|
+
import('./markdown/runtime'),
|
|
33
|
+
]).then(([metadataRendererModule, metadataWithSelectorModule, rendererModule, withSelectorModule, runtimeModule,]) => {
|
|
34
|
+
_svelteHtmlRuntime = runtimeModule.svelteHtmlRuntime;
|
|
35
|
+
return {
|
|
36
|
+
MarkdownMetadataRenderer: metadataRendererModule.default,
|
|
37
|
+
MarkdownMetadataWithSelector: metadataWithSelectorModule.default,
|
|
38
|
+
MarkdownRenderer: rendererModule.default,
|
|
39
|
+
MarkdownWithSelector: withSelectorModule.default,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
30
42
|
}
|
|
31
43
|
if (process.env.INTLAYER_NODE_TYPE_HTML !== 'false') {
|
|
32
44
|
void import('./html/HTMLWithSelector.svelte').then((m) => (_HTMLWithSelector = m.default));
|
|
33
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the Svelte component that renders a markdown node, awaiting the
|
|
48
|
+
* code-split chunk so the renderer is never lost to a load race.
|
|
49
|
+
*
|
|
50
|
+
* @param pickMetadataRenderer - Selects the metadata renderer instead of the
|
|
51
|
+
* content renderer.
|
|
52
|
+
* @returns A promise for the renderer, or `undefined` when markdown nodes are
|
|
53
|
+
* tree-shaken out of the bundle.
|
|
54
|
+
*/
|
|
55
|
+
const resolveMarkdownRenderer = (pickMetadataRenderer = false) => {
|
|
56
|
+
const useEditorSelector = process.env.INTLAYER_EDITOR_ENABLED !== 'false' && editor.enabled;
|
|
57
|
+
return markdownRendererModulesPromise?.then((modules) => {
|
|
58
|
+
if (pickMetadataRenderer) {
|
|
59
|
+
return useEditorSelector
|
|
60
|
+
? (modules.MarkdownMetadataWithSelector ??
|
|
61
|
+
modules.MarkdownMetadataRenderer)
|
|
62
|
+
: modules.MarkdownMetadataRenderer;
|
|
63
|
+
}
|
|
64
|
+
return useEditorSelector
|
|
65
|
+
? (modules.MarkdownWithSelector ?? modules.MarkdownRenderer)
|
|
66
|
+
: modules.MarkdownRenderer;
|
|
67
|
+
});
|
|
68
|
+
};
|
|
34
69
|
/**
|
|
35
70
|
* Basic Intlayer node plugins for content handling
|
|
36
71
|
* These handle the core content transformation logic
|
|
@@ -40,13 +75,25 @@ export const intlayerNodePlugins = {
|
|
|
40
75
|
canHandle: (node) => typeof node === 'bigint' ||
|
|
41
76
|
typeof node === 'string' ||
|
|
42
77
|
typeof node === 'number',
|
|
43
|
-
transform: (node, { children, ...rest }) =>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
transform: (node, { children, ...rest }) => {
|
|
79
|
+
// Node-level analytics: record which content is resolved for display.
|
|
80
|
+
// No-op (and dead-code-eliminated) when analytics is disabled.
|
|
81
|
+
if (process.env.INTLAYER_ANALYTICS_ENABLED !== 'false') {
|
|
82
|
+
reportExposure({
|
|
83
|
+
dictionaryKey: rest.dictionaryKey,
|
|
84
|
+
keyPath: rest.keyPath,
|
|
85
|
+
locale: rest.locale,
|
|
86
|
+
nodeType: 'text',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return renderIntlayerNode({
|
|
90
|
+
value: children ?? node,
|
|
91
|
+
component: process.env.INTLAYER_EDITOR_ENABLED !== 'false' && editor.enabled
|
|
92
|
+
? ContentSelector
|
|
93
|
+
: undefined,
|
|
94
|
+
props: rest,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
50
97
|
};
|
|
51
98
|
/**
|
|
52
99
|
* Svelte-specific node plugins for handling basic content types
|
|
@@ -119,8 +166,14 @@ export const insertionPlugin = process.env.INTLAYER_NODE_TYPE_INSERTION === 'fal
|
|
|
119
166
|
/** Insertion string plugin. Replaces string node with a component that render the insertion. */
|
|
120
167
|
const insertionStringPlugin = {
|
|
121
168
|
id: 'insertion-string-plugin',
|
|
122
|
-
canHandle: (node) => typeof node === 'string',
|
|
169
|
+
canHandle: (node) => typeof node === 'string' || isInterpolableWrapperNode(node),
|
|
123
170
|
transform: (node, subProps, deepTransformNode) => {
|
|
171
|
+
// `html()`/`markdown()` nodes carry their `{{ … }}` placeholders
|
|
172
|
+
// inside a raw string. Interpolate into that string, then re-run
|
|
173
|
+
// the transform so the html/markdown renderer applies afterwards.
|
|
174
|
+
if (isInterpolableWrapperNode(node)) {
|
|
175
|
+
return (values) => transformInterpolableNode(node, values, subProps, props.plugins, deepTransformNode);
|
|
176
|
+
}
|
|
124
177
|
const transformedResult = deepTransformNode(node, {
|
|
125
178
|
...subProps,
|
|
126
179
|
children: node,
|
|
@@ -177,11 +230,7 @@ export const markdownStringPlugin = process.env.INTLAYER_NODE_TYPE_MARKDOWN ===
|
|
|
177
230
|
!metadataNode,
|
|
178
231
|
transform: (metadataNode, props) => renderIntlayerNode({
|
|
179
232
|
value: metadataNode,
|
|
180
|
-
component:
|
|
181
|
-
editor.enabled
|
|
182
|
-
? (_MarkdownMetadataWithSelector ??
|
|
183
|
-
_MarkdownMetadataRenderer)
|
|
184
|
-
: _MarkdownMetadataRenderer,
|
|
233
|
+
component: resolveMarkdownRenderer(true),
|
|
185
234
|
props: {
|
|
186
235
|
...rest,
|
|
187
236
|
value: node, // The full markdown string
|
|
@@ -198,10 +247,7 @@ export const markdownStringPlugin = process.env.INTLAYER_NODE_TYPE_MARKDOWN ===
|
|
|
198
247
|
const render = (components) => {
|
|
199
248
|
const nodeResult = renderIntlayerNode({
|
|
200
249
|
value: node,
|
|
201
|
-
component:
|
|
202
|
-
editor.enabled
|
|
203
|
-
? (_MarkdownWithSelector ?? _MarkdownRenderer)
|
|
204
|
-
: _MarkdownRenderer,
|
|
250
|
+
component: resolveMarkdownRenderer(),
|
|
205
251
|
props: {
|
|
206
252
|
...rest,
|
|
207
253
|
value: node,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-intlayer",
|
|
3
|
-
"version": "9.0.0
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Easily internationalize i18n your Svelte applications with type-safe multilingual content management.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"intlayer",
|
|
@@ -82,31 +82,34 @@
|
|
|
82
82
|
"typecheck": "tsc --noEmit --project tsconfig.types.json"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@intlayer/api": "9.0.0
|
|
86
|
-
"@intlayer/config": "9.0.0
|
|
87
|
-
"@intlayer/core": "9.0.0
|
|
88
|
-
"@intlayer/editor": "9.0.0
|
|
89
|
-
"@intlayer/types": "9.0.0
|
|
85
|
+
"@intlayer/api": "9.0.0",
|
|
86
|
+
"@intlayer/config": "9.0.0",
|
|
87
|
+
"@intlayer/core": "9.0.0",
|
|
88
|
+
"@intlayer/editor": "9.0.0",
|
|
89
|
+
"@intlayer/types": "9.0.0"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
92
|
"@sveltejs/adapter-auto": "7.0.1",
|
|
93
|
-
"@sveltejs/package": "2.5.
|
|
94
|
-
"@sveltejs/vite-plugin-svelte": "7.
|
|
95
|
-
"@types/node": "
|
|
93
|
+
"@sveltejs/package": "2.5.8",
|
|
94
|
+
"@sveltejs/vite-plugin-svelte": "7.2.0",
|
|
95
|
+
"@types/node": "26.1.1",
|
|
96
96
|
"@utils/ts-config": "1.0.4",
|
|
97
97
|
"@utils/ts-config-types": "1.0.4",
|
|
98
98
|
"@utils/tsdown-config": "1.0.4",
|
|
99
99
|
"rimraf": "6.1.3",
|
|
100
|
-
"svelte": "5.
|
|
101
|
-
"svelte-check": "4.
|
|
102
|
-
"tsdown": "0.
|
|
103
|
-
"typescript": "
|
|
104
|
-
"vite": "8.1.
|
|
105
|
-
"vitest": "4.1.
|
|
100
|
+
"svelte": "5.56.7",
|
|
101
|
+
"svelte-check": "4.7.3",
|
|
102
|
+
"tsdown": "0.22.13",
|
|
103
|
+
"typescript": "5.9.3",
|
|
104
|
+
"vite": "8.1.5",
|
|
105
|
+
"vitest": "4.1.10"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
108
108
|
"svelte": ">=5.0.0"
|
|
109
109
|
},
|
|
110
|
+
"optionalDependencies": {
|
|
111
|
+
"@intlayer/analytics": "9.0.0"
|
|
112
|
+
},
|
|
110
113
|
"engines": {
|
|
111
114
|
"node": ">=14.18"
|
|
112
115
|
}
|