@devslab/site-kit 0.12.2 → 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 +94 -2
- package/README.md +94 -2
- package/dist/chrome.d.ts +20 -1
- package/dist/chrome.d.ts.map +1 -1
- package/dist/solid.js +197 -136
- package/dist/solid.js.map +1 -1
- package/dist/solid.server.js +93 -33
- package/dist/solid.server.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/site-sections.css +7 -1
- package/src/core/gtm.d.mts +12 -0
- package/src/core/gtm.mjs +76 -0
- package/src/core/index.d.mts +1 -0
- package/src/core/index.mjs +1 -0
- package/src/tanstack-start.d.mts +9 -3
- package/src/tanstack-start.mjs +21 -1
- package/styles.css +28 -6
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를 가져야 하며 런타임 문구 폴백은 없다.
|
|
@@ -21,6 +21,47 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
|
|
|
21
21
|
|
|
22
22
|
`MarketingShell`은 아무것도 렌더하기 전에 `header`·`footer`를 한 번(메모) 읽는다. `header={{ … }}`는 게터로 컴파일되므로 셸이 prop마다 다시 읽으면 리터럴 안에서 즉시 만들어진 JSX(`actions` 앵커, 로고)가 읽을 때마다 다시 만들어져 하이드레이션 키를 소모하고 — 서버와 브라우저의 횟수가 다르다 — 클라이언트는 헤더를 템플릿에서 다시 만든다. 셸 밖에서 `SiteHeader`·`SiteFooter`를 직접 마운트하는 제품은 같은 방식으로 자기 props를 한 번만 읽어야 한다. 국기 스프라이트는 이유가 무엇이든 클라이언트에 빈 채로 도착하면 본문을 로드한다.
|
|
23
23
|
|
|
24
|
+
헤더와 푸터는 `brand`(푸터는 `details`도)를 스스로 한 번만 읽는다. 그래서 셸 밖에서도 인라인으로 만든 로고·details 블록이 서버와 브라우저에서 각각 한 번씩만 만들어진다(D-029).
|
|
25
|
+
|
|
26
|
+
## 헤더·푸터 옵션
|
|
27
|
+
|
|
28
|
+
한 언어만 쓰는 제품과, 사업자 정보를 바닥에 적어야 하는 제품을 위한 옵션(D-029). props는 모두 선택이고, 아무것도 넘기지 않는 제품은 전과 같은 마크업을 받는다. 다만 모든 제품에 적용되는 기본값 몇 가지가 있다(옵션 목록 뒤).
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
<MarketingShell
|
|
32
|
+
mainWidth="bleed"
|
|
33
|
+
header={{
|
|
34
|
+
brand: { name: "", href: "/", label: "Acme 첫 화면", logo: <Wordmark /> }, // 워드마크가 로고 — 이름이 두 번 찍히지 않게 name ""
|
|
35
|
+
navigation: [{ href: "#how", label: "이용 방법" }, { href: "#contact", label: "문의" }],
|
|
36
|
+
messages,
|
|
37
|
+
get actions() { return <a class="dds-btn dds-btn--primary" href="#contact">이용 문의</a>; },
|
|
38
|
+
// locale 없음: 언어 메뉴 없음
|
|
39
|
+
}}
|
|
40
|
+
footer={{
|
|
41
|
+
brand: { name: "", href: "/", logo: <Wordmark /> },
|
|
42
|
+
get details() { return <><p>상호 Acme · 사업자등록번호 000-00-00000</p><address>서울 …</address></>; },
|
|
43
|
+
linksLabel: "바닥 메뉴",
|
|
44
|
+
links: [{ href: "/terms", label: "이용약관" }, { href: "/privacy", label: "개인정보처리방침", emphasis: true }],
|
|
45
|
+
copyright: "© Acme",
|
|
46
|
+
messages,
|
|
47
|
+
}}
|
|
48
|
+
messages={messages}
|
|
49
|
+
>…</MarketingShell>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `SiteHeader`의 `locale`은 선택이다. 넘기지 않으면 언어 메뉴를 그리지 않는다 — 언어가 하나뿐인 메뉴는 고장 난 것처럼 보인다. 푸터의 `locale`은 원래 이렇게 동작했다.
|
|
53
|
+
- `SiteBrand.label`은 헤더 브랜드 링크의 이름(`aria-label`)이다. 화면에 보이는 이름으로 시작한다. 워드마크를 `logo`로 넘기면 `name: ""` — 그러면 푸터도 빈 `<strong>`을 그리지 않는다.
|
|
54
|
+
- 좁은 화면의 메뉴는 Esc로 닫히고(포커스는 메뉴 버튼으로 돌아감), 안의 링크를 따라가도 닫힌다 — 같은 페이지 앵커도. 안 그러면 열린 메뉴가 방금 고른 섹션을 가린다. 안의 버튼(테마 전환)과 새 탭·새 창으로 여는 링크는 메뉴를 닫지 않는다. 헤더 안의 컨트롤(국기 메뉴)이 이미 처리한 Esc와 `aria-modal` 요소 안에서 온 Esc는 그 컨트롤에 맡긴다.
|
|
55
|
+
- 터치(`pointer: coarse`): 브랜드 링크·내비게이션·푸터·푸터 언어 링크가 버튼처럼 44px 누르는 면이다. 720px 이하에서 열린 메뉴의 링크는 44px 줄이고, 닫힌 헤더의 첫 줄은 64px 그대로다.
|
|
56
|
+
- `SiteFooter`의 `details`: 브랜드 줄 아래 블록(사업자 정보, `<address>`), 줄 간격 4px·문단 여백 없음. `linksLabel`은 링크를 `<nav aria-label>`로 감싼다 — 헤더 내비게이션과 다른 이름으로. `logo`로 넘긴 푸터 워드마크(`name: ""`)는 자기 크기를 지킨다 — 16px은 이름 옆 아이콘 마크용 — 그리고 접근 가능한 이름을 스스로 가진다(`<img alt>`나 글자). `label`은 헤더 링크의 이름일 뿐이다.
|
|
57
|
+
- `SiteLink.emphasis`는 링크를 굵게 그린다(헤더 내비게이션·푸터 링크·패밀리 링크). 법이 눈에 띄게 하라는 개인정보처리방침용.
|
|
58
|
+
- 섹션(그리고 히어로)은 같은 페이지 링크로 이동하면 붙어 있는 헤더 아래에 멈춘다: `scroll-margin-block-start` = 헤더 높이 + 8px. 헤더 높이는 `--site-header-block-size`(기본 64px) — `:root`나 `.site-shell`(헤더와 `<main>`의 공통 조상)에 정한다. `.site-header`에 정하면 섹션은 64px 간격 그대로다.
|
|
59
|
+
- `--site-hero-eyebrow-tracking`(기본 `.18em`)과 `--site-hero-eyebrow-weight`(기본 `normal`)로 히어로 키커를 조정한다. 넓은 모노 자간은 라틴 대문자에 맞고, 키커가 한국어인 제품은 랜딩 뿌리에서 자간을 `0`으로 둔다.
|
|
60
|
+
|
|
61
|
+
새 props를 쓰든 안 쓰든 모든 제품에 적용되는 기본값: 위의 44px 터치 누르는 면, 좁은 헤더 첫 줄이 `--site-header-block-size`(64px)이고 위아래 패딩 없음 — 전역 `box-sizing: border-box` 리셋이 없는 제품은 휴대폰 헤더가 24px 낮아지고(64px에 위아래 12px 패딩이었음), 메뉴 버튼이 44px인 제품은 4px 낮아진다 — 열린 메뉴의 링크는 간격 없는 44px 줄이고 컨트롤 줄 아래 12px, 섹션·히어로의 스크롤 간격, Esc·링크로 메뉴 닫힘, 푸터 링크의 기준선 정렬과 details가 있는 줄의 첫 줄 정렬.
|
|
62
|
+
|
|
63
|
+
타입 메모: `SiteHeaderProps["locale"]`은 이제 `LocaleState | undefined`다. `SiteHeaderProps` 값에서 읽는 코드(`header.locale.locale`)는 좁혀야 한다 — 예를 들어 제품이 늘 넘긴다면 그 값을 `SiteHeaderProps & { locale: LocaleState }`로 타입한다.
|
|
64
|
+
|
|
24
65
|
## 브랜드 아이콘
|
|
25
66
|
|
|
26
67
|
모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist/<product>/`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.
|
|
@@ -41,6 +82,57 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
|
|
|
41
82
|
|
|
42
83
|
옵션을 생략하면 어댑터는 아이콘 링크를 내지 않는다. 제품이 파일을 어디서 서빙하는지 어댑터가 알 수 없고, 서버가 404를 내는 아이콘을 링크한 head는 아무것도 링크하지 않은 head보다 나쁘기 때문이다.
|
|
43
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
|
+
|
|
44
136
|
## 섹션
|
|
45
137
|
|
|
46
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,
|
|
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.
|
|
@@ -21,6 +21,47 @@ control search indexing, citation crawlers, and model-training crawlers.
|
|
|
21
21
|
|
|
22
22
|
`MarketingShell` reads `header` and `footer` once (a memo) before it renders anything. `header={{ … }}` compiles to a getter; if the shell re-read it per prop, any JSX built eagerly inside the literal (an `actions` anchor, a logo) would be built again on each read and consume hydration keys — a different number of times on the server than in the browser — and the client would rebuild the header from templates. A product that mounts `SiteHeader` or `SiteFooter` directly, outside the shell, has to read its own props once the same way. The flag sprite loads its bodies whenever it reaches the client empty, whatever the reason.
|
|
23
23
|
|
|
24
|
+
The header and footer read `brand` (and the footer its `details`) once themselves, so a logo or details block built inline stays one build per side even outside the shell (D-029).
|
|
25
|
+
|
|
26
|
+
## Header and footer options
|
|
27
|
+
|
|
28
|
+
For a single-language product, and for a footer that has to print business details (D-029). The props are optional; a product that passes none of them gets the same markup. A few defaults apply to every product (listed after the options).
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
<MarketingShell
|
|
32
|
+
mainWidth="bleed"
|
|
33
|
+
header={{
|
|
34
|
+
brand: { name: "", href: "/", label: "Acme home", logo: <Wordmark /> }, // the wordmark is the logo; name "" so it does not print twice
|
|
35
|
+
navigation: [{ href: "#how", label: "How it works" }, { href: "#contact", label: "Contact" }],
|
|
36
|
+
messages,
|
|
37
|
+
get actions() { return <a class="dds-btn dds-btn--primary" href="#contact">Get in touch</a>; },
|
|
38
|
+
// no `locale`: no language picker
|
|
39
|
+
}}
|
|
40
|
+
footer={{
|
|
41
|
+
brand: { name: "", href: "/", logo: <Wordmark /> },
|
|
42
|
+
get details() { return <><p>Acme Ltd · Reg. 000-00-00000</p><address>1 Main St</address></>; },
|
|
43
|
+
linksLabel: "Footer links",
|
|
44
|
+
links: [{ href: "/terms", label: "Terms" }, { href: "/privacy", label: "Privacy policy", emphasis: true }],
|
|
45
|
+
copyright: "© Acme",
|
|
46
|
+
messages,
|
|
47
|
+
}}
|
|
48
|
+
messages={messages}
|
|
49
|
+
>…</MarketingShell>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `SiteHeader` `locale` is optional. Omit it and no language picker renders — a picker with one language reads as broken. The footer's `locale` already worked this way.
|
|
53
|
+
- `SiteBrand.label` names the header brand link (`aria-label`). Start it with the visible name. With a wordmark as `logo`, pass `name: ""`; the footer then renders no empty `<strong>`.
|
|
54
|
+
- The narrow-screen menu closes on Escape (focus returns to the menu button) and when a link inside it is followed — including a same-page anchor, which would otherwise leave the open menu covering the section. Buttons inside it (the theme toggle) and links that open a new tab or window leave it open. An Escape that a control inside the header already handled (the flag menu), or that comes from inside an `aria-modal` element, is left to that control.
|
|
55
|
+
- Touch (`pointer: coarse`): the brand link, navigation, footer and footer-language links are 44px targets, as buttons already are. At 720px and below the open menu's links are 44px rows, and the closed header's first row keeps its 64px height.
|
|
56
|
+
- `SiteFooter` `details`: a block under the brand line (business registration, an `<address>`), its lines 4px apart with no paragraph margins. `linksLabel` wraps the links in `<nav aria-label>`; name it differently from the header's navigation. A footer wordmark passed as `logo` (with `name: ""`) keeps its own size — the 16px size is for an icon mark beside a printed name — and carries its own accessible text (`<img alt>`, or text), because `label` names only the header link.
|
|
57
|
+
- `SiteLink.emphasis` draws a link heavier (header navigation, footer links, family links) — for a Korean site's privacy policy, which the law asks to stand out.
|
|
58
|
+
- Sections (and the hero) stop below the sticky header when an in-page link targets them: `scroll-margin-block-start` is the header height plus 8px. The header height is `--site-header-block-size` (default 64px); set it on `:root` or `.site-shell` — an ancestor of both the header and `<main>` — not on `.site-header`, or the sections keep the 64px offset.
|
|
59
|
+
- `--site-hero-eyebrow-tracking` (default `.18em`) and `--site-hero-eyebrow-weight` (default `normal`) tune the hero eyebrow. Wide mono tracking suits Latin capitals; a product whose eyebrow is Korean sets the tracking to `0` on its landing root.
|
|
60
|
+
|
|
61
|
+
Defaults that apply to every product, with or without the new props: the 44px touch targets above; the narrow header's first row is `--site-header-block-size` (64px) with no block padding — a product without a global `box-sizing: border-box` reset loses 24px of phone header height (it was 64px plus 12px padding each side), and one whose menu button is 44px loses 4px; the open menu's links are 44px rows with no gap and its controls row has 12px below it; sections and the hero get the scroll offset; the menu closes on Escape and on following a link; footer links are baseline-aligned, and a row with details aligns on the first line.
|
|
62
|
+
|
|
63
|
+
Type note: `SiteHeaderProps["locale"]` is now `LocaleState | undefined`. Code that reads it from a `SiteHeaderProps` value (`header.locale.locale`) must narrow it — for example type the value as `SiteHeaderProps & { locale: LocaleState }` where the product always sets it.
|
|
64
|
+
|
|
24
65
|
## Brand icons
|
|
25
66
|
|
|
26
67
|
Every product's icon files come from `@devslab/linq-brand` (`dist/<product>/`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order:
|
|
@@ -41,6 +82,57 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
|
|
|
41
82
|
|
|
42
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.
|
|
43
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
|
+
|
|
44
136
|
## Sections
|
|
45
137
|
|
|
46
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/dist/chrome.d.ts
CHANGED
|
@@ -14,7 +14,13 @@ export declare function ThemeToggle(props: ThemeToggleProps): JSX.Element;
|
|
|
14
14
|
export interface SiteHeaderProps {
|
|
15
15
|
brand: SiteBrand;
|
|
16
16
|
navigation: SiteLink[];
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The language picker's state. Omit it and no picker is rendered — a
|
|
19
|
+
* single-language product (FM덴탈서비스 is Korean-only) has nothing to pick,
|
|
20
|
+
* and a picker with one entry reads as broken. Same contract as the
|
|
21
|
+
* footer's `locale`.
|
|
22
|
+
*/
|
|
23
|
+
locale?: LocaleState;
|
|
18
24
|
messages: SiteMessages;
|
|
19
25
|
theme?: Omit<ThemeToggleProps, "messages">;
|
|
20
26
|
onLocaleChange?: LocaleMenuProps["onLocaleChange"];
|
|
@@ -49,6 +55,19 @@ export interface SiteFooterProps {
|
|
|
49
55
|
onLocaleSelect?: (locale: string) => void;
|
|
50
56
|
/** Links after the brand name, middot-separated: the family line, the operator. */
|
|
51
57
|
family?: SiteLink[];
|
|
58
|
+
/**
|
|
59
|
+
* A block under the brand line: the business registration line and the
|
|
60
|
+
* address a Korean commercial site must print, for one. Rendered as a block
|
|
61
|
+
* (the brand line is an inline `<p>`, where an `<address>` cannot go), and
|
|
62
|
+
* read once — JSX handed to the kit is built once per side (D-027, D-028).
|
|
63
|
+
*/
|
|
64
|
+
details?: JSX.Element;
|
|
65
|
+
/**
|
|
66
|
+
* Names the link list: with it the list sits in `<nav aria-label>`, a
|
|
67
|
+
* landmark a screen-reader user can jump to — name it differently from the
|
|
68
|
+
* header's navigation. Without it the list stays a plain list.
|
|
69
|
+
*/
|
|
70
|
+
linksLabel?: string;
|
|
52
71
|
}
|
|
53
72
|
/**
|
|
54
73
|
* The family footer: a language row, then the brand beside its family links,
|
package/dist/chrome.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/solid/chrome.tsx"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/solid/chrome.tsx"],"names":[],"mappings":"AACA,OAAO,EAAgD,KAAK,GAAG,EAAE,MAAM,UAAU,CAAC;AAElF,OAAO,EAAc,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACzF,OAAO,EAAkB,KAAK,cAAc,EAAmB,MAAM,qBAAqB,CAAC;AAC3F,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/F,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,YAAY,CAAC,EAAE,eAAe,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACjD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,eA0ClD;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAC3C,cAAc,CAAC,EAAE,eAAe,CAAC,gBAAgB,CAAC,CAAC;IACnD,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;CACvB;AAKD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eA0DhD;AA4DD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,sEAAsE;IACtE,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,mFAAmF;IACnF,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eAwDhD"}
|