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.
@@ -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 typeof Renderer === 'string'}
8
- <svelte:element this={Renderer} {...rendererProps}>
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 Renderer === 'function'}
12
- <Renderer {...rendererProps}>
36
+ {:else if typeof ResolvedRenderer === 'function'}
37
+ <ResolvedRenderer {...rendererProps}>
13
38
  {value}
14
- </Renderer>
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,3 @@
1
+ export * from './useAnalytics';
2
+ export * from './useConversion';
3
+ export * from './useExperiment';
@@ -0,0 +1,3 @@
1
+ export * from './useAnalytics';
2
+ export * from './useConversion';
3
+ export * from './useExperiment';
@@ -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
@@ -1,3 +1,4 @@
1
+ export * from './analytics';
1
2
  export * from './client';
2
3
  export * from './getDictionary';
3
4
  export * from './getIntlayer';
@@ -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
- void Promise.all([
24
- import('./markdown/MarkdownMetadataRenderer.svelte').then((m) => (_MarkdownMetadataRenderer = m.default)),
25
- import('./markdown/MarkdownMetadataWithSelector.svelte').then((m) => (_MarkdownMetadataWithSelector = m.default)),
26
- import('./markdown/MarkdownRenderer.svelte').then((m) => (_MarkdownRenderer = m.default)),
27
- import('./markdown/MarkdownWithSelector.svelte').then((m) => (_MarkdownWithSelector = m.default)),
28
- import('./markdown/runtime').then((m) => (_svelteHtmlRuntime = m.svelteHtmlRuntime)),
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 }) => renderIntlayerNode({
44
- value: children ?? node,
45
- component: process.env.INTLAYER_EDITOR_ENABLED !== 'false' && editor.enabled
46
- ? ContentSelector
47
- : undefined,
48
- props: rest,
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: process.env.INTLAYER_EDITOR_ENABLED !== 'false' &&
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: process.env.INTLAYER_EDITOR_ENABLED !== 'false' &&
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-canary.9",
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-canary.9",
86
- "@intlayer/config": "9.0.0-canary.9",
87
- "@intlayer/core": "9.0.0-canary.9",
88
- "@intlayer/editor": "9.0.0-canary.9",
89
- "@intlayer/types": "9.0.0-canary.9"
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.7",
94
- "@sveltejs/vite-plugin-svelte": "7.1.2",
95
- "@types/node": "25.9.4",
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.55.8",
101
- "svelte-check": "4.4.8",
102
- "tsdown": "0.21.10",
103
- "typescript": "6.0.3",
104
- "vite": "8.1.0",
105
- "vitest": "4.1.9"
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
  }