template/script/build-resource.ts

188 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Fontmin from 'fontmin';
import { copy } from 'fs-extra';
import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
import { basename, dirname, extname, join } from 'path';
/** 需要经 fontmin 做字形子集化的字体扩展名 */
const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'];
/** 子目录中声明保留素材的清单文件名,清单自身始终会压缩输出 */
const LIST_FILE_NAME = '.list.jsonc';
/** 永远全量输出、不参与清单过滤的素材目录名 */
const DEFAULTS_DIRECTORY = 'defaults';
export interface IContentBuildOptions {
/** content 素材的源目录 */
readonly sourceDir: string;
/** content 素材的输出目录 */
readonly outputDir: string;
/** 参与字体子集化的全部字符 */
readonly chars: string;
}
export interface IContentBuildStats {
/** 处理的文件总数 */
readonly files: number;
/** 压缩的 jsonc 文件数 */
readonly jsonc: number;
/** 压缩的字体文件数 */
readonly fonts: number;
}
interface IContentBuildCounter {
files: number;
jsonc: number;
fonts: number;
}
/**
* 将 jsonc 文本压缩为单行纯 JSON,按行去掉全部 `//` 注释与系统标记行。
* @param text jsonc 原文
*/
export function minifyJSONC(text: string): string {
const body = text
.split(/\r?\n/)
.filter(line => !line.trim().startsWith('//'))
.join('\n');
return JSON.stringify(JSON.parse(body));
}
/**
* 读取清单文件并收集其中声明的全部文件标识,清单中的注释会先被去除。
* @param listFile 清单文件路径
*/
async function readListNames(listFile: string): Promise<Set<string>> {
const text = await readFile(listFile, 'utf-8');
const names = new Set<string>();
collectListedNames(JSON.parse(minifyJSONC(text)), names);
return names;
}
/**
* 递归收集清单值中的全部字符串标识,数字、布尔与空值会被忽略。
* @param value 清单中的任意值
* @param output 收集结果
*/
function collectListedNames(value: unknown, output: Set<string>): void {
if (typeof value === 'string') {
output.add(value);
} else if (Array.isArray(value)) {
for (const item of value) {
collectListedNames(item, output);
}
} else if (value && typeof value === 'object') {
for (const item of Object.values(value)) {
collectListedNames(item, output);
}
}
}
/**
* 判断文件名是否被清单保留,只支持完整文件名与去扩展名主文件名两种精确匹配。
* @param name 待判断的文件名
* @param names 清单声明的名称集合
*/
function isListedFile(name: string, names: Set<string>): boolean {
if (names.has(name)) return true;
const extension = extname(name);
const stem = name.slice(0, name.length - extension.length);
return names.has(stem);
}
/**
* 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。
* @param source 字体源文件
* @param target 目标文件路径
* @param chars 参与子集化的全部字符
*/
async function compressFont(source: string, target: string, chars: string) {
const fontmin = new Fontmin();
const glyph = Fontmin.glyph({ text: chars });
await fontmin.src(source).dest(dirname(target)).use(glyph).runAsync();
}
/**
* 递归处理目录:jsonc 压缩为纯 JSON,字体做子集化,其它素材原样拷贝。
* @param sourceDir 源目录
* @param outputDir 输出目录
* @param chars 参与字体子集化的全部字符
* @param counter 统计计数器
*/
async function processDirectory(
sourceDir: string,
outputDir: string,
chars: string,
counter: IContentBuildCounter,
isRoot: boolean = false
) {
await mkdir(outputDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
const isDefaults = basename(sourceDir) === DEFAULTS_DIRECTORY;
const filterEnabled = !isRoot && !isDefaults;
const listEntry = filterEnabled
? entries.find(entry => entry.isFile() && entry.name === LIST_FILE_NAME)
: undefined;
const names = listEntry
? await readListNames(join(sourceDir, listEntry.name))
: null;
for (const entry of entries) {
const source = join(sourceDir, entry.name);
const target = join(outputDir, entry.name);
if (entry.isDirectory()) {
await processDirectory(source, target, chars, counter, false);
continue;
}
if (
names &&
entry.name !== LIST_FILE_NAME &&
!isListedFile(entry.name, names)
) {
continue;
}
const extension = extname(entry.name);
if (extension === '.jsonc') {
const text = await readFile(source, 'utf-8');
await writeFile(target, minifyJSONC(text), 'utf-8');
counter.jsonc += 1;
} else if (FONT_EXTENSIONS.includes(extension)) {
await compressFont(source, target, chars);
counter.fonts += 1;
} else {
await copy(source, target);
}
counter.files += 1;
}
}
/**
* 将 content 素材从源目录构建到输出目录,jsonc 压缩为纯 JSON,字体做子集化。
* @param options 构建选项
*/
export async function buildContent(
options: IContentBuildOptions
): Promise<IContentBuildStats> {
const counter: IContentBuildCounter = { files: 0, jsonc: 0, fonts: 0 };
await processDirectory(
options.sourceDir,
options.outputDir,
options.chars,
counter,
true
);
if (counter.fonts === 0) {
process.stdout.write('⚠️ 未找到可压缩的字体文件,已跳过字体压缩\r\n');
}
return {
files: counter.files,
jsonc: counter.jsonc,
fonts: counter.fonts
};
}