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 .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ on:
paths:
- 'docs/**'
- 'tools/publisher/**'
- 'tools/fonts/**'
- 'README*.md'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
pull_request:
paths:
- 'docs/**'
- 'tools/publisher/**'
- 'tools/fonts/**'
- 'README*.md'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
Expand Down Expand Up @@ -40,6 +42,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
- name: Verify DDS font artifact adapter
run: node --test tools/fonts/prepare.test.mjs

- name: Check shared publisher attribution
run: |
npm ci --prefix tools/publisher --ignore-scripts
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ out/
### Planning docs (local only) ###
devslab-kit-planning.md


# Generated by the pinned DDS font artifact
/docs/assets/dds-fonts/
/docs/overrides/partials/dds-fonts.html
/.cache/dds-fonts/
/tools/fonts/__pycache__/
94 changes: 0 additions & 94 deletions docs/assets/fonts/pretendard/LICENSE.txt

This file was deleted.

11 changes: 0 additions & 11 deletions docs/assets/fonts/pretendard/SOURCE.txt

This file was deleted.

2 changes: 1 addition & 1 deletion docs/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% block extrahead %}
{{ super() }}
<link rel="preload" href="{{ 'assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2' | url }}" as="font" type="font/woff2" crossorigin="anonymous">
{% include "partials/dds-fonts.html" %}
{% endblock %}

{% block site_meta %}
Expand Down
24 changes: 4 additions & 20 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,18 @@
* - Dark mode: zinc-900 header, zinc-900 body, white text/logo
* - Visual separation between header and body is a hairline border only
* - Cyan-electric used as a small accent (links, hovers, active tab)
* - Pretendard typography (same family as devslab.kr)
* - Typography supplied by DDS
* - Bilingual "EN / 한" language switcher (no CJK glyph, no generic globe)
*
* Material's default paints the header in primary color. We override the
* primary CSS variables to match the body bg so there's no visible color
* block at the top — just navigation sitting cleanly above content.
* --------------------------------------------------------------------------- */

/* === Typography: Pretendard (same family as devslab.kr) =================== */
/* One complete font face keeps Korean glyph selection atomic. Late loads
* retain the system fallback for this visit instead of swapping text. */
@font-face {
font-family: "Pretendard";
font-style: normal;
font-weight: 45 920;
font-display: optional;
src: url("../assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2") format("woff2");
}

/* === Typography: supplied by DDS ====================================== */
:root {
--md-text-font: "Pretendard",
-apple-system,
"Apple SD Gothic Neo",
"Noto Sans KR",
system-ui,
sans-serif;
--md-code-font: "JetBrains Mono", "SFMono-Regular", Menlo,
Consolas, "Liberation Mono", monospace;
--md-text-font: var(--dds-font-family-sans);
--md-code-font: var(--dds-font-family-mono);
}

