Prepare build-time static asset syncing without touching page templates
Constraint: Static images need to come from remote sources before log sync and build Rejected: Per-file source mapping | directory-grouped manifests are easier to maintain Confidence: high Scope-risk: narrow Directive: Prefer grouped target_dir manifests and keep page-facing paths stable Tested: npm run assets:sync; npm run build; bash -n scripts/deploy-homepage.sh; npx tsc --noEmit --pretty false --project tsconfig.json Not-tested: Real Seafile download against production URLs
This commit is contained in:
@@ -6,6 +6,10 @@ set -Eeuo pipefail
|
||||
# 默认值:/home/basil/bot/data/git-summary
|
||||
LOG_SOURCE_DIR="${LOG_SOURCE_DIR:-/home/basil/bot/data/git-summary}"
|
||||
|
||||
# 静态资源同步清单
|
||||
# 默认值:$SITE_DIR/src/content/static-assets/index.json
|
||||
STATIC_ASSET_MANIFEST="${STATIC_ASSET_MANIFEST:-src/content/static-assets/index.json}"
|
||||
|
||||
# 个人主页仓库目录
|
||||
# 默认值:/home/basil/source/personal-homepage
|
||||
SITE_DIR="${SITE_DIR:-/home/basil/source/personal-homepage}"
|
||||
@@ -28,6 +32,10 @@ RSYNC_DELETE="${RSYNC_DELETE:-false}"
|
||||
# 默认值:true
|
||||
RUN_LOCAL_REBUILD="${RUN_LOCAL_REBUILD:-true}"
|
||||
|
||||
# 是否在 log 同步前先同步静态资源
|
||||
# 默认值:true
|
||||
RUN_STATIC_ASSET_SYNC="${RUN_STATIC_ASSET_SYNC:-true}"
|
||||
|
||||
# 锁文件路径,避免定时任务重入
|
||||
# 默认值:/tmp/personal-homepage-deploy.lock
|
||||
LOCK_FILE="${LOCK_FILE:-/tmp/personal-homepage-deploy.lock}"
|
||||
@@ -79,6 +87,15 @@ sync_logs() {
|
||||
cp -a "${LOG_SOURCE_DIR}/." "$LOG_TARGET_DIR/"
|
||||
}
|
||||
|
||||
sync_static_assets() {
|
||||
log "同步静态资源清单: $STATIC_ASSET_MANIFEST"
|
||||
(
|
||||
cd "$SITE_DIR"
|
||||
STATIC_ASSET_MANIFEST="$STATIC_ASSET_MANIFEST" \
|
||||
node --env-file-if-exists=.env --experimental-strip-types ./scripts/sync-static-assets.ts
|
||||
)
|
||||
}
|
||||
|
||||
run_local_rebuild() {
|
||||
log "开始宿主机构建校验: npm run rebuild"
|
||||
(
|
||||
@@ -97,6 +114,9 @@ refresh_docker_service() {
|
||||
|
||||
main_impl() {
|
||||
require_command docker
|
||||
if is_true "$RUN_STATIC_ASSET_SYNC"; then
|
||||
require_command node
|
||||
fi
|
||||
if is_true "$RUN_LOCAL_REBUILD"; then
|
||||
require_command npm
|
||||
fi
|
||||
@@ -111,12 +131,20 @@ main_impl() {
|
||||
log "LOG_SOURCE_DIR=$LOG_SOURCE_DIR"
|
||||
log "LOG_TARGET_DIR=$LOG_TARGET_DIR"
|
||||
log "SITE_DIR=$SITE_DIR"
|
||||
log "STATIC_ASSET_MANIFEST=$STATIC_ASSET_MANIFEST"
|
||||
log "DOCKER_COMPOSE_FILE=$DOCKER_COMPOSE_FILE"
|
||||
log "DOCKER_SERVICE_NAME=$DOCKER_SERVICE_NAME"
|
||||
log "RSYNC_DELETE=$RSYNC_DELETE"
|
||||
log "RUN_STATIC_ASSET_SYNC=$RUN_STATIC_ASSET_SYNC"
|
||||
log "RUN_LOCAL_REBUILD=$RUN_LOCAL_REBUILD"
|
||||
log "LOCK_FILE=$LOCK_FILE"
|
||||
|
||||
if is_true "$RUN_STATIC_ASSET_SYNC"; then
|
||||
sync_static_assets
|
||||
else
|
||||
log "跳过静态资源同步(RUN_STATIC_ASSET_SYNC=false)"
|
||||
fi
|
||||
|
||||
sync_logs
|
||||
|
||||
if is_true "$RUN_LOCAL_REBUILD"; then
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
type StaticAssetManifestItem = {
|
||||
url: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
type StaticAssetManifestGroup = {
|
||||
target_dir: string;
|
||||
files: Array<{
|
||||
url: string;
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StaticAssetConfig = {
|
||||
rootDir: string;
|
||||
manifestPath: string;
|
||||
strict: boolean;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig(process.cwd());
|
||||
const items = await readManifest(config.manifestPath);
|
||||
|
||||
console.log('[sync-static-assets] start');
|
||||
console.log(`[sync-static-assets] manifest: ${config.manifestPath}`);
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('[sync-static-assets] no assets configured, skip');
|
||||
return;
|
||||
}
|
||||
|
||||
let failureCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await syncAsset(item, config);
|
||||
} catch (error) {
|
||||
failureCount += 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (config.strict) {
|
||||
throw new Error(`[sync-static-assets] failed: ${message}`);
|
||||
}
|
||||
|
||||
console.warn(`[sync-static-assets] skip failed asset: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[sync-static-assets] done: total=${items.length} failed=${failureCount} strict=${config.strict}`,
|
||||
);
|
||||
}
|
||||
|
||||
function loadConfig(rootDir: string): StaticAssetConfig {
|
||||
return {
|
||||
rootDir,
|
||||
manifestPath: resolveFromRoot(
|
||||
rootDir,
|
||||
process.env.STATIC_ASSET_MANIFEST ?? 'src/content/static-assets/index.json',
|
||||
),
|
||||
strict: getBooleanEnv('STATIC_ASSET_SYNC_STRICT', false),
|
||||
requestTimeoutMs: getNumberEnv('STATIC_ASSET_REQUEST_TIMEOUT_MS', 15000),
|
||||
};
|
||||
}
|
||||
|
||||
async function readManifest(filePath: string): Promise<StaticAssetManifestItem[]> {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('manifest must be a JSON array');
|
||||
}
|
||||
|
||||
return parsed.flatMap((item, index) => normalizeManifestEntry(item, index));
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifestEntry(item: unknown, index: number): StaticAssetManifestItem[] {
|
||||
if (!item || typeof item !== 'object') {
|
||||
throw new Error(`manifest item #${index + 1} must be an object`);
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const targetDir = typeof record.target_dir === 'string' ? record.target_dir.trim() : '';
|
||||
const files = Array.isArray(record.files) ? record.files : null;
|
||||
|
||||
if (targetDir || files) {
|
||||
return normalizeManifestGroup({ target_dir: targetDir, files: files ?? [] }, index);
|
||||
}
|
||||
|
||||
return [normalizeLegacyManifestItem(record, index)];
|
||||
}
|
||||
|
||||
function normalizeLegacyManifestItem(
|
||||
record: Record<string, unknown>,
|
||||
index: number,
|
||||
): StaticAssetManifestItem {
|
||||
const url = typeof record.url === 'string' ? record.url.trim() : '';
|
||||
const target = typeof record.target === 'string' ? record.target.trim() : '';
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`manifest item #${index + 1} is missing url`);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`manifest item #${index + 1} is missing target`);
|
||||
}
|
||||
|
||||
return { url, target };
|
||||
}
|
||||
|
||||
function normalizeManifestGroup(
|
||||
group: StaticAssetManifestGroup,
|
||||
index: number,
|
||||
): StaticAssetManifestItem[] {
|
||||
const targetDir = group.target_dir.trim();
|
||||
|
||||
if (!targetDir) {
|
||||
throw new Error(`manifest group #${index + 1} is missing target_dir`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(group.files)) {
|
||||
throw new Error(`manifest group #${index + 1} files must be an array`);
|
||||
}
|
||||
|
||||
return group.files.map((item, fileIndex) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} must be an object`);
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const url = typeof record.url === 'string' ? record.url.trim() : '';
|
||||
const filename = typeof record.filename === 'string' ? record.filename.trim() : '';
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`manifest group #${index + 1} file #${fileIndex + 1} is missing url`);
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
throw new Error(
|
||||
`manifest group #${index + 1} file #${fileIndex + 1} is missing filename`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
target: path.posix.join(targetDir.replace(/\\/g, '/'), filename),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function syncAsset(item: StaticAssetManifestItem, config: StaticAssetConfig) {
|
||||
const targetPath = ensurePathInsideRoot(config.rootDir, item.target);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
const tempPath = `${targetPath}.tmp`;
|
||||
const response = await fetchWithTimeout(item.url, config.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
await writeFile(tempPath, buffer);
|
||||
await rename(tempPath, targetPath);
|
||||
|
||||
console.log(
|
||||
`[sync-static-assets] wrote ${path.relative(config.rootDir, targetPath)} <- ${item.url}`,
|
||||
);
|
||||
} catch (error) {
|
||||
await safeRemove(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePathInsideRoot(rootDir: string, target: string) {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedTarget = path.resolve(rootDir, target);
|
||||
const relative = path.relative(resolvedRoot, resolvedTarget);
|
||||
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`target path escapes repo root: ${target}`);
|
||||
}
|
||||
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await safeReadText(response);
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getBooleanEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name];
|
||||
if (value == null || value.trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getNumberEnv(name: string, fallback: number) {
|
||||
const value = process.env[name];
|
||||
if (value == null || value.trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveFromRoot(rootDir: string, targetPath: string) {
|
||||
return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function safeReadText(response: Response) {
|
||||
try {
|
||||
return (await response.text()).slice(0, 300);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRemove(filePath: string) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// Ignore temp cleanup errors.
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[sync-static-assets] failed');
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user