@devslab/site-kit 0.13.0 → 0.14.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.ko.md CHANGED
@@ -4,9 +4,9 @@ DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다.
4
4
 
5
5
  ## 진입점
6
6
 
7
- - `@devslab/site-kit` — 런타임 중립 로케일·카탈로그·SEO·사이트맵·robots·검증된 사실 유틸리티
7
+ - `@devslab/site-kit` — 런타임 중립 로케일·카탈로그·SEO·사이트맵·robots·검증된 사실·Google Tag Manager 유틸리티
8
8
  - `@devslab/site-kit/solid` — 헤더·푸터·언어/테마 컨트롤·마케팅/법률/상태/오류 레이아웃·접근 요청 폼
9
- - `@devslab/site-kit/tanstack-start` — 중립 메타데이터를 TanStack Start head descriptor로 변환
9
+ - `@devslab/site-kit/tanstack-start` — 중립 메타데이터를 TanStack Start head descriptor로 변환(브랜드 아이콘·Tag Manager 로더는 옵트인)
10
10
  - `@devslab/site-kit/styles.css` — 논리 속성과 RTL을 지원하는 공통 사이트 스타일
11
11
 
12
12
  카탈로그 생성은 의도적으로 엄격하다. 레지스트리의 모든 로케일이 동일한 키와 이름 기반 placeholder를 가져야 하며 런타임 문구 폴백은 없다.
@@ -82,6 +82,57 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
82
82
 
83
83
  옵션을 생략하면 어댑터는 아이콘 링크를 내지 않는다. 제품이 파일을 어디서 서빙하는지 어댑터가 알 수 없고, 서버가 404를 내는 아이콘을 링크한 head는 아무것도 링크하지 않은 head보다 나쁘기 때문이다.
84
84
 