/* === Palette (light scheme) ==============================================
Expand Down
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ edit_uri: edit/main/docs/

docs_dir: docs

hooks:
- tools/fonts/mkdocs_hook.py

theme:
name: material
font: false
Expand Down
9 changes: 9 additions & 0 deletions tools/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# DDS documentation fonts

The only font source is the private `@devslab/family-fonts` artifact produced by DDS. The vendored archive is pinned by SHA256 and SHA512 integrity in `artifact.json`.

`python -m mkdocs build --strict` invokes the MkDocs hook before file discovery. The Node adapter verifies the archive, rejects unsafe tar entries, and runs the package exporter. It contains no font faces, family stacks, preload lists, or font copies.

Generated assets, head partial and temporary extraction files are ignored. Update the shared artifact and its verified pin together; do not edit generated font definitions or add service-specific fonts.

Run `node --test tools/fonts/prepare.test.mjs` for the archive integrity and safe-extraction contract.
14 changes: 14 additions & 0 deletions tools/fonts/artifact.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"name": "@devslab/family-fonts",
"version": "0.0.0-dds.8c931915f9ed",
"file": "vendor/devslab-family-fonts-a162eeae087a.tgz",
"sha256": "a162eeae087ad2304edb45cfbedaaed0d0d763988f12acb81f830857f0854b9b",
"integrity": "sha512-VX662QJQN+Rbb7bLWffoSZxLeiZfHE5YBU5+NgF5iFAAmPj5PNFmg0r/YA6QPw0pFlBQoHugt4Am2t086B0RPw==",
"source": {
"repository": "https://github.com/devslab-kr/dds",
"ref": "49baacb7aeaf0ea49dadb30c3e5a50b6bb1bf0eb",
"contentSha256": "8c931915f9ed98381ce74041334dd694cb74795a060b31055dccb36bf0136c44"
},
"distribution": "private-vendored"
}
7 changes: 7 additions & 0 deletions tools/fonts/mkdocs_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Generate documentation font assets from the pinned DDS package before file discovery."""
from pathlib import Path
import subprocess

def on_pre_build(config, **kwargs):
root = Path(__file__).resolve().parents[2]
subprocess.run(["node", str(root / "tools/fonts/prepare.mjs")], cwd=root, check=True)
110 changes: 110 additions & 0 deletions tools/fonts/prepare.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import { execFileSync } from 'node:child_process';

const MAX_TAR_BYTES = 32 * 1024 * 1024;
const inside = (parent, child) => {
const rel = path.relative(parent, child);
return rel !== '' && !rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel);
};

export function verifyArtifact(bytes, pin) {
if (!/^[a-f0-9]{64}$/.test(pin.sha256 ?? '') ||
!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(pin.integrity ?? '')) {
throw new Error('DDS artifact pin must contain SHA256 and sha512 integrity');
}
const sha256 = createHash('sha256').update(bytes).digest('hex');
const integrity = 'sha512-' + createHash('sha512').update(bytes).digest('base64');
if (sha256 !== pin.sha256 || integrity !== pin.integrity) {
throw new Error('DDS artifact integrity mismatch');
}
}

export function extractPackage(bytes, destination) {
const tar = gunzipSync(bytes, { maxOutputLength: MAX_TAR_BYTES });
const seen = new Set();
let offset = 0, count = 0, ended = false;
const string = (block, start, length) => block.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '');
const octal = value => {
const text = value.replace(/\0.*$/s, '').trim();
if (!/^[0-7]+$/.test(text)) throw new Error('Unsupported tar numeric field');
return Number.parseInt(text, 8);
};
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
if (header.every(byte => byte === 0)) {
if (tar.subarray(offset).some(byte => byte !== 0)) throw new Error('Unexpected data after tar trailer');
ended = true;
break;
}
if (++count > 512) throw new Error('DDS archive has too many entries');
const checksum = octal(string(header, 148, 8));
const actual = header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0);
if (checksum !== actual) throw new Error('Invalid tar header checksum');
const name = [string(header, 345, 155), string(header, 0, 100)].filter(Boolean).join('/');
const type = String.fromCharCode(header[156]);
const size = octal(string(header, 124, 12));
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_TAR_BYTES ||
offset + 512 + size > tar.length) throw new Error('Invalid tar entry size');
if (!['0', '\0', '5'].includes(type)) throw new Error('DDS archive links and extended entry types are forbidden');
if (name.includes('\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name) ||
name.split('/').some(part => part === '..' || part === '.')) {
throw new Error('Unsafe DDS archive path');
}
if (name !== 'package/' && name !== 'package' && !name.startsWith('package/')) {
throw new Error('DDS archive entries must use the npm package/ prefix');
}
const relative = name.replace(/^package\/?/, '').replace(/\/$/, '');
if (relative) {
const target = path.resolve(destination, ...relative.split('/'));
if (!inside(path.resolve(destination), target) || seen.has(relative)) throw new Error('Unsafe or duplicate DDS archive path');
seen.add(relative);
if (type === '5') {
if (size !== 0) throw new Error('Invalid tar directory size');
fs.mkdirSync(target, { recursive: true });
} else {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, tar.subarray(offset + 512, offset + 512 + size), { flag: 'wx' });
}
} else if (type !== '5') {
throw new Error('DDS archive package root must be a directory');
}
offset += 512 + Math.ceil(size / 512) * 512;
}
if (!ended || !count) throw new Error('Incomplete DDS tar archive');
}

export function prepare(root = fileURLToPath(new URL('../../', import.meta.url))) {
const tooling = path.join(root, 'tools', 'fonts');
const pin = JSON.parse(fs.readFileSync(path.join(tooling, 'artifact.json'), 'utf8'));
const archive = path.resolve(tooling, pin.file ?? '');
const vendor = path.join(tooling, 'vendor');
if (!inside(vendor, archive) || path.extname(archive) !== '.tgz') throw new Error('DDS artifact must be vendored under tools/fonts/vendor');
const bytes = fs.readFileSync(archive);
verifyArtifact(bytes, pin);
const cache = path.join(root, '.cache', 'dds-fonts');
fs.mkdirSync(cache, { recursive: true });
const temporary = fs.mkdtempSync(path.join(cache, 'package-'));
try {
extractPackage(bytes, temporary);
const pkg = JSON.parse(fs.readFileSync(path.join(temporary, 'package.json'), 'utf8'));
if (pkg.name !== '@devslab/family-fonts' || pkg.private !== true || pkg.version !== pin.version) throw new Error('Unexpected DDS font package identity');
execFileSync(process.execPath, [
path.join(temporary, 'export.mjs'),
'--out-dir', 'docs/assets/dds-fonts',
'--base-url', '/assets/dds-fonts',
'--head-output', 'docs/overrides/partials/dds-fonts.html',
'--monospace',
], { cwd: root, stdio: 'inherit' });
} finally {
if (!inside(cache, temporary) || !path.basename(temporary).startsWith('package-')) throw new Error('Unsafe DDS temporary cleanup target');
fs.rmSync(temporary, { recursive: true, force: true });
}
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { prepare(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}
Loading
Loading