From 508f73a33f7e14e698fc221fbcc82a404e96f0f9 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 27 Jul 2026 20:23:31 +0200 Subject: [PATCH] feat: replace CJS require with static imports, add comprehensive layout system tests --- .turbo/turbo-test.log | 699 +++++++++++++++----- package.json | 2 +- quartz/components/frames/frames.test.ts | 41 ++ quartz/components/registry.test.ts | 140 ++++ quartz/components/registry.ts | 10 +- quartz/plugins/loader/config-loader.test.ts | 300 +++++++++ quartz/plugins/loader/config-loader.ts | 16 +- quartz/plugins/pageTypes/dispatcher.test.ts | 68 +- quartz/plugins/pageTypes/dispatcher.ts | 3 +- 9 files changed, 1096 insertions(+), 183 deletions(-) create mode 100644 quartz/components/frames/frames.test.ts create mode 100644 quartz/components/registry.test.ts create mode 100644 quartz/plugins/loader/config-loader.test.ts diff --git a/.turbo/turbo-test.log b/.turbo/turbo-test.log index e4c3099..2c4ce4d 100644 --- a/.turbo/turbo-test.log +++ b/.turbo/turbo-test.log @@ -7,74 +7,232 @@ TAP version 13 # Subtest: creates a symlink on success ok 1 - creates a symlink on success --- - duration_ms: 1.794289 + duration_ms: 10.347298 type: 'test' ... # Subtest: silently succeeds when link already exists (EEXIST) ok 2 - silently succeeds when link already exists (EEXIST) --- - duration_ms: 0.881952 + duration_ms: 1.222197 type: 'test' ... # Subtest: re-throws non-EPERM errors ok 3 - re-throws non-EPERM errors --- - duration_ms: 9.41571 + duration_ms: 1.180418 type: 'test' ... # Subtest: falls back to junction on Windows EPERM ok 4 - falls back to junction on Windows EPERM --- - duration_ms: 1.366338 + duration_ms: 1.189624 type: 'test' ... # Subtest: falls back to copy when both symlink and junction fail on Windows ok 5 - falls back to copy when both symlink and junction fail on Windows --- - duration_ms: 1.424093 + duration_ms: 1.322496 type: 'test' ... # Subtest: does not fall back on EPERM when not on Windows ok 6 - does not fall back on EPERM when not on Windows --- - duration_ms: 0.661469 + duration_ms: 0.863018 type: 'test' ... 1..6 ok 1 - symlinkOrCopySync --- - duration_ms: 16.577591 + duration_ms: 17.312714 type: 'suite' ... # Subtest: symlinkOrCopy # Subtest: creates a symlink on success ok 1 - creates a symlink on success --- - duration_ms: 6.646098 + duration_ms: 7.81563 type: 'test' ... # Subtest: silently succeeds when link already exists (EEXIST) ok 2 - silently succeeds when link already exists (EEXIST) --- - duration_ms: 3.596443 + duration_ms: 3.541694 type: 'test' ... # Subtest: falls back to copy when both symlink and junction fail on Windows ok 3 - falls back to copy when both symlink and junction fail on Windows --- - duration_ms: 2.929394 + duration_ms: 3.69308 type: 'test' ... # Subtest: does not fall back on EPERM when not on Windows ok 4 - does not fall back on EPERM when not on Windows --- - duration_ms: 0.814288 + duration_ms: 1.121286 type: 'test' ... 1..4 ok 2 - symlinkOrCopy --- - duration_ms: 14.388319 + duration_ms: 16.678677 + type: 'suite' + ... +# Unknown page frame "nonexistent", falling back to "default". Available frames: default, full-width, minimal +# Unknown page frame "totally-unknown", falling back to "default". Available frames: default, full-width, minimal, custom-test-frame +# Subtest: resolveFrame + # Subtest: returns DefaultFrame for undefined + ok 1 - returns DefaultFrame for undefined + --- + duration_ms: 0.809538 + type: 'test' + ... + # Subtest: returns DefaultFrame for 'default' + ok 2 - returns DefaultFrame for 'default' + --- + duration_ms: 0.152167 + type: 'test' + ... + # Subtest: returns named built-in frame + ok 3 - returns named built-in frame + --- + duration_ms: 0.199626 + type: 'test' + ... + # Subtest: returns DefaultFrame for unknown frame name + ok 4 - returns DefaultFrame for unknown frame name + --- + duration_ms: 0.701093 + type: 'test' + ... + # Subtest: plugin-registered frame takes priority + ok 5 - plugin-registered frame takes priority + --- + duration_ms: 0.167897 + type: 'test' + ... + # Subtest: returns DefaultFrame for unknown name even with plugin frames registered + ok 6 - returns DefaultFrame for unknown name even with plugin frames registered + --- + duration_ms: 0.244692 + type: 'test' + ... + 1..6 +ok 3 - resolveFrame + --- + duration_ms: 3.307502 + type: 'suite' + ... +# Component "foo" is being overwritten by src2 +# Subtest: register and get + # Subtest: registers a component and retrieves it by name + ok 1 - registers a component and retrieves it by name + --- + duration_ms: 0.64098 + type: 'test' + ... + # Subtest: returns undefined for unregistered names + ok 2 - returns undefined for unregistered names + --- + duration_ms: 0.279297 + type: 'test' + ... + # Subtest: overwrites component from different source + ok 3 - overwrites component from different source + --- + duration_ms: 0.72562 + type: 'test' + ... + 1..3 +ok 4 - register and get + --- + duration_ms: 2.346599 + type: 'suite' + ... +# Subtest: instantiate + # Subtest: returns a component instance from a constructor + ok 1 - returns a component instance from a constructor + --- + duration_ms: 1.233718 + type: 'test' + ... + # Subtest: caches instances by constructor + options + ok 2 - caches instances by constructor + options + --- + duration_ms: 0.33407 + type: 'test' + ... + # Subtest: different options produce different instances + ok 3 - different options produce different instances + --- + duration_ms: 0.175021 + type: 'test' + ... + # Subtest: undefined options and no-arg call produce same cache key + ok 4 - undefined options and no-arg call produce same cache key + --- + duration_ms: 0.108114 + type: 'test' + ... + 1..4 +ok 5 - instantiate + --- + duration_ms: 2.19369 + type: 'suite' + ... +# Subtest: getAllComponents + # Subtest: deduplicates components registered under multiple names + ok 1 - deduplicates components registered under multiple names + --- + duration_ms: 0.246746 + type: 'test' + ... + # Subtest: reuses cached instance from prior instantiate call + ok 2 - reuses cached instance from prior instantiate call + --- + duration_ms: 0.212641 + type: 'test' + ... + # Subtest: skips components that fail to instantiate + ok 3 - skips components that fail to instantiate + --- + duration_ms: 0.557683 + type: 'test' + ... + 1..3 +ok 6 - getAllComponents + --- + duration_ms: 1.152325 + type: 'suite' + ... +# Subtest: setOptionOverrides and cache invalidation + # Subtest: stores and retrieves option overrides + ok 1 - stores and retrieves option overrides + --- + duration_ms: 0.211429 + type: 'test' + ... + # Subtest: merges with existing overrides + ok 2 - merges with existing overrides + --- + duration_ms: 0.111962 + type: 'test' + ... + # Subtest: clears instance cache when overrides change + ok 3 - clears instance cache when overrides change + --- + duration_ms: 0.111701 + type: 'test' + ... + # Subtest: ignores empty or undefined overrides + ok 4 - ignores empty or undefined overrides + --- + duration_ms: 0.084078 + type: 'test' + ... + 1..4 +ok 7 - setOptionOverrides and cache invalidation + --- + duration_ms: 0.623196 type: 'suite' ... # Warning: Skipping circular transclusion: pageA -> pageA @@ -83,112 +241,112 @@ ok 2 - symlinkOrCopy # Subtest: resolves a single page transclusion ok 1 - resolves a single page transclusion --- - duration_ms: 1.26955 + duration_ms: 0.949652 type: 'test' ... # Subtest: allows the same page to be embedded twice as siblings ok 2 - allows the same page to be embedded twice as siblings --- - duration_ms: 0.22418 + duration_ms: 0.176373 type: 'test' ... # Subtest: allows different sections of the same page to be embedded ok 3 - allows different sections of the same page to be embedded --- - duration_ms: 0.37216 + duration_ms: 0.341846 type: 'test' ... # Subtest: detects actual circular transclusion (A -> B -> A) ok 4 - detects actual circular transclusion (A -> B -> A) --- - duration_ms: 0.965395 + duration_ms: 0.754284 type: 'test' ... # Subtest: self-referencing transclusion is blocked ok 5 - self-referencing transclusion is blocked --- - duration_ms: 0.169319 + duration_ms: 0.192293 type: 'test' ... 1..5 -ok 3 - renderTranscludes +ok 8 - renderTranscludes --- - duration_ms: 3.861318 + duration_ms: 3.161576 type: 'suite' ... # Subtest: pageResources # Subtest: uses baseDir prefix for resource paths in production mode ok 1 - uses baseDir prefix for resource paths in production mode --- - duration_ms: 0.415289 + duration_ms: 0.349429 type: 'test' ... # Subtest: omits subpath prefix when baseDir is empty (serve mode) ok 2 - omits subpath prefix when baseDir is empty (serve mode) --- - duration_ms: 0.272739 + duration_ms: 0.203203 type: 'test' ... # Subtest: contentIndex path reflects baseDir ok 3 - contentIndex path reflects baseDir --- - duration_ms: 0.296542 + duration_ms: 0.315976 type: 'test' ... 1..3 -ok 4 - pageResources +ok 9 - pageResources --- - duration_ms: 1.250977 + duration_ms: 1.111928 type: 'suite' ... # Subtest: showPopover on cache-hit with hash # Subtest: does not reference any lexical popoverInner from an outer scope ok 1 - does not reference any lexical popoverInner from an outer scope --- - duration_ms: 1.304254 + duration_ms: 1.210334 type: 'test' ... # Subtest: skips scroll when hash is empty ok 2 - skips scroll when hash is empty --- - duration_ms: 0.276135 + duration_ms: 0.225175 type: 'test' ... # Subtest: skips scroll when heading is not found ok 3 - skips scroll when heading is not found --- - duration_ms: 0.200547 + duration_ms: 0.168037 type: 'test' ... # Subtest: decodes percent-encoded fragments when building the selector ok 4 - decodes percent-encoded fragments when building the selector --- - duration_ms: 0.281885 + duration_ms: 0.220486 type: 'test' ... 1..4 -ok 5 - showPopover on cache-hit with hash +ok 10 - showPopover on cache-hit with hash --- - duration_ms: 3.103934 + duration_ms: 2.739089 type: 'suite' ... # Subtest: buggy showPopover (lexical-capture pattern) regression guard # Subtest: accessing a capture-before-declaration variable throws ReferenceError (TDZ simulation) ok 1 - accessing a capture-before-declaration variable throws ReferenceError (TDZ simulation) --- - duration_ms: 0.569761 + duration_ms: 0.52448 type: 'test' ... # Subtest: same pattern does NOT throw when hash is empty (explains why first link without fragment works) ok 2 - same pattern does NOT throw when hash is empty (explains why first link without fragment works) --- - duration_ms: 0.276566 + duration_ms: 0.239422 type: 'test' ... 1..2 -ok 6 - buggy showPopover (lexical-capture pattern) regression guard +ok 11 - buggy showPopover (lexical-capture pattern) regression guard --- - duration_ms: 1.093709 + duration_ms: 0.975251 type: 'suite' ... # Subtest: search encoder @@ -196,228 +354,427 @@ ok 6 - buggy showPopover (lexical-capture pattern) regression guard # Subtest: should tokenize simple English words ok 1 - should tokenize simple English words --- - duration_ms: 1.127912 + duration_ms: 1.227527 type: 'test' ... # Subtest: should handle multiple spaces ok 2 - should handle multiple spaces --- - duration_ms: 0.411602 + duration_ms: 0.198414 type: 'test' ... # Subtest: should handle tabs and newlines ok 3 - should handle tabs and newlines --- - duration_ms: 0.155003 + duration_ms: 0.196671 type: 'test' ... # Subtest: should lowercase all text ok 4 - should lowercase all text --- - duration_ms: 0.091617 + duration_ms: 0.119034 type: 'test' ... 1..4 ok 1 - English text --- - duration_ms: 2.536476 + duration_ms: 2.452649 type: 'suite' ... # Subtest: CJK text # Subtest: should tokenize Japanese Hiragana character by character ok 1 - should tokenize Japanese Hiragana character by character --- - duration_ms: 0.233617 + duration_ms: 0.30194 type: 'test' ... # Subtest: should tokenize Japanese Katakana character by character ok 2 - should tokenize Japanese Katakana character by character --- - duration_ms: 0.247863 + duration_ms: 0.20158 type: 'test' ... # Subtest: should tokenize Japanese Kanji character by character ok 3 - should tokenize Japanese Kanji character by character --- - duration_ms: 0.130799 + duration_ms: 0.120377 type: 'test' ... # Subtest: should tokenize Korean Hangul character by character ok 4 - should tokenize Korean Hangul character by character --- - duration_ms: 0.321528 + duration_ms: 0.315275 type: 'test' ... # Subtest: should tokenize Chinese characters character by character ok 5 - should tokenize Chinese characters character by character --- - duration_ms: 0.207129 + duration_ms: 0.253038 type: 'test' ... # Subtest: should handle mixed Hiragana/Katakana/Kanji ok 6 - should handle mixed Hiragana/Katakana/Kanji --- - duration_ms: 0.194125 + duration_ms: 0.240715 type: 'test' ... 1..6 ok 2 - CJK text --- - duration_ms: 1.739019 + duration_ms: 1.813713 type: 'suite' ... # Subtest: Mixed CJK and English # Subtest: should handle Japanese with English words ok 1 - should handle Japanese with English words --- - duration_ms: 0.152819 + duration_ms: 0.1858 type: 'test' ... # Subtest: should handle English with Japanese words ok 2 - should handle English with Japanese words --- - duration_ms: 0.081549 + duration_ms: 0.115117 type: 'test' ... # Subtest: should handle complex mixed content ok 3 - should handle complex mixed content --- - duration_ms: 0.084444 + duration_ms: 0.087656 type: 'test' ... # Subtest: should handle mixed Korean and English ok 4 - should handle mixed Korean and English --- - duration_ms: 0.096957 + duration_ms: 0.083588 type: 'test' ... # Subtest: should handle mixed Chinese and English ok 5 - should handle mixed Chinese and English --- - duration_ms: 0.072713 + duration_ms: 0.183396 type: 'test' ... 1..5 ok 3 - Mixed CJK and English --- - duration_ms: 0.613962 + duration_ms: 0.831119 type: 'suite' ... # Subtest: Edge cases # Subtest: should handle empty string ok 1 - should handle empty string --- - duration_ms: 0.191751 + duration_ms: 0.232679 type: 'test' ... # Subtest: should handle only whitespace ok 2 - should handle only whitespace --- - duration_ms: 0.091076 + duration_ms: 0.077075 type: 'test' ... # Subtest: should handle single character ok 3 - should handle single character --- - duration_ms: 0.073254 + duration_ms: 0.084369 type: 'test' ... # Subtest: should handle single CJK character ok 4 - should handle single CJK character --- - duration_ms: 0.084565 + duration_ms: 0.086433 type: 'test' ... # Subtest: should handle CJK with trailing whitespace ok 5 - should handle CJK with trailing whitespace --- - duration_ms: 0.089614 + duration_ms: 0.085973 type: 'test' ... # Subtest: should handle English with trailing whitespace ok 6 - should handle English with trailing whitespace --- - duration_ms: 0.079265 + duration_ms: 0.083127 type: 'test' ... 1..6 ok 4 - Edge cases --- - duration_ms: 0.83129 + duration_ms: 0.888507 type: 'suite' ... 1..4 -ok 7 - search encoder +ok 12 - search encoder --- - duration_ms: 6.293504 + duration_ms: 6.569308 + type: 'suite' + ... +# Subtest: position assignment + # Subtest: places component in correct position from layout.position + ok 1 - places component in correct position from layout.position + --- + duration_ms: 1.209152 + type: 'test' + ... + # Subtest: places component in footer position + ok 2 - places component in footer position + --- + duration_ms: 0.282163 + type: 'test' + ... + # Subtest: places component in header position + ok 3 - places component in header position + --- + duration_ms: 0.228391 + type: 'test' + ... + # Subtest: returns empty arrays when no entries have layout + ok 4 - returns empty arrays when no entries have layout + --- + duration_ms: 0.271863 + type: 'test' + ... + 1..4 +ok 13 - position assignment + --- + duration_ms: 2.671401 + type: 'suite' + ... +# Subtest: defaultPosition fallback + # Subtest: uses manifest defaultPosition when no explicit layout + ok 1 - uses manifest defaultPosition when no explicit layout + --- + duration_ms: 0.320575 + type: 'test' + ... + # Subtest: explicit layout takes precedence over defaultPosition + ok 2 - explicit layout takes precedence over defaultPosition + --- + duration_ms: 0.178777 + type: 'test' + ... + # Subtest: silently skips invalid defaultPosition + ok 3 - silently skips invalid defaultPosition + --- + duration_ms: 0.133051 + type: 'test' + ... + 1..3 +ok 14 - defaultPosition fallback + --- + duration_ms: 0.865834 + type: 'suite' + ... +# Subtest: priority sorting + # Subtest: sorts components within a position by priority + ok 1 - sorts components within a position by priority + --- + duration_ms: 0.268567 + type: 'test' + ... + # Subtest: defaults to priority 50 for defaultPosition without defaultPriority + ok 2 - defaults to priority 50 for defaultPosition without defaultPriority + --- + duration_ms: 0.245263 + type: 'test' + ... + 1..2 +ok 15 - priority sorting + --- + duration_ms: 0.651219 + type: 'suite' + ... +# Subtest: resolveGroups + # Subtest: returns ungrouped items in priority order + ok 1 - returns ungrouped items in priority order + --- + duration_ms: 0.207081 + type: 'test' + ... + # Subtest: returns ungrouped items unchanged + ok 2 - returns ungrouped items unchanged + --- + duration_ms: 0.097744 + type: 'test' + ... + # Subtest: returns empty array for empty input + ok 3 - returns empty array for empty input + --- + duration_ms: 0.082496 + type: 'test' + ... + # Subtest: wraps grouped items in a Flex component + ok 4 - wraps grouped items in a Flex component + --- + duration_ms: 0.221578 + type: 'test' + ... + # Subtest: uses explicit group priority from config + ok 5 - uses explicit group priority from config + --- + duration_ms: 0.131869 + type: 'test' + ... + # Subtest: falls back to first member priority when no group config + ok 6 - falls back to first member priority when no group config + --- + duration_ms: 0.109707 + type: 'test' + ... + # Subtest: single-member group still wraps in Flex + ok 7 - single-member group still wraps in Flex + --- + duration_ms: 0.094528 + type: 'test' + ... + 1..7 +ok 16 - resolveGroups + --- + duration_ms: 1.08611 + type: 'suite' + ... +# Subtest: buildLayoutForEntries with display wrappers + # Subtest: applies display wrapper for mobile-only + ok 1 - applies display wrapper for mobile-only + --- + duration_ms: 0.155694 + type: 'test' + ... + 1..1 +ok 17 - buildLayoutForEntries with display wrappers + --- + duration_ms: 0.194417 + type: 'suite' + ... +# Subtest: buildLayoutForEntries with constructors + # Subtest: instantiates constructor components via registry + ok 1 - instantiates constructor components via registry + --- + duration_ms: 0.162096 + type: 'test' + ... + # Subtest: merges entry options with TS overrides for constructors + ok 2 - merges entry options with TS overrides for constructors + --- + duration_ms: 0.141928 + type: 'test' + ... + 1..2 +ok 18 - buildLayoutForEntries with constructors + --- + duration_ms: 0.358567 type: 'suite' ... # Subtest: resolveLayout # Subtest: footer defaults to [] when sharedDefaults omits footer ok 1 - footer defaults to [] when sharedDefaults omits footer --- - duration_ms: 0.775438 + duration_ms: 0.909126 type: 'test' ... # Subtest: header defaults to [] when sharedDefaults omits header ok 2 - header defaults to [] when sharedDefaults omits header --- - duration_ms: 0.088121 + duration_ms: 0.105359 type: 'test' ... # Subtest: footer from sharedDefaults is used when no override ok 3 - footer from sharedDefaults is used when no override --- - duration_ms: 0.137291 + duration_ms: 0.147659 type: 'test' ... # Subtest: byPageType override replaces footer ok 4 - byPageType override replaces footer --- - duration_ms: 0.135939 + duration_ms: 0.160012 type: 'test' ... # Subtest: byPageType override clears footer with [] ok 5 - byPageType override clears footer with [] --- - duration_ms: 0.078884 + duration_ms: 0.099648 type: 'test' ... # Subtest: byPageType override clears header with [] ok 6 - byPageType override clears header with [] --- - duration_ms: 0.081689 + duration_ms: 0.079129 type: 'test' ... - 1..6 -ok 8 - resolveLayout + # Subtest: all array slots default to [] when sharedDefaults only has head + ok 7 - all array slots default to [] when sharedDefaults only has head + --- + duration_ms: 0.144553 + type: 'test' + ... + # Subtest: preserves component references through override + ok 8 - preserves component references through override + --- + duration_ms: 0.197553 + type: 'test' + ... + 1..8 +ok 19 - resolveLayout --- - duration_ms: 1.943983 + duration_ms: 2.590449 type: 'suite' ... # Subtest: resolveLayout frame resolution # Subtest: config override frame wins over page type frame ok 1 - config override frame wins over page type frame --- - duration_ms: 0.33387 + duration_ms: 0.234171 type: 'test' ... # Subtest: page type frame wins when no config override ok 2 - page type frame wins when no config override --- - duration_ms: 0.19159 + duration_ms: 0.177174 type: 'test' ... # Subtest: defaults to 'default' when no frame specified ok 3 - defaults to 'default' when no frame specified --- - duration_ms: 0.216105 + duration_ms: 0.078077 + type: 'test' + ... + # Subtest: defaults to 'default' when byPageType entry exists but has no frame + ok 4 - defaults to 'default' when byPageType entry exists but has no frame + --- + duration_ms: 0.067788 + type: 'test' + ... + 1..4 +ok 20 - resolveLayout frame resolution + --- + duration_ms: 0.68885 + type: 'suite' + ... +# Subtest: collectComponents + # Subtest: collects all unique components across page types + ok 1 - collects all unique components across page types + --- + duration_ms: 0.184549 + type: 'test' + ... + # Subtest: deduplicates shared components + ok 2 - deduplicates shared components + --- + duration_ms: 0.103365 + type: 'test' + ... + # Subtest: handles empty footer and header arrays + ok 3 - handles empty footer and header arrays + --- + duration_ms: 0.208233 type: 'test' ... 1..3 -ok 9 - resolveLayout frame resolution +ok 21 - collectComponents --- - duration_ms: 1.03427 + duration_ms: 0.581508 type: 'suite' ... # Subtest: FileTrie @@ -425,324 +782,324 @@ ok 9 - resolveLayout frame resolution # Subtest: should create an empty trie ok 1 - should create an empty trie --- - duration_ms: 1.219169 + duration_ms: 0.905829 type: 'test' ... # Subtest: should set displayName from data title ok 2 - should set displayName from data title --- - duration_ms: 0.34463 + duration_ms: 0.228841 type: 'test' ... # Subtest: should be able to set displayName ok 3 - should be able to set displayName --- - duration_ms: 0.2462 + duration_ms: 0.190479 type: 'test' ... 1..3 ok 1 - constructor --- - duration_ms: 2.714932 + duration_ms: 1.931294 type: 'suite' ... # Subtest: add # Subtest: should add a file at root level ok 1 - should add a file at root level --- - duration_ms: 0.437219 + duration_ms: 0.476289 type: 'test' ... # Subtest: should handle index files ok 2 - should handle index files --- - duration_ms: 0.315296 + duration_ms: 0.227038 type: 'test' ... # Subtest: should add nested files ok 3 - should add nested files --- - duration_ms: 0.297815 + duration_ms: 0.234392 type: 'test' ... # Subtest: last-insert-wins on folder-note collision (matches emitter semantics) ok 4 - last-insert-wins on folder-note collision (matches emitter semantics) --- - duration_ms: 0.238426 + duration_ms: 0.173768 type: 'test' ... # Subtest: last-insert-wins on root-level index collision ok 5 - last-insert-wins on root-level index collision --- - duration_ms: 0.255106 + duration_ms: 0.187674 type: 'test' ... # Subtest: collision does not affect sibling files in the same folder ok 6 - collision does not affect sibling files in the same folder --- - duration_ms: 0.337747 + duration_ms: 0.293253 type: 'test' ... 1..6 ok 2 - add --- - duration_ms: 2.485002 + duration_ms: 2.106896 type: 'suite' ... # Subtest: filter # Subtest: should filter nodes based on condition ok 1 - should filter nodes based on condition --- - duration_ms: 0.313664 + duration_ms: 0.236667 type: 'test' ... 1..1 ok 3 - filter --- - duration_ms: 0.406112 + duration_ms: 0.298734 type: 'suite' ... # Subtest: map # Subtest: should apply function to all nodes ok 1 - should apply function to all nodes --- - duration_ms: 0.242464 + duration_ms: 0.200378 type: 'test' ... # Subtest: map over folders should work ok 2 - map over folders should work --- - duration_ms: 0.215254 + duration_ms: 0.222289 type: 'test' ... 1..2 ok 4 - map --- - duration_ms: 0.563751 + duration_ms: 0.507598 type: 'suite' ... # Subtest: entries # Subtest: should return all entries ok 1 - should return all entries --- - duration_ms: 0.53589 + duration_ms: 0.345122 type: 'test' ... 1..1 ok 5 - entries --- - duration_ms: 0.618851 + duration_ms: 0.401127 type: 'suite' ... # Subtest: fromEntries # Subtest: nested ok 1 - nested --- - duration_ms: 0.259985 + duration_ms: 0.201771 type: 'test' ... 1..1 ok 6 - fromEntries --- - duration_ms: 0.331937 + duration_ms: 0.257887 type: 'suite' ... # Subtest: findNode # Subtest: should find root node with empty path ok 1 - should find root node with empty path --- - duration_ms: 0.249265 + duration_ms: 0.162717 type: 'test' ... # Subtest: should find node at first level ok 2 - should find node at first level --- - duration_ms: 0.187964 + duration_ms: 0.108686 type: 'test' ... # Subtest: should find nested node ok 3 - should find nested node --- - duration_ms: 0.36677 + duration_ms: 0.130115 type: 'test' ... # Subtest: should return undefined for non-existent path ok 4 - should return undefined for non-existent path --- - duration_ms: 0.413606 + duration_ms: 0.282844 type: 'test' ... # Subtest: should return undefined for partial path ok 5 - should return undefined for partial path --- - duration_ms: 0.193163 + duration_ms: 0.142459 type: 'test' ... 1..5 ok 7 - findNode --- - duration_ms: 1.618028 + duration_ms: 0.958178 type: 'suite' ... # Subtest: getFolderPaths # Subtest: should return all folder paths ok 1 - should return all folder paths --- - duration_ms: 0.272088 + duration_ms: 0.301038 type: 'test' ... 1..1 ok 8 - getFolderPaths --- - duration_ms: 0.341484 + duration_ms: 0.369187 type: 'suite' ... # Subtest: sort # Subtest: should sort nodes according to sort function ok 1 - should sort nodes according to sort function --- - duration_ms: 5.333489 + duration_ms: 5.928509 type: 'test' ... 1..1 ok 9 - sort --- - duration_ms: 5.418013 + duration_ms: 6.028056 type: 'suite' ... # Subtest: pathToNode # Subtest: should return root node for empty path ok 1 - should return root node for empty path --- - duration_ms: 0.244226 + duration_ms: 0.335423 type: 'test' ... # Subtest: should return root node for index path ok 2 - should return root node for index path --- - duration_ms: 0.111654 + duration_ms: 0.163468 type: 'test' ... # Subtest: should return path to first level node ok 3 - should return path to first level node --- - duration_ms: 0.110121 + duration_ms: 0.166875 type: 'test' ... # Subtest: should return path to nested node ok 4 - should return path to nested node --- - duration_ms: 0.105232 + duration_ms: 0.16442 type: 'test' ... # Subtest: should return undefined for non-existent path ok 5 - should return undefined for non-existent path --- - duration_ms: 0.089062 + duration_ms: 0.147658 type: 'test' ... # Subtest: should return file data for intermediate folders ok 6 - should return file data for intermediate folders --- - duration_ms: 0.130208 + duration_ms: 0.179549 type: 'test' ... # Subtest: should return path for partial path ok 7 - should return path for partial path --- - duration_ms: 0.111404 + duration_ms: 0.152077 type: 'test' ... 1..7 ok 10 - pathToNode --- - duration_ms: 1.075696 + duration_ms: 1.565454 type: 'suite' ... 1..10 -ok 10 - FileTrie +ok 22 - FileTrie --- - duration_ms: 16.524563 + duration_ms: 15.228581 type: 'suite' ... # Subtest: typeguards # Subtest: isSimpleSlug ok 1 - isSimpleSlug --- - duration_ms: 0.883505 + duration_ms: 0.97492 type: 'test' ... # Subtest: isRelativeURL ok 2 - isRelativeURL --- - duration_ms: 0.172896 + duration_ms: 0.195779 type: 'test' ... # Subtest: isAbsoluteURL ok 3 - isAbsoluteURL --- - duration_ms: 0.165823 + duration_ms: 0.19604 type: 'test' ... # Subtest: isFullSlug ok 4 - isFullSlug --- - duration_ms: 0.170051 + duration_ms: 0.214975 type: 'test' ... # Subtest: isFilePath ok 5 - isFilePath --- - duration_ms: 0.089053 + duration_ms: 0.136919 type: 'test' ... 1..5 -ok 11 - typeguards +ok 23 - typeguards --- - duration_ms: 2.272023 + duration_ms: 2.576863 type: 'suite' ... # Subtest: transforms # Subtest: simplifySlug ok 1 - simplifySlug --- - duration_ms: 0.26798 + duration_ms: 0.356002 type: 'test' ... # Subtest: slugifyFilePath ok 2 - slugifyFilePath --- - duration_ms: 0.697315 + duration_ms: 0.669042 type: 'test' ... # Subtest: slugifyFilePath + simplifySlug end-to-end canonicalization ok 3 - slugifyFilePath + simplifySlug end-to-end canonicalization --- - duration_ms: 0.206718 + duration_ms: 0.293133 type: 'test' ... # Subtest: transformInternalLink ok 4 - transformInternalLink --- - duration_ms: 0.893644 + duration_ms: 1.162725 type: 'test' ... # Subtest: pathToRoot ok 5 - pathToRoot --- - duration_ms: 0.282005 + duration_ms: 0.351684 type: 'test' ... # Subtest: joinSegments ok 6 - joinSegments --- - duration_ms: 0.129106 + duration_ms: 0.183346 type: 'test' ... 1..6 -ok 12 - transforms +ok 24 - transforms --- - duration_ms: 2.744245 + duration_ms: 3.370802 type: 'suite' ... # Subtest: link strategies @@ -750,206 +1107,206 @@ ok 12 - transforms # Subtest: from a/b/c ok 1 - from a/b/c --- - duration_ms: 0.427531 + duration_ms: 0.621303 type: 'test' ... # Subtest: from a/b/index ok 2 - from a/b/index --- - duration_ms: 0.132623 + duration_ms: 0.164641 type: 'test' ... # Subtest: from index ok 3 - from index --- - duration_ms: 0.119779 + duration_ms: 0.150684 type: 'test' ... 1..3 ok 1 - absolute --- - duration_ms: 0.822824 + duration_ms: 1.103632 type: 'suite' ... # Subtest: shortest # Subtest: from a/b/c ok 1 - from a/b/c --- - duration_ms: 0.283478 + duration_ms: 0.415023 type: 'test' ... # Subtest: from a/b/index ok 2 - from a/b/index --- - duration_ms: 0.12545 + duration_ms: 0.189498 type: 'test' ... # Subtest: from index ok 3 - from index --- - duration_ms: 0.160313 + duration_ms: 0.189237 type: 'test' ... 1..3 ok 2 - shortest --- - duration_ms: 0.665467 + duration_ms: 0.92683 type: 'suite' ... # Subtest: relative # Subtest: from a/b/c ok 1 - from a/b/c --- - duration_ms: 0.177294 + duration_ms: 0.252567 type: 'test' ... # Subtest: from a/b/index ok 2 - from a/b/index --- - duration_ms: 0.097328 + duration_ms: 0.132881 type: 'test' ... # Subtest: from index ok 3 - from index --- - duration_ms: 0.08224 + duration_ms: 0.110048 type: 'test' ... 1..3 ok 3 - relative --- - duration_ms: 0.438261 + duration_ms: 0.612807 type: 'suite' ... 1..3 -ok 13 - link strategies +ok 25 - link strategies --- - duration_ms: 2.063622 + duration_ms: 2.857253 type: 'suite' ... # Subtest: resolveRelative # Subtest: from index ok 1 - from index --- - duration_ms: 0.105263 + duration_ms: 0.156054 type: 'test' ... # Subtest: from nested page ok 2 - from nested page --- - duration_ms: 0.068305 + duration_ms: 0.109888 type: 'test' ... # Subtest: with index paths ok 3 - with index paths --- - duration_ms: 0.063787 + duration_ms: 0.112602 type: 'test' ... # Subtest: with simple slugs ok 4 - with simple slugs --- - duration_ms: 0.079125 + duration_ms: 0.10637 type: 'test' ... 1..4 -ok 14 - resolveRelative +ok 26 - resolveRelative --- - duration_ms: 0.411662 + duration_ms: 0.634959 type: 'suite' ... # Subtest: detectSlugCollisions # Subtest: returns empty array when there are no collisions ok 1 - returns empty array when there are no collisions --- - duration_ms: 1.516302 + duration_ms: 1.175829 type: 'test' ... # Subtest: returns empty array for empty input ok 2 - returns empty array for empty input --- - duration_ms: 0.194716 + duration_ms: 0.135776 type: 'test' ... # Subtest: detects a two-file collision with winner = last file ok 3 - detects a two-file collision with winner = last file --- - duration_ms: 0.174559 + duration_ms: 0.133882 type: 'test' ... # Subtest: detects a three-file collision with all files listed, winner = last ok 4 - detects a three-file collision with all files listed, winner = last --- - duration_ms: 0.14758 + duration_ms: 0.141608 type: 'test' ... # Subtest: detects multiple separate collisions ok 5 - detects multiple separate collisions --- - duration_ms: 0.275203 + duration_ms: 0.198494 type: 'test' ... # Subtest: ignores entries without a slug ok 6 - ignores entries without a slug --- - duration_ms: 0.161285 + duration_ms: 0.10039 type: 'test' ... # Subtest: winner annotation matches fileTrie last-insert-wins semantics ok 7 - winner annotation matches fileTrie last-insert-wins semantics --- - duration_ms: 0.133424 + duration_ms: 0.092425 type: 'test' ... 1..7 -ok 15 - detectSlugCollisions +ok 27 - detectSlugCollisions --- - duration_ms: 3.771814 + duration_ms: 3.218564 type: 'suite' ... # Subtest: formatCollisionWarning # Subtest: returns empty string for empty input ok 1 - returns empty string for empty input --- - duration_ms: 0.356021 + duration_ms: 0.236537 type: 'test' ... # Subtest: formats single collision with winner and shadowed markers ok 2 - formats single collision with winner and shadowed markers --- - duration_ms: 0.479707 + duration_ms: 0.416075 type: 'test' ... # Subtest: formats multiple collisions with count in header ok 3 - formats multiple collisions with count in header --- - duration_ms: 0.334 + duration_ms: 0.426344 type: 'test' ... # Subtest: output mentions Folder Notes convention as a common cause ok 4 - output mentions Folder Notes convention as a common cause --- - duration_ms: 0.156646 + duration_ms: 0.155453 type: 'test' ... # Subtest: falls back to filePath when relativePath is missing ok 5 - falls back to filePath when relativePath is missing --- - duration_ms: 0.189837 + duration_ms: 0.180831 type: 'test' ... 1..5 -ok 16 - formatCollisionWarning +ok 28 - formatCollisionWarning --- - duration_ms: 1.94738 + duration_ms: 1.754481 type: 'suite' ... -1..16 -# tests 118 -# suites 33 -# pass 118 +1..28 +# tests 163 +# suites 45 +# pass 163 # fail 0 # cancelled 0 # skipped 0 # todo 0 -# duration_ms 225.288364 +# duration_ms 334.058263 diff --git a/package.json b/package.json index 3b0a158..8705f4e 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@quartz-community/types": "^0.2.1", + "@quartz-community/types": "^0.3.0", "@quartz-community/utils": "^0.1.0", "@types/hast": "^3.0.4", "@types/node": "^25.0.10", diff --git a/quartz/components/frames/frames.test.ts b/quartz/components/frames/frames.test.ts new file mode 100644 index 0000000..e00a5aa --- /dev/null +++ b/quartz/components/frames/frames.test.ts @@ -0,0 +1,41 @@ +import test, { describe } from "node:test" +import assert from "node:assert" +import { resolveFrame, frameRegistry } from "./index" +import { DefaultFrame } from "./DefaultFrame" +import { FullWidthFrame } from "./FullWidthFrame" +import type { PageFrame } from "./types" + +const customFrame: PageFrame = { + name: "custom-test-frame", + render: () => null as any, +} + +describe("resolveFrame", () => { + test("returns DefaultFrame for undefined", () => { + assert.strictEqual(resolveFrame(undefined), DefaultFrame) + }) + + test("returns DefaultFrame for 'default'", () => { + assert.strictEqual(resolveFrame("default"), DefaultFrame) + }) + + test("returns named built-in frame", () => { + assert.strictEqual(resolveFrame("full-width"), FullWidthFrame) + }) + + test("returns DefaultFrame for unknown frame name", () => { + assert.strictEqual(resolveFrame("nonexistent"), DefaultFrame) + }) + + test("plugin-registered frame takes priority", () => { + frameRegistry.register("custom-test-frame", customFrame, "test-plugin") + const result = resolveFrame("custom-test-frame") + assert.strictEqual(result, customFrame) + }) + + test("returns DefaultFrame for unknown name even with plugin frames registered", () => { + frameRegistry.register("custom-test-frame", customFrame, "test-plugin") + const result = resolveFrame("totally-unknown") + assert.strictEqual(result, DefaultFrame) + }) +}) diff --git a/quartz/components/registry.test.ts b/quartz/components/registry.test.ts new file mode 100644 index 0000000..5252f86 --- /dev/null +++ b/quartz/components/registry.test.ts @@ -0,0 +1,140 @@ +import test, { describe, afterEach } from "node:test" +import assert from "node:assert" +import { ComponentRegistry } from "./registry" +import { QuartzComponent, QuartzComponentConstructor } from "./types" + +const StubA = (() => null) as unknown as QuartzComponent +const StubB = (() => null) as unknown as QuartzComponent +StubA.displayName = "StubA" +StubB.displayName = "StubB" + +const StubConstructor = ((opts?: any) => { + const c = (() => null) as unknown as QuartzComponent + c.displayName = opts?.name ?? "stub" + return c +}) as unknown as QuartzComponentConstructor + +let registry: ComponentRegistry | null = null + +afterEach(() => { + registry?.clear() + registry = null +}) + +describe("register and get", () => { + test("registers a component and retrieves it by name", () => { + registry = new ComponentRegistry() + registry.register("foo", StubA, "source") + + const result = registry.get("foo") + assert.strictEqual(result?.component, StubA) + assert.strictEqual(result?.source, "source") + }) + + test("returns undefined for unregistered names", () => { + registry = new ComponentRegistry() + const result = registry.get("nonexistent") + assert.strictEqual(result, undefined) + }) + + test("overwrites component from different source", () => { + registry = new ComponentRegistry() + registry.register("foo", StubA, "src1") + registry.register("foo", StubB, "src2") + + const result = registry.get("foo") + assert.strictEqual(result?.component, StubB) + }) +}) + +describe("instantiate", () => { + test("returns a component instance from a constructor", () => { + registry = new ComponentRegistry() + const instance = registry.instantiate(StubConstructor) + assert.ok(instance) + assert.strictEqual(typeof instance, "function") + }) + + test("caches instances by constructor + options", () => { + registry = new ComponentRegistry() + const first = registry.instantiate(StubConstructor) + const second = registry.instantiate(StubConstructor) + assert.strictEqual(first, second) + }) + + test("different options produce different instances", () => { + registry = new ComponentRegistry() + const first = registry.instantiate(StubConstructor, { a: 1 }) + const second = registry.instantiate(StubConstructor, { a: 2 }) + assert.notStrictEqual(first, second) + }) + + test("undefined options and no-arg call produce same cache key", () => { + registry = new ComponentRegistry() + const first = registry.instantiate(StubConstructor) + const second = registry.instantiate(StubConstructor, undefined) + assert.strictEqual(first, second) + }) +}) + +describe("getAllComponents", () => { + test("deduplicates components registered under multiple names", () => { + registry = new ComponentRegistry() + registry.register("foo", StubConstructor, "source") + registry.register("bar", StubConstructor, "source") + + const result = registry.getAllComponents() + assert.strictEqual(result.length, 1) + }) + + test("reuses cached instance from prior instantiate call", () => { + registry = new ComponentRegistry() + registry.register("x", StubConstructor, "source") + const instance = registry.instantiate(StubConstructor, { opt: 1 }) + + const result = registry.getAllComponents() + assert.strictEqual(result[0], instance) + }) + + test("skips components that fail to instantiate", () => { + registry = new ComponentRegistry() + const ThrowingConstructor = (() => { + throw new Error("boom") + }) as unknown as QuartzComponentConstructor + + registry.register("bad", ThrowingConstructor, "source") + const result = registry.getAllComponents() + assert.deepStrictEqual(result, []) + }) +}) + +describe("setOptionOverrides and cache invalidation", () => { + test("stores and retrieves option overrides", () => { + registry = new ComponentRegistry() + registry.setOptionOverrides("plugin", { key: "val" }) + assert.deepStrictEqual(registry.getOptionOverrides("plugin"), { key: "val" }) + }) + + test("merges with existing overrides", () => { + registry = new ComponentRegistry() + registry.setOptionOverrides("plugin", { a: 1 }) + registry.setOptionOverrides("plugin", { b: 2 }) + assert.deepStrictEqual(registry.getOptionOverrides("plugin"), { a: 1, b: 2 }) + }) + + test("clears instance cache when overrides change", () => { + registry = new ComponentRegistry() + const first = registry.instantiate(StubConstructor, { name: "cached" }) + registry.setOptionOverrides("anything", { trigger: true }) + const second = registry.instantiate(StubConstructor, { name: "cached" }) + assert.notStrictEqual(first, second) + }) + + test("ignores empty or undefined overrides", () => { + registry = new ComponentRegistry() + registry.setOptionOverrides("plugin", {}) + assert.strictEqual(registry.getOptionOverrides("plugin"), undefined) + registry.setOptionOverrides("plugin", undefined) + assert.strictEqual(registry.getOptionOverrides("plugin"), undefined) + }) +}) diff --git a/quartz/components/registry.ts b/quartz/components/registry.ts index 1a1d575..45053b1 100644 --- a/quartz/components/registry.ts +++ b/quartz/components/registry.ts @@ -18,7 +18,8 @@ export interface RegisteredComponent { manifest?: ComponentManifest } -class ComponentRegistry { +/** @internal Exported for testing only. */ +export class ComponentRegistry { private components = new Map() private instanceCache = new Map() private optionOverrides = new Map>() @@ -111,6 +112,13 @@ class ComponentRegistry { return results } + /** @internal For testing only — resets all registry state. */ + clear(): void { + this.components.clear() + this.instanceCache.clear() + this.optionOverrides.clear() + } + private findCachedInstance( constructor: QuartzComponentConstructor, ): QuartzComponent | undefined { diff --git a/quartz/plugins/loader/config-loader.test.ts b/quartz/plugins/loader/config-loader.test.ts new file mode 100644 index 0000000..1c62040 --- /dev/null +++ b/quartz/plugins/loader/config-loader.test.ts @@ -0,0 +1,300 @@ +import test, { describe, afterEach } from "node:test" +import assert from "node:assert" +import { buildLayoutForEntries, resolveGroups } from "./config-loader" +import { componentRegistry } from "../../components/registry" +import type { QuartzComponent, QuartzComponentConstructor } from "../../components/types" +import { PluginJsonEntry, LayoutPosition } from "./types" + +const makeComponent = (name: string): QuartzComponent => { + const c = (() => null) as unknown as QuartzComponent + c.displayName = name + return c +} + +const makeConstructor = (name: string): QuartzComponentConstructor => { + return () => makeComponent(name) +} + +function makeEntry( + source: string, + layout?: { position: LayoutPosition; priority: number }, +): PluginJsonEntry { + return { + source, + enabled: true, + options: {}, + ...(layout ? { layout: { position: layout.position, priority: layout.priority } } : {}), + } +} + +afterEach(() => { + componentRegistry.clear() +}) + +describe("position assignment", () => { + test("places component in correct position from layout.position", () => { + const component = makeComponent("MyPlugin") + componentRegistry.register("my-plugin", component, "test-source") + + const result = buildLayoutForEntries([makeEntry("my-plugin", { position: "left", priority: 10 })], {}) + assert.deepStrictEqual(result.left, [component]) + }) + + test("places component in footer position", () => { + const component = makeComponent("FooterComp") + componentRegistry.register("footer-comp", component, "test-source") + + const result = buildLayoutForEntries( + [makeEntry("footer-comp", { position: "footer", priority: 20 })], + {}, + ) + assert.deepStrictEqual(result.footer, [component]) + }) + + test("places component in header position", () => { + const component = makeComponent("HeaderComp") + componentRegistry.register("header-comp", component, "test-source") + + const result = buildLayoutForEntries( + [makeEntry("header-comp", { position: "header", priority: 5 })], + {}, + ) + assert.deepStrictEqual(result.header, [component]) + }) + + test("returns empty arrays when no entries have layout", () => { + const component = makeComponent("NoLayout") + componentRegistry.register("no-layout", component, "test-source") + + const result = buildLayoutForEntries([makeEntry("no-layout")], {}) + assert.deepStrictEqual(result.header, []) + assert.deepStrictEqual(result.left, []) + assert.deepStrictEqual(result.right, []) + assert.deepStrictEqual(result.beforeBody, []) + assert.deepStrictEqual(result.afterBody, []) + assert.deepStrictEqual(result.footer, []) + }) +}) + +describe("defaultPosition fallback", () => { + test("uses manifest defaultPosition when no explicit layout", () => { + const component = makeComponent("F") + componentRegistry.register("f", component, "test-source", { + name: "f", + displayName: "F", + description: "", + version: "1", + defaultPosition: "footer", + defaultPriority: 50, + }) + + const result = buildLayoutForEntries([makeEntry("f")], {}) + assert.deepStrictEqual(result.footer, [component]) + }) + + test("explicit layout takes precedence over defaultPosition", () => { + const component = makeComponent("P") + componentRegistry.register("p", component, "test-source", { + name: "p", + displayName: "P", + description: "", + version: "1", + defaultPosition: "right", + }) + + const result = buildLayoutForEntries( + [makeEntry("p", { position: "left", priority: 10 })], + {}, + ) + assert.deepStrictEqual(result.left, [component]) + assert.deepStrictEqual(result.right, []) + }) + + test("silently skips invalid defaultPosition", () => { + const component = makeComponent("Bad") + componentRegistry.register("bad", component, "test-source", { + name: "bad", + displayName: "Bad", + description: "", + version: "1", + defaultPosition: "body", + }) + + const result = buildLayoutForEntries([makeEntry("bad")], {}) + assert.deepStrictEqual(result.header, []) + assert.deepStrictEqual(result.left, []) + assert.deepStrictEqual(result.right, []) + assert.deepStrictEqual(result.beforeBody, []) + assert.deepStrictEqual(result.afterBody, []) + assert.deepStrictEqual(result.footer, []) + }) +}) + +describe("priority sorting", () => { + test("sorts components within a position by priority", () => { + const compA = makeComponent("A") + const compB = makeComponent("B") + const compC = makeComponent("C") + componentRegistry.register("a", compA, "test-source") + componentRegistry.register("b", compB, "test-source") + componentRegistry.register("c", compC, "test-source") + + const result = buildLayoutForEntries( + [ + makeEntry("a", { position: "left", priority: 30 }), + makeEntry("b", { position: "left", priority: 10 }), + makeEntry("c", { position: "left", priority: 20 }), + ], + {}, + ) + + const names = result.left?.map((component) => component.displayName) + assert.deepStrictEqual(names, ["B", "C", "A"]) + }) + + test("defaults to priority 50 for defaultPosition without defaultPriority", () => { + const explicit = makeComponent("Explicit") + const ctor = makeConstructor("Default") + const defaulted = ctor(undefined) + + componentRegistry.register("explicit", explicit, "test-source") + componentRegistry.register("defaulted", defaulted, "test-source", { + name: "defaulted", + displayName: "Default", + description: "", + version: "1", + defaultPosition: "left", + }) + + const result = buildLayoutForEntries( + [makeEntry("explicit", { position: "left", priority: 40 }), makeEntry("defaulted")], + {}, + ) + + const names = result.left?.map((component) => component.displayName) + assert.deepStrictEqual(names, ["Explicit", "Default"]) + }) +}) + +describe("resolveGroups", () => { + test("returns ungrouped items in priority order", () => { + const a = makeComponent("A") + const b = makeComponent("B") + const c = makeComponent("C") + const items = [ + { component: a, priority: 30 }, + { component: b, priority: 10 }, + { component: c, priority: 20 }, + ] + const result = resolveGroups(items, {}) + assert.strictEqual(result.length, 3) + assert.strictEqual(result[0], b) + assert.strictEqual(result[1], c) + assert.strictEqual(result[2], a) + }) + + test("returns ungrouped items unchanged", () => { + const a = makeComponent("A") + const items = [{ component: a, priority: 10 }] + const result = resolveGroups(items, {}) + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0], a) + }) + + test("returns empty array for empty input", () => { + const result = resolveGroups([], {}) + assert.deepStrictEqual(result, []) + }) + + test("wraps grouped items in a Flex component", () => { + const a = makeComponent("A") + const b = makeComponent("B") + const items = [ + { component: a, priority: 10, group: "toolbar" }, + { component: b, priority: 20, group: "toolbar" }, + ] + const result = resolveGroups(items, {}) + assert.strictEqual(result.length, 1) + assert.notStrictEqual(result[0], a) + assert.notStrictEqual(result[0], b) + }) + + test("uses explicit group priority from config", () => { + const grouped = makeComponent("Grouped") + const ungrouped = makeComponent("Ungrouped") + const items = [ + { component: grouped, priority: 50, group: "nav" }, + { component: ungrouped, priority: 10 }, + ] + const result = resolveGroups(items, { nav: { priority: 5 } }) + assert.strictEqual(result.length, 2) + assert.strictEqual(result[1], ungrouped) + }) + + test("falls back to first member priority when no group config", () => { + const a = makeComponent("A") + const b = makeComponent("B") + const solo = makeComponent("Solo") + const items = [ + { component: a, priority: 20, group: "nav" }, + { component: b, priority: 40, group: "nav" }, + { component: solo, priority: 30 }, + ] + const result = resolveGroups(items, {}) + assert.strictEqual(result.length, 2) + assert.strictEqual(result[1], solo) + }) + + test("single-member group still wraps in Flex", () => { + const a = makeComponent("A") + const items = [{ component: a, priority: 10, group: "solo" }] + const result = resolveGroups(items, {}) + assert.strictEqual(result.length, 1) + assert.notStrictEqual(result[0], a) + }) +}) + +describe("buildLayoutForEntries with display wrappers", () => { + test("applies display wrapper for mobile-only", () => { + const component = makeComponent("Wrapped") + componentRegistry.register("wrapped-plugin", component, "test-source") + + const entry: PluginJsonEntry = { + source: "wrapped-plugin", + enabled: true, + options: {}, + layout: { position: "left" as LayoutPosition, priority: 10, display: "mobile-only" }, + } + const result = buildLayoutForEntries([entry], {}) + assert.strictEqual(result.left?.length, 1) + assert.notStrictEqual(result.left?.[0], component) + }) +}) + +describe("buildLayoutForEntries with constructors", () => { + test("instantiates constructor components via registry", () => { + const ctor = makeConstructor("Instantiated") + componentRegistry.register("ctor-plugin", ctor, "test-source") + + const result = buildLayoutForEntries( + [makeEntry("ctor-plugin", { position: "left", priority: 10 })], + {}, + ) + assert.strictEqual(result.left?.length, 1) + assert.strictEqual(result.left?.[0].displayName, "Instantiated") + }) + + test("merges entry options with TS overrides for constructors", () => { + const ctor = makeConstructor("Merged") + componentRegistry.register("merge-plugin", ctor, "test-source") + componentRegistry.setOptionOverrides("merge-plugin", { extra: true }) + + const result = buildLayoutForEntries( + [{ source: "merge-plugin", enabled: true, options: { base: 1 }, layout: { position: "right" as LayoutPosition, priority: 10 } }], + {}, + ) + assert.strictEqual(result.right?.length, 1) + }) +}) + + diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index d873dbe..030f64c 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -27,6 +27,10 @@ import { loadComponentsFromPackage } from "./componentLoader" import { loadFramesFromPackage } from "./frameLoader" import { componentRegistry } from "../../components/registry" import { getCondition } from "./conditions" +import Flex from "../../components/Flex" +import MobileOnly from "../../components/MobileOnly" +import DesktopOnly from "../../components/DesktopOnly" +import ConditionalRender from "../../components/ConditionalRender" const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml") const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml") @@ -717,7 +721,8 @@ export async function loadQuartzLayout(layoutOverrides?: { return { defaults: mergedDefaults, byPageType: mergedByPageType } } -function buildLayoutForEntries( +/** @internal Exported for testing only. */ +export function buildLayoutForEntries( entries: PluginJsonEntry[], layoutConfig: LayoutConfig, ): Partial { @@ -863,7 +868,8 @@ function buildLayoutForEntries( return result } -function resolveGroups( +/** @internal Exported for testing only. */ +export function resolveGroups( items: { component: QuartzComponent priority: number @@ -924,9 +930,6 @@ function resolveGroups( justify: m.groupOptions?.justify, })) - // Dynamically import Flex to avoid circular dependencies - const FlexModule = require("../../components/Flex") - const Flex = FlexModule.default as Function const flexComponent = Flex({ components: flexComponents, direction: groupConfig.direction ?? "row", @@ -951,10 +954,8 @@ function applyDisplayWrapper( display: "mobile-only" | "desktop-only", ): QuartzComponent { if (display === "mobile-only") { - const MobileOnly = require("../../components/MobileOnly").default as Function return MobileOnly(component) as QuartzComponent } else { - const DesktopOnly = require("../../components/DesktopOnly").default as Function return DesktopOnly(component) as QuartzComponent } } @@ -969,7 +970,6 @@ function applyConditionWrapper(component: QuartzComponent, conditionName: string return component } - const ConditionalRender = require("../../components/ConditionalRender").default as Function return ConditionalRender({ component, condition: predicate, diff --git a/quartz/plugins/pageTypes/dispatcher.test.ts b/quartz/plugins/pageTypes/dispatcher.test.ts index 2c1118a..cc63436 100644 --- a/quartz/plugins/pageTypes/dispatcher.test.ts +++ b/quartz/plugins/pageTypes/dispatcher.test.ts @@ -1,6 +1,6 @@ import test, { describe } from "node:test" import assert from "node:assert" -import { resolveLayout } from "./dispatcher" +import { collectComponents, resolveLayout } from "./dispatcher" import { QuartzPageTypePluginInstance } from "../types" import { QuartzComponent } from "../../components/types" @@ -62,6 +62,26 @@ describe("resolveLayout", () => { ) assert.deepStrictEqual(result.header, []) }) + + test("all array slots default to [] when sharedDefaults only has head", () => { + const result = resolveLayout(makePageType(), { head: StubHead }, {}) + assert.deepStrictEqual(result.header, []) + assert.deepStrictEqual(result.left, []) + assert.deepStrictEqual(result.right, []) + assert.deepStrictEqual(result.beforeBody, []) + assert.deepStrictEqual(result.afterBody, []) + assert.deepStrictEqual(result.footer, []) + }) + + test("preserves component references through override", () => { + const result = resolveLayout( + makePageType(), + { head: StubHead, footer: [StubA, StubB] }, + {}, + ) + assert.strictEqual(result.footer[0], StubA) + assert.strictEqual(result.footer[1], StubB) + }) }) describe("resolveLayout frame resolution", () => { @@ -83,4 +103,50 @@ describe("resolveLayout frame resolution", () => { const result = resolveLayout(makePageType(), { head: StubHead }, {}) assert.strictEqual(result.frame, "default") }) + + test("defaults to 'default' when byPageType entry exists but has no frame", () => { + const result = resolveLayout( + makePageType(), + { head: StubHead }, + { content: { left: [StubA] } }, + ) + assert.strictEqual(result.frame, "default") + }) +}) + +describe("collectComponents", () => { + test("collects all unique components across page types", () => { + const pageTypes = [makePageType(), makePageType({ layout: "landing" })] + const sharedDefaults = { head: StubHead } + const byPageType = { + content: { footer: [StubA] }, + landing: { footer: [StubB] }, + } + + const result = collectComponents(pageTypes, sharedDefaults, byPageType) + assert.ok(result.includes(StubA)) + assert.ok(result.includes(StubB)) + }) + + test("deduplicates shared components", () => { + const pageTypes = [makePageType(), makePageType({ layout: "landing" })] + const sharedDefaults = { head: StubHead } + const byPageType = { + content: { left: [StubA] }, + landing: { left: [StubA] }, + } + + const result = collectComponents(pageTypes, sharedDefaults, byPageType) + const matches = result.filter((component) => component === StubA) + assert.strictEqual(matches.length, 1) + }) + + test("handles empty footer and header arrays", () => { + const pageTypes = [makePageType({ layout: "empty" })] + const sharedDefaults = { head: StubHead } + const byPageType = { empty: { footer: [], header: [] } } + + const result = collectComponents(pageTypes, sharedDefaults, byPageType) + assert.ok(result.every((component) => component)) + }) }) diff --git a/quartz/plugins/pageTypes/dispatcher.ts b/quartz/plugins/pageTypes/dispatcher.ts index f2166d2..e3e117d 100644 --- a/quartz/plugins/pageTypes/dispatcher.ts +++ b/quartz/plugins/pageTypes/dispatcher.ts @@ -37,7 +37,8 @@ export function resolveLayout( } } -function collectComponents( +/** @internal Exported for testing only. */ +export function collectComponents( pageTypes: QuartzPageTypePluginInstance[], sharedDefaults: Partial, byPageType: Record>,