@megabudino/stack-utils 1.4.1 → 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.
package/README.md CHANGED
@@ -43,25 +43,37 @@ export const handle = createProxyHandle({
43
43
  });
44
44
  ```
45
45
 
46
+ If you want client-side proxy islands with auto-generated browser wiring, also install the Vite plugin once:
47
+
48
+ ```ts
49
+ import { sveltekit } from '@sveltejs/kit/vite';
50
+ import { defineConfig } from 'vite';
51
+ import { proxyIslands } from '@megabudino/stack-utils/proxy/vite';
52
+
53
+ export default defineConfig({
54
+ plugins: [proxyIslands(), sveltekit()]
55
+ });
56
+ ```
57
+
46
58
  Proxy config:
47
59
 
48
60
  | Option | Type | Default | Description |
49
61
  | --- | --- | --- | --- |
50
62
  | `originUrl` | `string` | required | Origin server URL |
51
63
  | `siteUrl` | `string` | required | Public site URL |
52
- | `sitemaps` | `string[]` | `[]` | SvelteKit sitemap URLs to inject into sitemap indexes |
53
- | `sitemapCacheSeconds` | `number` | `3600` | Cache max-age for sitemap responses |
64
+ | `sitemap` | `SitemapConfig` | `{}` | Sitemap index injection and origin URL filtering |
54
65
  | `proxyAssets` | `boolean` | `false` | Rewrite origin asset URLs to relative paths |
55
66
  | `additionalOrigins` | `string[]` | `[]` | Additional absolute origins to rewrite like `originUrl` |
56
- | `domActions` | `DomAction[]` | `[]` | Declarative DOM transformations for proxied HTML |
57
- | `textReplacements` | `Record<string, string>` | `{}` | Final string replacements applied after DOM actions |
67
+ | `transformDom` | `TransformDomHook` | — | `($, ctx)` cheerio hook against the proxied document |
68
+ | `islands` | `ProxyIslandsConfig` | | Svelte component registry used by `transformDom`'s `ctx.renderIsland(...)` |
69
+ | `textReplacements` | `Record<string, string>` | `{}` | Final string replacements applied after `transformDom` |
58
70
 
59
71
  What it does:
60
72
 
61
73
  - lets SvelteKit routes resolve normally when a route exists
62
74
  - proxies unmatched requests to the configured origin
63
75
  - rewrites redirects back to the public domain
64
- - rewrites HTML, applies optional DOM actions, and injects extra sitemap entries when configured
76
+ - rewrites HTML, runs the optional `transformDom` hook against cheerio, and rewrites sitemap XML when configured
65
77
 
66
78
  ### `images`
67
79
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Browser-side island bootstrap.
3
+ *
4
+ * Lives in a separate subpath (`@megabudino/stack-utils/proxy/client`) so the
5
+ * server proxy can keep depending on `svelte/server` while this entry point
6
+ * resolves to the browser build of `svelte` (for `mount` / `hydrate`).
7
+ */
8
+ import type { IslandComponent, IslandMode } from './types.js';
9
+ export interface MountIslandsConfig {
10
+ /** Registry of components keyed by the same names used on the server. */
11
+ components: Record<string, IslandComponent>;
12
+ /**
13
+ * Override the default mode used when an island has no
14
+ * `data-stack-island-mode` attribute. Defaults to `'mount'`.
15
+ */
16
+ defaultMode?: Exclude<IslandMode, 'serverOnly'>;
17
+ /**
18
+ * Root element to search. Defaults to `document`. Pass a specific node
19
+ * when re-mounting a subtree after client-side navigation.
20
+ */
21
+ root?: ParentNode;
22
+ /**
23
+ * Optional logger for missing-component or malformed-props warnings.
24
+ * Defaults to `console.warn`. Pass `null` to silence.
25
+ */
26
+ onWarn?: ((message: string) => void) | null;
27
+ }
28
+ /**
29
+ * Walk the DOM and activate every island the server tagged with
30
+ * `data-stack-island`. Safe to call more than once: already-activated islands
31
+ * carry a marker and are skipped on subsequent calls.
32
+ */
33
+ export declare function mountIslands(config: MountIslandsConfig): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Browser-side island bootstrap.
3
+ *
4
+ * Lives in a separate subpath (`@megabudino/stack-utils/proxy/client`) so the
5
+ * server proxy can keep depending on `svelte/server` while this entry point
6
+ * resolves to the browser build of `svelte` (for `mount` / `hydrate`).
7
+ */
8
+ import { hydrate, mount } from 'svelte';
9
+ const DATA_ISLAND_ATTR = 'data-stack-island';
10
+ const DATA_ISLAND_MODE_ATTR = 'data-stack-island-mode';
11
+ const DATA_ISLAND_ID_ATTR = 'data-stack-island-id';
12
+ const DATA_PROPS_FOR_ATTR = 'data-stack-island-for';
13
+ /**
14
+ * Walk the DOM and activate every island the server tagged with
15
+ * `data-stack-island`. Safe to call more than once: already-activated islands
16
+ * carry a marker and are skipped on subsequent calls.
17
+ */
18
+ export function mountIslands(config) {
19
+ const root = config.root ?? document;
20
+ const warn = config.onWarn === undefined ? defaultWarn : config.onWarn;
21
+ const defaultMode = config.defaultMode ?? 'mount';
22
+ const elements = root.querySelectorAll(`[${DATA_ISLAND_ATTR}]`);
23
+ for (const el of elements) {
24
+ activateIsland(el, config.components, defaultMode, warn);
25
+ }
26
+ }
27
+ const ACTIVATED = Symbol.for('stack-utils.island.activated');
28
+ function activateIsland(el, components, defaultMode, warn) {
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ if (el[ACTIVATED]) {
31
+ return;
32
+ }
33
+ const name = el.getAttribute(DATA_ISLAND_ATTR);
34
+ if (!name) {
35
+ return;
36
+ }
37
+ const rawMode = el.getAttribute(DATA_ISLAND_MODE_ATTR);
38
+ const mode = rawMode ?? defaultMode;
39
+ if (mode === 'serverOnly') {
40
+ return;
41
+ }
42
+ const Component = components[name];
43
+ if (!Component) {
44
+ warn?.(`[stack-utils] island component "${name}" is not registered on the client.`);
45
+ return;
46
+ }
47
+ const props = readProps(el, name, warn);
48
+ try {
49
+ if (mode === 'hydrate') {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ hydrate(Component, { target: el, props, recover: true });
52
+ }
53
+ else {
54
+ el.replaceChildren();
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ mount(Component, { target: el, props });
57
+ }
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ el[ACTIVATED] = true;
60
+ }
61
+ catch (error) {
62
+ warn?.(`[stack-utils] island "${name}" failed to ${mode}: ${stringifyError(error)}`);
63
+ }
64
+ }
65
+ function readProps(el, name, warn) {
66
+ const islandId = el.getAttribute(DATA_ISLAND_ID_ATTR);
67
+ if (!islandId) {
68
+ return {};
69
+ }
70
+ const script = document.querySelector(`script[type="application/json"][${DATA_PROPS_FOR_ATTR}="${cssEscape(islandId)}"]`);
71
+ if (!script) {
72
+ return {};
73
+ }
74
+ try {
75
+ return JSON.parse(script.textContent ?? '{}');
76
+ }
77
+ catch (error) {
78
+ warn?.(`[stack-utils] failed to parse props for island "${name}": ${stringifyError(error)}`);
79
+ return {};
80
+ }
81
+ }
82
+ function defaultWarn(message) {
83
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
84
+ console.warn(message);
85
+ }
86
+ }
87
+ function stringifyError(error) {
88
+ if (error instanceof Error) {
89
+ return error.message;
90
+ }
91
+ return String(error);
92
+ }
93
+ /** Minimal CSS.escape fallback (covers the id strings we emit). */
94
+ function cssEscape(value) {
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ const native = globalThis.CSS?.escape;
97
+ if (typeof native === 'function') {
98
+ return native(value);
99
+ }
100
+ return value.replace(/[^a-zA-Z0-9\-_]/g, (ch) => `\\${ch}`);
101
+ }
@@ -0,0 +1,16 @@
1
+ import type { ProxyIslandsConfig } from './types.js';
2
+ declare const AUTO_WIRED_PROXY_ISLANDS: unique symbol;
3
+ type AutoWiredProxyIslandsConfig = ProxyIslandsConfig & {
4
+ readonly [AUTO_WIRED_PROXY_ISLANDS]?: true;
5
+ };
6
+ /**
7
+ * Declare a proxy islands registry in a form the `proxyIslands()` Vite plugin
8
+ * can recognize and auto-wire for the browser.
9
+ *
10
+ * Runtime behavior stays minimal: the helper returns the original config
11
+ * object with a non-enumerable internal marker so the proxy can produce a
12
+ * helpful error when client islands are used without the build plugin.
13
+ */
14
+ export declare function defineProxyIslands<T extends ProxyIslandsConfig>(config: T): T;
15
+ export declare function isAutoWiredProxyIslandsConfig(config: ProxyIslandsConfig | undefined): config is AutoWiredProxyIslandsConfig;
16
+ export {};
@@ -0,0 +1,21 @@
1
+ const AUTO_WIRED_PROXY_ISLANDS = Symbol.for('stack-utils.proxy.auto-wired-islands');
2
+ /**
3
+ * Declare a proxy islands registry in a form the `proxyIslands()` Vite plugin
4
+ * can recognize and auto-wire for the browser.
5
+ *
6
+ * Runtime behavior stays minimal: the helper returns the original config
7
+ * object with a non-enumerable internal marker so the proxy can produce a
8
+ * helpful error when client islands are used without the build plugin.
9
+ */
10
+ export function defineProxyIslands(config) {
11
+ Object.defineProperty(config, AUTO_WIRED_PROXY_ISLANDS, {
12
+ value: true,
13
+ enumerable: false,
14
+ configurable: false,
15
+ writable: false
16
+ });
17
+ return config;
18
+ }
19
+ export function isAutoWiredProxyIslandsConfig(config) {
20
+ return Boolean(config && config[AUTO_WIRED_PROXY_ISLANDS]);
21
+ }
@@ -1,5 +1,6 @@
1
- import { applyDomActions } from './dom-actions.js';
2
- import { injectIntoSitemapIndex, rewriteHtml } from './rewrite.js';
1
+ import { validateIslandsConfigAtStartup } from './islands.js';
2
+ import { injectIntoSitemapIndex, normalizeSitemapPath, removeExcludedUrlsFromSitemap, rewriteHtml } from './rewrite.js';
3
+ import { transformProxiedHtml } from './transform-html.js';
3
4
  /**
4
5
  * Create a SvelteKit `Handle` hook that reverse-proxies requests to an origin.
5
6
  *
@@ -7,13 +8,19 @@ import { injectIntoSitemapIndex, rewriteHtml } from './rewrite.js';
7
8
  * everything else is forwarded to the origin.
8
9
  */
9
10
  export function createProxyHandle(config) {
11
+ assertNoRemovedConfig(config);
10
12
  const { originUrl, siteUrl } = config;
11
- const sitemaps = config.sitemaps ?? [];
12
- const sitemapCache = `public, max-age=${config.sitemapCacheSeconds ?? 3600}`;
13
+ const sitemap = config.sitemap ?? {};
14
+ const sitemapIndexPath = sitemap.indexPath ? normalizeSitemapPath(sitemap.indexPath) : undefined;
15
+ const sitemapIncludeFiles = sitemap.includeFiles ?? [];
16
+ const sitemapExcludePaths = sitemap.excludePaths ?? [];
17
+ const sitemapCache = `public, max-age=${sitemap.cacheSeconds ?? 3600}`;
13
18
  const proxyAssets = config.proxyAssets ?? false;
14
19
  const additionalOrigins = config.additionalOrigins ?? [];
15
20
  const maxRedirects = config.maxInternalRedirects ?? 5;
16
- const domActions = config.domActions ?? [];
21
+ const transformDom = config.transformDom;
22
+ const islandsConfig = config.islands;
23
+ validateIslandsConfigAtStartup(islandsConfig);
17
24
  const textReplacements = config.textReplacements ?? {};
18
25
  const siteHost = new URL(siteUrl).hostname;
19
26
  const originHosts = new Set([
@@ -68,12 +75,25 @@ export function createProxyHandle(config) {
68
75
  cleanHeaders.delete('content-encoding');
69
76
  cleanHeaders.delete('content-length');
70
77
  cleanHeaders.delete('transfer-encoding');
78
+ if (isNullBodyStatus(upstreamResponse.status)) {
79
+ return new Response(null, {
80
+ status: upstreamResponse.status,
81
+ statusText: upstreamResponse.statusText,
82
+ headers: cleanHeaders
83
+ });
84
+ }
71
85
  const contentType = upstreamResponse.headers.get('content-type') ?? '';
72
86
  if (contentType.includes('text/xml') || contentType.includes('application/xml')) {
73
87
  let xml = await upstreamResponse.text();
74
88
  xml = xml.replace(/<\?xml-stylesheet[^?]*\?>\s*/g, '');
75
- if (sitemaps.length > 0 && xml.includes('</sitemapindex>')) {
76
- xml = injectIntoSitemapIndex(xml, siteUrl, sitemaps);
89
+ const isConfiguredSitemapIndex = sitemapIndexPath !== undefined && normalizeSitemapPath(pathname) === sitemapIndexPath;
90
+ if (isConfiguredSitemapIndex &&
91
+ sitemapIncludeFiles.length > 0 &&
92
+ xml.includes('</sitemapindex>')) {
93
+ xml = injectIntoSitemapIndex(xml, siteUrl, sitemapIncludeFiles);
94
+ }
95
+ else if (sitemapExcludePaths.length > 0 && xml.includes('</urlset>')) {
96
+ xml = removeExcludedUrlsFromSitemap(xml, sitemapExcludePaths);
77
97
  }
78
98
  cleanHeaders.set('cache-control', sitemapCache);
79
99
  return new Response(xml, {
@@ -85,7 +105,12 @@ export function createProxyHandle(config) {
85
105
  if (contentType.includes('text/html')) {
86
106
  const html = await upstreamResponse.text();
87
107
  let result = rewriteHtml(html, originUrl, siteUrl, proxyAssets, additionalOrigins);
88
- result = applyDomActions(result, domActions);
108
+ result = await transformProxiedHtml(result, {
109
+ transformDom,
110
+ islandsConfig,
111
+ event,
112
+ url: event.url
113
+ });
89
114
  for (const [search, replace] of Object.entries(textReplacements)) {
90
115
  result = result.replaceAll(search, replace);
91
116
  }
@@ -103,3 +128,23 @@ export function createProxyHandle(config) {
103
128
  }
104
129
  };
105
130
  }
131
+ /**
132
+ * Surface removed config options at startup with a migration-friendly error
133
+ * instead of silently ignoring them or — worse — applying half of the legacy
134
+ * behavior. The TypeScript types already reject these via
135
+ * `DeprecatedProxyOption` sentinels; this is the JS-runtime safety net.
136
+ */
137
+ function assertNoRemovedConfig(config) {
138
+ if ('sitemaps' in config) {
139
+ throw new Error('`sitemaps` is deprecated and no longer supported. Use `sitemap.includeFiles` with `sitemap.indexPath` instead.');
140
+ }
141
+ if ('sitemapCacheSeconds' in config) {
142
+ throw new Error('`sitemapCacheSeconds` is deprecated and no longer supported. Use `sitemap.cacheSeconds` instead.');
143
+ }
144
+ if ('domActions' in config) {
145
+ throw new Error('`domActions` was removed in v2. Migrate every transformation to `transformDom: ($, ctx) => { ... }` and call `ctx.renderIsland(...)` for islands.');
146
+ }
147
+ }
148
+ function isNullBodyStatus(status) {
149
+ return status === 101 || status === 103 || status === 204 || status === 205 || status === 304;
150
+ }
@@ -1,2 +1,3 @@
1
+ export { defineProxyIslands } from './define-islands.js';
1
2
  export { createProxyHandle } from './handle.js';
2
- export type { DomAction, ProxyConfig } from './types.js';
3
+ export type { IslandComponent, IslandMode, IslandSpec, ProxyConfig, ProxyIslandsConfig, SitemapConfig, TransformDomContext, TransformDomHook, TransformDomTarget } from './types.js';
@@ -1 +1,2 @@
1
+ export { defineProxyIslands } from './define-islands.js';
1
2
  export { createProxyHandle } from './handle.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Server-side island rendering helpers.
3
+ *
4
+ * Kept in its own module so `svelte/server` is only imported when the proxy
5
+ * actually needs to render an island. The first call lazily resolves the
6
+ * `render` export and caches it for the lifetime of the process.
7
+ */
8
+ import type { IslandMode, IslandSpec, ProxyIslandsConfig } from './types.js';
9
+ export declare const ISLAND_ATTRS: Readonly<{
10
+ island: "data-stack-island";
11
+ mode: "data-stack-island-mode";
12
+ id: "data-stack-island-id";
13
+ props: "data-stack-island-props";
14
+ propsFor: "data-stack-island-for";
15
+ }>;
16
+ export interface RenderedIsland {
17
+ name: string;
18
+ mode: IslandMode;
19
+ headHtml: string;
20
+ bodyHtml: string;
21
+ serializedProps: string | null;
22
+ }
23
+ /** Render an island spec into SSR HTML plus optional serialized props. */
24
+ export declare function renderIsland(spec: IslandSpec, islandsConfig: ProxyIslandsConfig): Promise<RenderedIsland>;
25
+ /**
26
+ * Escape a JSON payload for safe embedding in a `<script>` tag.
27
+ * Prevents premature `</script>` termination and HTML comment hijacks.
28
+ */
29
+ export declare function escapeJsonForScript(json: string): string;
30
+ /** Build the `<script>` tag that carries serialized props for a rendered island. */
31
+ export declare function buildPropsScript(islandId: string, serializedProps: string): string;
32
+ /**
33
+ * Validate the islands config at startup.
34
+ *
35
+ * Whether or not a request actually renders a client island is a runtime
36
+ * decision (`transformDom` is dynamic), so the per-island checks
37
+ * (unknown name, invalid mode, JSON-serializable props) happen lazily in
38
+ * {@link renderIsland}. Still, when the registry was declared with
39
+ * `defineProxyIslands(...)` we know the consumer expects the
40
+ * `proxyIslands()` Vite plugin to inject `clientEntry`. If it didn't, the
41
+ * plugin is missing from `vite.config.ts` and every client island would fail
42
+ * at the first request — surface that mistake at startup instead.
43
+ */
44
+ export declare function validateIslandsConfigAtStartup(islandsConfig: ProxyIslandsConfig | undefined): void;
45
+ /**
46
+ * Build the `<script type="module">` tag that loads the consumer's client
47
+ * bootstrap. Returns an empty string when no client entry is configured.
48
+ */
49
+ export declare function buildBootstrapScript(clientEntry: string): string;
50
+ export declare function buildModulePreloadLink(clientEntry: string): string;
51
+ export declare function buildStylesheetLink(stylesheetUrl: string): string;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Server-side island rendering helpers.
3
+ *
4
+ * Kept in its own module so `svelte/server` is only imported when the proxy
5
+ * actually needs to render an island. The first call lazily resolves the
6
+ * `render` export and caches it for the lifetime of the process.
7
+ */
8
+ import { isAutoWiredProxyIslandsConfig } from './define-islands.js';
9
+ const DATA_ISLAND_ATTR = 'data-stack-island';
10
+ const DATA_ISLAND_MODE_ATTR = 'data-stack-island-mode';
11
+ const DATA_ISLAND_ID_ATTR = 'data-stack-island-id';
12
+ const DATA_PROPS_ATTR = 'data-stack-island-props';
13
+ const DATA_PROPS_FOR_ATTR = 'data-stack-island-for';
14
+ export const ISLAND_ATTRS = Object.freeze({
15
+ island: DATA_ISLAND_ATTR,
16
+ mode: DATA_ISLAND_MODE_ATTR,
17
+ id: DATA_ISLAND_ID_ATTR,
18
+ props: DATA_PROPS_ATTR,
19
+ propsFor: DATA_PROPS_FOR_ATTR
20
+ });
21
+ let cachedRender = null;
22
+ async function getSvelteRender() {
23
+ if (cachedRender) {
24
+ return cachedRender;
25
+ }
26
+ let mod;
27
+ try {
28
+ mod = (await import('svelte/server'));
29
+ }
30
+ catch (cause) {
31
+ throw new Error('[stack-utils proxy] failed to load `svelte/server`. Islands require Svelte 5 as a peer dependency.', { cause: cause });
32
+ }
33
+ cachedRender = mod.render;
34
+ return cachedRender;
35
+ }
36
+ /** Render an island spec into SSR HTML plus optional serialized props. */
37
+ export async function renderIsland(spec, islandsConfig) {
38
+ const component = islandsConfig.components[spec.name];
39
+ if (!component) {
40
+ throw new Error(`[stack-utils proxy] island component "${spec.name}" is not registered.`);
41
+ }
42
+ const mode = spec.mode ?? 'serverOnly';
43
+ const props = spec.props ?? {};
44
+ const render = await getSvelteRender();
45
+ const output = render(component, { props });
46
+ const headHtml = output.head ?? '';
47
+ const bodyHtml = output.body ?? output.html ?? '';
48
+ let serializedProps = null;
49
+ if (mode !== 'serverOnly') {
50
+ try {
51
+ serializedProps = escapeJsonForScript(JSON.stringify(props));
52
+ }
53
+ catch (cause) {
54
+ throw new Error(`[stack-utils proxy] island "${spec.name}" props must be JSON-serializable for mode "${mode}".`, { cause: cause });
55
+ }
56
+ }
57
+ return { name: spec.name, mode, headHtml, bodyHtml, serializedProps };
58
+ }
59
+ /**
60
+ * Escape a JSON payload for safe embedding in a `<script>` tag.
61
+ * Prevents premature `</script>` termination and HTML comment hijacks.
62
+ */
63
+ export function escapeJsonForScript(json) {
64
+ return json.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
65
+ }
66
+ /** Build the `<script>` tag that carries serialized props for a rendered island. */
67
+ export function buildPropsScript(islandId, serializedProps) {
68
+ return `<script type="application/json" ${DATA_PROPS_ATTR} ${DATA_PROPS_FOR_ATTR}="${islandId}">${serializedProps}</script>`;
69
+ }
70
+ /**
71
+ * Validate the islands config at startup.
72
+ *
73
+ * Whether or not a request actually renders a client island is a runtime
74
+ * decision (`transformDom` is dynamic), so the per-island checks
75
+ * (unknown name, invalid mode, JSON-serializable props) happen lazily in
76
+ * {@link renderIsland}. Still, when the registry was declared with
77
+ * `defineProxyIslands(...)` we know the consumer expects the
78
+ * `proxyIslands()` Vite plugin to inject `clientEntry`. If it didn't, the
79
+ * plugin is missing from `vite.config.ts` and every client island would fail
80
+ * at the first request — surface that mistake at startup instead.
81
+ */
82
+ export function validateIslandsConfigAtStartup(islandsConfig) {
83
+ if (!islandsConfig) {
84
+ return;
85
+ }
86
+ if (isAutoWiredProxyIslandsConfig(islandsConfig) && !islandsConfig.clientEntry) {
87
+ throw new Error('[stack-utils proxy] `islands` was declared with `defineProxyIslands(...)` but no `clientEntry` was injected. Add `proxyIslands()` to your `vite.config.ts` plugins.');
88
+ }
89
+ }
90
+ /**
91
+ * Build the `<script type="module">` tag that loads the consumer's client
92
+ * bootstrap. Returns an empty string when no client entry is configured.
93
+ */
94
+ export function buildBootstrapScript(clientEntry) {
95
+ const escaped = clientEntry.replace(/"/g, '&quot;');
96
+ return `<script type="module" src="${escaped}"></script>`;
97
+ }
98
+ export function buildModulePreloadLink(clientEntry) {
99
+ const escaped = clientEntry.replace(/"/g, '&quot;');
100
+ return `<link rel="modulepreload" href="${escaped}">`;
101
+ }
102
+ export function buildStylesheetLink(stylesheetUrl) {
103
+ const escaped = stylesheetUrl.replace(/"/g, '&quot;');
104
+ return `<link rel="stylesheet" href="${escaped}">`;
105
+ }
@@ -9,5 +9,9 @@
9
9
  * - When `proxyAssets` is false, absolute origin URLs are left as-is.
10
10
  */
11
11
  export declare function rewriteHtml(html: string, originUrl: string, siteUrl: string, proxyAssets?: boolean, additionalOrigins?: string[]): string;
12
- /** Inject SvelteKit sitemap references into a `<sitemapindex>`. */
13
- export declare function injectIntoSitemapIndex(xml: string, siteUrl: string, sitemaps: string[]): string;
12
+ /** Normalize URL-like values to comparable sitemap pathnames. */
13
+ export declare function normalizeSitemapPath(value: string): string;
14
+ /** Inject local sitemap references into a configured `<sitemapindex>`. */
15
+ export declare function injectIntoSitemapIndex(xml: string, siteUrl: string, includeFiles: string[]): string;
16
+ /** Remove `<url>` entries whose `<loc>` pathname is listed in `excludePaths`. */
17
+ export declare function removeExcludedUrlsFromSitemap(xml: string, excludePaths: string[]): string;
@@ -41,10 +41,44 @@ export function rewriteHtml(html, originUrl, siteUrl, proxyAssets = false, addit
41
41
  }
42
42
  return html;
43
43
  }
44
- /** Inject SvelteKit sitemap references into a `<sitemapindex>`. */
45
- export function injectIntoSitemapIndex(xml, siteUrl, sitemaps) {
46
- const entries = sitemaps
44
+ /** Normalize URL-like values to comparable sitemap pathnames. */
45
+ export function normalizeSitemapPath(value) {
46
+ let pathname = value.trim();
47
+ try {
48
+ pathname = new URL(pathname, 'https://stack-utils.local').pathname;
49
+ }
50
+ catch {
51
+ // Fall back to the raw value below; invalid URL-like values should not crash rewriting.
52
+ }
53
+ if (!pathname.startsWith('/')) {
54
+ pathname = `/${pathname}`;
55
+ }
56
+ if (pathname === '/') {
57
+ return pathname;
58
+ }
59
+ return pathname.replace(/\/+$/g, '') || '/';
60
+ }
61
+ /** Inject local sitemap references into a configured `<sitemapindex>`. */
62
+ export function injectIntoSitemapIndex(xml, siteUrl, includeFiles) {
63
+ if (includeFiles.length === 0) {
64
+ return xml;
65
+ }
66
+ const entries = includeFiles
47
67
  .map((url) => `\t<sitemap>\n\t\t<loc>${siteUrl}${url}</loc>\n\t</sitemap>`)
48
68
  .join('\n');
49
69
  return xml.replace('</sitemapindex>', `${entries}\n</sitemapindex>`);
50
70
  }
71
+ /** Remove `<url>` entries whose `<loc>` pathname is listed in `excludePaths`. */
72
+ export function removeExcludedUrlsFromSitemap(xml, excludePaths) {
73
+ if (excludePaths.length === 0) {
74
+ return xml;
75
+ }
76
+ const excluded = new Set(excludePaths.map(normalizeSitemapPath));
77
+ return xml.replace(/<url\b[\s\S]*?<\/url>/g, (entry) => {
78
+ const loc = entry.match(/<loc\b[^>]*>\s*([\s\S]*?)\s*<\/loc>/i)?.[1];
79
+ if (!loc) {
80
+ return entry;
81
+ }
82
+ return excluded.has(normalizeSitemapPath(loc)) ? '' : entry;
83
+ });
84
+ }
@@ -0,0 +1,20 @@
1
+ import type { ProxyIslandsConfig, TransformDomHook } from './types.js';
2
+ export interface TransformProxiedHtmlOptions {
3
+ /** Power-user hook. Receives the cheerio root for the proxied document. */
4
+ transformDom?: TransformDomHook;
5
+ /** Island registry and bootstrap config. Required for any island rendering. */
6
+ islandsConfig?: ProxyIslandsConfig;
7
+ /** SvelteKit request event, forwarded to `transformDom`. */
8
+ event: import('@sveltejs/kit').RequestEvent;
9
+ /** Request URL, forwarded to `transformDom`. Usually `event.url`. */
10
+ url: URL;
11
+ }
12
+ /**
13
+ * Run the `transformDom` hook against the proxied document and finalize the
14
+ * island bootstrap (SSR head dedup, modulepreload, stylesheets, module
15
+ * script).
16
+ *
17
+ * Async because rendering Svelte islands lazily loads `svelte/server` on the
18
+ * first call.
19
+ */
20
+ export declare function transformProxiedHtml(html: string, options: TransformProxiedHtmlOptions): Promise<string>;
@@ -0,0 +1,80 @@
1
+ import { load } from 'cheerio';
2
+ import { ISLAND_ATTRS, buildBootstrapScript, buildModulePreloadLink, buildPropsScript, buildStylesheetLink, renderIsland } from './islands.js';
3
+ /**
4
+ * Run the `transformDom` hook against the proxied document and finalize the
5
+ * island bootstrap (SSR head dedup, modulepreload, stylesheets, module
6
+ * script).
7
+ *
8
+ * Async because rendering Svelte islands lazily loads `svelte/server` on the
9
+ * first call.
10
+ */
11
+ export async function transformProxiedHtml(html, options) {
12
+ const { transformDom, islandsConfig, event, url } = options;
13
+ if (!transformDom) {
14
+ return html;
15
+ }
16
+ const $ = load(html);
17
+ const tracker = {
18
+ islandCounter: 0,
19
+ clientIslandsRendered: false,
20
+ headSnippets: new Set()
21
+ };
22
+ await transformDom($, {
23
+ event,
24
+ url,
25
+ renderIsland: (target, spec) => renderIslandInto($, target, spec, islandsConfig, tracker)
26
+ });
27
+ finalizeIslandBootstrap($, tracker, islandsConfig);
28
+ return $.html();
29
+ }
30
+ async function renderIslandInto($, target, spec, islandsConfig, tracker) {
31
+ if (target.length === 0) {
32
+ return;
33
+ }
34
+ if (!islandsConfig?.components) {
35
+ throw new Error('[stack-utils proxy] `renderIsland(...)` was called but no `islands.components` registry was provided on `ProxyConfig.islands`.');
36
+ }
37
+ const rendered = await renderIsland(spec, islandsConfig);
38
+ if (rendered.headHtml) {
39
+ tracker.headSnippets.add(rendered.headHtml);
40
+ }
41
+ target.each((_, raw) => {
42
+ const $el = $(raw);
43
+ const islandId = `stack-island-${++tracker.islandCounter}`;
44
+ $el.html(rendered.bodyHtml);
45
+ $el.attr(ISLAND_ATTRS.island, rendered.name);
46
+ $el.attr(ISLAND_ATTRS.mode, rendered.mode);
47
+ if (rendered.serializedProps !== null) {
48
+ $el.attr(ISLAND_ATTRS.id, islandId);
49
+ $el.after(buildPropsScript(islandId, rendered.serializedProps));
50
+ tracker.clientIslandsRendered = true;
51
+ }
52
+ });
53
+ }
54
+ function finalizeIslandBootstrap($, tracker, islandsConfig) {
55
+ if (tracker.headSnippets.size > 0) {
56
+ const head = $('head');
57
+ if (head.length > 0) {
58
+ for (const snippet of tracker.headSnippets) {
59
+ head.append(`\n${snippet}\n`);
60
+ }
61
+ }
62
+ }
63
+ if (!tracker.clientIslandsRendered) {
64
+ return;
65
+ }
66
+ if (!islandsConfig?.clientEntry) {
67
+ throw new Error('[stack-utils proxy] client islands were rendered but `islands.clientEntry` is not configured. Either provide `islands.clientEntry` directly or wrap the config with `defineProxyIslands(...)` and install the `proxyIslands()` Vite plugin.');
68
+ }
69
+ const head = $('head');
70
+ if (head.length > 0 && !islandsConfig.clientEntry.startsWith('data:')) {
71
+ head.append(`\n${buildModulePreloadLink(islandsConfig.clientEntry)}\n`);
72
+ }
73
+ if (head.length > 0) {
74
+ const stylesheets = islandsConfig.clientStylesheets ?? [];
75
+ for (const stylesheetUrl of stylesheets) {
76
+ head.append(`\n${buildStylesheetLink(stylesheetUrl)}\n`);
77
+ }
78
+ }
79
+ $('body').append(`\n${buildBootstrapScript(islandsConfig.clientEntry)}\n`);
80
+ }