diff --git a/apps/server/src/integrations/import/utils/confluence-anchor-id.spec.ts b/apps/server/src/integrations/import/utils/confluence-anchor-id.spec.ts new file mode 100644 index 000000000..c13315086 --- /dev/null +++ b/apps/server/src/integrations/import/utils/confluence-anchor-id.spec.ts @@ -0,0 +1,32 @@ +import { nodeIdFromConfluenceAnchor } from './confluence-anchor-id'; + +describe('nodeIdFromConfluenceAnchor', () => { + it('is deterministic for the same (pageId, anchorName)', () => { + const a = nodeIdFromConfluenceAnchor('page-1', 'My Anchor'); + const b = nodeIdFromConfluenceAnchor('page-1', 'My Anchor'); + expect(a).toBe(b); + }); + + it('returns different ids when the anchor name differs', () => { + const a = nodeIdFromConfluenceAnchor('page-1', 'one'); + const b = nodeIdFromConfluenceAnchor('page-1', 'two'); + expect(a).not.toBe(b); + }); + + it('returns different ids when the pageId differs', () => { + const a = nodeIdFromConfluenceAnchor('page-1', 'same'); + const b = nodeIdFromConfluenceAnchor('page-2', 'same'); + expect(a).not.toBe(b); + }); + + it('returns exactly 12 lowercase a-z characters', () => { + const id = nodeIdFromConfluenceAnchor('page-xyz', 'Section ยท 1'); + expect(id).toHaveLength(12); + expect(id).toMatch(/^[a-z]{12}$/); + }); + + it('treats an empty anchor name as a valid input', () => { + const id = nodeIdFromConfluenceAnchor('page-1', ''); + expect(id).toMatch(/^[a-z]{12}$/); + }); +}); diff --git a/apps/server/src/integrations/import/utils/confluence-anchor-id.ts b/apps/server/src/integrations/import/utils/confluence-anchor-id.ts new file mode 100644 index 000000000..cee2ac3e4 --- /dev/null +++ b/apps/server/src/integrations/import/utils/confluence-anchor-id.ts @@ -0,0 +1,28 @@ +import { createHash } from 'crypto'; + +// Matches the alphabet used by generateNodeId() in +// packages/editor-ext/src/lib/utils.ts (customAlphabet from nanoid). +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; +const NODE_ID_LENGTH = 12; + +/** + * Returns a deterministic 12-character nodeId for a Confluence anchor. + * The same (pageId, anchorName) pair always produces the same result, so + * cross-page anchor links resolve to the anchor target without a + * precomputed map. The output uses the same alphabet and length as + * generateNodeId() from @docmost/editor-ext, so it is interchangeable + * with editor-generated nodeIds. + */ +export function nodeIdFromConfluenceAnchor( + pageId: string, + anchorName: string, +): string { + const digest = createHash('sha256') + .update(`${pageId}#${anchorName}`) + .digest(); + let out = ''; + for (let i = 0; i < NODE_ID_LENGTH; i++) { + out += ALPHABET[digest[i] % ALPHABET.length]; + } + return out; +}