Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
|
||||
await Promise.all([
|
||||
rm('dist', { recursive: true, force: true }),
|
||||
rm('.astro', { recursive: true, force: true }),
|
||||
rm('node_modules/.astro', { recursive: true, force: true })
|
||||
]);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readdir, readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const cacheDir = join(root, '.astro-cache');
|
||||
const cachePath = process.env.GITHUB_CACHE_PATH ? join(root, process.env.GITHUB_CACHE_PATH) : join(cacheDir, 'github.json');
|
||||
const contentDir = join(root, 'src', 'content');
|
||||
const ttlMs = Number(process.env.GITHUB_CACHE_TTL_MS ?? 24 * 60 * 60 * 1000);
|
||||
const timeoutMs = Number(process.env.GITHUB_API_TIMEOUT_MS ?? 8000);
|
||||
const validRepository = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,38})\/[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})$/;
|
||||
|
||||
async function contentFiles(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...await contentFiles(path));
|
||||
else if (/\.(md|mdx)$/i.test(entry.name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function repositoriesFrom(source) {
|
||||
const repositories = [];
|
||||
let fence = null;
|
||||
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
|
||||
const text = line[0].trim();
|
||||
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
if (!fence) fence = fenceMatch[1][0];
|
||||
else if (fenceMatch[1][0] === fence) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (fence) continue;
|
||||
const tag = text.match(/^\[github\b([^\]]*)/i);
|
||||
const value = tag?.[1].match(/(?:^|\s)(?:repo|repository)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/i);
|
||||
const repository = value?.[1] ?? value?.[2] ?? value?.[3];
|
||||
if (repository && validRepository.test(repository.trim())) repositories.push(repository.trim().toLowerCase());
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
async function readCache() {
|
||||
try {
|
||||
const cache = JSON.parse(await readFile(cachePath, 'utf8'));
|
||||
return cache?.repositories && typeof cache.repositories === 'object' ? cache : { repositories: {} };
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') console.warn('[github] Could not read .astro-cache/github.json; rebuilding it.');
|
||||
return { repositories: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRepository(repository) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'mirages-astro-build' };
|
||||
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${repository}`, { headers, signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.private) throw new Error('private repositories are not supported');
|
||||
if (typeof data.stargazers_count !== 'number' || typeof data.forks_count !== 'number' || typeof data.html_url !== 'string') throw new Error('unexpected API response');
|
||||
return {
|
||||
stars: String(data.stargazers_count),
|
||||
forks: String(data.forks_count),
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
commitDate: typeof data.pushed_at === 'string' ? data.pushed_at : '',
|
||||
htmlUrl: data.html_url,
|
||||
defaultBranch: typeof data.default_branch === 'string' && data.default_branch ? data.default_branch : 'main',
|
||||
fetchedAt: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const repositories = new Set();
|
||||
for (const file of await contentFiles(contentDir)) {
|
||||
for (const repository of repositoriesFrom(await readFile(file, 'utf8'))) repositories.add(repository);
|
||||
}
|
||||
|
||||
const cache = await readCache();
|
||||
const now = Date.now();
|
||||
let successful = 0;
|
||||
for (const repository of repositories) {
|
||||
const cached = cache.repositories[repository];
|
||||
if (cached?.fetchedAt && now - Date.parse(cached.fetchedAt) < ttlMs) continue;
|
||||
try {
|
||||
cache.repositories[repository] = await fetchRepository(repository);
|
||||
successful += 1;
|
||||
console.log(`[github] fetched ${repository}`);
|
||||
} catch (error) {
|
||||
console.warn(`[github] ${repository}: ${error instanceof Error ? error.message : 'request failed'}; using ${cached ? 'cached data' : 'fallback values'}.`);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, `${JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), repositories: cache.repositories }, null, 2)}\n`);
|
||||
await rm(join(root, '.astro'), { recursive: true, force: true });
|
||||
console.log(`[github] cache ${cachePath} (${successful} request${successful === 1 ? '' : 's'}, ${repositories.size} repos)`);
|
||||
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const root = process.cwd();
|
||||
const backupPath = process.env.TYPECHO_SQL_GZ ?? 'C:/Users/hi/Desktop/备份/www_luming_cool_2026-08-01_01-30-01_mysql_data.sql.gz';
|
||||
const postsDir = path.join(root, 'src', 'content', 'posts');
|
||||
const coversDir = path.join(root, 'public', 'images', 'typecho-covers');
|
||||
|
||||
function sqlString(value) {
|
||||
if (value === 'NULL') return null;
|
||||
if (value.startsWith("'") && value.endsWith("'")) {
|
||||
return value.slice(1, -1).replace(/\\([\\'"\\0bnrtZ])/g, (_, character) => ({ '\\': '\\', "'": "'", '"': '"', '0': '\0', b: '\b', n: '\n', r: '\r', t: '\t', Z: '\x1a' }[character] ?? character));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function splitSqlValues(input) {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let quote = false;
|
||||
let escaped = false;
|
||||
for (const character of input) {
|
||||
if (quote) {
|
||||
current += character;
|
||||
if (escaped) escaped = false;
|
||||
else if (character === '\\') escaped = true;
|
||||
else if (character === "'") quote = false;
|
||||
} else if (character === "'") {
|
||||
quote = true;
|
||||
current += character;
|
||||
} else if (character === ',') {
|
||||
values.push(sqlString(current.trim()));
|
||||
current = '';
|
||||
} else {
|
||||
current += character;
|
||||
}
|
||||
}
|
||||
values.push(sqlString(current.trim()));
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseInsertRows(sql, table) {
|
||||
const marker = `INSERT INTO \`${table}\` VALUES `;
|
||||
const start = sql.indexOf(marker);
|
||||
if (start < 0) return [];
|
||||
let end = start + marker.length;
|
||||
let scanQuote = false;
|
||||
let scanEscaped = false;
|
||||
for (; end < sql.length; end += 1) {
|
||||
const character = sql[end];
|
||||
if (scanQuote) {
|
||||
if (scanEscaped) scanEscaped = false;
|
||||
else if (character === '\\') scanEscaped = true;
|
||||
else if (character === "'") scanQuote = false;
|
||||
} else if (character === "'") {
|
||||
scanQuote = true;
|
||||
} else if (character === ';') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const input = sql.slice(start + marker.length, end);
|
||||
const rows = [];
|
||||
let row = '';
|
||||
let depth = 0;
|
||||
let rowQuote = false;
|
||||
let rowEscaped = false;
|
||||
for (const character of input) {
|
||||
if (rowQuote) {
|
||||
row += character;
|
||||
if (rowEscaped) rowEscaped = false;
|
||||
else if (character === '\\') rowEscaped = true;
|
||||
else if (character === "'") rowQuote = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "'") rowQuote = true;
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') depth -= 1;
|
||||
if (depth > 0) row += character;
|
||||
if (depth === 0 && row) {
|
||||
rows.push(splitSqlValues(row.slice(1, -1)));
|
||||
row = '';
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function tableColumns(sql, table) {
|
||||
const tick = String.fromCharCode(96);
|
||||
const match = sql.match(new RegExp('CREATE TABLE ' + tick + table + tick + ' \\(([\\s\\S]*?)\\)\\s+ENGINE', 'i'));
|
||||
if (!match) throw new Error(`missing CREATE TABLE for ${table}`);
|
||||
return [...match[1].matchAll(/^\s*`([^`]+)`/gm)].map((item) => item[1]);
|
||||
}
|
||||
|
||||
function records(sql, table) {
|
||||
const columns = tableColumns(sql, table);
|
||||
return parseInsertRows(sql, table).map((values) => Object.fromEntries(columns.map((column, index) => [column, values[index] ?? null])));
|
||||
}
|
||||
|
||||
function frontmatterRange(content) {
|
||||
if (!content.startsWith('---')) return null;
|
||||
const end = content.indexOf('\n---', 3);
|
||||
return end < 0 ? null : [0, end + 4];
|
||||
}
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function findPosts() {
|
||||
const files = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(target);
|
||||
else if (/\.md$|\.mdx$/i.test(entry.name)) files.push(target);
|
||||
}
|
||||
};
|
||||
visit(postsDir);
|
||||
return files.map((file) => ({ file, content: fs.readFileSync(file, 'utf8') }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = zlib.gunzipSync(fs.readFileSync(backupPath)).toString('utf8');
|
||||
const contents = records(sql, 'luming_contents');
|
||||
const fields = records(sql, 'luming_fields');
|
||||
const banners = new Map(fields.filter((field) => field.name === 'banner' && field.str_value).map((field) => [String(field.cid), field.str_value]));
|
||||
const posts = findPosts();
|
||||
const byTitle = new Map(posts.map((post) => [post.content.match(/^title:\s*["']?(.*?)["']?\s*$/m)?.[1]?.replace(/^['"]|['"]$/g, ''), post]));
|
||||
fs.mkdirSync(coversDir, { recursive: true });
|
||||
let downloaded = 0;
|
||||
let failed = 0;
|
||||
let changed = 0;
|
||||
let matched = 0;
|
||||
let existing = 0;
|
||||
const coverFiles = new Map();
|
||||
for (const [cid, url] of banners) {
|
||||
const filename = path.basename(new URL(url).pathname);
|
||||
const target = path.join(coversDir, filename);
|
||||
if (!coverFiles.has(url)) {
|
||||
try {
|
||||
if (!fs.existsSync(target)) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||
fs.writeFileSync(target, Buffer.from(await response.arrayBuffer()));
|
||||
}
|
||||
coverFiles.set(url, `/images/typecho-covers/${filename}`);
|
||||
downloaded += 1;
|
||||
} catch (error) {
|
||||
coverFiles.set(url, url);
|
||||
failed += 1;
|
||||
console.error(`download failed: ${url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const content = contents.find((item) => String(item.cid) === cid);
|
||||
const post = content && byTitle.get(content.title);
|
||||
if (!post) continue;
|
||||
matched += 1;
|
||||
const range = frontmatterRange(post.content);
|
||||
if (!range) continue;
|
||||
if (/^cover:\s*/m.test(post.content.slice(...range))) {
|
||||
existing += 1;
|
||||
continue;
|
||||
}
|
||||
const insertion = `cover: ${yamlString(coverFiles.get(url))}\noriginalCover: ${yamlString(url)}\n`;
|
||||
const next = `${post.content.slice(0, range[0])}${post.content.slice(range[0], range[1]).replace(/^---\n/, `---\n${insertion}`)}${post.content.slice(range[1])}`;
|
||||
fs.writeFileSync(post.file, next, 'utf8');
|
||||
changed += 1;
|
||||
}
|
||||
console.log(JSON.stringify({ sqlContents: contents.length, bannerFields: banners.size, matchedPosts: matched, existingCovers: existing, changedFiles: changed, successfulDownloads: downloaded, failedDownloads: failed, unresolvedBanners: banners.size - matched }, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,210 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const backupDir = process.env.TYPECHO_BACKUP_DIR ?? 'C:/Users/hi/Desktop/备份';
|
||||
const datPath = process.env.TYPECHO_DAT ?? path.join(backupDir, '20260801_www.luming.cool_6a6e0323b06da.dat');
|
||||
const twikooExamplePath = path.join(backupDir, 'twikoo-comment (1).json');
|
||||
const postsDir = path.join(root, 'src', 'content', 'posts');
|
||||
const migrationDir = path.join(root, 'migration');
|
||||
const commentsPath = path.join(migrationDir, 'twikoo-comments.json');
|
||||
const MAGIC = Buffer.from('%TYPECHO_BACKUP_0001%');
|
||||
|
||||
const text = (value) => value == null ? null : value.toString('utf8');
|
||||
const number = (value, fallback = 0) => Number.parseInt(text(value) ?? '', 10) || fallback;
|
||||
const field = (record, name) => record.fields[name];
|
||||
|
||||
function parseBackup(filePath) {
|
||||
const blob = fs.readFileSync(filePath);
|
||||
if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) throw new Error('invalid Typecho backup magic');
|
||||
const records = [];
|
||||
let pos = MAGIC.length;
|
||||
while (pos < blob.length) {
|
||||
if (blob.subarray(pos, pos + MAGIC.length).equals(MAGIC) && pos + MAGIC.length === blob.length) {
|
||||
pos += MAGIC.length;
|
||||
break;
|
||||
}
|
||||
const start = pos;
|
||||
if (blob.length - pos < 8) throw new Error(`truncated record header at ${pos}`);
|
||||
const tableId = blob.readUInt16LE(pos);
|
||||
const schemaLength = blob.readUInt16LE(pos + 2);
|
||||
const dataLength = blob.readUInt32LE(pos + 4);
|
||||
pos += 8;
|
||||
const schemaBytes = blob.subarray(pos, pos + schemaLength);
|
||||
const schema = JSON.parse(schemaBytes.toString('utf8'));
|
||||
pos += schemaLength;
|
||||
const data = blob.subarray(pos, pos + dataLength);
|
||||
if (data.length !== dataLength) throw new Error(`truncated record data at ${pos}`);
|
||||
pos += dataLength;
|
||||
const checksum = blob.subarray(pos, pos + 32).toString('ascii');
|
||||
pos += 32;
|
||||
const expected = crypto.createHash('md5').update(blob.subarray(start, start + 8 + schemaLength + dataLength)).digest('hex');
|
||||
if (checksum !== expected) throw new Error(`checksum mismatch at ${start}`);
|
||||
let cursor = 0;
|
||||
const fields = {};
|
||||
for (const [name, length] of Object.entries(schema)) {
|
||||
if (length === null) { fields[name] = null; continue; }
|
||||
const value = data.subarray(cursor, cursor + length);
|
||||
if (value.length !== length) throw new Error(`field ${name} exceeds record at ${start}`);
|
||||
cursor += length;
|
||||
fields[name] = value;
|
||||
}
|
||||
if (cursor !== dataLength) throw new Error(`record length mismatch at ${start}`);
|
||||
records.push({ tableId, fields, signature: Object.keys(schema).sort().join(',') });
|
||||
}
|
||||
if (pos !== blob.length) throw new Error(`unparsed trailing bytes at ${pos}`);
|
||||
return records;
|
||||
}
|
||||
|
||||
function dateFromUnix(value) {
|
||||
const timestamp = number(value);
|
||||
const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp;
|
||||
const date = new Date(milliseconds);
|
||||
if (Number.isNaN(date.valueOf())) throw new Error(`invalid timestamp: ${timestamp}`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(value ?? '');
|
||||
}
|
||||
|
||||
function englishSlug(raw, cid) {
|
||||
const candidate = (raw ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return candidate || `post-${cid}`;
|
||||
}
|
||||
|
||||
function uniqueSlug(raw, cid, used) {
|
||||
const base = englishSlug(raw, cid);
|
||||
let slug = base;
|
||||
let suffix = 2;
|
||||
while (used.has(slug)) slug = `${base}-${suffix++}`;
|
||||
used.add(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
function relationMap(records) {
|
||||
const map = new Map();
|
||||
for (const record of records) {
|
||||
const cid = number(field(record, 'cid'));
|
||||
const mid = number(field(record, 'mid'));
|
||||
if (!cid || !mid) continue;
|
||||
if (!map.has(cid)) map.set(cid, []);
|
||||
map.get(cid).push(mid);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function classify(records) {
|
||||
const allContents = records.filter((r) => ['cid', 'title', 'text'].every((key) => key in r.fields));
|
||||
// Pages and drafts must also be materialized when comments or relationships
|
||||
// refer to them; attachments are binary/media records, not post content.
|
||||
const contents = allContents.filter((r) => text(field(r, 'type')) !== 'attachment');
|
||||
const comments = records.filter((r) => ['coid', 'cid', 'author', 'text'].every((key) => key in r.fields));
|
||||
const metas = records.filter((r) => ['mid', 'name', 'slug', 'type'].every((key) => key in r.fields));
|
||||
const relations = records.filter((r) => ['cid', 'mid'].every((key) => key in r.fields) && !('title' in r.fields));
|
||||
return { allContents, contents, comments, metas, relations };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const records = parseBackup(datPath);
|
||||
const { allContents, contents, comments, metas, relations } = classify(records);
|
||||
const relation = relationMap(relations);
|
||||
const metaById = new Map(metas.map((r) => [number(field(r, 'mid')), r]));
|
||||
const used = new Set();
|
||||
const postPaths = new Map();
|
||||
const generated = [];
|
||||
const categories = new Set();
|
||||
const tags = new Set();
|
||||
|
||||
for (const record of contents) {
|
||||
const cid = number(field(record, 'cid'));
|
||||
const sourceSlug = text(field(record, 'slug'));
|
||||
const slug = uniqueSlug(sourceSlug, cid, used);
|
||||
const created = dateFromUnix(field(record, 'created'));
|
||||
const modified = field(record, 'modified') == null ? null : dateFromUnix(field(record, 'modified'));
|
||||
const date = new Date(created);
|
||||
const year = String(date.getUTCFullYear());
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const route = `/posts/${year}/${month}/${slug}`;
|
||||
const target = path.join(postsDir, year, month, `${slug}.md`);
|
||||
const taxonomy = { categories: [], tags: [] };
|
||||
for (const mid of relation.get(cid) ?? []) {
|
||||
const meta = metaById.get(mid);
|
||||
if (!meta) continue;
|
||||
const name = text(field(meta, 'name')) ?? '';
|
||||
const type = text(field(meta, 'type'));
|
||||
if (type === 'category') taxonomy.categories.push(name), categories.add(name);
|
||||
if (type === 'tag') taxonomy.tags.push(name), tags.add(name);
|
||||
}
|
||||
const description = ['description', 'summary', 'excerpt'].map((key) => text(field(record, key))).find(Boolean);
|
||||
const lines = [
|
||||
'---',
|
||||
`title: ${yamlString(text(field(record, 'title')) ?? '')}`,
|
||||
description ? `description: ${yamlString(description)}` : null,
|
||||
`pubDate: ${yamlString(created)}`,
|
||||
modified ? `updatedDate: ${yamlString(modified)}` : null,
|
||||
taxonomy.categories.length ? 'categories:' : 'categories: []',
|
||||
...taxonomy.categories.map((value) => ` - ${yamlString(value)}`),
|
||||
taxonomy.tags.length ? 'tags:' : 'tags: []',
|
||||
...taxonomy.tags.map((value) => ` - ${yamlString(value)}`),
|
||||
sourceSlug && sourceSlug !== slug ? `originalSlug: ${yamlString(sourceSlug)}` : null,
|
||||
`comments: ${number(field(record, 'commentsNum')) > 0 || comments.some((comment) => number(field(comment, 'cid')) === cid)}`,
|
||||
'---',
|
||||
'',
|
||||
text(field(record, 'text')) ?? '',
|
||||
''
|
||||
].filter((line) => line !== null);
|
||||
postPaths.set(cid, { route, slug, target, exists: fs.existsSync(target) });
|
||||
if (!fs.existsSync(target)) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, lines.join('\n'), 'utf8');
|
||||
generated.push(path.relative(root, target));
|
||||
}
|
||||
}
|
||||
|
||||
const example = JSON.parse(fs.readFileSync(twikooExamplePath, 'utf8'));
|
||||
if (!Array.isArray(example)) throw new Error('Twikoo example must be an array');
|
||||
const twikooComments = comments.map((record, index) => {
|
||||
const cid = number(field(record, 'cid'));
|
||||
const target = postPaths.get(cid);
|
||||
if (!target) throw new Error(`comment ${number(field(record, 'coid'), index)} points to missing post ${cid}`);
|
||||
const created = number(field(record, 'created')) * (number(field(record, 'created')) < 100000000000 ? 1000 : 1);
|
||||
const id = String(number(field(record, 'coid'), index + 1));
|
||||
const mail = text(field(record, 'mail')) ?? '';
|
||||
return {
|
||||
_id: id,
|
||||
uid: crypto.createHash('md5').update(`${text(field(record, 'author')) ?? ''}:${mail}`).digest('hex'),
|
||||
nick: text(field(record, 'author')) ?? '匿名用户',
|
||||
...(mail ? { mail, mailMd5: crypto.createHash('md5').update(mail.trim().toLowerCase()).digest('hex') } : {}),
|
||||
...(text(field(record, 'url')) ? { link: text(field(record, 'url')) } : {}),
|
||||
...(text(field(record, 'agent')) ? { ua: text(field(record, 'agent')) } : {}),
|
||||
...(text(field(record, 'ip')) ? { ip: text(field(record, 'ip')) } : {}),
|
||||
master: false,
|
||||
url: `${target.route}/`,
|
||||
href: `https://www.luming.cool${target.route}/`,
|
||||
comment: text(field(record, 'text')) ?? '',
|
||||
isSpam: text(field(record, 'status')) === 'spam',
|
||||
created,
|
||||
updated: created,
|
||||
id
|
||||
};
|
||||
});
|
||||
fs.mkdirSync(migrationDir, { recursive: true });
|
||||
fs.writeFileSync(commentsPath, `${JSON.stringify(twikooComments, null, 2)}\n`, 'utf8');
|
||||
generated.push(path.relative(root, commentsPath));
|
||||
console.log(JSON.stringify({
|
||||
records: records.length,
|
||||
contents: contents.length,
|
||||
generatedPosts: generated.filter((file) => file.replaceAll('\\', '/').startsWith('src/content/posts/')).length,
|
||||
skippedExistingPosts: [...postPaths.values()].filter((post) => post.exists).length,
|
||||
sourceContentTypes: Object.fromEntries([...new Set(allContents.map((record) => text(field(record, 'type')) ?? 'unknown'))].sort().map((type) => [type, allContents.filter((record) => (text(field(record, 'type')) ?? 'unknown') === type).length])),
|
||||
categories: [...categories].sort(),
|
||||
tags: [...tags].sort(),
|
||||
comments: twikooComments.length,
|
||||
twikooExampleComments: example.length,
|
||||
generated
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import readline from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
|
||||
const root = process.cwd();
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear());
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const postsDir = path.join(root, 'src', 'content', 'posts', year, month);
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
let answers;
|
||||
if (input.isTTY) {
|
||||
answers = [
|
||||
await rl.question('文章标题: '),
|
||||
await rl.question('文章类名: '),
|
||||
await rl.question('文章封面(可选,直接回车跳过): ')
|
||||
];
|
||||
} else {
|
||||
answers = [];
|
||||
for await (const line of rl) answers.push(line);
|
||||
}
|
||||
|
||||
const [rawTitle = '', rawSlug = '', rawCover = ''] = answers;
|
||||
const title = rawTitle.trim();
|
||||
const slug = rawSlug.trim();
|
||||
const cover = rawCover.trim();
|
||||
|
||||
if (!title) throw new Error('文章标题不能为空');
|
||||
if (!slug) throw new Error('文章类名不能为空');
|
||||
if (!/^[^\\/:*?"<>|]+$/.test(slug) || slug === '.' || slug === '..') {
|
||||
throw new Error('文章类名只能是单个文件名,不能包含路径或 Windows 保留字符');
|
||||
}
|
||||
|
||||
const target = path.join(postsDir, `${slug}.md`);
|
||||
if (fs.existsSync(target)) throw new Error(`文章已存在: ${path.relative(root, target)}`);
|
||||
|
||||
const pubDate = now.toISOString();
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`title: ${JSON.stringify(title)}`,
|
||||
`pubDate: ${JSON.stringify(pubDate)}`,
|
||||
...(cover ? [`cover: ${JSON.stringify(cover)}`] : []),
|
||||
'categories: []',
|
||||
'tags: []',
|
||||
'draft: true',
|
||||
'---',
|
||||
'',
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(postsDir, { recursive: true });
|
||||
fs.writeFileSync(target, frontmatter, 'utf8');
|
||||
console.log(`已新建文章: ${path.relative(root, target)}`);
|
||||
} catch (error) {
|
||||
console.error(`新建文章失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`新建文章失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user