85
+ ## Google Tag Manager
86
+
87
+ 제품 사이트마다 자기 컨테이너 ID로 공개 마케팅·법적 페이지에 Tag Manager를 싣는다. 콘솔·대시보드·채팅 위젯은 제외한다. 스니펫은 여기 한 곳에 둔다(D-031).
88
+
89
+ | 내보내기 | 진입점 | 무엇인가 |
90
+ |---|---|---|
91
+ | `gtmHeadScript(id)` | `@devslab/site-kit` | head 로더의 스크립트 본문. `<script>` 태그도 nonce도 없음 |
92
+ | `gtmNoscriptIframe(id)` | `@devslab/site-kit` | `<body>`가 열리자마자 오는 `<noscript>` 안의 `<iframe>` HTML |
93
+ | `GTM_CSP_SOURCES` | `@devslab/site-kit` | Tag Manager + GA4에 필요한 CSP 출처, 지시어별 |
94
+ | `GTM_CONTAINER_ID_PATTERN` | `@devslab/site-kit` | `/^GTM-[A-Z0-9]+$/` |
95
+ | `toTanStackHead(metadata, { gtm })` | `@devslab/site-kit/tanstack-start` | 로더를 `scripts[0]`에 담은 라우트 head |
96
+ | `gtmHeadEntry(id)` | `@devslab/site-kit/tanstack-start` | 같은 `scripts` 항목. head를 직접 만드는 라우트용 |
97
+
98
+ 로더는 구글의 [Use Tag Manager with a Content Security Policy](https://developers.google.com/tag-platform/security/guides/csp)에 실린 nonce 대응 스니펫을 바이트 그대로 쓴다. 표준 스니펫에 문장 하나를 더한 것으로, 페이지의 nonce를 `gtm.js` 요소에 옮겨 Tag Manager가 자기가 추가하는 스크립트에 넘길 수 있게 한다. `GTM_CONTAINER_ID_PATTERN`에 맞지 않는 ID는 스크립트 문자열에 닿기 전에 `RangeError`를 던진다. `""`도 마찬가지이니 "여기는 Tag Manager 없음"은 빈 환경 변수가 아니라 `undefined`로 넘긴다.
99
+
100
+ **Head, 라우트별.** 실을 라우트에서만 켠다.
101
+
102
+ ```ts
103
+ // 공개 라우트
104
+ head: () => toTanStackHead(metadata, { icons: true, gtm: GTM_ID }),
105
+ ```
106
+
107
+ 항목에는 자기 nonce가 없다. `HeadContent`가 렌더하는 모든 head 스크립트에 `router.options.ssr.nonce`를 찍으므로, 로더는 meta·link 태그와 같은 길로 요청의 nonce를 받는다. 그래서 라우터에 요구 사항이 둘 생기고, 둘 다 라우터를 만들 때 정한다(asklinq#427).
108
+
109
+ - **서버:** `ssr.nonce`는 요청의 nonce. 없으면 로더가 nonce 없이 렌더되고 CSP가 막는다.
110
+ - **클라이언트:** `ssr.nonce`는 `""`. 헤더로 CSP를 받은 페이지에서 브라우저는 `getAttribute`로부터 nonce를 숨기므로, 서버가 렌더한 로더는 `nonce=""`로 읽힌다. 라우터의 하이드레이션 검사는 이것을 클라이언트의 `ssr.nonce`와 비교해 다르면 사본을 하나 더 붙인다. `""`이면 원본을 찾아 아무것도 붙이지 않고, 다른 값이면 그 사본이 CSP에 막혀 페이지를 열 때마다 위반을 기록한다.
111
+
112
+ 로더는 그것을 가진 라우트의 전체 페이지 로드에서 돈다. 그런 페이지에서 클라이언트 내비게이션으로 떠나도 Tag Manager는 살아 있다(페이지뷰는 History Change 트리거로). 반대로 로더가 없는 라우트에서 클라이언트 내비게이션으로 들어오면 로드되지 않는다. 그때 라우터가 붙이는 사본은 CSP에 막히고, 그 덕에 Tag Manager가 두 번 로드되는 일도 없다.
113
+
114
+ **Body.** 셸은 `<body>` 자체가 아니라 그 안을 렌더하므로, 제품의 루트 문서가 같은 라우트 조건 뒤에서 noscript를 `<body>`의 첫 자식으로 렌더한다.
115
+
116
+ ```tsx
117
+ <body>
118
+ <Show when={gtmIdForThisRoute()}>{(id) => <noscript innerHTML={gtmNoscriptIframe(id())} />}</Show>
119
+ {props.children}
120
+ </body>
121
+ ```
122
+
123
+ `tests/site-kit-gtm-noscript-hydration.test.mjs`가 바로 이 코드를 Solid 컴파일러로 컴파일해 개발 빌드로 하이드레이션한다. 불일치 없음, 잃은 키 없음, 자바스크립트가 켜져 있으면 noscript는 텍스트로 남는다.
124
+
125
+ **CSP.** `GTM_CSP_SOURCES`의 각 목록을 같은 이름의 지시어에 더한다.
126
+
127
+ | 지시어 | 출처 |
128
+ |---|---|
129
+ | `script-src` | `https://www.googletagmanager.com` |
130
+ | `connect-src` | `https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com` |
131
+ | `img-src` | `https://www.googletagmanager.com https://*.google-analytics.com` |
132
+ | `frame-src` | `https://www.googletagmanager.com` |
133
+
134
+ Tag Manager 컨테이너와 "Ads 기능 없는 Google Analytics"에 대한 구글의 목록에, noscript iframe용 `frame-src`를 더한 것이다. 구글은 `script-src-elem`으로 적지만 그 지시어가 없는 정책은 `script-src`로 넘어간다. `*.google.com`은 `www.google.com`과 GA4의 `*.analytics.google.com` 호스트도 덮는다. 포함하지 않은 것(컨테이너가 필요로 하면 같은 가이드에서 더한다): 미리보기 모드(`tagmanager.google.com`, `gstatic`, Google Fonts), 맞춤 자바스크립트 변수(`'unsafe-eval'`), Ads·Google 신호 호스트(`*.g.doubleclick.net`, `pagead2.googlesyndication.com`, `www.googleadservices.com`, `*.google.<TLD>`).
135
+
85
136
  ## 섹션
86
137
 
87
138
  가족 랜딩 페이지를 위한 상태 없는 원시 컴포넌트 여섯 개, VisionLinq에서 추출했다. `@devslab/site-kit/solid`에서 import한다; 스타일시트는 `styles.css` 안에 들어 있다. 이 원시 컴포넌트들은 full-bleed — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `<MarketingShell mainWidth="bleed">` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `<main>` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다.
package/README.md CHANGED
@@ -4,9 +4,9 @@ Public product-site infrastructure for DevsLab products. It provides strict cata
4
4
 
5
5
  ## Entry points
6
6
 
7
- - `@devslab/site-kit` — runtime-neutral locale, catalog, SEO, sitemap, robots, and verified-fact utilities.
7
+ - `@devslab/site-kit` — runtime-neutral locale, catalog, SEO, sitemap, robots, verified-fact, and Google Tag Manager utilities.
8
8
  - `@devslab/site-kit/solid` — header, footer, locale/theme controls, marketing/legal/status/error layouts, and request-access form.
9
- - `@devslab/site-kit/tanstack-start` — conversion of neutral metadata to TanStack Start head descriptors.
9
+ - `@devslab/site-kit/tanstack-start` — conversion of neutral metadata to TanStack Start head descriptors (with opt-in brand icons and Tag Manager loader).
10
10
  - `@devslab/site-kit/styles.css` — logical-property, RTL-aware shared site styles.
11
11
 
12
12
  Catalog construction is intentionally strict: every locale in the registry must have exactly the same keys and named placeholders. There is no runtime copy fallback.
@@ -82,6 +82,57 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
82
82
 
83
83
  Omitted, the adapter emits no icon links: it cannot know where a product serves the files, and a head that links icons the server 404s is worse than one that links none.
84
84
 
85
+ ## Google Tag Manager
86
+
87
+ Each product site loads Tag Manager on its public marketing and legal pages with its own container id; consoles, dashboards and the chat widget stay out. The snippet lives here once (D-031).
88
+
89
+ | Export | Entry | What it is |
90
+ |---|---|---|
91
+ | `gtmHeadScript(id)` | `@devslab/site-kit` | the head loader's script body, no `<script>` tag, no nonce |
92
+ | `gtmNoscriptIframe(id)` | `@devslab/site-kit` | the `<iframe>` HTML that goes inside `<noscript>` right after `<body>` opens |
93
+ | `GTM_CSP_SOURCES` | `@devslab/site-kit` | the CSP sources Tag Manager + GA4 need, by directive |
94
+ | `GTM_CONTAINER_ID_PATTERN` | `@devslab/site-kit` | `/^GTM-[A-Z0-9]+$/` |
95
+ | `toTanStackHead(metadata, { gtm })` | `@devslab/site-kit/tanstack-start` | the route head with the loader as `scripts[0]` |
96
+ | `gtmHeadEntry(id)` | `@devslab/site-kit/tanstack-start` | the same `scripts` entry, for a route that builds its own head |
97
+
98
+ The loader is Google's nonce-aware snippet from [Use Tag Manager with a Content Security Policy](https://developers.google.com/tag-platform/security/guides/csp), byte for byte: the standard snippet plus one statement that copies the page's nonce onto the `gtm.js` element, so Tag Manager can pass it on to the scripts it adds. An id that does not match `GTM_CONTAINER_ID_PATTERN` throws `RangeError` before it can reach the script string. That includes `""`, so pass `undefined` (not an empty env var) for "no Tag Manager here".
99
+
100
+ **Head, per route.** Opt in on the routes that should carry it:
101
+
102
+ ```ts
103
+ // a public route
104
+ head: () => toTanStackHead(metadata, { icons: true, gtm: GTM_ID }),
105
+ ```
106
+
107
+ The entry carries no nonce of its own. `HeadContent` stamps `router.options.ssr.nonce` on every head script it renders, so the loader gets the request's nonce the same way the meta and link tags do. That puts two requirements on the router, both set when it is created (asklinq#427):
108
+
109
+ - **server:** `ssr.nonce` is the request's nonce. Without it the loader renders with no nonce and the CSP blocks it.
110
+ - **client:** `ssr.nonce` is `""`. Browsers hide a nonce from `getAttribute` once the page is under a header CSP, so the server-rendered loader reads back as `nonce=""`; the router's hydration check compares that with the client's `ssr.nonce` and, if they differ, appends a second copy. With `""` it finds the original and adds nothing; with anything else the copy is blocked by the CSP and logs a violation on every page load.
111
+
112
+ The loader runs on a full page load of a route that has it. Client-side navigation away from such a page keeps Tag Manager loaded (use a History Change trigger for page views). Navigating client-side *into* one from a route without it does not load it: the copy the router appends then is blocked by the CSP, which also keeps Tag Manager from loading twice.
113
+
114
+ **Body.** The shells render inside `<body>`, not `<body>` itself, so the product's root document renders the noscript as the first child of `<body>`, behind the same per-route condition:
115
+
116
+ ```tsx
117
+ <body>
118
+ <Show when={gtmIdForThisRoute()}>{(id) => <noscript innerHTML={gtmNoscriptIframe(id())} />}</Show>
119
+ {props.children}
120
+ </body>
121
+ ```
122
+
123
+ `tests/site-kit-gtm-noscript-hydration.test.mjs` compiles exactly this with Solid's compiler and hydrates it under the development build: no mismatch, no lost key, and with JavaScript on the noscript stays text.
124
+
125
+ **CSP.** Add each list in `GTM_CSP_SOURCES` to the directive of the same name:
126
+
127
+ | Directive | Sources |
128
+ |---|---|
129
+ | `script-src` | `https://www.googletagmanager.com` |
130
+ | `connect-src` | `https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com` |
131
+ | `img-src` | `https://www.googletagmanager.com https://*.google-analytics.com` |
132
+ | `frame-src` | `https://www.googletagmanager.com` |
133
+
134
+ These are Google's lists for the Tag Manager container and for "Google Analytics without any Ads features", plus `frame-src` for the noscript iframe. Google names `script-src-elem`; a policy without it falls back to `script-src`. `*.google.com` also covers `www.google.com` and GA4's `*.analytics.google.com` hosts. Not included (add them from the same guide if a container needs them): preview mode (`tagmanager.google.com`, `gstatic`, Google Fonts), Custom JavaScript variables (`'unsafe-eval'`), and Ads or Google signals hosts (`*.g.doubleclick.net`, `pagead2.googlesyndication.com`, `www.googleadservices.com`, `*.google.<TLD>`).
135
+
85
136
  ## Sections
86
137
 
87
138
  Six stateless primitives for a family landing page, extracted from VisionLinq. Import from `@devslab/site-kit/solid`; the stylesheet ships inside `styles.css`. The primitives are full-bleed — each carries its own inner width — so a page built from them should render inside `<MarketingShell mainWidth="bleed">`; the shell's default `<main>` inset would otherwise double-inset them and turn `tone="band"` into a boxed rectangle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devslab/site-kit",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Strict i18n, SEO/GEO, and Solid public-site shells for DevsLab products",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -60,7 +60,7 @@
60
60
  "./site-sections.css": "./site-sections.css"
61
61
  },
62
62
  "dependencies": {
63
- "@devslab/dds-solid": "0.13.0"
63
+ "@devslab/dds-solid": "0.14.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@tanstack/solid-router": "1.170.30",
@@ -0,0 +1,12 @@
1
+ /** `GTM-` followed by uppercase letters and digits. Anything else is refused before it reaches a script string. */
2
+ export declare const GTM_CONTAINER_ID_PATTERN: RegExp;
3
+ /**
4
+ * Body of Google's nonce-aware Tag Manager head loader for `containerId`, without the `<script>` tag or a nonce —
5
+ * the renderer owns the element and its per-request nonce. Throws RangeError for an id that does not match GTM_CONTAINER_ID_PATTERN.
6
+ */
7
+ export declare function gtmHeadScript(containerId: string): string;
8
+ /** The `<iframe>` HTML Google puts inside `<noscript>` right after `<body>` opens. Throws RangeError for a malformed id. */
9
+ export declare function gtmNoscriptIframe(containerId: string): string;
10
+ export type GtmCspDirective = "script-src" | "connect-src" | "img-src" | "frame-src";
11
+ /** CSP sources for Tag Manager + GA4 without Ads features (Google's CSP guide). Excludes preview mode, Custom JavaScript variables and Ads hosts. */
12
+ export declare const GTM_CSP_SOURCES: Readonly<Record<GtmCspDirective, readonly string[]>>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Google Tag Manager, written once for the family sites (D-031).
3
+ *
4
+ * The strings below are Google's, character for character, with only the
5
+ * container id substituted. The loader is the nonce-aware variant Google
6
+ * publishes for pages under a Content Security Policy
7
+ * (https://developers.google.com/tag-platform/security/guides/csp): it is the
8
+ * standard snippet plus one statement that copies the page's nonce onto the
9
+ * gtm.js element it creates, so Tag Manager can pass that nonce on to the
10
+ * scripts it adds. Every family site runs a nonce CSP, so this is the variant
11
+ * that fits all of them; on a page without nonces the extra statement finds no
12
+ * `[nonce]` element and does nothing.
13
+ *
14
+ * The id is validated before it is placed inside a script string: a value
15
+ * that is not `GTM-` plus uppercase letters and digits never reaches the
16
+ * output. The pattern admits no quote, backslash, `<` or whitespace, so the
17
+ * id cannot close the string literal or the element it sits in.
18
+ */
19
+
20
+ export const GTM_CONTAINER_ID_PATTERN = /^GTM-[A-Z0-9]+$/;
21
+
22
+ const containerId = (value) => {
23
+ if (typeof value !== "string" || !GTM_CONTAINER_ID_PATTERN.test(value)) {
24
+ throw new RangeError(`Google Tag Manager container id must match ${GTM_CONTAINER_ID_PATTERN} (for example "GTM-AB12CD3"); got ${JSON.stringify(value)}`);
25
+ }
26
+ return value;
27
+ };
28
+
29
+ /**
30
+ * The body of the head loader `<script>` — no `<script>` tag and no nonce:
31
+ * the page's renderer owns the element and puts the per-request nonce on it
32
+ * (in TanStack Start the router does, from `ssr.nonce`).
33
+ */
34
+ export function gtmHeadScript(id) {
35
+ return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
36
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
37
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
38
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
39
+ n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
40
+ })(window,document,'script','dataLayer','${containerId(id)}');`;
41
+ }
42
+
43
+ /** The contents of the `<noscript>` Google places right after `<body>` opens. */
44
+ export function gtmNoscriptIframe(id) {
45
+ return `<iframe src="https://www.googletagmanager.com/ns.html?id=${containerId(id)}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`;
46
+ }
47
+
48
+ const sources = (...list) => Object.freeze(list);
49
+
50
+ /**
51
+ * CSP sources a page needs for Tag Manager plus Google Analytics 4 without
52
+ * Ads features, per Google's "Use Tag Manager with a Content Security
53
+ * Policy" guide. Add each list to the directive of the same name, next to
54
+ * the page's own nonce.
55
+ *
56
+ * - script-src: Google lists it under `script-src-elem`; a policy without
57
+ * that directive falls back to `script-src`, which is where the family
58
+ * sites keep their nonce.
59
+ * - connect-src: `*.google.com` is Google's own entry and also covers
60
+ * `www.google.com` (the container's) and `*.analytics.google.com` (GA4's
61
+ * regional collection hosts) — a CSP wildcard matches any depth of
62
+ * subdomain.
63
+ * - frame-src: the `<noscript>` iframe.
64
+ *
65
+ * Not included, by design: Tag Manager preview mode (tagmanager.google.com,
66
+ * gstatic, Google Fonts), Custom JavaScript variables (`'unsafe-eval'`), and
67
+ * the Google Ads / Google signals hosts (doubleclick, googlesyndication,
68
+ * googleadservices, `*.google.<TLD>`). A product that turns one of those on
69
+ * adds its sources from the same guide.
70
+ */
71
+ export const GTM_CSP_SOURCES = Object.freeze({
72
+ "script-src": sources("https://www.googletagmanager.com"),
73
+ "connect-src": sources("https://www.googletagmanager.com", "https://*.google-analytics.com", "https://*.google.com"),
74
+ "img-src": sources("https://www.googletagmanager.com", "https://*.google-analytics.com"),
75
+ "frame-src": sources("https://www.googletagmanager.com"),
76
+ });
@@ -3,3 +3,4 @@ export * from "./catalog.mjs";
3
3
  export * from "./seo.mjs";
4
4
  export * from "./geo.mjs";
5
5
  export * from "./publisher.mjs";
6
+ export * from "./gtm.mjs";
@@ -3,3 +3,4 @@ export { CatalogValidationError, createTranslator, validateCatalogs } from "./ca
3
3
  export { BRAND_ICON_FILES, ROBOTS_USER_AGENTS, brandIconLinks, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
4
4
  export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
5
5
  export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
6
+ export { GTM_CONTAINER_ID_PATTERN, GTM_CSP_SOURCES, gtmHeadScript, gtmNoscriptIframe } from "./gtm.mjs";
@@ -6,7 +6,13 @@ import type { SiteLocale } from "./core/locales.mjs";
6
6
  * registry (D-018) yields `SiteMetadata<string>`; the adapter reads the
7
7
  * same fields whatever the code type is, so it must not refuse that.
8
8
  */
9
- /** `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted. */
10
- export interface TanStackHeadOptions { icons?: boolean | { basePath?: string } }
11
- export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>, options?: TanStackHeadOptions): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
9
+ /**
10
+ * `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted.
11
+ * `gtm` adds Google Tag Manager's head loader for that container id as `scripts[0]`; omitted or `undefined` (a route without Tag Manager), the result has no `scripts` key.
12
+ */
13
+ export interface TanStackHeadOptions { icons?: boolean | { basePath?: string }; gtm?: string | undefined }
14
+ /** A route `head().scripts` entry: the loader body only. The router adds `ssr.nonce` when it renders the tag. Throws RangeError for a malformed id. */
15
+ export interface GtmHeadEntry { children: string }
16
+ export declare function gtmHeadEntry(containerId: string): GtmHeadEntry;
17
+ export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>, options?: TanStackHeadOptions): { meta: Array<Record<string, string>>; links: Array<Record<string, string>>; scripts?: GtmHeadEntry[] };
12
18
  export declare const toHtmlAttributes: <Code extends string = SiteLocale>(metadata: SiteMetadata<Code>) => { lang: Code; dir: string };
