diff --git a/.github/workflows/publisher.yml b/.github/workflows/publisher.yml
new file mode 100644
index 0000000..b9fafe1
--- /dev/null
+++ b/.github/workflows/publisher.yml
@@ -0,0 +1,17 @@
+name: Publisher attribution
+on:
+ pull_request:
+ push:
+ branches: [main]
+permissions:
+ contents: read
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - run: npm ci --prefix tools/publisher --ignore-scripts
+ - run: npm run check --prefix tools/publisher
diff --git a/README.ko.md b/README.ko.md
index 33f05c0..0fbf6e6 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -4,7 +4,11 @@
-**Open source by [DevsLab](https://devslab.kr/)** · [OSS brand guide](https://devslab.kr/brand/open-source/) · Collection O12
+
+Open source by [데브스랩(DevsLab)](https://devslab.kr/).
+
+
+[OSS brand guide](https://devslab.kr/brand/open-source/) · Collection O12
**English** · [한국어](README.ko.md)
diff --git a/scripts/check-brand-assets.mjs b/scripts/check-brand-assets.mjs
index df02d3d..e113095 100644
--- a/scripts/check-brand-assets.mjs
+++ b/scripts/check-brand-assets.mjs
@@ -35,8 +35,8 @@ assert(mark.includes('M15 13V23') && mark.includes('M23 13V23') && mark.includes
assert(lockup.includes('data-oss-lockup="O12"') && lockup.includes('Open source by DevsLab'), 'collection lockup must provide the O12 endorsement');
for (const [file, endorsement] of [
- ['README.md', 'Open source by [DevsLab](https://devslab.kr/)'],
- ['README.ko.md', '[DevsLab](https://devslab.kr/) 오픈소스'],
+ ['README.md', 'Open source by [데브스랩(DevsLab)](https://devslab.kr/)'],
+ ['README.ko.md', 'Open source by [데브스랩(DevsLab)](https://devslab.kr/)'],
]) {
const content = read(file);
assert(content.includes('.github/assets/readme-header.png'), `${file} must use the local O12 README header`);
@@ -53,6 +53,13 @@ const rootOnly = new Set([
'README.md',
'README.ko.md',
'scripts/check-brand-assets.mjs',
+ '.github/workflows/publisher.yml',
+ 'tools/publisher/.gitignore',
+ 'tools/publisher/README.md',
+ 'tools/publisher/config.json',
+ 'tools/publisher/package.json',
+ 'tools/publisher/package-lock.json',
+ 'tools/publisher/sync.mjs',
]);
for (const file of changedFiles()) {
assert(rootOnly.has(file), `O12 is collection-level only; unexpected changed path: ${file}`);
diff --git a/tools/publisher/.gitignore b/tools/publisher/.gitignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/tools/publisher/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/tools/publisher/README.md b/tools/publisher/README.md
new file mode 100644
index 0000000..6eadfe6
--- /dev/null
+++ b/tools/publisher/README.md
@@ -0,0 +1,9 @@
+# Publisher attribution
+
+Documentation-only tooling; this dependency is not shipped with the library.
+
+`npm ci --prefix tools/publisher` installs the pinned shared publisher helper.
+`npm run sync --prefix tools/publisher` regenerates the marked README and public HTML attribution from `@devslab/site-kit/devslab`.
+`npm run check --prefix tools/publisher` detects drift without writing files.
+
+Edit the product URL or repository in `config.json`; update the shared preset in site-kit for company-wide identity changes. Do not hand-edit generated publisher blocks.
diff --git a/tools/publisher/config.json b/tools/publisher/config.json
new file mode 100644
index 0000000..be28bbb
--- /dev/null
+++ b/tools/publisher/config.json
@@ -0,0 +1,7 @@
+{
+ "name": "devslab-examples",
+ "kind": "readme",
+ "url": "https://github.com/devslab-kr/devslab-examples",
+ "repository": "https://github.com/devslab-kr/devslab-examples",
+ "pages": []
+}
diff --git a/tools/publisher/package-lock.json b/tools/publisher/package-lock.json
new file mode 100644
index 0000000..2174ef0
--- /dev/null
+++ b/tools/publisher/package-lock.json
@@ -0,0 +1,118 @@
+{
+ "name": "devslab-examples-publisher-docs",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "devslab-examples-publisher-docs",
+ "devDependencies": {
+ "@devslab/site-kit": "0.8.0"
+ }
+ },
+ "node_modules/@devslab/dds-css": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@devslab/dds-css/-/dds-css-0.8.0.tgz",
+ "integrity": "sha512-PxibTv92uMzU3rKtOesFUOSR5x3RWp3JHoO9LBidlqwFOjg37+Wd/Y7prLA8mw7USJ1FnF/R5Q4xHDJHk3iCAQ==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE"
+ },
+ "node_modules/@devslab/dds-icons": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@devslab/dds-icons/-/dds-icons-0.8.0.tgz",
+ "integrity": "sha512-kspidNuH1A1E9euuVYlsw5D+vLsPZf01+lGShyB186ru7UEsLkz3epdNH9ry+OPx4lloGYzigyyqq1jwBK8Ofg==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE"
+ },
+ "node_modules/@devslab/dds-solid": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@devslab/dds-solid/-/dds-solid-0.8.0.tgz",
+ "integrity": "sha512-Q05I3QeiaIJga1ixgw+usw3U9Oc4V8tps8mv/Jylu1B0uip4IiAILIM2sePaeJe/ciGfRTxJRsAtprqfyk9I/w==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@devslab/dds-css": "0.8.0",
+ "@devslab/dds-icons": "0.8.0",
+ "@devslab/dds-tokens": "0.8.0"
+ },
+ "peerDependencies": {
+ "solid-js": "1.9.15"
+ }
+ },
+ "node_modules/@devslab/dds-tokens": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@devslab/dds-tokens/-/dds-tokens-0.8.0.tgz",
+ "integrity": "sha512-lI/K50/jbKFWjlnTIBce23xry7JbJ1Io461VwUjpLvTAxTrZ5vee9ybosSMSTIs/BDWQidt6V80ghZxsGjX5yg==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE"
+ },
+ "node_modules/@devslab/site-kit": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@devslab/site-kit/-/site-kit-0.8.0.tgz",
+ "integrity": "sha512-m2J6/GH7zid3db+BhoN/2GGUg4tlOsDamXBLFjJg+r8JqDgUv5QyocM5tPgqhW6G/jwKIxS0cZo8RcJJAvU7gQ==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@devslab/dds-solid": "0.8.0"
+ },
+ "peerDependencies": {
+ "@tanstack/solid-router": "1.170.30",
+ "@tanstack/solid-start": "1.168.47",
+ "solid-js": "1.9.15"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/solid-router": {
+ "optional": true
+ },
+ "@tanstack/solid-start": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/seroval": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz",
+ "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/seroval-plugins": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz",
+ "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "seroval": "^1.0"
+ }
+ },
+ "node_modules/solid-js": {
+ "version": "1.9.15",
+ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.15.tgz",
+ "integrity": "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.1.0",
+ "seroval": "~1.5.4",
+ "seroval-plugins": "~1.5.4"
+ }
+ }
+ }
+}
diff --git a/tools/publisher/package.json b/tools/publisher/package.json
new file mode 100644
index 0000000..d83e548
--- /dev/null
+++ b/tools/publisher/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "devslab-examples-publisher-docs",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "sync": "node sync.mjs",
+ "check": "node sync.mjs --check"
+ },
+ "devDependencies": {
+ "@devslab/site-kit": "0.8.0"
+ }
+}
diff --git a/tools/publisher/sync.mjs b/tools/publisher/sync.mjs
new file mode 100644
index 0000000..d7dd219
--- /dev/null
+++ b/tools/publisher/sync.mjs
@@ -0,0 +1,50 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { buildPublisher, renderPublisherHtml, serializeJsonLd } from "@devslab/site-kit";
+import { DEVSLAB_PUBLISHER } from "@devslab/site-kit/devslab";
+
+const root = new URL("../../", import.meta.url);
+const config = JSON.parse(await readFile(new URL("./config.json", import.meta.url), "utf8"));
+const check = process.argv.includes("--check");
+const { link, reference } = buildPublisher(DEVSLAB_PUBLISHER);
+const start = "";
+const end = "";
+const markdown = `${start}\n${config.attributionLabel ?? "Open source by"} [${link.label}](${link.href}).\n${end}`;
+const source = {
+ "@context": "https://schema.org",
+ "@type": "SoftwareSourceCode",
+ "@id": `${config.repository}#software`,
+ name: config.name,
+ url: config.url,
+ codeRepository: config.repository,
+ publisher: reference,
+};
+const html = `${start}\nOpen source by ${renderPublisherHtml(DEVSLAB_PUBLISHER)}
\n\n${end}`; + +async function update(relative, transform) { + const url = new URL(relative, root); + const current = (await readFile(url, "utf8").catch((error) => { + if (error.code === "ENOENT") return ""; + throw error; + })).replaceAll("\r\n", "\n"); + const next = transform(current); + if (current === next) return; + if (check) throw new Error(`Publisher output is stale: ${fileURLToPath(url)}; run npm run sync --prefix tools/publisher`); + await writeFile(url, next); +} +function replaceBlock(text, value) { + const from = text.indexOf(start); + const to = text.indexOf(end); + if (from < 0 || to < from) throw new Error("Missing publisher markers"); + return text.slice(0, from) + value + text.slice(to + end.length); +} +for (const path of config.readmes ?? ["README.md", "README.ko.md"]) { + await update(path, (text) => replaceBlock(text, markdown)); +} +if (config.kind === "mkdocs") { + await update("docs/overrides/publisher.html", () => html + "\n"); +} +for (const path of config.pages) { + await update(path, (text) => replaceBlock(text, html)); +} +console.log(check ? "Publisher output is current." : "Publisher output synchronized.");