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
5 changes: 5 additions & 0 deletions .changeset/shared-publisher-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@devslab/site-kit": minor
---

Add framework-neutral publisher identity, Organization references, safe JSON-LD and static attribution rendering, with a shared bilingual DevsLab preset at `@devslab/site-kit/devslab`.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"check:consumers": "node scripts/check-consumers.mjs",
"verify": "pnpm run build && pnpm run check",
"verify:source:stage1-2": "node --test packages/compatibility-canary/tests/contracts.test.mjs packages/compatibility-canary/tests/application-contracts.test.mjs tests/foundation-contracts.test.mjs",
"verify:source:stage3-4": "node --test tests/dds-solid-contracts.test.mjs tests/site-kit-core.test.mjs tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs",
"verify:source:stage3-4": "node --test tests/dds-solid-contracts.test.mjs tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs",
"storybook": "pnpm run build && storybook dev -p 6006",
"build-storybook": "pnpm run build && storybook build",
"verify:canary:dependencies": "pnpm --filter @devslab/compatibility-canary run verify:dependencies",
Expand All @@ -22,9 +22,9 @@
"verify:solid:test": "node --test tests/dds-solid-contracts.test.mjs && pnpm --filter @devslab/dds-solid run test && pnpm --filter @devslab/dds-solid run check && pnpm --filter @devslab/dds-solid run build",
"verify:solid:a11y": "pnpm --filter @devslab/dds-solid run test:a11y",
"verify:solid:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-solid run build && node scripts/verify-solid-release.mjs",
"verify:site-kit:i18n": "node --test tests/site-kit-core.test.mjs",
"verify:site-kit:i18n": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:ui": "node --test tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs && pnpm --filter @devslab/site-kit run test && pnpm --filter @devslab/site-kit run check && pnpm --filter @devslab/site-kit run build && pnpm --filter @devslab/site-kit run test:worker",
"verify:site-kit:seo": "node --test tests/site-kit-core.test.mjs",
"verify:site-kit:seo": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:browser": "playwright test --config playwright.site-kit.config.ts",
"verify:site-kit:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-solid run build && pnpm --filter @devslab/site-kit run build && node scripts/verify-site-kit-release.mjs",
"changeset": "changeset",
Expand Down
36 changes: 35 additions & 1 deletion packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,41 @@ export const locales = defineLocaleRegistry({
메타데이터를 레지스트리 없이 만들면, 페이지는 타밀어로 렌더되면서 검색
엔진에는 타밀어가 없다고 말한다.

## 로케일 메뉴 variant
## 제작사 표시

루트 entry는 프레임워크 중립 `definePublisher`, `buildPublisher`,
`serializeJsonLd`, `renderPublisherHtml`을 제공한다. DevsLab 제품과 OSS는
별도 entry `@devslab/site-kit/devslab`의 공통 설정을 사용한다.

```js
import { buildPublisher, renderPublisherHtml } from "@devslab/site-kit";
import { DEVSLAB_PUBLISHER } from "@devslab/site-kit/devslab";

const publisher = buildPublisher(DEVSLAB_PUBLISHER, { locale: "ko" });
// footer에는 publisher.link.href / .label을 사용한다.
// JSON-LD graph에는 publisher.organization을 넣고,
// 제품/WebSite publisher에는 publisher.reference를 사용한다.
const html = renderPublisherHtml(DEVSLAB_PUBLISHER);
// 정적 페이지 빌드에서 삽입: 보이는 링크 + Organization.
```

`PublisherIdentity`의 필수 필드는 `id`, `name`, `url`이다. 선택 필드
`alternateName`, `sameAs`, `labels`, `defaultLabel`은 정체성만 표현하며
제품 기능 주장을 추가하지 않는다. URL은 사용자 정보 없는 절대 HTTP(S)만
허용한다. 설정은 복사 후 동결한다. 라벨은 정확한 locale, 소문자/기본 언어,
`defaultLabel`(없으면 `name`) 순서다. DevsLab 기본 라벨은
`데브스랩(DevsLab)`이고 `en`은 `DevsLab`이다. 공식 홈페이지·한국어 이름·
공식 `sameAs` 링크는 이 설정이 소유한다.

`renderPublisherHtml`은 링크와 JSON-LD를 escape하며 CSP용 `nonce`를
선택적으로 받는다. DOM이나 Solid 없이 문자열을 반환하므로 Node 빌드,
MkDocs 준비 작업, SSR에서 공통으로 쓴다. 기존 graph를 script에 넣을 때는
`serializeJsonLd`를 사용한다. Organization은 페이지마다 한 번 출력한다.
다른 제작사는 자체 설정으로 `definePublisher`를 호출한다. 기능 주장은
계속 `VerifiedFactRegistry`로 검증한다. robots·동의·분석·학습 정책은
이 API가 변경하지 않는다.

## 로케일 메뉴 렌더링

`LocaleMenu`는 기본으로 네이티브 `<select>`를 렌더링한다. `variant="flag"`는
트리거가 현재 로케일의 국기이고 행마다 국기 + 자국어 이름 링크인 `<details>`
Expand Down
35 changes: 34 additions & 1 deletion packages/site-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,40 @@ Six stateless primitives for a family landing page, extracted from VisionLinq. I

`defineLocaleRegistry({ only: ["ko", "en", "ja"] })` keeps only those family locales (family order, before any `extra`). Pass the registry to `SiteHeader localeRegistry` and to `validateCatalogs(…, { registry })`; a visitor asking for a locale outside the subset resolves to your `defaultLocale`. `defaultLocale` must itself be inside `only`; an alias whose target falls outside the subset (e.g. `zh` → `zh-TW` when only `zh-HK` is kept) resolves to `undefined` and so falls through to `defaultLocale`, never to another script.

## Product locales
## Publisher attribution

The root entry provides framework-neutral `definePublisher`, `buildPublisher`,
`serializeJsonLd`, and `renderPublisherHtml`. DevsLab products and OSS sites use
one identity preset from the separate `@devslab/site-kit/devslab` entry:

```js
import { buildPublisher, renderPublisherHtml } from "@devslab/site-kit";
import { DEVSLAB_PUBLISHER } from "@devslab/site-kit/devslab";

const publisher = buildPublisher(DEVSLAB_PUBLISHER, { locale: "ko" });
// Render publisher.link.href / .label in the footer.
// Put publisher.organization in the JSON-LD graph and use
// publisher.reference as the product/WebSite publisher.
const html = renderPublisherHtml(DEVSLAB_PUBLISHER);
// Insert html into a static page during its build: visible link + Organization.
```

`PublisherIdentity` requires `id`, `name`, and `url`; optional `alternateName`,
`sameAs`, `labels`, and `defaultLabel` describe identity, not product capabilities.
URLs must be absolute HTTP(S) without credentials. Configuration is copied and
frozen. Labels use exact locale, lowercase/base language, then `defaultLabel`
(or `name`). The DevsLab default is `데브스랩(DevsLab)`; `en` uses `DevsLab`.
The preset owns the official homepage, Korean alias and official `sameAs` links.

`renderPublisherHtml` escapes the anchor and JSON-LD and accepts an optional
`nonce` for CSP. It returns a string without reading the DOM or importing Solid;
Node build scripts, MkDocs preparation, and SSR can use the same output.
For an existing graph, use `serializeJsonLd` when inserting JSON into a script.
Render the Organization once per page. Other publishers call `definePublisher`
with their own configuration. Capability claims still use `VerifiedFactRegistry`;
this API does not change robots rules, consent, analytics or training policy.

## Product locale registries

`LOCALES` is the family list — the fourteen languages devslab.kr markets in,
and the floor every product gets. It is not every product's list. A product
Expand Down
4 changes: 4 additions & 0 deletions packages/site-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"main": "./src/core/index.mjs",
"types": "./src/core/index.d.mts",
"exports": {
"./devslab": {
"types": "./src/core/devslab.d.mts",
"import": "./src/core/devslab.mjs"
},
".": {
"types": "./src/core/index.d.mts",
"import": "./src/core/index.mjs"
Expand Down
2 changes: 2 additions & 0 deletions packages/site-kit/src/core/devslab.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type { PublisherIdentity } from "./publisher.mjs";
export declare const DEVSLAB_PUBLISHER: Readonly<PublisherIdentity>;
12 changes: 12 additions & 0 deletions packages/site-kit/src/core/devslab.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { definePublisher } from "./publisher.mjs";

/** Shared DevsLab identity. Product names, capabilities and policies stay with consumers. */
export const DEVSLAB_PUBLISHER = definePublisher({
id: "https://devslab.kr/#organization",
name: "DevsLab",
alternateName: ["데브스랩", "devslab"],
url: "https://devslab.kr/",
sameAs: ["https://github.com/devslab-kr", "https://devslab-kr.github.io/"],
defaultLabel: "데브스랩(DevsLab)",
labels: { ko: "데브스랩(DevsLab)", en: "DevsLab" },
});
1 change: 1 addition & 0 deletions packages/site-kit/src/core/index.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./locales.mjs";
export * from "./catalog.mjs";
export * from "./seo.mjs";
export * from "./geo.mjs";
export * from "./publisher.mjs";
1 change: 1 addition & 0 deletions packages/site-kit/src/core/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeA
export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs";
export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
27 changes: 27 additions & 0 deletions packages/site-kit/src/core/publisher.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface PublisherIdentity {
readonly id: string;
readonly name: string;
readonly url: string;
readonly alternateName?: string | readonly string[];
readonly sameAs?: readonly string[];
readonly labels?: Readonly<Record<string, string>>;
readonly defaultLabel?: string;
}
export interface PublisherOrganization {
"@context": "https://schema.org";
"@type": "Organization";
"@id": string;
name: string;
url: string;
alternateName?: string | readonly string[];
sameAs?: readonly string[];
}
export interface BuiltPublisher {
organization: PublisherOrganization;
reference: { "@id": string };
link: { href: string; label: string };
}
export declare function definePublisher(input: PublisherIdentity): Readonly<PublisherIdentity>;
export declare function buildPublisher(identity: PublisherIdentity, options?: { locale?: string }): BuiltPublisher;
export declare function serializeJsonLd(value: unknown): string;
export declare function renderPublisherHtml(identity: PublisherIdentity, options?: { locale?: string; nonce?: string }): string;
62 changes: 62 additions & 0 deletions packages/site-kit/src/core/publisher.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
function text(value, field) {
if (typeof value !== "string" || !value.trim()) throw new TypeError(`Publisher ${field} must be a nonempty string`);
return value;
}

function webUrl(value, field) {
text(value, field);
let parsed;
try { parsed = new URL(value); } catch { throw new TypeError(`Publisher ${field} must be an absolute HTTP(S) URL`); }
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) throw new TypeError(`Publisher ${field} must be an HTTP(S) URL without credentials`);
return parsed.href;
}

/** Identity only. Capabilities, endorsements and other GEO claims remain verified facts. */
export function definePublisher(input) {
const labels = Object.fromEntries(Object.entries(input.labels ?? {}).map(([locale, label]) => [locale, text(label, "label")]));
const alternateName = input.alternateName === undefined ? undefined : Array.isArray(input.alternateName)
? Object.freeze(input.alternateName.map((name) => text(name, "alternateName"))) : text(input.alternateName, "alternateName");
const sameAs = input.sameAs === undefined ? undefined : Object.freeze(input.sameAs.map((url) => webUrl(url, "sameAs URL")));
return Object.freeze({
id: webUrl(input.id, "id URL"), name: text(input.name, "name"), url: webUrl(input.url, "url URL"),
...(alternateName === undefined ? {} : { alternateName }),
...(sameAs === undefined ? {} : { sameAs }),
labels: Object.freeze(labels),
defaultLabel: text(input.defaultLabel ?? input.name, "defaultLabel"),
});
}

export function buildPublisher(input, { locale } = {}) {
const identity = definePublisher(input);
const language = locale?.toLowerCase();
const labelFor = (key) => Object.hasOwn(identity.labels, key) ? identity.labels[key] : undefined;
const label = labelFor(locale) ?? labelFor(language) ?? labelFor(language?.split("-")[0]) ?? identity.defaultLabel;
return {
organization: {
"@context": "https://schema.org", "@type": "Organization", "@id": identity.id,
name: identity.name, url: identity.url,
...(identity.alternateName === undefined ? {} : { alternateName: identity.alternateName }),
...(identity.sameAs === undefined ? {} : { sameAs: identity.sameAs }),
},
reference: { "@id": identity.id },
link: { href: identity.url, label },
};
}

/** Safe inside an HTML script element, including when values contain </script>. */
export function serializeJsonLd(value) {
const json = JSON.stringify(value);
if (json === undefined) throw new TypeError("JSON-LD must be JSON serializable");
return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
}

function escapeHtml(value) {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}

/** Build-time/SSR snippet: a visible attribution link and its matching identity. */
export function renderPublisherHtml(identity, { locale, nonce } = {}) {
const { organization, link } = buildPublisher(identity, { locale });
const nonceAttribute = nonce === undefined ? "" : ` nonce="${escapeHtml(nonce)}"`;
return `<a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a>\n<script type="application/ld+json"${nonceAttribute}>${serializeJsonLd(organization)}</script>`;
}
4 changes: 4 additions & 0 deletions scripts/verify-site-kit-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ try {
for (const path of [
"dist/solid.js", "dist/index.d.ts",
"src/core/index.mjs", "src/core/index.d.mts",
"src/core/publisher.mjs", "src/core/publisher.d.mts", "src/core/devslab.mjs", "src/core/devslab.d.mts",
"src/core/flags.mjs", "src/core/flags.d.mts",
"src/tanstack-start.mjs", "src/tanstack-start.d.mts",
"styles.css", "site-sections.css", "flags/LICENSE-flag-icons.txt",
Expand All @@ -83,6 +84,9 @@ try {
assert.equal(manifest.peerDependencies["solid-js"], "1.9.15");
const core = await import(pathToFileURL(join(installedRoot, "src", "core", "index.mjs")));
assert.equal(core.LOCALES.length, 14);
const { DEVSLAB_PUBLISHER } = await import(pathToFileURL(join(installedRoot, "src/core/devslab.mjs")));
assert.equal(core.buildPublisher(DEVSLAB_PUBLISHER).link.label, "데브스랩(DevsLab)");
assert.equal(manifest.exports["./devslab"].types, "./src/core/devslab.d.mts");
assert.equal(
typeof (await import(pathToFileURL(join(installedRoot, "src", "core", "flags.mjs")))).flagFor,
"function",
Expand Down
6 changes: 4 additions & 2 deletions tests/site-kit-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ test("site-kit exposes runtime-neutral, Solid, TanStack, and stylesheet boundari
assert.equal(manifest.name, "@devslab/site-kit");
assert.equal(manifest.license, "SEE LICENSE IN LICENSE");
assert.equal(manifest.publishConfig.access, "public");
for (const path of [".", "./solid", "./tanstack-start", "./styles.css"]) assert.ok(manifest.exports[path]);
for (const path of [".", "./devslab", "./solid", "./tanstack-start", "./styles.css"]) assert.ok(manifest.exports[path]);
assert.equal(manifest.exports["./devslab"].types, "./src/core/devslab.d.mts");
assert.equal(manifest.exports["./devslab"].import, "./src/core/devslab.mjs");
assert.ok(manifest.files.includes("src/core"));
assert.equal(manifest.exports["./solid"].types, "./dist/index.d.ts");
assert.equal(manifest.exports["./solid"].browser, "./dist/solid.js");
assert.equal(manifest.exports["./solid"].worker, "./dist/solid.server.js");
Expand Down Expand Up @@ -164,4 +167,3 @@ test("the TanStack adapter accepts metadata built from a product registry", asyn
assert.match(dts, /toTanStackHead<Code extends string = SiteLocale>\(metadata: SiteMetadata<Code>\)/);
assert.match(dts, /toHtmlAttributes: <Code extends string = SiteLocale>\(metadata: SiteMetadata<Code>\)/);
});

Loading