@devslab/site-kit 0.7.0 → 0.8.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
@@ -72,7 +72,41 @@ export const locales = defineLocaleRegistry({
72
72
  메타데이터를 레지스트리 없이 만들면, 페이지는 타밀어로 렌더되면서 검색
73
73
  엔진에는 타밀어가 없다고 말한다.
74
74
 
75
- ## 로케일 메뉴 variant
75
+ ## 제작사 표시
76
+
77
+ 루트 entry는 프레임워크 중립 `definePublisher`, `buildPublisher`,
78
+ `serializeJsonLd`, `renderPublisherHtml`을 제공한다. DevsLab 제품과 OSS는
79
+ 별도 entry `@devslab/site-kit/devslab`의 공통 설정을 사용한다.
80
+
81
+ ```js
82
+ import { buildPublisher, renderPublisherHtml } from "@devslab/site-kit";
83
+ import { DEVSLAB_PUBLISHER } from "@devslab/site-kit/devslab";
84
+
85
+ const publisher = buildPublisher(DEVSLAB_PUBLISHER, { locale: "ko" });
86
+ // footer에는 publisher.link.href / .label을 사용한다.
87
+ // JSON-LD graph에는 publisher.organization을 넣고,
88
+ // 제품/WebSite publisher에는 publisher.reference를 사용한다.
89
+ const html = renderPublisherHtml(DEVSLAB_PUBLISHER);
90
+ // 정적 페이지 빌드에서 삽입: 보이는 링크 + Organization.
91
+ ```
92
+
93
+ `PublisherIdentity`의 필수 필드는 `id`, `name`, `url`이다. 선택 필드
94
+ `alternateName`, `sameAs`, `labels`, `defaultLabel`은 정체성만 표현하며
95
+ 제품 기능 주장을 추가하지 않는다. URL은 사용자 정보 없는 절대 HTTP(S)만
96
+ 허용한다. 설정은 복사 후 동결한다. 라벨은 정확한 locale, 소문자/기본 언어,
97
+ `defaultLabel`(없으면 `name`) 순서다. DevsLab 기본 라벨은
98
+ `데브스랩(DevsLab)`이고 `en`은 `DevsLab`이다. 공식 홈페이지·한국어 이름·
99
+ 공식 `sameAs` 링크는 이 설정이 소유한다.
100
+
101
+ `renderPublisherHtml`은 링크와 JSON-LD를 escape하며 CSP용 `nonce`를
102
+ 선택적으로 받는다. DOM이나 Solid 없이 문자열을 반환하므로 Node 빌드,
103
+ MkDocs 준비 작업, SSR에서 공통으로 쓴다. 기존 graph를 script에 넣을 때는
104
+ `serializeJsonLd`를 사용한다. Organization은 페이지마다 한 번 출력한다.
105
+ 다른 제작사는 자체 설정으로 `definePublisher`를 호출한다. 기능 주장은
106
+ 계속 `VerifiedFactRegistry`로 검증한다. robots·동의·분석·학습 정책은
107
+ 이 API가 변경하지 않는다.
108
+
109
+ ## 로케일 메뉴 렌더링
76
110
 
77
111
  `LocaleMenu`는 기본으로 네이티브 `<select>`를 렌더링한다. `variant="flag"`는
78
112
  트리거가 현재 로케일의 국기이고 행마다 국기 + 자국어 이름 링크인 `<details>`
package/README.md CHANGED
@@ -41,7 +41,40 @@ Six stateless primitives for a family landing page, extracted from VisionLinq. I
41
41
 
42
42
  `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.
43
43
 
44
- ## Product locales
44
+ ## Publisher attribution
45
+
46
+ The root entry provides framework-neutral `definePublisher`, `buildPublisher`,
47
+ `serializeJsonLd`, and `renderPublisherHtml`. DevsLab products and OSS sites use
48
+ one identity preset from the separate `@devslab/site-kit/devslab` entry:
49
+
50
+ ```js
51
+ import { buildPublisher, renderPublisherHtml } from "@devslab/site-kit";
52
+ import { DEVSLAB_PUBLISHER } from "@devslab/site-kit/devslab";
53
+
54
+ const publisher = buildPublisher(DEVSLAB_PUBLISHER, { locale: "ko" });
55
+ // Render publisher.link.href / .label in the footer.
56
+ // Put publisher.organization in the JSON-LD graph and use
57
+ // publisher.reference as the product/WebSite publisher.
58
+ const html = renderPublisherHtml(DEVSLAB_PUBLISHER);
59
+ // Insert html into a static page during its build: visible link + Organization.
60
+ ```
61
+
62
+ `PublisherIdentity` requires `id`, `name`, and `url`; optional `alternateName`,
63
+ `sameAs`, `labels`, and `defaultLabel` describe identity, not product capabilities.
64
+ URLs must be absolute HTTP(S) without credentials. Configuration is copied and
65
+ frozen. Labels use exact locale, lowercase/base language, then `defaultLabel`
66
+ (or `name`). The DevsLab default is `데브스랩(DevsLab)`; `en` uses `DevsLab`.
67
+ The preset owns the official homepage, Korean alias and official `sameAs` links.
68
+
69
+ `renderPublisherHtml` escapes the anchor and JSON-LD and accepts an optional
70
+ `nonce` for CSP. It returns a string without reading the DOM or importing Solid;
71
+ Node build scripts, MkDocs preparation, and SSR can use the same output.
72
+ For an existing graph, use `serializeJsonLd` when inserting JSON into a script.
73
+ Render the Organization once per page. Other publishers call `definePublisher`
74
+ with their own configuration. Capability claims still use `VerifiedFactRegistry`;
75
+ this API does not change robots rules, consent, analytics or training policy.
76
+
77
+ ## Product locale registries
45
78
 
46
79
  `LOCALES` is the family list — the fourteen languages devslab.kr markets in,
47
80
  and the floor every product gets. It is not every product's list. A product
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devslab/site-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -32,6 +32,10 @@
32
32
  "main": "./src/core/index.mjs",
33
33
  "types": "./src/core/index.d.mts",
34
34
  "exports": {
35
+ "./devslab": {
36
+ "types": "./src/core/devslab.d.mts",
37
+ "import": "./src/core/devslab.mjs"
38
+ },
35
39
  ".": {
36
40
  "types": "./src/core/index.d.mts",
37
41
  "import": "./src/core/index.mjs"
@@ -56,7 +60,7 @@
56
60
  "./site-sections.css": "./site-sections.css"
57
61
  },
58
62
  "dependencies": {
59
- "@devslab/dds-solid": "0.7.0"
63
+ "@devslab/dds-solid": "0.8.0"
60
64
  },
61
65
  "peerDependencies": {
62
66
  "@tanstack/solid-router": "1.170.30",
@@ -0,0 +1,2 @@
1
+ import type { PublisherIdentity } from "./publisher.mjs";
2
+ export declare const DEVSLAB_PUBLISHER: Readonly<PublisherIdentity>;
@@ -0,0 +1,12 @@
1
+ import { definePublisher } from "./publisher.mjs";
2
+
3
+ /** Shared DevsLab identity. Product names, capabilities and policies stay with consumers. */
4
+ export const DEVSLAB_PUBLISHER = definePublisher({
5
+ id: "https://devslab.kr/#organization",
6
+ name: "DevsLab",
7
+ alternateName: ["데브스랩", "devslab"],
8
+ url: "https://devslab.kr/",
9
+ sameAs: ["https://github.com/devslab-kr", "https://devslab-kr.github.io/"],
10
+ defaultLabel: "데브스랩(DevsLab)",
11
+ labels: { ko: "데브스랩(DevsLab)", en: "DevsLab" },
12
+ });
@@ -2,3 +2,4 @@ export * from "./locales.mjs";
2
2
  export * from "./catalog.mjs";
3
3
  export * from "./seo.mjs";
4
4
  export * from "./geo.mjs";
5
+ export * from "./publisher.mjs";
@@ -2,3 +2,4 @@ export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeA
2
2
  export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs";
3
3
  export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
4
4
  export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
5
+ export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
@@ -0,0 +1,27 @@
1
+ export interface PublisherIdentity {
2
+ readonly id: string;
3
+ readonly name: string;
4
+ readonly url: string;
5
+ readonly alternateName?: string | readonly string[];
6
+ readonly sameAs?: readonly string[];
7
+ readonly labels?: Readonly<Record<string, string>>;
8
+ readonly defaultLabel?: string;
9
+ }
10
+ export interface PublisherOrganization {
11
+ "@context": "https://schema.org";
12
+ "@type": "Organization";
13
+ "@id": string;
14
+ name: string;
15
+ url: string;
16
+ alternateName?: string | readonly string[];
17
+ sameAs?: readonly string[];
18
+ }
19
+ export interface BuiltPublisher {
20
+ organization: PublisherOrganization;
21
+ reference: { "@id": string };
22
+ link: { href: string; label: string };
23
+ }
24
+ export declare function definePublisher(input: PublisherIdentity): Readonly<PublisherIdentity>;
25
+ export declare function buildPublisher(identity: PublisherIdentity, options?: { locale?: string }): BuiltPublisher;
26
+ export declare function serializeJsonLd(value: unknown): string;
27
+ export declare function renderPublisherHtml(identity: PublisherIdentity, options?: { locale?: string; nonce?: string }): string;
@@ -0,0 +1,62 @@
1
+ function text(value, field) {
2
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`Publisher ${field} must be a nonempty string`);
3
+ return value;
4
+ }
5
+
6
+ function webUrl(value, field) {
7
+ text(value, field);
8
+ let parsed;
9
+ try { parsed = new URL(value); } catch { throw new TypeError(`Publisher ${field} must be an absolute HTTP(S) URL`); }
10
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) throw new TypeError(`Publisher ${field} must be an HTTP(S) URL without credentials`);
11
+ return parsed.href;
12
+ }
13
+
14
+ /** Identity only. Capabilities, endorsements and other GEO claims remain verified facts. */
15
+ export function definePublisher(input) {
16
+ const labels = Object.fromEntries(Object.entries(input.labels ?? {}).map(([locale, label]) => [locale, text(label, "label")]));
17
+ const alternateName = input.alternateName === undefined ? undefined : Array.isArray(input.alternateName)
18
+ ? Object.freeze(input.alternateName.map((name) => text(name, "alternateName"))) : text(input.alternateName, "alternateName");
19
+ const sameAs = input.sameAs === undefined ? undefined : Object.freeze(input.sameAs.map((url) => webUrl(url, "sameAs URL")));
20
+ return Object.freeze({
21
+ id: webUrl(input.id, "id URL"), name: text(input.name, "name"), url: webUrl(input.url, "url URL"),
22
+ ...(alternateName === undefined ? {} : { alternateName }),
23
+ ...(sameAs === undefined ? {} : { sameAs }),
24
+ labels: Object.freeze(labels),
25
+ defaultLabel: text(input.defaultLabel ?? input.name, "defaultLabel"),
26
+ });
27
+ }
28
+
29
+ export function buildPublisher(input, { locale } = {}) {
30
+ const identity = definePublisher(input);
31
+ const language = locale?.toLowerCase();
32
+ const labelFor = (key) => Object.hasOwn(identity.labels, key) ? identity.labels[key] : undefined;
33
+ const label = labelFor(locale) ?? labelFor(language) ?? labelFor(language?.split("-")[0]) ?? identity.defaultLabel;
34
+ return {
35
+ organization: {
36
+ "@context": "https://schema.org", "@type": "Organization", "@id": identity.id,
37
+ name: identity.name, url: identity.url,
38
+ ...(identity.alternateName === undefined ? {} : { alternateName: identity.alternateName }),
39
+ ...(identity.sameAs === undefined ? {} : { sameAs: identity.sameAs }),
40
+ },
41
+ reference: { "@id": identity.id },
42
+ link: { href: identity.url, label },
43
+ };
44
+ }
45
+
46
+ /** Safe inside an HTML script element, including when values contain </script>. */
47
+ export function serializeJsonLd(value) {
48
+ const json = JSON.stringify(value);
49
+ if (json === undefined) throw new TypeError("JSON-LD must be JSON serializable");
50
+ return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
51
+ }
52
+
53
+ function escapeHtml(value) {
54
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
55
+ }
56
+
57
+ /** Build-time/SSR snippet: a visible attribution link and its matching identity. */
58
+ export function renderPublisherHtml(identity, { locale, nonce } = {}) {
59
+ const { organization, link } = buildPublisher(identity, { locale });
60
+ const nonceAttribute = nonce === undefined ? "" : ` nonce="${escapeHtml(nonce)}"`;
61
+ return `<a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a>\n<script type="application/ld+json"${nonceAttribute}>${serializeJsonLd(organization)}</script>`;
62
+ }