diff --git a/shared/base.types.ts b/shared/base.types.ts index 4819076..a726f44 100644 --- a/shared/base.types.ts +++ b/shared/base.types.ts @@ -16,6 +16,12 @@ export interface EntryMetaBase { lastModified?: string; hidden?: boolean; sticky?: boolean; + /** + * Optional manual sort key. Entries with a sortKey come first, + * sorted ascending. Entries without a sortKey fall back to the + * default order (sticky, then published date). + */ + sortKey?: number; } export interface EntryBase { diff --git a/shared/base.utils.spec.ts b/shared/base.utils.spec.ts index fba7300..b9ad2d9 100644 --- a/shared/base.utils.spec.ts +++ b/shared/base.utils.spec.ts @@ -416,5 +416,60 @@ describe('base.utils', () => { expect(result[1].slug).toBe('mmm-post'); expect(result[2].slug).toBe('aaa-post'); }); + + it('should sort entries with sortKey ascending before entries without sortKey', async () => { + const entries = [ + { dir: 'no-key-new', date: '2025-01-01', sortKey: undefined as number | undefined }, + { dir: 'key-3', date: '2020-01-01', sortKey: 3 }, + { dir: 'key-1', date: '2020-01-01', sortKey: 1 }, + { dir: 'no-key-old', date: '2020-06-01', sortKey: undefined }, + { dir: 'key-2', date: '2020-01-01', sortKey: 2 }, + ]; + + for (const e of entries) { + const entryDir = path.join(testDir, e.dir); + await fs.mkdir(entryDir, { recursive: true }); + const sortKeyLine = typeof e.sortKey === 'number' ? `sortKey: ${e.sortKey}\n` : ''; + await fs.writeFile( + path.join(entryDir, 'README.md'), + `---\ntitle: ${e.dir}\npublished: ${e.date}\n${sortKeyLine}---\nContent` + ); + } + + const result = await getEntryList(testDir, 'https://example.com/'); + + expect(result).toHaveLength(5); + // sortKey entries first, ascending + expect(result[0].slug).toBe('key-1'); + expect(result[1].slug).toBe('key-2'); + expect(result[2].slug).toBe('key-3'); + // Then entries without sortKey, by published date (newest first) + expect(result[3].slug).toBe('no-key-new'); + expect(result[4].slug).toBe('no-key-old'); + }); + + it('should rank sortKey above sticky', async () => { + const entries = [ + { dir: 'sticky-no-key', date: '2025-01-01', sortKey: undefined as number | undefined, sticky: true }, + { dir: 'key-5', date: '2020-01-01', sortKey: 5, sticky: false }, + ]; + + for (const e of entries) { + const entryDir = path.join(testDir, e.dir); + await fs.mkdir(entryDir, { recursive: true }); + const sortKeyLine = typeof e.sortKey === 'number' ? `sortKey: ${e.sortKey}\n` : ''; + const stickyLine = e.sticky ? 'sticky: true\n' : ''; + await fs.writeFile( + path.join(entryDir, 'README.md'), + `---\ntitle: ${e.dir}\npublished: ${e.date}\n${sortKeyLine}${stickyLine}---\nContent` + ); + } + + const result = await getEntryList(testDir, 'https://example.com/'); + + expect(result).toHaveLength(2); + expect(result[0].slug).toBe('key-5'); + expect(result[1].slug).toBe('sticky-no-key'); + }); }); }); diff --git a/shared/base.utils.ts b/shared/base.utils.ts index b6d41e5..ba44b72 100644 --- a/shared/base.utils.ts +++ b/shared/base.utils.ts @@ -55,20 +55,36 @@ export async function copyEntriesToDist( } /** - * Compare two entries for sorting (newest first, sticky on top). + * Compare two entries for sorting. + * + * Order of criteria: + * 1. sortKey: entries with a sortKey come first, sorted ascending. + * 2. Sticky: sticky entries come before non-sticky. + * 3. Published date (newest first). + * 4. Slug (descending) as tiebreaker. + * * @returns negative if a comes first, positive if b comes first */ function compareEntries(a: EntryBase, b: EntryBase): number { - // 1. Sticky entries first (treat undefined and false the same) + // 1. sortKey: entries with a sortKey come first, sorted ascending + const aHasKey = typeof a.meta.sortKey === 'number'; + const bHasKey = typeof b.meta.sortKey === 'number'; + if (aHasKey && bHasKey) { + return a.meta.sortKey! - b.meta.sortKey!; + } + if (aHasKey !== bHasKey) { + return aHasKey ? -1 : 1; + } + // 2. Sticky entries first (treat undefined and false the same) const aSticky = !!a.meta.sticky; const bSticky = !!b.meta.sticky; if (aSticky !== bSticky) { return aSticky ? -1 : 1; } - // 2. Then by date (newest first) - ISO 8601 strings sort lexicographically + // 3. Then by date (newest first) - ISO 8601 strings sort lexicographically const dateCompare = b.meta.published.localeCompare(a.meta.published); if (dateCompare !== 0) return dateCompare; - // 3. Slug as tiebreaker (descending) + // 4. Slug as tiebreaker (descending) return b.slug.localeCompare(a.slug); }