Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/site-kit-brand-icon-links.md
Original file line number Diff line number Diff line change
@@ -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 }`). 생략하면 아무것도 바뀌지 않는다. 이전에는 가족 사이트 넷이 각자 다른 부분집합을 링크했고, 하나는 아무것도 링크하지 않아 마크가 바뀐 뒤에도 검색 결과가 옛 아이콘을 계속 보여줬다.
33 changes: 33 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@

---

## D-026 — 파비콘 `<link>` 묶음은 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/<product>/`에 만든다 — site-kit은 그 이름을 부를 뿐 그리지 않는다.

**계기.** AskLinq 검색 결과 파비콘이 마크 교체(D-125, 2026-09-03) 열흘 뒤에도 옛
물음표였다. 프로덕션 파일은 이미 새 마크였고, 원인은 둘: web-next 컷오버가 레거시
head의 `<link rel="icon">`을 옮기지 않아 크롤러가 `/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 레지스트리에 링크 목록을 싣기 — 레지스트리는 프레임워크
중립 자산 목록이고 `<link>` 속성(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으로 옮긴 뒤에도 제품 쪽에 남겨 둔
Expand Down
20 changes: 20 additions & 0 deletions packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱,
인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다.

## 브랜드 아이콘

모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist/<product>/`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.

| 링크 | 파일 | 이유 |
|---|---|---|
| `icon` `image/svg+xml` | `favicon.svg` | 탭 아이콘. SVG를 아는 브라우저가 래스터를 받지 않도록 맨 앞 |
| `icon` `48x48` | `mark-48.png` | 검색엔진은 `<link>`로 선언된 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 — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `<MarketingShell mainWidth="bleed">` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `<main>` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다.
Expand Down
20 changes: 20 additions & 0 deletions packages/site-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>/`); `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 `<link>`-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 `<MarketingShell mainWidth="bleed">`; the shell's default `<main>` inset would otherwise double-inset them and turn `tone="band"` into a boxed rectangle.
Expand Down
2 changes: 1 addition & 1 deletion packages/site-kit/src/core/index.mjs
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions packages/site-kit/src/core/seo.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ export declare function renderSitemapXml(entries: ReturnType<typeof buildSitemap
export type RobotsPolicy = "allow" | "disallow";
export declare const ROBOTS_USER_AGENTS: { readonly citation: readonly string[]; readonly modelTraining: readonly string[] };
export declare function buildRobots(input: { baseUrl: string; environment: "production" | "preview" | "development"; policies?: { search: RobotsPolicy; citation: RobotsPolicy; modelTraining: RobotsPolicy } }): string;
/** The @devslab/linq-brand icon files a page head links, in link order. */
export declare const BRAND_ICON_FILES: readonly ["favicon.svg", "mark-48.png", "favicon.ico", "apple-touch-icon.png"];
export interface BrandIconLink { rel: "icon" | "apple-touch-icon"; href: string; type?: string; sizes?: string }
/** `<link>` 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[];
43 changes: 43 additions & 0 deletions packages/site-kit/src/core/seo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>/, 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
* <link>-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}` : "";
};

/**
* `<link>` 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` },
];
}
4 changes: 3 additions & 1 deletion packages/site-kit/src/tanstack-start.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ import type { SiteLocale } from "./core/locales.mjs";
* registry (D-018) yields `SiteMetadata<string>`; the adapter reads the
* same fields whatever the code type is, so it must not refuse that.
*/
export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
/** `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<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>, options?: TanStackHeadOptions): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
export declare const toHtmlAttributes: <Code extends string = SiteLocale>(metadata: SiteMetadata<Code>) => { lang: Code; dir: string };
9 changes: 8 additions & 1 deletion packages/site-kit/src/tanstack-start.mjs
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -18,6 +24,7 @@ export function toTanStackHead(metadata) {
links: [
{ rel: "canonical", href: metadata.canonical },
...metadata.alternates.map(({ hreflang, href }) => ({ rel: "alternate", hreflang, href })),
...icons,
],
};
}
Expand Down
2 changes: 1 addition & 1 deletion tests/site-kit-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Code extends string = SiteLocale>\(metadata: SiteMetadata<Code>\)/);
assert.match(dts, /toTanStackHead<Code extends string = SiteLocale>\(metadata: SiteMetadata<Code>, options\?: TanStackHeadOptions\)/);
assert.match(dts, /toHtmlAttributes: <Code extends string = SiteLocale>\(metadata: SiteMetadata<Code>\)/);
});
50 changes: 50 additions & 0 deletions tests/site-kit-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<product>/, 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);
});