From f15cd2e7744146da6d6a818909a51f8468644fbf Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Mon, 14 Sep 2026 04:06:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(site-kit):=20brandIconLinks=20=E2=80=94=20?= =?UTF-8?q?one=20head=20link=20set=20for=20the=20linq-brand=20icons=20(D-0?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskLinq's search result was still showing the icon from before the mark changed (D-125, ten days earlier). The production files were already the new mark; the page head had no at all since the web-next cutover, so crawlers saw only the /favicon.ico fallback, and that file was a 32px PNG — under Google's "preferably >48x48px". Checking the siblings: VisionLinq, BookLinq, TraceLinq and AskLinq each linked a different subset of the same @devslab/linq-brand files. `brandIconLinks({ basePath })` is now the one place that says which files a head links and in what order: favicon.svg first, mark-48.png (48x48), favicon.ico (16/32/48), apple-touch-icon.png (180x180). `BRAND_ICON_FILES` names the same four so a product serves them from one directory. `toTanStackHead(metadata, { icons })` appends them — opt-in, because the adapter cannot know where a product serves the files and a head that links icons the server 404s is worse than one that links none. basePath is a same-origin path; a URL is a RangeError. Tests: link set and order, base-path handling, adapter appends after the alternates only when asked. README en/ko, changeset (minor), D-026. --- .changeset/site-kit-brand-icon-links.md | 7 +++ docs/decisions.md | 33 ++++++++++++++ packages/site-kit/README.ko.md | 20 +++++++++ packages/site-kit/README.md | 20 +++++++++ packages/site-kit/src/core/index.mjs | 2 +- packages/site-kit/src/core/seo.d.mts | 5 +++ packages/site-kit/src/core/seo.mjs | 43 +++++++++++++++++++ packages/site-kit/src/tanstack-start.d.mts | 4 +- packages/site-kit/src/tanstack-start.mjs | 9 +++- tests/site-kit-contracts.test.mjs | 2 +- tests/site-kit-core.test.mjs | 50 ++++++++++++++++++++++ 11 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 .changeset/site-kit-brand-icon-links.md diff --git a/.changeset/site-kit-brand-icon-links.md b/.changeset/site-kit-brand-icon-links.md new file mode 100644 index 0000000..cbc06be --- /dev/null +++ b/.changeset/site-kit-brand-icon-links.md @@ -0,0 +1,7 @@ +--- +"@devslab/site-kit": minor +--- + +`brandIconLinks()` and `BRAND_ICON_FILES`: one place that says which `@devslab/linq-brand` icon files a page head links, and in what order — SVG first, a 48px PNG so search engines have a declared square of the size they ask for, the 16/32/48 ICO for the bare-URL convention, the 180px apple-touch icon. `toTanStackHead(metadata, { icons })` appends them (`true` for the `/brand` default, or `{ basePath }`); omitted, nothing changes. Before this, the four family sites each linked a different subset, and one linked none — its search result kept showing the icon from before the mark changed. + +`brandIconLinks()`와 `BRAND_ICON_FILES`: 페이지 head가 `@devslab/linq-brand`의 어떤 아이콘 파일을 어떤 순서로 링크하는지 정하는 한 자리 — SVG 먼저, 검색엔진이 요구하는 크기의 선언된 정사각형으로 48px PNG, 주소만으로 요청되는 관례용 16/32/48 ICO, 180px 애플 터치 아이콘. `toTanStackHead(metadata, { icons })`가 이를 덧붙인다(`true`면 `/brand` 기본값, 또는 `{ basePath }`). 생략하면 아무것도 바뀌지 않는다. 이전에는 가족 사이트 넷이 각자 다른 부분집합을 링크했고, 하나는 아무것도 링크하지 않아 마크가 바뀐 뒤에도 검색 결과가 옛 아이콘을 계속 보여줬다. diff --git a/docs/decisions.md b/docs/decisions.md index 6ba0b4b..d549a02 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,39 @@ --- +## D-026 — 파비콘 `` 묶음은 site-kit이 정하고, 파일은 linq-brand가 만든다 (2026-09-14) + +**결정.** `@devslab/site-kit`에 `brandIconLinks({ basePath })`와 `BRAND_ICON_FILES`를 +두고, `toTanStackHead(metadata, { icons })`가 옵트인으로 덧붙인다. 묶음은 넷으로 +고정: `favicon.svg`(먼저), `mark-48.png`(`48x48`), `favicon.ico`(`16x16 32x32 48x48`), +`apple-touch-icon.png`(`180x180`). 파일 자체는 계속 `@devslab/linq-brand`가 +`dist//`에 만든다 — site-kit은 그 이름을 부를 뿐 그리지 않는다. + +**계기.** AskLinq 검색 결과 파비콘이 마크 교체(D-125, 2026-09-03) 열흘 뒤에도 옛 +물음표였다. 프로덕션 파일은 이미 새 마크였고, 원인은 둘: web-next 컷오버가 레거시 +head의 ``을 옮기지 않아 크롤러가 `/favicon.ico` 폴백만 봤고, 그 +파일이 32px PNG라 구글 권장(48px 초과)에 미달했다. 그리고 확인해 보니 가족 넷이 +각자 다른 조합이었다 — VisionLinq는 svg+ico(`any`)+apple, BookLinq는 +svg+32png+ico(`48x48`)+apple+manifest, TraceLinq는 자기 head, AskLinq는 없음. +같은 패키지 파일을 네 가지로 부르고 있었으니 공통 자리는 head를 만드는 site-kit이다. + +**대안.** ① linq-brand 레지스트리에 링크 목록을 싣기 — 레지스트리는 프레임워크 +중립 자산 목록이고 `` 속성(rel/sizes/type)은 head의 어휘라 반려. ② 각 제품이 +계속 자기 head에 쓰기 — 지금 네 가지로 갈라진 그 상태. ③ `toTanStackHead`가 항상 +아이콘을 내기 — 제품이 파일을 어디서 서빙하는지 어댑터가 모르고, 404 나는 +아이콘을 링크한 head는 안 링크한 head보다 나쁘므로 옵트인. + +**트레이드오프.** `basePath`는 같은 오리진 경로만 받는다(CDN URL은 RangeError) — +아이콘은 제품이 직접 서빙한다는 전제를 코드로 둔다. manifest 링크는 묶음에 넣지 +않았다: PWA 매니페스트는 제품마다 내용이 달라 링크만 공통화해도 파일은 못 만든다. + +**재검토.** linq-brand가 파일 이름이나 크기 세트를 바꾸면 `BRAND_ICON_FILES`와 +같은 PR에서 움직여야 한다(두 레포라 자동 게이트 없음 — 소비자 테스트가 서빙 +바이트의 sha를 패키지 checksums와 대조하는 것이 유일한 그물). 구글이 요구 크기를 +바꾸면 `mark-48.png` 항목만 바뀐다. + +--- + ## D-025 — 테이블 안 버튼: 행 높이는 고정, 터치에서는 하한이 이긴다 (2026-09-13) **결정.** VisionLinq(VL-058)가 DDS 0.11.0으로 옮긴 뒤에도 제품 쪽에 남겨 둔 diff --git a/packages/site-kit/README.ko.md b/packages/site-kit/README.ko.md index 2c8564b..178b3c6 100644 --- a/packages/site-kit/README.ko.md +++ b/packages/site-kit/README.ko.md @@ -17,6 +17,26 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다. 기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱, 인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다. +## 브랜드 아이콘 + +모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist//`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다. + +| 링크 | 파일 | 이유 | +|---|---|---| +| `icon` `image/svg+xml` | `favicon.svg` | 탭 아이콘. SVG를 아는 브라우저가 래스터를 받지 않도록 맨 앞 | +| `icon` `48x48` | `mark-48.png` | 검색엔진은 ``로 선언된 48px 이상 정사각형을 원한다(구글: "최소 8x8px, 48x48px 초과 권장") | +| `icon` `16x16 32x32 48x48` | `favicon.ico` | 주소만으로 요청되는 관례, 세 크기를 한 컨테이너에 | +| `apple-touch-icon` `180x180` | `apple-touch-icon.png` | iOS 홈 화면 | + +`BRAND_ICON_FILES`는 같은 네 파일명을 나열하므로 제품은 한 디렉터리에서 그대로 서빙할 수 있다. 기본 `basePath`는 `/brand`이고, 다른 곳에서 서빙하는 제품은 자기 경로를 넘긴다. 같은 오리진 경로만 받는다 — 아이콘은 제품이 직접 서빙한다. + +```ts +toTanStackHead(metadata, { icons: true }); // …/brand/favicon.svg, … +toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-48.png, … +``` + +옵션을 생략하면 어댑터는 아이콘 링크를 내지 않는다. 제품이 파일을 어디서 서빙하는지 어댑터가 알 수 없고, 서버가 404를 내는 아이콘을 링크한 head는 아무것도 링크하지 않은 head보다 나쁘기 때문이다. + ## 섹션 가족 랜딩 페이지를 위한 상태 없는 원시 컴포넌트 여섯 개, VisionLinq에서 추출했다. `@devslab/site-kit/solid`에서 import한다; 스타일시트는 `styles.css` 안에 들어 있다. 이 원시 컴포넌트들은 full-bleed — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `
` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다. diff --git a/packages/site-kit/README.md b/packages/site-kit/README.md index edd2af3..8adaea7 100644 --- a/packages/site-kit/README.md +++ b/packages/site-kit/README.md @@ -17,6 +17,26 @@ still references the verified-fact registry. `buildRobots` keeps its legacy environment-only output, while an optional `policies` object can independently control search indexing, citation crawlers, and model-training crawlers. +## Brand icons + +Every product's icon files come from `@devslab/linq-brand` (`dist//`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order: + +| Link | File | Why | +|---|---|---| +| `icon` `image/svg+xml` | `favicon.svg` | the tab icon, first so a browser that understands SVG never fetches a raster | +| `icon` `48x48` | `mark-48.png` | search engines want a ``-declared square of at least 48px (Google: "at least 8x8px, preferably >48x48px") | +| `icon` `16x16 32x32 48x48` | `favicon.ico` | the bare-URL convention, three sizes in one container | +| `apple-touch-icon` `180x180` | `apple-touch-icon.png` | iOS home screen | + +`BRAND_ICON_FILES` lists the same four names, so a product can serve them from one directory. The default `basePath` is `/brand`; a product that serves the files elsewhere passes its own path. Only same-origin paths are accepted — a product serves its own icons. + +```ts +toTanStackHead(metadata, { icons: true }); // …/brand/favicon.svg, … +toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-48.png, … +``` + +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. + ## Sections 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 ``; the shell's default `
` inset would otherwise double-inset them and turn `tone="band"` into a boxed rectangle. diff --git a/packages/site-kit/src/core/index.mjs b/packages/site-kit/src/core/index.mjs index 4d14c72..132bb3d 100644 --- a/packages/site-kit/src/core/index.mjs +++ b/packages/site-kit/src/core/index.mjs @@ -1,5 +1,5 @@ export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeAttributes, resolveLocale } from "./locales.mjs"; export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs"; -export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs"; +export { BRAND_ICON_FILES, ROBOTS_USER_AGENTS, brandIconLinks, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs"; export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs"; export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs"; diff --git a/packages/site-kit/src/core/seo.d.mts b/packages/site-kit/src/core/seo.d.mts index 462cd9d..aa72198 100644 --- a/packages/site-kit/src/core/seo.d.mts +++ b/packages/site-kit/src/core/seo.d.mts @@ -15,3 +15,8 @@ export declare function renderSitemapXml(entries: ReturnType` descriptors for BRAND_ICON_FILES served at `basePath` (default "/brand"; a same-origin path, never a URL). */ +export declare function brandIconLinks(options?: { basePath?: string }): BrandIconLink[]; diff --git a/packages/site-kit/src/core/seo.mjs b/packages/site-kit/src/core/seo.mjs index 76dcc30..73e1ad5 100644 --- a/packages/site-kit/src/core/seo.mjs +++ b/packages/site-kit/src/core/seo.mjs @@ -92,3 +92,46 @@ export function buildRobots({ baseUrl, environment, policies }) { if (environment !== "production") return "User-agent: *\nDisallow: /\n"; return `User-agent: *\nAllow: /\nSitemap: ${cleanBase(baseUrl)}/sitemap.xml\n`; } + +/** + * The icon files @devslab/linq-brand ships for every product under + * dist//, in the order a page head should link them. A product + * serves that directory as-is (VisionLinq, BookLinq: /brand/*) or vendors + * the bytes and serves them under the same names (AskLinq); either way the + * head links come from here, so the four family sites cannot each pick a + * different subset — which is what they did before this existed. + * + * favicon.svg — the tab icon, crisp at every size, linked first so a + * browser that understands it never fetches a raster. + * mark-48.png — one raster of at least 48px: search engines want a + * -declared square, Google "at least 8x8px, + * preferably >48x48px", and a 32px-only site shows its + * previous icon in results long after the file changed. + * favicon.ico — 16/32/48 in one container, the bare-URL convention. + * apple-touch-icon.png — 180px, iOS home screen. + */ +export const BRAND_ICON_FILES = Object.freeze(["favicon.svg", "mark-48.png", "favicon.ico", "apple-touch-icon.png"]); + +const iconBase = (basePath) => { + const value = String(basePath ?? "/brand"); + if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(value)) { + throw new RangeError("brandIconLinks basePath is a same-origin path, not a URL: a product serves its own icons"); + } + const trimmed = value.replace(/^\/+|\/+$/g, ""); + return trimmed ? `/${trimmed}` : ""; +}; + +/** + * `` descriptors for BRAND_ICON_FILES at `basePath` (default /brand). + * Same shape as toTanStackHead's `links`; a non-Start renderer can print + * them as attributes in this order. + */ +export function brandIconLinks({ basePath = "/brand" } = {}) { + const base = iconBase(basePath); + return [ + { rel: "icon", type: "image/svg+xml", href: `${base}/favicon.svg` }, + { rel: "icon", type: "image/png", sizes: "48x48", href: `${base}/mark-48.png` }, + { rel: "icon", sizes: "16x16 32x32 48x48", href: `${base}/favicon.ico` }, + { rel: "apple-touch-icon", sizes: "180x180", href: `${base}/apple-touch-icon.png` }, + ]; +} diff --git a/packages/site-kit/src/tanstack-start.d.mts b/packages/site-kit/src/tanstack-start.d.mts index fdbccd4..9120efc 100644 --- a/packages/site-kit/src/tanstack-start.d.mts +++ b/packages/site-kit/src/tanstack-start.d.mts @@ -6,5 +6,7 @@ import type { SiteLocale } from "./core/locales.mjs"; * registry (D-018) yields `SiteMetadata`; the adapter reads the * same fields whatever the code type is, so it must not refuse that. */ -export declare function toTanStackHead(metadata: SiteMetadata): { meta: Array>; links: Array> }; +/** `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted. */ +export interface TanStackHeadOptions { icons?: boolean | { basePath?: string } } +export declare function toTanStackHead(metadata: SiteMetadata, options?: TanStackHeadOptions): { meta: Array>; links: Array> }; export declare const toHtmlAttributes: (metadata: SiteMetadata) => { lang: Code; dir: string }; diff --git a/packages/site-kit/src/tanstack-start.mjs b/packages/site-kit/src/tanstack-start.mjs index 815365c..d477ee5 100644 --- a/packages/site-kit/src/tanstack-start.mjs +++ b/packages/site-kit/src/tanstack-start.mjs @@ -1,4 +1,10 @@ -export function toTanStackHead(metadata) { +import { brandIconLinks } from "./core/seo.mjs"; + +// `icons` is opt-in: the adapter cannot know where (or whether) a product +// serves the linq-brand files, and a head that links icons the server 404s +// is worse than one that links none. `true` takes the /brand default. +export function toTanStackHead(metadata, options = {}) { + const icons = options.icons === true ? brandIconLinks() : options.icons ? brandIconLinks(options.icons) : []; return { meta: [ { title: metadata.title }, @@ -18,6 +24,7 @@ export function toTanStackHead(metadata) { links: [ { rel: "canonical", href: metadata.canonical }, ...metadata.alternates.map(({ hreflang, href }) => ({ rel: "alternate", hreflang, href })), + ...icons, ], }; } diff --git a/tests/site-kit-contracts.test.mjs b/tests/site-kit-contracts.test.mjs index 09e3865..00cfc3a 100644 --- a/tests/site-kit-contracts.test.mjs +++ b/tests/site-kit-contracts.test.mjs @@ -195,6 +195,6 @@ test("the TanStack adapter accepts metadata built from a product registry", asyn // family default alone made every product-registry consumer cast or // augment the module to get its head descriptors out. const dts = await readFile(new URL("../packages/site-kit/src/tanstack-start.d.mts", import.meta.url), "utf8"); - assert.match(dts, /toTanStackHead\(metadata: SiteMetadata\)/); + assert.match(dts, /toTanStackHead\(metadata: SiteMetadata, options\?: TanStackHeadOptions\)/); assert.match(dts, /toHtmlAttributes: \(metadata: SiteMetadata\)/); }); diff --git a/tests/site-kit-core.test.mjs b/tests/site-kit-core.test.mjs index 18e535b..e2a8153 100644 --- a/tests/site-kit-core.test.mjs +++ b/tests/site-kit-core.test.mjs @@ -11,10 +11,13 @@ import { validateCatalogs, } from "../packages/site-kit/src/core/index.mjs"; import { + BRAND_ICON_FILES, + brandIconLinks, buildMetadata, buildRobots, buildSitemap, } from "../packages/site-kit/src/core/seo.mjs"; +import { toTanStackHead } from "../packages/site-kit/src/tanstack-start.mjs"; import { VerifiedFactRegistry, buildVerifiedJsonLd, @@ -245,3 +248,50 @@ test("the family registry is untouched by the subset option", () => { assert.equal(FAMILY_LOCALES.LOCALES.length, 14); assert.equal(defineLocaleRegistry().LOCALES.length, 14); }); + +test("brand icon links name the linq-brand files a product serves, in one fixed order", () => { + const links = brandIconLinks(); + assert.deepEqual(links, [ + { rel: "icon", type: "image/svg+xml", href: "/brand/favicon.svg" }, + { rel: "icon", type: "image/png", sizes: "48x48", href: "/brand/mark-48.png" }, + { rel: "icon", sizes: "16x16 32x32 48x48", href: "/brand/favicon.ico" }, + { rel: "apple-touch-icon", sizes: "180x180", href: "/brand/apple-touch-icon.png" }, + ]); + // Every href is a file @devslab/linq-brand ships under dist//, so a + // product that serves that directory as-is at basePath serves all of them. + for (const { href } of links) assert.ok(BRAND_ICON_FILES.includes(href.slice("/brand/".length)), href); + assert.deepEqual(new Set(BRAND_ICON_FILES), new Set(links.map(({ href }) => href.slice("/brand/".length)))); + // Search engines need one raster of at least 48px (Google: "at least 8x8px, preferably >48x48px"). + assert.ok(links.some(({ sizes }) => /^(?:48|96|144|192)x(?:48|96|144|192)$/.test(sizes ?? ""))); + // The SVG comes first so browsers that understand it never fetch the raster. + assert.equal(links[0].type, "image/svg+xml"); +}); + +test("brand icon links follow the base path a product serves the files at", () => { + assert.deepEqual(brandIconLinks({ basePath: "/" }).map(({ href }) => href), ["/favicon.svg", "/mark-48.png", "/favicon.ico", "/apple-touch-icon.png"]); + assert.equal(brandIconLinks({ basePath: "assets/brand/" })[0].href, "/assets/brand/favicon.svg"); + assert.equal(brandIconLinks({ basePath: "/brand" })[0].href, "/brand/favicon.svg"); + assert.throws(() => brandIconLinks({ basePath: "https://cdn.example.com/brand" }), RangeError); +}); + +test("the TanStack adapter appends the icon links only when asked, after canonical and alternates", () => { + const metadata = buildMetadata({ + baseUrl: "https://example.com", + path: "/", + locale: "ko", + defaultLocale: "ko", + title: "Example", + description: "Example site", + siteName: "Example", + image: "/og.png", + }); + const bare = toTanStackHead(metadata); + assert.ok(!bare.links.some(({ rel }) => rel === "icon" || rel === "apple-touch-icon")); + const withIcons = toTanStackHead(metadata, { icons: true }); + const rels = withIcons.links.map(({ rel }) => rel); + assert.equal(rels.filter((rel) => rel === "icon").length, 3); + assert.ok(rels.indexOf("icon") > rels.lastIndexOf("alternate")); + assert.deepEqual(withIcons.links.slice(0, bare.links.length), bare.links); + assert.equal(toTanStackHead(metadata, { icons: { basePath: "/" } }).links.at(-1).href, "/apple-touch-icon.png"); + assert.deepEqual(toTanStackHead(metadata, { icons: false }), bare); +});