Files
zhu1090093659 21f3b4d72a refactor(preset-center): consume the extracted preset center instead of carrying it
The preset center now lives in its own repository (dsh-presets) and is consumed
here as the published npm package @linxin666/dsh-client-ui-preset-center, the
way the skin center, the pet plugin and the community plugin index already are.

- packages/dsh-preset-center leaves this tree (plugin sources, tests and the
  presets/ catalog; history stays in this repository);
- the aggregate mounts it through an external rows: entry (web-ui-preset-center)
  with a hand-written ^0.4.1 range, and ./preset-center stays in the exports map
  as a tombstone so profiles written against the patchFrom era keep importing;
- market-inputs.lock.json gains a presets input pinned to the
  satellites/dsh-presets gitlink, and scripts/market-build reads
  .market-inputs/presets with the in-repo fallback kept for the fixture tests;
- scripts/sync-shared.mjs stops mirroring five host helpers into the package
  (101 -> 96 copies); the lib fingerprints, coverage baseline, test-standards
  baseline, runtime-deps scan and i18n audit each shrink by one package;
- the contribution gate has no exception left: reject-non-content-pr.yml
  redirects a 新预设收录 declaration to dsh-presets, and the PR template,
  CONTRIBUTING.md, PR_TRIAGE.md and ISSUE_TRIAGE.md now state that this
  repository accepts no external contribution directly;
- docs, the family-satellite owning note and the two preset notes follow, and
  the new Agent Note records the split.

Gated by: typecheck, aggregate:check, libs:check, sync-shared:check,
runtime-deps:check, i18n:check, emoji:check, docs:check, market:check,
test:standards, test:scripts (340 tests), test (every package) and build.

Not yet safe to merge on its own: the satellites/dsh-presets gitlink needs that
repository to exist on GitHub, so the satellite commits and this pin have to be
pushed together.
2026-09-24 13:13:17 +08:00

871 lines
38 KiB
JavaScript

