/* 发布集(publish set)—— 「哪些文件会随部署出去」的唯一实现。 * * 为什么抽成模块:这份清单有两个消费者,必须同源,否则又会出现"打包器说发了、核对脚本说没发": * 1. tools/pack-deploy.mjs —— 生成 dist-deploy/ 暂存目录与 tar.gz; * 2. tools/verify-deploy-consistency.mjs —— 拿它逐字节核对线上快照与工作树。 * 规则真源仍然是 `.dockerignore`(Docker 匹配语义,见下方注释),本模块只解析它 + 枚举目录, * 不维护第二份排除清单。NEVER 是打包器自身的边界(版本库 / 依赖 / 本地产物),与 .dockerignore 无关。 */ import fs from 'node:fs'; import path from 'node:path'; /* 部署目录必需文件:与 Dockerfile 的 COPY 对应,且必须留在 /opt 供 docker compose 使用。 注意:它们**始终**随包发布,不受 .dockerignore 影响 —— .dockerignore 只决定 「哪些文件进入镜像构建上下文」,而 docker-compose.yml 这类文件必须留在部署目录里 (compose 从磁盘读取它,不从构建上下文读取)。 */ export const TOP_FILES = ['Dockerfile', '.dockerignore', 'nginx.conf', 'docker-compose.yml', 'sitemap.xml', 'versions.json']; /* 受 .dockerignore 约束的内容目录(镜像里真正要用的资源) */ export const CONTENT_DIRS = [ 'site', 'frameworks', /* 移动端平台实现 + PC×uni-app 端实现(两轴平台/端模型见 PLATFORMS.md) */ 'frameworks-mobile', 'frameworks-uniapp-pc', 'tests', '.design_library', ]; /* 永不打包:版本库、依赖、本地工具与产物(与 .dockerignore 无关,属于打包器自身边界) */ export const NEVER = [/^\.git(\/|$)/, /^node_modules(\/|$)/, /^\.zcode(\/|$)/, /^\.playwright-mcp(\/|$)/, /^dist(-deploy)?(\/|$)/, /^\.gitignore$/]; /* 不经过 HTTP 暴露的发布文件:它们是构建/运维配置,nginx 的 web 根里没有。 逐字节核对线上时跳过(核对脚本用;pack-deploy 不关心)。 */ export const NOT_WEB_SERVED = ['Dockerfile', '.dockerignore', 'nginx.conf', 'docker-compose.yml', 'index.html']; /* ---------- Docker .dockerignore 语义 ---------- * 实测确认:不含 "/" 的模式(如 *.md)只匹配**构建上下文根目录**,不匹配嵌套路径; * 故一律按「完整相对路径」匹配,其中 * 不跨 "/",** 可跨任意层(含零层)。 * 命中某目录即视为命中其下全部内容。 */ export function patternToRegExp(pat) { let re = ''; for (let i = 0; i < pat.length; i++) { const c = pat[i]; if (c === '*') { if (pat[i + 1] === '*') { i++; if (pat[i + 1] === '/') { i++; re += '(?:[^/]*/)*'; } else re += '.*'; } else re += '[^/]*'; } else if (c === '?') re += '[^/]'; else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); } return new RegExp('^' + re + '$'); } export function loadDockerignore(root) { const file = path.join(root, '.dockerignore'); if (!fs.existsSync(file)) throw new Error('.dockerignore 不存在——排除规则无法确定,拒绝打包'); const rules = []; for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const negated = line.startsWith('!'); const body = (negated ? line.slice(1) : line).replace(/^\.\//, '').replace(/\/+$/, ''); if (!body) continue; rules.push({ negated, re: patternToRegExp(body) }); } return rules; } export function isIgnored(rules, rel) { const segments = rel.split('/'); let excluded = false; for (const rule of rules) { let hit = rule.re.test(rel); if (!hit) { for (let i = 1; i < segments.length && !hit; i++) hit = rule.re.test(segments.slice(0, i).join('/')); } if (hit) excluded = !rule.negated; } return excluded; } export function walkTree(absDir, relPrefix, out) { for (const e of fs.readdirSync(absDir, { withFileTypes: true })) { const rel = relPrefix ? relPrefix + '/' + e.name : e.name; if (NEVER.some((re) => re.test(rel))) continue; if (e.isDirectory()) walkTree(path.join(absDir, e.name), rel, out); else out.push(rel); } } /* 历史版本文档站快照(tools/snapshot-site.mjs 产出):仓库根的 / 目录。 没有快照时这里为空数组,部署包与从前一致(版本菜单此时只有当前版本)。 */ export function versionSnapshotDirs(root) { return fs.readdirSync(root, { withFileTypes: true }) .filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) .filter((e) => fs.existsSync(path.join(root, e.name, 'site', 'index.html'))) .map((e) => e.name) .sort(); } /** * 采集发布集。 * @returns {{ kept: string[], candidates: string[], excludedCount: number, versionDirs: string[] }} * 缺目录 / 缺必需文件 / 缺 .dockerignore 时抛错(调用方按环境问题处理)。 */ export function collectPublishSet(root) { const rules = loadDockerignore(root); const candidates = []; for (const top of CONTENT_DIRS) { const abs = path.join(root, top); if (!fs.existsSync(abs)) throw new Error('缺少部署必需目录:' + top); walkTree(abs, top, candidates); } const versionDirs = versionSnapshotDirs(root); for (const dir of versionDirs) walkTree(path.join(root, dir), dir, candidates); for (const f of TOP_FILES) { if (!fs.existsSync(path.join(root, f))) throw new Error('缺少部署必需文件:' + f); } const kept = [...TOP_FILES, ...candidates.filter((f) => !isIgnored(rules, f))].sort(); return { kept, candidates, excludedCount: candidates.length - (kept.length - TOP_FILES.length), versionDirs }; }