@@ -1,11 +1,29 @@
1
1
  import { brandIconLinks } from "./core/seo.mjs";
2
+ import { gtmHeadScript } from "./core/gtm.mjs";
3
+
4
+ /**
5
+ * A route `head().scripts` entry that loads Google Tag Manager (D-031).
6
+ *
7
+ * Only `children`: no `nonce`, on purpose. The router's HeadContent stamps
8
+ * `router.options.ssr.nonce` on every head script it renders, after the
9
+ * entry's own attributes, so the per-request nonce reaches this script the
10
+ * same way it reaches the meta and link tags. A nonce written here would be
11
+ * overwritten anyway. The product sets `ssr.nonce` when it creates the
12
+ * router (asklinq#427); without it the loader renders with no nonce and the
13
+ * page's CSP blocks it.
14
+ */
15
+ export function gtmHeadEntry(containerId) {
16
+ return { children: gtmHeadScript(containerId) };
17
+ }
2
18
 
3
19
  // `icons` is opt-in: the adapter cannot know where (or whether) a product
4
20
  // serves the linq-brand files, and a head that links icons the server 404s
5
21
  // is worse than one that links none. `true` takes the /brand default.
22
+ // `gtm` is opt-in the same way, and per route: the product decides which
23
+ // pages carry Tag Manager (public marketing and legal pages, not consoles).
6
24
  export function toTanStackHead(metadata, options = {}) {
7
25
  const icons = options.icons === true ? brandIconLinks() : options.icons ? brandIconLinks(options.icons) : [];
8
- return {
26
+ const head = {
9
27
  meta: [
10
28
  { title: metadata.title },
11
29
  { name: "description", content: metadata.description },
@@ -27,6 +45,8 @@ export function toTanStackHead(metadata, options = {}) {
27
45
  ...icons,
28
46
  ],
29
47
  };
48
+ if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
49
+ return head;
30
50
  }
31
51
 
32
52
  export const toHtmlAttributes = (metadata) => ({ lang: metadata.html.lang, dir: metadata.html.dir });