diff --git a/.vitepress/scripts/check-share-links.ts b/.vitepress/scripts/check-share-links.ts index f2e16bd..e514c36 100644 --- a/.vitepress/scripts/check-share-links.ts +++ b/.vitepress/scripts/check-share-links.ts @@ -3,13 +3,12 @@ import path from "node:path"; import { checkShareLinkFiles } from "../utilities/share-link-registry-files.ts"; const projectRoot = path.resolve(import.meta.dirname, "../.."); -const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json"); const pageIds = await fg("posts/**/*.md", { cwd: projectRoot, onlyFiles: true, }); -const result = await checkShareLinkFiles({ registryFile, pageIds }); +const result = await checkShareLinkFiles({ pageIds }); console.info( - `[share-links] ${result.activeCount} active, ${result.goneCount} gone; registry check passed`, + `[share-links] ${result.activeCount} deterministic IDs; generation check passed`, ); diff --git a/.vitepress/scripts/prepare-share-links.ts b/.vitepress/scripts/prepare-share-links.ts index 2c2ac86..6014c52 100644 --- a/.vitepress/scripts/prepare-share-links.ts +++ b/.vitepress/scripts/prepare-share-links.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { prepareShareLinkFiles } from "../utilities/share-link-registry-files.ts"; const projectRoot = path.resolve(import.meta.dirname, "../.."); -const baseRegistryFile = path.join(projectRoot, ".vitepress/data/share-links.json"); const generatedRegistryFile = path.join( projectRoot, ".vitepress/generated/share-links.json", @@ -18,12 +17,11 @@ const pageIds = await fg("posts/**/*.md", { onlyFiles: true, }); const result = await prepareShareLinkFiles({ - baseRegistryFile, generatedRegistryFile, generatedManifestFile, pageIds, }); console.info( - `[share-links] ${result.added.length} added, ${result.unchangedCount} retained, ${result.generatedRegistryChanged ? "generated registry updated" : "generated registry unchanged"}, ${result.manifestChanged ? "manifest updated" : "manifest unchanged"}`, + `[share-links] ${result.generatedCount} generated, ${result.generatedRegistryChanged ? "registry updated" : "registry unchanged"}, ${result.manifestChanged ? "manifest updated" : "manifest unchanged"}`, ); diff --git a/.vitepress/utilities/share-link-registry-files.ts b/.vitepress/utilities/share-link-registry-files.ts index 48c1c4e..358532a 100644 --- a/.vitepress/utilities/share-link-registry-files.ts +++ b/.vitepress/utilities/share-link-registry-files.ts @@ -7,11 +7,9 @@ import { } from "../shared/share-link-contract.ts"; import { createShareLinkIndex, - isShareId, - prepareShareLinks, + generateShareLinks, resolveCanonicalHref, - validateShareLinkRegistry, - type PrepareShareLinksResult, + type GenerateShareLinksResult, type ShareId, type ShareLinkRegistry, } from "./share-links.ts"; @@ -22,24 +20,21 @@ export { } from "../shared/share-link-contract.ts"; export interface ShareLinkFilePaths { - baseRegistryFile: string; generatedRegistryFile: string; generatedManifestFile: string; } export interface PrepareShareLinkFilesInput extends ShareLinkFilePaths { pageIds: Iterable; - readTextIfExists?: (file: string) => Promise; } -export interface PrepareShareLinkFilesResult extends PrepareShareLinksResult { +export interface PrepareShareLinkFilesResult extends GenerateShareLinksResult { generatedRegistryChanged: boolean; manifestChanged: boolean; manifest: ShareLinkManifest; } export interface CheckShareLinkFilesInput { - registryFile: string; pageIds: Iterable; } @@ -83,14 +78,13 @@ async function readTextIfExists(file: string): Promise { export async function loadShareLinkRegistry( registryFile: string, ): Promise { - return loadShareLinkRegistryFromReader(registryFile, readTextIfExists); + return loadShareLinkRegistryFromReader(registryFile); } async function loadShareLinkRegistryFromReader( registryFile: string, - readText: (file: string) => Promise, ): Promise { - const content = await readText(registryFile); + const content = await readTextIfExists(registryFile); if (content === undefined) return defaultRegistry(); @@ -196,27 +190,9 @@ export async function prepareShareLinkFiles( const releaseLock = await acquireRegistryLock(input.generatedRegistryFile); try { - const readText = input.readTextIfExists ?? readTextIfExists; - const initialRegistryContent = await readText(input.baseRegistryFile); - const initialRegistryHash = hashContent(initialRegistryContent ?? ""); - const existingRegistry = await loadShareLinkRegistryFromReader( - input.baseRegistryFile, - readText, - ); - const prepared = prepareShareLinks({ - registry: existingRegistry, - pageIds: input.pageIds, - }); + const prepared = generateShareLinks({ pageIds: input.pageIds }); const registryContent = serializeJson(prepared.registry); - const beforeWriteRegistryContent = await readText(input.baseRegistryFile); - - if (hashContent(beforeWriteRegistryContent ?? "") !== initialRegistryHash) { - throw new Error( - `基础分享注册表在 prepare 期间已被修改,拒绝生成:${input.baseRegistryFile}`, - ); - } - const generatedRegistryChanged = await writeTextAtomicallyIfChanged( input.generatedRegistryFile, registryContent, @@ -244,20 +220,6 @@ export async function prepareShareLinkFiles( export async function checkShareLinkFiles( input: CheckShareLinkFilesInput, ): Promise { - const registry = await loadShareLinkRegistry(input.registryFile); - validateShareLinkRegistry(registry, input.pageIds); - - let activeCount = 0; - let goneCount = 0; - - for (const [id, record] of Object.entries(registry.records)) { - if (!isShareId(id)) { - throw new Error(`非法分享 ID:${id}`); - } - - if (record.status === "active") activeCount += 1; - if (record.status === "gone") goneCount += 1; - } - - return { activeCount, goneCount }; + const { generatedCount } = generateShareLinks({ pageIds: input.pageIds }); + return { activeCount: generatedCount, goneCount: 0 }; } diff --git a/.vitepress/utilities/share-links.ts b/.vitepress/utilities/share-links.ts index e1ddea0..47aaa7c 100644 --- a/.vitepress/utilities/share-links.ts +++ b/.vitepress/utilities/share-links.ts @@ -13,7 +13,6 @@ export { isShareId, type ShareId, } from "../shared/share-link-contract.ts"; -export const SHARE_ID_MAX_ATTEMPTS = 1024; const SHARE_ID_SPACE = BigInt(SHARE_ID_ALPHABET.length) ** BigInt(SHARE_ID_LENGTH); export type ShareLinkStatus = "active" | "gone"; @@ -33,10 +32,9 @@ export interface ShareLinkIndex { byPageId: ReadonlyMap; } -export interface PrepareShareLinksResult { +export interface GenerateShareLinksResult { registry: ShareLinkRegistry; - added: ReadonlyArray<{ id: ShareId; pageId: string }>; - unchangedCount: number; + generatedCount: number; } export function normalizeSharePageId(pageId: string): string { @@ -59,12 +57,6 @@ function compareStrings(left: string, right: string): number { return 0; } -function assertSafeAttempt(attempt: number): void { - if (!Number.isSafeInteger(attempt) || attempt < 0) { - throw new Error(`短 ID attempt 必须是非负安全整数:${attempt}`); - } -} - function encodeFixedBase31(value: bigint): ShareId { const radix = BigInt(SHARE_ID_ALPHABET.length); const output = new Array(SHARE_ID_LENGTH); @@ -82,15 +74,12 @@ function encodeFixedBase31(value: bigint): ShareId { return output.join(""); } -export function generateStaticShareId(input: { - pageId: string; - attempt: number; -}): ShareId { - const pageId = normalizeSharePageId(input.pageId); - assertSafeAttempt(input.attempt); +export function generateStaticShareId(pageIdInput: string): ShareId { + const pageId = normalizeSharePageId(pageIdInput); const digest = createHash("sha256") - .update(`yuufrag-share-v1\0${pageId}\0${input.attempt}`, "utf8") + // Keep the former attempt=0 payload so existing paths retain their IDs. + .update(`yuufrag-share-v1\0${pageId}\0${0}`, "utf8") .digest("hex"); const value = BigInt(`0x${digest}`) % SHARE_ID_SPACE; @@ -228,51 +217,23 @@ function createRegistryCopy(registry: ShareLinkRegistry): ShareLinkRegistry { return { version: 1, records }; } -export function prepareShareLinks(input: { - registry: ShareLinkRegistry; +export function generateShareLinks(input: { pageIds: Iterable; -}): PrepareShareLinksResult { +}): GenerateShareLinksResult { const currentPageIds = normalizeCurrentPageIds(input.pageIds); - const existingIndex = createShareLinkIndex(input.registry); - const currentPageIdSet = new Set(currentPageIds); - - for (const [pageId, shareId] of existingIndex.byPageId) { - if (!currentPageIdSet.has(pageId)) { - throw new Error( - `active 分享 ID 指向不存在页面:${shareId} -> ${pageId}`, - ); - } - } - - const registry = createRegistryCopy(input.registry); - const claimedIds = new Set(existingIndex.byId.keys()); - const assignedPageIds = new Set(existingIndex.byPageId.keys()); - const added: Array<{ id: ShareId; pageId: string }> = []; + const registry: ShareLinkRegistry = { version: 1, records: {} }; for (const pageId of currentPageIds) { - if (assignedPageIds.has(pageId)) continue; - - let shareId: ShareId | undefined; - - for (let attempt = 0; attempt < SHARE_ID_MAX_ATTEMPTS; attempt += 1) { - const candidate = generateStaticShareId({ pageId, attempt }); - - if (claimedIds.has(candidate)) continue; - - shareId = candidate; - break; - } + const shareId = generateStaticShareId(pageId); + const existingRecord = registry.records[shareId]; - if (shareId === undefined) { + if (existingRecord !== undefined) { throw new Error( - `短 ID 分配超过最大尝试次数:${pageId}(${SHARE_ID_MAX_ATTEMPTS})`, + `确定性短 ID 碰撞:${shareId}\n- ${existingRecord.pageId}\n- ${pageId}`, ); } - claimedIds.add(shareId); - assignedPageIds.add(pageId); registry.records[shareId] = { pageId, status: "active" }; - added.push({ id: shareId, pageId }); } const sortedRegistry = createRegistryCopy(registry); @@ -280,8 +241,7 @@ export function prepareShareLinks(input: { return { registry: sortedRegistry, - added, - unchangedCount: currentPageIds.length - added.length, + generatedCount: currentPageIds.length, }; } diff --git a/tests/unit/share-link-registry-files.test.ts b/tests/unit/share-link-registry-files.test.ts index 6339139..547d5a1 100644 --- a/tests/unit/share-link-registry-files.test.ts +++ b/tests/unit/share-link-registry-files.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -10,11 +9,10 @@ import { loadShareLinkRegistry, prepareShareLinkFiles, } from "../../.vitepress/utilities/share-link-registry-files.ts"; +import { generateStaticShareId } from "../../.vitepress/utilities/share-links.ts"; async function withTemporaryFiles( callback: (input: { - root: string; - baseRegistryFile: string; generatedRegistryFile: string; manifestFile: string; }) => Promise, @@ -23,8 +21,6 @@ async function withTemporaryFiles( try { await callback({ - root, - baseRegistryFile: path.join(root, "data/share-links.json"), generatedRegistryFile: path.join(root, "generated/share-links.json"), manifestFile: path.join(root, "generated/share-links-manifest.json"), }); @@ -33,32 +29,25 @@ async function withTemporaryFiles( } } -function hash(value: string): string { - return createHash("sha256").update(value, "utf8").digest("hex"); -} - -test("prepare derives missing ids without modifying the base registry", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { - const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"]; - const baseRegistryContent = `${JSON.stringify( - { +test("prepare rebuilds the registry from current pages only", async () => { + await withTemporaryFiles(async ({ generatedRegistryFile, manifestFile }) => { + await fs.mkdir(path.dirname(generatedRegistryFile), { recursive: true }); + await fs.writeFile( + generatedRegistryFile, + JSON.stringify({ version: 1, records: { - k7m2p9x4qd: { pageId: "posts/A.md", status: "active" }, + k7m2p9x4qd: { + pageId: "posts/已删除.md", + status: "active", + }, }, - }, - null, - 2, - )}\n`; - await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true }); - await fs.writeFile(baseRegistryFile, baseRegistryContent, "utf8"); + }), + "utf8", + ); - const first = await prepareShareLinkFiles({ - baseRegistryFile, + const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"]; + const result = await prepareShareLinkFiles({ generatedRegistryFile, generatedManifestFile: manifestFile, pageIds, @@ -69,40 +58,30 @@ test("prepare derives missing ids without modifying the base registry", async () shortOrigin: string; }; - assert.equal(first.added.length, 2); - assert.equal(first.unchangedCount, 1); - assert.equal(first.generatedRegistryChanged, true); - assert.equal(first.manifestChanged, true); + assert.equal(result.generatedCount, 3); + assert.equal(result.generatedRegistryChanged, true); assert.equal(Object.keys(registry.records).length, 3); + assert.equal(registry.records.k7m2p9x4qd, undefined); + assert.equal( + registry.records[generateStaticShareId("posts/A.md")].pageId, + "posts/A.md", + ); assert.equal(Object.keys(manifest.byCanonicalPath).length, 3); assert.equal(manifest.shortOrigin, "https://yuufrag.machillka.com"); - assert.ok(manifest.byCanonicalPath["/posts/中文"]); - assert.equal(registry.records.k7m2p9x4qd.pageId, "posts/A.md"); - assert.equal(await fs.readFile(baseRegistryFile, "utf8"), baseRegistryContent); }); }); test("repeated prepare is stable and does not rewrite unchanged files", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { - const pageIds = ["posts/A.md"]; - await prepareShareLinkFiles({ - baseRegistryFile, + await withTemporaryFiles(async ({ generatedRegistryFile, manifestFile }) => { + const input = { generatedRegistryFile, generatedManifestFile: manifestFile, - pageIds, - }); + pageIds: ["posts/A.md"], + }; + await prepareShareLinkFiles(input); const registryBefore = await fs.readFile(generatedRegistryFile, "utf8"); const manifestBefore = await fs.readFile(manifestFile, "utf8"); - const second = await prepareShareLinkFiles({ - baseRegistryFile, - generatedRegistryFile, - generatedManifestFile: manifestFile, - pageIds, - }); + const second = await prepareShareLinkFiles(input); assert.equal(second.generatedRegistryChanged, false); assert.equal(second.manifestChanged, false); @@ -111,38 +90,19 @@ test("repeated prepare is stable and does not rewrite unchanged files", async () }); }); -test("check is read-only", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { - const pageIds = ["posts/A.md"]; - await prepareShareLinkFiles({ - baseRegistryFile, - generatedRegistryFile, - generatedManifestFile: manifestFile, - pageIds, - }); - const before = await fs.readFile(generatedRegistryFile, "utf8"); - - const result = await checkShareLinkFiles({ - registryFile: generatedRegistryFile, - pageIds, - }); +test("check validates deterministic generation without writing files", async () => { + await withTemporaryFiles(async ({ generatedRegistryFile, manifestFile }) => { + const result = await checkShareLinkFiles({ pageIds: ["posts/A.md"] }); assert.equal(result.activeCount, 1); assert.equal(result.goneCount, 0); - assert.equal(await fs.readFile(generatedRegistryFile, "utf8"), before); + await assert.rejects(() => fs.access(generatedRegistryFile)); + await assert.rejects(() => fs.access(manifestFile)); }); }); -test("prepare refuses to run while the registry lock exists", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { +test("prepare refuses to run while the generated registry lock exists", async () => { + await withTemporaryFiles(async ({ generatedRegistryFile, manifestFile }) => { await fs.mkdir(path.dirname(generatedRegistryFile), { recursive: true }); await fs.writeFile( `${generatedRegistryFile}.lock`, @@ -153,108 +113,12 @@ test("prepare refuses to run while the registry lock exists", async () => { await assert.rejects( () => prepareShareLinkFiles({ - baseRegistryFile, generatedRegistryFile, generatedManifestFile: manifestFile, pageIds: ["posts/A.md"], }), /分享注册表正被另一个进程更新/, ); - await assert.rejects(() => fs.access(baseRegistryFile)); - await assert.rejects(() => fs.access(generatedRegistryFile)); - }); -}); - -test("prepare refuses to replace a registry changed outside its lock", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { - await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true }); - await fs.writeFile( - baseRegistryFile, - JSON.stringify({ version: 1, records: {} }), - "utf8", - ); - - const changedContent = JSON.stringify({ - version: 1, - records: { - k7m2p9x4qd: { pageId: "posts/A.md", status: "active" }, - }, - }); - const originalReadFile = fs.readFile.bind(fs); - let registryReadCount = 0; - - const readTextIfExists = async (file: string): Promise => { - if (file === baseRegistryFile) { - registryReadCount += 1; - if (registryReadCount === 3) { - await fs.writeFile(baseRegistryFile, changedContent, "utf8"); - } - } - - try { - return await originalReadFile(file, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return undefined; - } - - throw error; - } - }; - - await assert.rejects( - () => - prepareShareLinkFiles({ - baseRegistryFile, - generatedRegistryFile, - generatedManifestFile: manifestFile, - pageIds: ["posts/A.md"], - readTextIfExists, - }), - /基础分享注册表在 prepare 期间已被修改/, - ); - assert.equal( - hash(await fs.readFile(baseRegistryFile, "utf8")), - hash(changedContent), - ); - await assert.rejects(() => fs.access(generatedRegistryFile)); - }); -}); - -test("active records that no longer point to a page fail before writing", async () => { - await withTemporaryFiles(async ({ - baseRegistryFile, - generatedRegistryFile, - manifestFile, - }) => { - await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true }); - await fs.writeFile( - baseRegistryFile, - JSON.stringify({ - version: 1, - records: { - k7m2p9x4qd: { pageId: "posts/旧页面.md", status: "active" }, - }, - }), - "utf8", - ); - const before = await fs.readFile(baseRegistryFile, "utf8"); - - await assert.rejects( - () => - prepareShareLinkFiles({ - baseRegistryFile, - generatedRegistryFile, - generatedManifestFile: manifestFile, - pageIds: ["posts/新页面.md"], - }), - /active 分享 ID 指向不存在页面:k7m2p9x4qd -> posts\/旧页面\.md/, - ); - assert.equal(await fs.readFile(baseRegistryFile, "utf8"), before); await assert.rejects(() => fs.access(generatedRegistryFile)); }); }); diff --git a/tests/unit/share-links.test.ts b/tests/unit/share-links.test.ts index acbef04..27f2608 100644 --- a/tests/unit/share-links.test.ts +++ b/tests/unit/share-links.test.ts @@ -7,10 +7,10 @@ import { SHARE_ID_ALPHABET, SHARE_ID_LENGTH, createShareLinkIndex, + generateShareLinks, generateStaticShareId, isShareId, normalizeSharePageId, - prepareShareLinks, resolveCanonicalHref, validateShareLinkRegistry, type ShareLinkRegistry, @@ -44,51 +44,41 @@ test("accepts only the fixed share id alphabet and length", () => { }); test("generates deterministic fixed-width share ids", () => { - const input = { pageId: "posts/软件工程/设计.md", attempt: 0 }; - const first = generateStaticShareId(input); - const second = generateStaticShareId(input); + const pageId = "posts/软件工程/设计.md"; + const first = generateStaticShareId(pageId); + const second = generateStaticShareId(pageId); assert.equal(first, second); assert.equal(first.length, SHARE_ID_LENGTH); assert.equal(isShareId(first), true); - assert.notEqual( - first, - generateStaticShareId({ ...input, attempt: 1 }), + assert.notEqual(first, generateStaticShareId("posts/软件工程/其他.md")); + assert.equal( + generateStaticShareId( + "posts/游戏开发/ChikaEngine/Job System/JobStorage.md", + ), + "xjhcvhed3c", ); }); -test("prepares missing pages in stable order without mutating input", () => { - const input = registry({}); +test("generates the same registry regardless of page scan order", () => { const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"]; - const first = prepareShareLinks({ registry: input, pageIds }); - const second = prepareShareLinks({ - registry: input, - pageIds: [...pageIds].reverse(), - }); + const first = generateShareLinks({ pageIds }); + const second = generateShareLinks({ pageIds: [...pageIds].reverse() }); assert.deepEqual(first.registry, second.registry); - assert.deepEqual(input, registry({})); - assert.equal(first.added.length, 3); - assert.equal(first.unchangedCount, 0); + assert.equal(first.generatedCount, 3); assert.doesNotThrow(() => validateShareLinkRegistry(first.registry, pageIds)); }); -test("retries a claimed candidate instead of overwriting it", () => { - const pageId = "posts/碰撞.md"; - const firstCandidate = generateStaticShareId({ pageId, attempt: 0 }); - const nextCandidate = generateStaticShareId({ pageId, attempt: 1 }); - const input = registry({ - [firstCandidate]: { pageId: "posts/已有.md", status: "active" }, - }); +test("removing a page does not change another page's deterministic id", () => { + const pageId = "posts/B.md"; + const before = generateShareLinks({ pageIds: ["posts/A.md", pageId] }); + const after = generateShareLinks({ pageIds: [pageId] }); - const result = prepareShareLinks({ - registry: input, - pageIds: ["posts/已有.md", pageId], - }); - - assert.equal(result.registry.records[firstCandidate].pageId, "posts/已有.md"); - assert.equal(result.registry.records[nextCandidate].pageId, pageId); - assert.deepEqual(result.added, [{ id: nextCandidate, pageId }]); + assert.equal( + createShareLinkIndex(before.registry).byPageId.get(pageId), + createShareLinkIndex(after.registry).byPageId.get(pageId), + ); }); test("rejects multiple active ids for one page", () => { @@ -122,15 +112,10 @@ test("allows gone ids without allowing them to resolve", () => { const input = registry({ [shareId]: { pageId: "posts/已删除.md", status: "gone" }, }); - const result = prepareShareLinks({ - registry: input, - pageIds: ["posts/当前.md"], - }); - const index = createShareLinkIndex(result.registry); + const index = createShareLinkIndex(input); assert.equal(resolveCanonicalHref(shareId, index), undefined); assert.equal(index.byId.get(shareId)?.status, "gone"); - assert.equal(result.added.length, 1); }); test("resolves active ids through the canonical route utility", () => { @@ -156,8 +141,8 @@ test("current posts inventory can be assigned unique active ids", async () => { cwd: projectRoot, onlyFiles: true, }); - const result = prepareShareLinks({ registry: registry({}), pageIds }); + const result = generateShareLinks({ pageIds }); - assert.equal(result.added.length, pageIds.length); + assert.equal(result.generatedCount, pageIds.length); assert.doesNotThrow(() => validateShareLinkRegistry(result.registry, pageIds)); });