#!/usr/bin/env node
'use strict'
/**
* market-build — regenerate the dsh-market.com static site assets.
*
* Single source of truth: the repository itself.
* skins — .market-inputs/skins/<id>/skin.json (v2)
* pets — .market-inputs/pet/<dir>/pet.json (v2)
* plugins — .market-inputs/community/community.json
*
* The skin, pet, community-index and preset content lives in its own
* repositories, carried here as git submodules under satellites/;
* scripts/market-fetch-inputs.mjs materializes the content directory of each
* pinned gitlink into .market-inputs/.
* presets — .market-inputs/presets/<id>/preset.yml
* + presets/catalog.json (market metadata: author, version,
* tags, English copy); the preset directory is the asset
* picks — market/editor-picks.json (hand-curated skin / pet / plugin
* references behind the store card's 编辑推荐 category)
*
* Emits market/dist/:
* index.html, app.js, preview.html copied from market/src/
* _headers copied from market/src/_headers
* robots.txt copied from market/src/
* official-facade.js copied from market/src/official-facade.js
* styles.js window.SKIN_STYLES = { "<id>": { stylesheet, patches } }
* manifest.js window.SKIN_MANIFEST = { skins: [...] } (preview.html compat)
* sitemap.xml generated from the skin catalog
* manifest/skins.json pets.json plugins.json presets.json
* manifest/editor-picks.json
* assets/... previews, background media, pet media,
* market/src/assets/** site artwork
*
* All emitted paths are site-root-relative ("assets/..."), so the deployed
* static site resolves them directly. Output is deterministic: no absolute
* paths, stable key order, LF normalized text.
*
* Usage: node scripts/market-build [--check]
*/
const fs = require('node:fs')
const path = require('node:path')
const { createRequire } = require('node:module')
const { pathToFileURL } = require('node:url')
const crypto = require('node:crypto')
const { crc32 } = require('node:zlib')
const ROOT = path.resolve(__dirname, '..')
/**
* Content sources. The skin, pet and preset assets live in their own
* repositories (dsh-skins, dsh-pet, dsh-presets) since the family split;
* scripts/market-fetch-inputs.mjs materializes the pinned gitlinks into
* MARKET_INPUTS_DIR. The in-repo paths remain as a fallback so the build keeps
* working from a checkout that still carries the packages (migration window
* and the fixture-based script tests).
*/
const MARKET_INPUTS_DIR = process.env.MARKET_INPUTS_DIR
? path.resolve(process.env.MARKET_INPUTS_DIR)
: path.join(ROOT, '.market-inputs')
const IN_REPO_SKINS_DIR = path.join(ROOT, 'packages', 'skins', 'skin-center', 'skins')
const IN_REPO_PETS_DIR = path.join(ROOT, 'packages', 'dsh-pet', 'assets')
const IN_REPO_PRESETS_DIR = path.join(ROOT, 'packages', 'dsh-preset-center', 'presets')
const SKINS_DIR = fs.existsSync(path.join(MARKET_INPUTS_DIR, 'skins')) ? path.join(MARKET_INPUTS_DIR, 'skins') : IN_REPO_SKINS_DIR
const PETS_DIR = fs.existsSync(path.join(MARKET_INPUTS_DIR, 'pet')) ? path.join(MARKET_INPUTS_DIR, 'pet') : IN_REPO_PETS_DIR
const PRESETS_DIR = fs.existsSync(path.join(MARKET_INPUTS_DIR, 'presets')) ? path.join(MARKET_INPUTS_DIR, 'presets') : IN_REPO_PRESETS_DIR
/**
* Data sources that ship inside a published package: the community index and
* the skin-center CSS pipeline. Resolving through the aggregate's dependency
* tree keeps them pinned to the same version the runtime consumes, and keeps
* working after the packages leave this repository.
*/
const AGGREGATE_DIR = path.join(ROOT, 'packages', 'dsh-web-all')
const marketRequire = createRequire(path.join(ROOT, 'scripts', 'market-build'))
function resolveFamilyAsset(specifier, fallback) {
try {
return marketRequire.resolve(specifier, { paths: [AGGREGATE_DIR, ROOT] })
} catch {
return fallback
}
}
const INPUT_COMMUNITY_INDEX = path.join(MARKET_INPUTS_DIR, 'community', 'community.json')
const PLUGINS_FILE = fs.existsSync(INPUT_COMMUNITY_INDEX)
? INPUT_COMMUNITY_INDEX
: resolveFamilyAsset(
'@linxin666/dsh-client-ui-community-plugins/community.json',
path.join(ROOT, 'packages', 'dsh-community-plugins', 'community.json'),
)
const SKIN_CENTER_LIB = resolveFamilyAsset(
'@linxin666/dsh-client-ui-skin-center',
path.join(ROOT, 'packages', 'skins', 'skin-center', 'lib', 'index.js'),
)
const PRESET_CATALOG = path.join(PRESETS_DIR, 'catalog.json')
const INSTALLER_SOURCE = path.join(ROOT, 'packages', 'dsh-market', 'src', 'core', 'installer.ts')
const EDITOR_PICKS_FILE = path.join(ROOT, 'market', 'editor-picks.json')
const SRC_DIR = path.join(ROOT, 'market', 'src')
const DIST_DIR = path.join(ROOT, 'market', 'dist')
const CHECK = process.argv.includes('--check')
const committedManifest = path.join(DIST_DIR, 'manifest', 'skins.json')
const GENERATED_DATE = CHECK && fs.existsSync(committedManifest)
? readJson(committedManifest, 'committed skin manifest').generated
: new Date().toISOString().slice(0, 10)
function fail(msg) {
console.error('market-build: ' + msg)
process.exit(1)
}
/**
* Read the installer's per-asset file cap from its source, so the content
* pipeline and the installer policy can never drift apart again: jyn shipped
* 1565 files and passed every content check while the installer refused
* anything over 200, making the asset uninstallable for every user
* (issue #1578).
*/
function installerMaxFiles() {
const text = fs.readFileSync(INSTALLER_SOURCE, 'utf8')
const m = /export const MAX_FILES_PER_ASSET = (\d+)/.exec(text)
if (!m) throw new Error('MAX_FILES_PER_ASSET not found in ' + INSTALLER_SOURCE)
return Number(m[1])
}
/** Every catalog asset must stay installable by the market installer. */
function assertInstallableFileCounts(maxFiles, groups) {
for (const { kind, entries } of groups) {
for (const { item } of entries) {
const count = Array.isArray(item.files) ? item.files.length : 0
if (count > maxFiles) {
throw new Error(
kind + ' ' + item.id + ': declares ' + count + ' files, over the installer limit of ' +
maxFiles + ' (packages/dsh-market/src/core/installer.ts MAX_FILES_PER_ASSET)',
)
}
}
}
}
function assertInside(base, rel, label) {
const abs = path.resolve(base, rel)
const relPath = path.relative(base, abs)
if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
throw new Error(`${label} escapes source dir: ${rel}`)
}
if (!fs.existsSync(abs)) throw new Error(`${label} not found: ${rel}`)
return abs
}
/** 列出目录内全部文件的相对路径(无视隐藏文件与目录名要求),排序稳定。 */
function listFiles(dir, base) {
base = base || ''
const out = []
for (const name of fs.readdirSync(dir).sort()) {
if (name.startsWith('.')) continue
const abs = path.join(dir, name)
const rel = base ? base + '/' + name : name
if (fs.statSync(abs).isDirectory()) out.push(...listFiles(abs, rel))
else out.push(rel)
}
return out
}
function readJson(file, label) {
let text
try {
text = fs.readFileSync(file, 'utf8')
} catch (err) {
throw new Error(`${label} unreadable: ${file}`)
}
try {
return JSON.parse(text)
} catch (err) {
throw new Error(`${label} JSON parse error in ${file}: ${err.message}`)
}
}
/** --- skins ---------------------------------------------------------------- */
function scanSkins() {
if (!fs.existsSync(SKINS_DIR)) return []
const skins = []
const dirs = fs.readdirSync(SKINS_DIR).sort()
if (!dirs.length) throw new Error(`no skins found under ${SKINS_DIR}; run node scripts/market-fetch-inputs.mjs`)
for (const name of dirs) {
const dir = path.join(SKINS_DIR, name)
if (!fs.statSync(dir).isDirectory()) continue
const metaFile = path.join(dir, 'skin.json')
if (!fs.existsSync(metaFile)) continue
const meta = readJson(metaFile, 'skin')
if (meta.skinManifestVersion !== 2) throw new Error(`skin ${name}: skin.json must declare "skinManifestVersion": 2`)
for (const key of ['id', 'name', 'nameEn', 'author', 'version', 'accent']) {
if (!meta[key]) throw new Error(`skin ${name}: missing required skin.json field "${key}"`)
}
if (meta.id !== name) throw new Error(`skin ${name}: skin.json id "${meta.id}" must equal the directory name`)
const preview = meta.preview || {}
for (const mode of ['light', 'dark']) {
if (typeof preview[mode] !== 'string' || !preview[mode]) {
throw new Error(`skin ${name}: preview.${mode} is mandatory for the market`)
}
assertInside(dir, preview[mode], `skin ${name} preview.${mode}`)
}
const media = (meta.contributes && meta.contributes.backgroundMedia) || {}
const bg = {}
for (const mode of ['light', 'dark']) {
const variant = media[mode]
if (!variant) continue
if (variant.type !== 'image' && variant.type !== 'video') {
throw new Error(`skin ${name}: backgroundMedia.${mode}.type must be "image" or "video"`)
}
if (typeof variant.src !== 'string' || !variant.src || variant.src.includes('..') || path.isAbsolute(variant.src)) {
throw new Error(`skin ${name}: backgroundMedia.${mode}.src must be skin-relative`)
}
assertInside(dir, variant.src, `skin ${name} backgroundMedia.${mode}`)
bg[mode] = {
type: variant.type,
src: `assets/skins/${name}/${variant.src}`,
...(variant.scrim ? { scrim: variant.scrim } : {}),
}
}
const contributes = {}
if (meta.contributes && typeof meta.contributes.stylesheet === 'string') {
contributes.stylesheet = meta.contributes.stylesheet
}
if (meta.contributes && typeof meta.contributes.patches === 'string') {
contributes.patches = meta.contributes.patches
}
if (bg.light || bg.dark) contributes.backgroundMedia = bg
const item = {
id: name,
name: meta.name,
nameEn: meta.nameEn,
author: meta.author,
rank: typeof meta.order === 'number' ? meta.order : 999,
}
if (meta.tagline) item.tagline = meta.tagline
if (meta.description) item.description = meta.description
if (Array.isArray(meta.tags)) item.tags = meta.tags
if (meta.version) item.version = meta.version
if (meta.accent) item.accent = meta.accent
item.preview = { light: `assets/skins/${name}/${preview.light}`, dark: `assets/skins/${name}/${preview.dark}` }
item.files = listFiles(dir)
// Source GitHub link: preserve an explicit sourceUrl, otherwise point to
// this repository's catalog directory so every published skin is discoverable.
item.repo = meta.sourceUrl || `https://github.com/zhu1090093659/dsh-skins/tree/main/skins/${name}`
if (Object.keys(contributes).length) item.contributes = contributes
if (meta.requires) item.requires = meta.requires
if (meta.facets) item.facets = meta.facets
skins.push({ name, dir, item })
}
skins.sort((a, b) => (a.item.rank - b.item.rank) || a.name.localeCompare(b.name))
return skins
}
/** --- pets ----------------------------------------------------------------- */
function scanPets() {
if (!fs.existsSync(PETS_DIR)) return []
const pets = []
const dirs = fs.readdirSync(PETS_DIR).sort()
for (const name of dirs) {
const dir = path.join(PETS_DIR, name)
if (!fs.statSync(dir).isDirectory()) continue
const metaFile = path.join(dir, 'pet.json')
if (!fs.existsSync(metaFile)) continue
const meta = readJson(metaFile, 'pet')
if (meta.petManifestVersion !== 2) {
throw new Error(`pet ${name}: pet.json must declare "petManifestVersion": 2`)
}
if (!meta.id || !meta.displayName) throw new Error(`pet ${name}: pet.json needs id and displayName`)
const renderer = meta.renderer || 'sprite2d'
// frames2d pets (directory-style frame sequences, e.g. miku) ship no
// spritesheet: the card rides the previews, and the installer downloads
// the whole files list into $DSH_HOME/pets/<id>.
const sprite2d = meta.sprite2d || {}
const frames2d = meta.frames2d || {}
if (renderer === 'frames2d') {
if (typeof frames2d !== 'object' || frames2d === null || typeof frames2d.tracks !== 'object' || frames2d.tracks === null) {
throw new Error(`pet ${name}: frames2d.tracks is mandatory for renderer frames2d`)
}
} else if (typeof sprite2d.spritesheetPath !== 'string' || !sprite2d.spritesheetPath) {
throw new Error(`pet ${name}: sprite2d.spritesheetPath is mandatory`)
} else {
assertInside(dir, sprite2d.spritesheetPath, `pet ${name} spritesheet`)
}
const previewsDir = path.join(dir, 'previews')
let previews = []
if (fs.existsSync(previewsDir)) {
// 顺序对齐 tracks(如 idle 优先),无 tracks 时按字母序
const trackNames = renderer === 'frames2d' ? Object.keys(frames2d.tracks) : (sprite2d.tracks ? Object.keys(sprite2d.tracks) : [])
const files = fs.readdirSync(previewsDir).filter((f) => /\.(gif|webp|png)$/i.test(f))
files.sort((a, b) => {
const ka = a.replace(/\.[^.]+$/, '')
const kb = b.replace(/\.[^.]+$/, '')
const ia = trackNames.indexOf(ka)
const ib = trackNames.indexOf(kb)
if (ia !== -1 && ib !== -1) return ia - ib
if (ia !== -1) return -1
if (ib !== -1) return 1
return a.localeCompare(b)
})
previews = files.map((f) => `assets/pets/${meta.id}/previews/${f}`)
}
const item = {
id: meta.id,
displayName: meta.displayName,
rank: pets.length + 1,
renderer,
...(renderer === 'frames2d' ? {} : { spritesheet: `assets/pets/${meta.id}/${sprite2d.spritesheetPath}` }),
}
if (previews.length) item.previews = previews
if (Array.isArray(sprite2d.frames)) item.frames = sprite2d.frames
if (renderer === 'frames2d') item.tracks = Object.keys(frames2d.tracks)
else if (sprite2d.tracks) item.tracks = Object.keys(sprite2d.tracks)
item.files = listFiles(dir)
pets.push({ name, dir, item })
}
return pets
}
/** --- plugins -------------------------------------------------------------- */
function scanPlugins() {
if (!fs.existsSync(PLUGINS_FILE)) return []
const raw = readJson(PLUGINS_FILE, 'community.json')
if (!Array.isArray(raw)) throw new Error('community.json must be an array')
const plugins = []
for (let i = 0; i < raw.length; i++) {
const p = raw[i]
if (!p.id || !p.name) throw new Error(`plugin #${i}: id and name are mandatory`)
const item = {
id: p.id,
name: p.name,
rank: i + 1,
}
if (p.nameEn) item.nameEn = p.nameEn
if (p.author) item.author = p.author
if (p.description) item.description = p.description
if (p.descriptionEn) item.descriptionEn = p.descriptionEn
if (p.repo) item.repo = p.repo
if (p.npm) item.npm = p.npm
item.category = typeof p.category === 'string' && p.category ? p.category : 'other'
if (typeof p.subcategory === 'string' && p.subcategory) item.subcategory = p.subcategory
plugins.push({ item })
}
return plugins
}
/** --- editor picks --------------------------------------------------------- */
/**
* Catalog kinds the 编辑推荐 category may reference. Presets are deliberately
* out of scope: the category curates skins, pets and community plugins only.
*/
const EDITOR_PICK_KINDS = ['skin', 'pet', 'plugin']
/**
* Read the hand-curated "editor picks" list (market/editor-picks.json) and
* resolve every reference against the catalogs this build just scanned. A pick
* that names a removed, renamed or mistyped asset fails the build, so the
* store card can never advertise a fixed entry that no longer exists.
*/
function scanEditorPicks(catalogIds) {
const raw = readJson(EDITOR_PICKS_FILE, 'editor picks')
if (!Array.isArray(raw.items)) throw new Error('editor picks: "items" must be an array')
const seen = new Set()
return raw.items.map((entry, index) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`editor picks #${index}: entry must be an object`)
}
const { kind, id } = entry
if (!EDITOR_PICK_KINDS.includes(kind)) {
throw new Error(`editor picks #${index}: kind must be one of ${EDITOR_PICK_KINDS.join(' / ')}: ${String(kind)}`)
}
if (typeof id !== 'string' || id === '') {
throw new Error(`editor picks #${index}: id must be a non-empty string`)
}
const key = kind + ':' + id
if (seen.has(key)) throw new Error(`editor picks #${index}: duplicate reference: ${key}`)
seen.add(key)
if (!catalogIds[kind].has(id)) {
throw new Error(`editor picks #${index}: ${key} is not in the ${kind} catalog`)
}
return { kind, id }
})
}
/** Preset ids the harness ships; a community preset may never claim one. */
const PRESET_RESERVED_IDS = ['minimal', 'ptc', 'standard', 'cordis']
/**
* Stable preset category ids rendered as filter pills on the market surfaces
* (mirrored by the workshop card's PRESET_CATEGORY_IDS and market/src/app.js).
* A catalog entry may omit the field; it then lands in 'other'.
*/
const PRESET_CATEGORIES = ['roleplay']
/** Official preset directory rule (mirrors the registry's own preset id rule). */
const PRESET_ID_RE = /^[a-z0-9][a-z0-9-]*$/
/**
* Read the display scalars of a preset.yml. The official file carries only
* `name`, `description` and `order`; this is deliberately a shallow scalar
* reader, not a YAML parser (the build script has no YAML dependency). A
* published preset must keep its display text on single-line scalars.
*/
function parsePresetYml(text) {
const out = {}
for (const raw of text.split(/\r?\n/)) {
const line = raw.replace(/\s+#.*$/, '')
const m = /^(name|description|order):\s*(.+?)\s*$/.exec(line)
if (!m) continue
let value = m[2]
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1)
}
if (m[1] === 'order') {
const n = Number(value)
if (Number.isFinite(n)) out.order = n
continue
}
out[m[1]] = value
}
return out
}
/**
* Scan the community preset catalog. The catalog is the market's metadata
* source; each entry's directory supplies the asset bytes and the display
* text the DSH roster shows.
*/
function scanPresets() {
if (!fs.existsSync(PRESET_CATALOG)) return []
const raw = readJson(PRESET_CATALOG, 'preset catalog')
if (!Array.isArray(raw)) throw new Error('preset catalog must be an array')
const presets = []
const seen = new Set()
for (let i = 0; i < raw.length; i++) {
const entry = raw[i]
if (!entry || typeof entry.id !== 'string' || entry.id === '') throw new Error(`preset #${i}: id is mandatory`)
if (!PRESET_ID_RE.test(entry.id)) throw new Error(`preset #${i}: id must match ${PRESET_ID_RE}: ${entry.id}`)
if (PRESET_RESERVED_IDS.includes(entry.id)) throw new Error(`preset #${i}: id is reserved by a shipped preset: ${entry.id}`)
if (seen.has(entry.id)) throw new Error(`preset #${i}: duplicate id: ${entry.id}`)
seen.add(entry.id)
const dir = path.join(PRESETS_DIR, entry.id)
if (!fs.existsSync(dir)) throw new Error(`preset #${i}: directory not found: presets/${entry.id}`)
const composition = path.join(dir, 'agent.cordis.yml')
if (!fs.existsSync(composition)) throw new Error(`preset #${i}: agent.cordis.yml missing: presets/${entry.id}`)
const metadataFile = path.join(dir, 'preset.yml')
if (!fs.existsSync(metadataFile)) throw new Error(`preset #${i}: preset.yml missing: presets/${entry.id}`)
const metadata = parsePresetYml(fs.readFileSync(metadataFile, 'utf8').replace(/\r\n?/g, '\n'))
if (!metadata.name) throw new Error(`preset #${i}: preset.yml needs a single-line name: presets/${entry.id}`)
const category = typeof entry.category === 'string' && entry.category ? entry.category : 'other'
if (category !== 'other' && !PRESET_CATEGORIES.includes(category)) {
throw new Error(
`preset #${i}: category must be one of ${PRESET_CATEGORIES.join(' / ')} or omitted: ${entry.id}`,
)
}
const item = {
id: entry.id,
name: metadata.name,
rank: typeof entry.rank === 'number' ? entry.rank : i + 1,
files: listFiles(dir),
category,
}
if (entry.nameEn) item.nameEn = entry.nameEn
if (entry.author) item.author = entry.author
if (metadata.description) item.description = metadata.description
if (entry.descriptionEn) item.descriptionEn = entry.descriptionEn
if (entry.version) item.version = entry.version
if (Array.isArray(entry.tags) && entry.tags.length) item.tags = entry.tags
if (entry.repo) item.repo = entry.repo
presets.push({ id: entry.id, dir, item })
}
return presets
}
/** --- robots / sitemap ----------------------------------------------------- */
const SITE_ORIGIN = 'https://dsh-market.com'
function xmlEscape(text) {
return String(text).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
})[c])
}
/**
* Build the sitemap (Sitemaps protocol 0.9) from the published catalog so new
* skins become discoverable as soon as they land and drop out when removed.
* Every skin deep-links into the try-on shell, which renders from ?skin=.
*/
function buildSitemap(skins) {
const url = (loc, changefreq, priority) => [
' <url>',
' <loc>' + xmlEscape(loc) + '</loc>',
' <changefreq>' + changefreq + '</changefreq>',
' <priority>' + priority + '</priority>',
' </url>',
].join('\n')
const entries = [
url(SITE_ORIGIN + '/', 'weekly', '1.0'),
url(SITE_ORIGIN + '/preview.html', 'monthly', '0.5'),
url(SITE_ORIGIN + '/tryon/', 'monthly', '0.5'),
...skins.map(({ item }) => url(SITE_ORIGIN + '/tryon/?skin=' + encodeURIComponent(item.id), 'monthly', '0.7')),
]
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!-- Generated by scripts/market-build — do not edit by hand. -->',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
entries.join('\n'),
'</urlset>',
'',
].join('\n')
}
/** --- emit ---------------------------------------------------------------- */
function emit(outDir, files, binary, skins, pets, plugins, presets, picks, transformSkinCss) {
let preservedTryon = null
const tryonPath = path.join(outDir, 'tryon')
const canRebuildTryon = fs.existsSync(TRYON_SHELL_DIR) && fs.existsSync(path.join(TRYON_SHELL_DIR, 'index.html'))
if (!canRebuildTryon && fs.existsSync(tryonPath)) {
const tmpPreserve = path.join(ROOT, '.tryon-preserve-' + Date.now())
fs.renameSync(tryonPath, tmpPreserve)
preservedTryon = tmpPreserve
}
fs.rmSync(outDir, { recursive: true, force: true })
fs.mkdirSync(outDir, { recursive: true })
if (preservedTryon) {
fs.renameSync(preservedTryon, tryonPath)
}
for (const [rel, text] of files) {
const abs = path.join(outDir, rel)
fs.mkdirSync(path.dirname(abs), { recursive: true })
fs.writeFileSync(abs, text)
}
for (const [rel, srcAbs] of binary) {
if (!rel.startsWith('assets/')) continue
const abs = path.join(outDir, rel)
fs.mkdirSync(path.dirname(abs), { recursive: true })
fs.copyFileSync(srcAbs, abs)
}
for (const { dir, name } of skins) {
const entries = listFiles(dir).map((rel) => ({
name: name + '/' + rel,
data: fs.readFileSync(path.join(dir, rel)),
}))
const zipDest = path.join(outDir, 'assets', 'skins', name + '.zip')
fs.mkdirSync(path.dirname(zipDest), { recursive: true })
fs.writeFileSync(zipDest, zipStore(entries))
}
// Try-on CSS uses the exact Skin Center safety transform. In particular it
// clones light tokens from :root to body, where they can override the
// official body's token sheet. Keep authored assets untouched for installs.
for (const { name, dir, item } of skins) {
const contributes = item.contributes || {}
for (const [field, fallback] of [['stylesheet', 'skin.css'], ['patches', 'patches.css']]) {
const rel = contributes[field]
if (!rel) continue
const source = fs.readFileSync(assertInside(dir, rel, field), 'utf8')
const transformed = transformSkinCss(source, { skinId: name, filename: fallback }).code
const dest = path.join(outDir, 'tryon-assets', 'skins', name, fallback)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, transformed)
}
}
// styles.js — window.SKIN_STYLES (stylesheet texts inlined)
const styles = {}
for (const { name, item } of skins) {
const entry = {}
const st = item.contributes && item.contributes.stylesheet
const pa = item.contributes && item.contributes.patches
if (st) entry.stylesheet = fs.readFileSync(assertInside(path.join(SKINS_DIR, name), st, 'stylesheet'), 'utf8').replace(/\r\n?/g, '\n')
if (pa) entry.patches = fs.readFileSync(assertInside(path.join(SKINS_DIR, name), pa, 'patches'), 'utf8').replace(/\r\n?/g, '\n')
styles[name] = entry
}
fs.writeFileSync(path.join(outDir, 'styles.js'),
'/* Generated by scripts/market-build — do not edit by hand. */\nwindow.SKIN_STYLES = ' +
JSON.stringify(styles, null, 2) + '\n')
// manifest.js — window.SKIN_MANIFEST (preview.html compat)
const skinsMeta = skins.map(({ item }) => ({
id: item.id,
name: item.name,
nameEn: item.nameEn,
author: item.author,
...(item.tagline ? { tagline: item.tagline } : {}),
...(item.description ? { description: item.description } : {}),
...(item.accent ? { accent: item.accent } : {}),
preview: item.preview,
...(item.contributes ? { contributes: item.contributes } : {}),
}))
fs.writeFileSync(path.join(outDir, 'manifest.js'),
'/* Generated by scripts/market-build — do not edit by hand. */\nwindow.SKIN_MANIFEST = ' +
JSON.stringify({ generated: GENERATED_DATE, skins: skinsMeta }, null, 2) + '\n')
// tiered manifests
const mk = (o) => JSON.stringify({ generated: GENERATED_DATE, items: o }, null, 2) + '\n'
const manDir = path.join(outDir, 'manifest')
fs.mkdirSync(manDir, { recursive: true })
fs.writeFileSync(path.join(manDir, 'skins.json'), mk(skins.map(({ item }) => item)))
fs.writeFileSync(path.join(manDir, 'pets.json'), mk(pets.map(({ item }) => item)))
fs.writeFileSync(path.join(manDir, 'plugins.json'), mk(plugins.map(({ item }) => item)))
fs.writeFileSync(path.join(manDir, 'presets.json'), mk(presets.map(({ item }) => item)))
fs.writeFileSync(path.join(manDir, 'editor-picks.json'), mk(picks))
}
function walkFiles(dir, base) {
base = base || ''
const out = []
for (const name of fs.readdirSync(dir).sort()) {
const abs = path.join(dir, name)
const rel = base ? base + '/' + name : name
if (fs.statSync(abs).isDirectory()) out.push(...walkFiles(abs, rel))
else out.push(rel)
}
return out
}
function sha256File(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
}
// --- try-on shell (market/shell/dist -> dist/tryon) -------------------------
const TRYON_SHELL_DIR = path.join(ROOT, 'market', 'shell', 'dist')
/**
* Minimal standard-compliant ZIP writer (store method, no compression).
* Output is fully deterministic (fixed 1980-01-01 DOS timestamps).
*
* Previous implementation had a fatal 2-byte shift in both Local File Header
* and Central Directory Entry (extra writeUInt16LE at offset 14/16 pushed all
* subsequent fields by 2 bytes), causing most extractors to reject the
* archive. This rewrite aligns every field to the PKWARE APPNOTE.TXT spec.
*/
function zipStore(files) {
const chunks = []
const central = []
let offset = 0
// 1980-01-01 00:00:00 in MS-DOS format
const DOS_TIME = 0x0000
const DOS_DATE = 0x0021 // (0 << 9) | (1 << 5) | 1
for (const { name, data } of files) {
const nameBytes = Buffer.from(name, 'utf8')
const crc = crc32(data) >>> 0
// Local File Header (30 bytes)
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50, 0) // Signature
header.writeUInt16LE(20, 4) // Version needed (2.0)
header.writeUInt16LE(0x0800, 6) // General purpose flag (UTF-8)
header.writeUInt16LE(0, 8) // Compression method (Store)
header.writeUInt16LE(DOS_TIME, 10) // Last mod time
header.writeUInt16LE(DOS_DATE, 12) // Last mod date
header.writeUInt32LE(crc, 14) // CRC-32
header.writeUInt32LE(data.length, 18) // Compressed size
header.writeUInt32LE(data.length, 22) // Uncompressed size
header.writeUInt16LE(nameBytes.length, 26) // File name length
header.writeUInt16LE(0, 28) // Extra field length
chunks.push(header, nameBytes, data)
// Central Directory Header (46 bytes)
const cen = Buffer.alloc(46)
cen.writeUInt32LE(0x02014b50, 0) // Signature
cen.writeUInt16LE(0x0314, 4) // Version made by (UNIX 2.0)
cen.writeUInt16LE(20, 6) // Version needed (2.0)
cen.writeUInt16LE(0x0800, 8) // General purpose flag (UTF-8)
cen.writeUInt16LE(0, 10) // Compression method (Store)
cen.writeUInt16LE(DOS_TIME, 12) // Last mod time
cen.writeUInt16LE(DOS_DATE, 14) // Last mod date
cen.writeUInt32LE(crc, 16) // CRC-32
cen.writeUInt32LE(data.length, 20) // Compressed size
cen.writeUInt32LE(data.length, 24) // Uncompressed size
cen.writeUInt16LE(nameBytes.length, 28) // File name length
cen.writeUInt16LE(0, 30) // Extra field length
cen.writeUInt16LE(0, 32) // File comment length
cen.writeUInt16LE(0, 34) // Disk number start
cen.writeUInt16LE(0, 36) // Internal file attributes
cen.writeUInt32LE(((0o100644 << 16) | 0x20) >>> 0, 38) // External attrs (POSIX rw-r--r-- + DOS archive)
cen.writeUInt32LE(offset, 42) // Relative offset of LFH
central.push(cen, nameBytes)
offset += 30 + nameBytes.length + data.length
}
let cenSize = 0
for (const c of central) cenSize += c.length
// End of Central Directory Record (22 bytes)
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054b50, 0) // Signature
eocd.writeUInt16LE(0, 4) // Disk number
eocd.writeUInt16LE(0, 6) // Disk with start of central dir
eocd.writeUInt16LE(files.length, 8) // Records on this disk
eocd.writeUInt16LE(files.length, 10) // Total records
eocd.writeUInt32LE(cenSize, 12) // Size of central directory
eocd.writeUInt32LE(offset, 16) // Offset of central directory
eocd.writeUInt16LE(0, 20) // Comment length
return Buffer.concat([...chunks, ...central, eocd])
}
/**
* Emit the try-on shell plus per-skin install archives.
* The shell is a separate build (market/shell, vendored webdsh); the market
* build only copies its already-built dist.
*/
function emitTryon(outDir, skins) {
const tryonDir = path.join(outDir, 'tryon')
if (!fs.existsSync(TRYON_SHELL_DIR) || !fs.existsSync(path.join(TRYON_SHELL_DIR, 'index.html'))) {
console.warn('market-build: market/shell/dist missing; tryon/ not emitted')
return
}
fs.rmSync(tryonDir, { recursive: true, force: true })
fs.cpSync(TRYON_SHELL_DIR, tryonDir, { recursive: true })
// The shell dist may carry local-testing symlinks (manifest/ and
// assets/skins point at the market output); deployment must receive real
// files only. The shell resolves those paths one level up at runtime
// (../assets/skins, ../manifest), so nothing inside tryon/ depends on them.
const stripSymlinks = (dir) => {
for (const name of fs.readdirSync(dir)) {
const abs = path.join(dir, name)
if (fs.lstatSync(abs).isSymbolicLink()) {
fs.rmSync(abs, { force: true })
continue
}
if (fs.statSync(abs).isDirectory()) stripSymlinks(abs)
}
}
stripSymlinks(tryonDir)
// Deterministic hash manifest so market-build --check can verify the
// committed tryon/ without rebuilding the vendored shell (its dist is not
// tracked and CI does not build it).
const hashes = {}
for (const rel of walkFiles(tryonDir)) {
if (rel === '.tryon-hashes.json') continue
hashes[rel] = sha256File(path.join(tryonDir, rel))
}
fs.writeFileSync(path.join(tryonDir, '.tryon-hashes.json'),
JSON.stringify({ files: hashes }, null, 2) + '\n')
}
/** Verify a committed tryon/ against its own hash manifest. */
function verifyTryonManifest(outDir) {
const tryonDir = path.join(outDir, 'tryon')
const manifestFile = path.join(tryonDir, '.tryon-hashes.json')
if (!fs.existsSync(manifestFile)) fail('market/dist/tryon/.tryon-hashes.json missing; run scripts/market-build')
const manifest = readJson(manifestFile, 'tryon hashes')
const files = manifest.files || {}
const missing = []
const changed = []
for (const rel of Object.keys(files)) {
const abs = path.join(tryonDir, rel)
if (!fs.existsSync(abs)) { missing.push(rel); continue }
if (sha256File(abs) !== files[rel]) changed.push(rel)
}
const extra = walkFiles(tryonDir).filter((rel) => rel !== '.tryon-hashes.json' && !(rel in files))
if (missing.length || changed.length || extra.length) {
fail('market/dist/tryon stale — run scripts/market-build (build market/shell first): ' +
(missing.length ? 'missing: ' + missing.join(', ') : '') +
(changed.length ? ' changed: ' + changed.join(', ') : '') +
(extra.length ? ' extra: ' + extra.join(', ') : ''))
}
console.log('market-build: tryon/ verified against hash manifest (' + Object.keys(files).length + ' files)')
}
async function main() {
const skins = scanSkins()
const pets = scanPets()
const plugins = scanPlugins()
const presets = scanPresets()
// Policy gate first: the catalog is pure source data, so a content/policy
// mismatch is reported before the build needs its generated inputs.
assertInstallableFileCounts(installerMaxFiles(), [
{ kind: 'skin', entries: skins },
{ kind: 'pet', entries: pets },
{ kind: 'preset', entries: presets },
])
// Curated store-card category: every reference must resolve in the catalogs
// scanned above.
const picks = scanEditorPicks({
skin: new Set(skins.map(({ item }) => item.id)),
pet: new Set(pets.map(({ item }) => item.id)),
plugin: new Set(plugins.map(({ item }) => item.id)),
})
const skinCenter = await import(pathToFileURL(SKIN_CENTER_LIB).href)
const transformSkinCss = skinCenter.transformSkinCss
// static sources
const files = new Map()
for (const f of ['index.html', 'app.js', 'preview.html', 'official-facade.js', '_headers', 'robots.txt']) {
const abs = path.join(SRC_DIR, f)
if (!fs.existsSync(abs)) throw new Error(`market/src missing: ${f}`)
files.set(f, fs.readFileSync(abs, 'utf8').replace(/\r\n?/g, '\n'))
}
files.set('sitemap.xml', buildSitemap(skins))
// binary assets — full tree copy per skin/pet so a consumer can install
// the exact directory from the static site (files list mirrors this layout)
const binary = new Map()
// site-owned artwork (market/src/assets/**) — copied verbatim so the
// deployed site can reference it like any other static asset.
const SRC_ASSETS = path.join(SRC_DIR, 'assets')
if (fs.existsSync(SRC_ASSETS)) {
for (const rel of listFiles(SRC_ASSETS)) {
binary.set('assets/' + rel, path.join(SRC_ASSETS, rel))
}
}
for (const { name, dir, item } of skins) {
for (const rel of item.files) {
binary.set(`assets/skins/${name}/${rel}`, assertInside(dir, rel, 'skin file'))
}
}
for (const { name, dir, item } of pets) {
for (const rel of item.files) {
binary.set(`assets/pets/${item.id}/${rel}`, assertInside(dir, rel, 'pet file'))
}
}
for (const { id, dir, item } of presets) {
for (const rel of item.files) {
binary.set(`assets/presets/${id}/${rel}`, assertInside(dir, rel, 'preset file'))
}
}
if (CHECK) {
const tmp = path.join(ROOT, '.market-check-tmp')
fs.rmSync(tmp, { recursive: true, force: true })
emit(tmp, files, binary, skins, pets, plugins, presets, picks, transformSkinCss)
const canRebuildTryon = fs.existsSync(path.join(TRYON_SHELL_DIR, 'index.html'))
if (canRebuildTryon) {
emitTryon(tmp, skins)
} else {
// CI does not build the vendored shell; verify the committed tryon/
// against its own hash manifest instead.
verifyTryonManifest(DIST_DIR)
}
const generatedWithoutShell = new Set(['tryon', ...skins.map(({ name }) => 'assets/skins/' + name + '.zip')])
const includeInComparison = (rel) => canRebuildTryon || ![...generatedWithoutShell].some((prefix) => rel === prefix || rel.startsWith(prefix + '/'))
const tmpFiles = walkFiles(tmp).filter(includeInComparison).sort()
const distFiles = walkFiles(DIST_DIR).filter(includeInComparison).sort()
const diffs = []
const all = new Set([...tmpFiles, ...distFiles])
for (const rel of all) {
const a = path.join(tmp, rel)
const b = path.join(DIST_DIR, rel)
const same = fs.existsSync(a) && fs.existsSync(b) && sha256File(a) === sha256File(b)
if (!same) diffs.push(rel)
}
fs.rmSync(tmp, { recursive: true, force: true })
if (diffs.length) {
fail(`market/dist stale — run scripts/market-build and commit: ${diffs.join(', ')}`)
}
console.log('market-build: dist up to date (' + distFiles.length + ' files)')
return
}
emit(DIST_DIR, files, binary, skins, pets, plugins, presets, picks, transformSkinCss)
emitTryon(DIST_DIR, skins)
console.log(`market-build: wrote ${walkFiles(DIST_DIR).length} files (${skins.length} skins, ${pets.length} pets, ${plugins.length} plugins, ${presets.length} presets, ${picks.length} editor picks)`)
}
main().catch((error) => { console.error(error); process.exit(1) })