@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511-snapshot.20260804140004
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-protocol/decode.d.ts.map +1 -1
- package/dist/chat-protocol/frames.d.ts +26 -1
- package/dist/chat-protocol/frames.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +24 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +24 -0
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
- package/dist/chunk-27OKD7ZV.js.map +1 -0
- package/dist/{chunk-RHQ7BJKK.cjs → chunk-2WWDZR4D.cjs} +73 -73
- package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
- package/dist/{chunk-J2JUAS4C.cjs → chunk-2XL7UK5Y.cjs} +11 -11
- package/dist/{chunk-J2JUAS4C.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
- package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
- package/dist/chunk-3NECEXDF.cjs.map +1 -0
- package/dist/{chunk-JOCFPAS6.js → chunk-4NFDSMB5.js} +6 -6
- package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
- package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
- package/dist/{chunk-53JKCFFE.cjs → chunk-5JBDT2MQ.cjs} +12 -12
- package/dist/{chunk-53JKCFFE.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
- package/dist/{chunk-XJVRAKEX.cjs → chunk-67YDSTH7.cjs} +56 -33
- package/dist/chunk-67YDSTH7.cjs.map +1 -0
- package/dist/{chunk-WNOGR5AA.js → chunk-6LMTBNLN.js} +3 -3
- package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
- package/dist/{chunk-JMBIGH4H.cjs → chunk-7VBEOGCR.cjs} +83 -83
- package/dist/{chunk-JMBIGH4H.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
- package/dist/{chunk-5AZZQQYS.cjs → chunk-AWLFJMOJ.cjs} +40 -40
- package/dist/{chunk-5AZZQQYS.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
- package/dist/{chunk-RJ3D3PE2.cjs → chunk-DQDPDUI3.cjs} +9 -9
- package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
- package/dist/{chunk-2RLI5XVI.cjs → chunk-F2YI5N7E.cjs} +16 -16
- package/dist/{chunk-2RLI5XVI.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
- package/dist/{chunk-LBOWSKGM.js → chunk-FI35LJ3L.js} +5 -5
- package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
- package/dist/chunk-FS3YSNSU.js.map +1 -0
- package/dist/{chunk-RB2EW6IZ.cjs → chunk-GOFTC2HV.cjs} +3690 -3472
- package/dist/chunk-GOFTC2HV.cjs.map +1 -0
- package/dist/{chunk-CTMLY2GN.js → chunk-HGZPH7S5.js} +5 -5
- package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
- package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
- package/dist/{chunk-DBR5JLX3.js → chunk-IHNUMRMU.js} +46 -23
- package/dist/chunk-IHNUMRMU.js.map +1 -0
- package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
- package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
- package/dist/{chunk-TBGSQTJK.js → chunk-IK2WRYVA.js} +6 -6
- package/dist/{chunk-WSAPKKDU.cjs → chunk-JIW5JI3E.cjs} +9 -9
- package/dist/{chunk-WSAPKKDU.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
- package/dist/{chunk-MLGGF24T.js → chunk-JKAX5K7C.js} +8 -8
- package/dist/{chunk-UWPT23SA.js → chunk-JO3NPEK6.js} +3 -3
- package/dist/{chunk-HCZJZMU7.js → chunk-JOFGJYIT.js} +3 -3
- package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
- package/dist/chunk-KI4EGKNV.cjs.map +1 -0
- package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
- package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
- package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
- package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
- package/dist/{chunk-HX4CJYXL.js → chunk-OT4RV6WY.js} +10 -10
- package/dist/{chunk-Z35QWWJH.cjs → chunk-RJXI25TJ.cjs} +34 -34
- package/dist/{chunk-Z35QWWJH.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
- package/dist/{chunk-6TL5XIAW.cjs → chunk-SG25J6LV.cjs} +119 -119
- package/dist/{chunk-6TL5XIAW.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
- package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
- package/dist/{chunk-FEGEC6FY.js → chunk-V5WLIFKN.js} +5660 -5442
- package/dist/chunk-V5WLIFKN.js.map +1 -0
- package/dist/{chunk-EJ3EBYNV.cjs → chunk-VVXUUBML.cjs} +32 -32
- package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
- package/dist/{chunk-SRYV4H3M.js → chunk-VZX3SSFL.js} +3 -3
- package/dist/{chunk-5CDPRCPS.cjs → chunk-WVZHCDB5.cjs} +23 -23
- package/dist/{chunk-5CDPRCPS.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
- package/dist/{chunk-VDJF4ZAU.js → chunk-XG7FLAKQ.js} +3 -3
- package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
- package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
- package/dist/{chunk-2AASLY6D.js → chunk-ZIQMVAGA.js} +3 -3
- package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
- package/dist/components/calendar.cjs +4 -4
- package/dist/components/calendar.js +3 -3
- package/dist/components/case-studies/index.cjs +17 -17
- package/dist/components/case-studies/index.js +7 -7
- package/dist/components/chat/approval-batch-message.d.ts +2 -2
- package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/deleted-data-card.d.ts +27 -0
- package/dist/components/chat/entity-cards/deleted-data-card.d.ts.map +1 -0
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-card-item.d.ts +3 -0
- package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +13 -7
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +12 -6
- package/dist/components/chat/remark-mention-chips.d.ts +5 -2
- package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
- package/dist/components/chat/remark-strip-citations.d.ts +22 -0
- package/dist/components/chat/remark-strip-citations.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts +49 -5
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +7 -7
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +11 -4
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +4 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
- package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
- package/dist/components/chat/utils/index.d.ts +2 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -3
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
- package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
- package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/contact/index.js +7 -7
- package/dist/components/docs/index.cjs +10 -10
- package/dist/components/docs/index.js +9 -9
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/embeds/index.js +7 -7
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/faq/index.js +8 -8
- package/dist/components/features/index.cjs +7 -7
- package/dist/components/features/index.js +6 -6
- package/dist/components/help-center-pages/index.cjs +28 -28
- package/dist/components/help-center-pages/index.js +19 -19
- package/dist/components/icons/index.cjs +3 -3
- package/dist/components/icons/index.js +2 -2
- package/dist/components/index.cjs +253 -247
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -17
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +92 -92
- package/dist/components/meeting-scheduler/index.js +9 -9
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +11 -11
- package/dist/components/onboarding-guides/index.js +10 -10
- package/dist/components/related-content/index.cjs +9 -9
- package/dist/components/related-content/index.js +8 -8
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
- package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
- package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +11 -11
- package/dist/components/tickets/index.js +10 -10
- package/dist/components/ui/actions-menu.d.ts.map +1 -1
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +70 -70
- package/dist/components/ui/file-manager/index.js +3 -3
- package/dist/components/ui/index.cjs +13 -7
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +12 -6
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
- package/dist/components/ui/overlay-open-registry.d.ts +50 -0
- package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
- package/dist/components/ui/portal-container.d.ts +24 -0
- package/dist/components/ui/portal-container.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -4
- package/dist/hooks/index.js +3 -3
- package/dist/index.cjs +20 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -14
- package/dist/utils/anchor-id.d.ts +32 -0
- package/dist/utils/anchor-id.d.ts.map +1 -0
- package/dist/utils/content-href.d.ts +27 -6
- package/dist/utils/content-href.d.ts.map +1 -1
- package/dist/utils/index.cjs +213 -152
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +212 -153
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/list-url.d.ts.map +1 -1
- package/dist/utils/same-page-hash-nav.d.ts +17 -6
- package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/decode.ts +43 -0
- package/src/chat-protocol/frames.ts +27 -0
- package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
- package/src/components/chat/approval-batch-message.tsx +2 -2
- package/src/components/chat/chat-message-enhanced.tsx +5 -3
- package/src/components/chat/chat-message-list.tsx +49 -5
- package/src/components/chat/embeddable-chat.tsx +19 -2
- package/src/components/chat/entity-cards/deleted-data-card.tsx +61 -0
- package/src/components/chat/entity-cards/dispatch.tsx +205 -102
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +1 -1
- package/src/components/chat/hooks/use-chat-card-item.ts +15 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +57 -32
- package/src/components/chat/remark-mention-chips.ts +6 -3
- package/src/components/chat/remark-strip-citations.ts +39 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-approval-batch.test.ts +196 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +67 -2
- package/src/components/chat/stream/message-mutations.ts +170 -40
- package/src/components/chat/types/component.types.ts +9 -7
- package/src/components/chat/types/message.types.ts +14 -4
- package/src/components/chat/types/unified-chat-state.types.ts +4 -1
- package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
- package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
- package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
- package/src/components/chat/utils/index.ts +16 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +24 -29
- package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
- package/src/components/chat/utils/source-row-cta.ts +5 -1
- package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
- package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
- package/src/components/shared/doc-search/use-doc-search.ts +50 -14
- package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
- package/src/components/ui/actions-menu.tsx +65 -2
- package/src/components/ui/dropdown-menu.tsx +62 -12
- package/src/components/ui/markdown/base-components.tsx +26 -0
- package/src/components/ui/overlay-open-registry.tsx +99 -0
- package/src/components/ui/portal-container.tsx +31 -0
- package/src/utils/__tests__/anchor-id.test.ts +139 -0
- package/src/utils/__tests__/content-href.test.ts +74 -0
- package/src/utils/__tests__/list-url.test.ts +11 -1
- package/src/utils/anchor-id.ts +106 -0
- package/src/utils/content-href.ts +113 -13
- package/src/utils/index.ts +8 -0
- package/src/utils/list-url.ts +6 -1
- package/src/utils/same-page-hash-nav.ts +28 -9
- package/dist/chunk-A62C4C4V.cjs.map +0 -1
- package/dist/chunk-BTUHMCYW.js.map +0 -1
- package/dist/chunk-DBR5JLX3.js.map +0 -1
- package/dist/chunk-EYTXD62B.cjs.map +0 -1
- package/dist/chunk-FEGEC6FY.js.map +0 -1
- package/dist/chunk-GE3JUTW2.js.map +0 -1
- package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
- package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
- package/dist/chunk-XJVRAKEX.cjs.map +0 -1
- /package/dist/{chunk-JOCFPAS6.js.map → chunk-4NFDSMB5.js.map} +0 -0
- /package/dist/{chunk-WNOGR5AA.js.map → chunk-6LMTBNLN.js.map} +0 -0
- /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
- /package/dist/{chunk-LBOWSKGM.js.map → chunk-FI35LJ3L.js.map} +0 -0
- /package/dist/{chunk-CTMLY2GN.js.map → chunk-HGZPH7S5.js.map} +0 -0
- /package/dist/{chunk-TBGSQTJK.js.map → chunk-IK2WRYVA.js.map} +0 -0
- /package/dist/{chunk-MLGGF24T.js.map → chunk-JKAX5K7C.js.map} +0 -0
- /package/dist/{chunk-UWPT23SA.js.map → chunk-JO3NPEK6.js.map} +0 -0
- /package/dist/{chunk-HCZJZMU7.js.map → chunk-JOFGJYIT.js.map} +0 -0
- /package/dist/{chunk-HX4CJYXL.js.map → chunk-OT4RV6WY.js.map} +0 -0
- /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
- /package/dist/{chunk-SRYV4H3M.js.map → chunk-VZX3SSFL.js.map} +0 -0
- /package/dist/{chunk-VDJF4ZAU.js.map → chunk-XG7FLAKQ.js.map} +0 -0
- /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
- /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
- /package/dist/{chunk-2AASLY6D.js.map → chunk-ZIQMVAGA.js.map} +0 -0
- /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
useHeadingId,
|
|
22
22
|
} from './heading-ids';
|
|
23
23
|
import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
|
|
24
|
+
import {
|
|
25
|
+
getHashTargetElement,
|
|
26
|
+
navigateSamePageHash,
|
|
27
|
+
HUB_HEADER_OFFSET_PX,
|
|
28
|
+
} from '../../../utils/same-page-hash-nav';
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* True when an image `src` is worth rendering. Shared by the base `img` and
|
|
@@ -284,12 +289,33 @@ export function buildBaseComponents({
|
|
|
284
289
|
);
|
|
285
290
|
}
|
|
286
291
|
|
|
292
|
+
// In-page anchor. Doc TOCs are authored against GitHub's slugger
|
|
293
|
+
// (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading
|
|
294
|
+
// ids trim the emoji's leftover hyphen, so the raw href resolves to
|
|
295
|
+
// nothing and the browser silently ignores the click. Resolve through
|
|
296
|
+
// `getHashTargetElement` — the same resolver deep links use — then hand
|
|
297
|
+
// the real id to the canonical hash-nav helper, which also lands the
|
|
298
|
+
// heading BELOW the sticky header instead of under it, matching how the
|
|
299
|
+
// "On this page" rail scrolls.
|
|
300
|
+
const isInPageAnchor = typeof href === 'string' && href.startsWith('#') && href.length > 1;
|
|
301
|
+
const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
302
|
+
// Let modifier / non-primary clicks keep their native behavior.
|
|
303
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
304
|
+
const target = getHashTargetElement(href.slice(1));
|
|
305
|
+
// Nothing matched anywhere in the document → leave the browser's
|
|
306
|
+
// default alone rather than swallowing a click that isn't ours.
|
|
307
|
+
if (!target?.id) return;
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });
|
|
310
|
+
};
|
|
311
|
+
|
|
287
312
|
return (
|
|
288
313
|
<a
|
|
289
314
|
href={href}
|
|
290
315
|
className={`text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 ${linkClassName || ''}`}
|
|
291
316
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
292
317
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
318
|
+
onClick={isInPageAnchor ? handleAnchorClick : undefined}
|
|
293
319
|
>
|
|
294
320
|
{children}
|
|
295
321
|
</a>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A surface-scoped count of overlays (⋯ menus, popovers) currently open INSIDE
|
|
5
|
+
* that surface, so the surface can suspend behaviour that would move the ground
|
|
6
|
+
* under them.
|
|
7
|
+
*
|
|
8
|
+
* ## Why
|
|
9
|
+
*
|
|
10
|
+
* An open menu stays anchored to its trigger. In a chat thread the ground moves
|
|
11
|
+
* on its own: a streaming reply grows the transcript and the auto-scroll follows
|
|
12
|
+
* the bottom, carrying the trigger — and its menu — upward until the menu is
|
|
13
|
+
* pinned to an off-screen anchor at the panel edge, visually detached from the
|
|
14
|
+
* card it belongs to.
|
|
15
|
+
*
|
|
16
|
+
* The honest fix is not to dismiss the menu on every scroll event (that breaks
|
|
17
|
+
* touch, where momentum scrolling fires constantly) but to stop pulling the
|
|
18
|
+
* ground: while an overlay is open the thread stops chasing the bottom, exactly
|
|
19
|
+
* how ChatGPT/Claude stop chasing once you interact with the transcript. When
|
|
20
|
+
* the overlay closes, the thread catches up if it was at the bottom before.
|
|
21
|
+
*
|
|
22
|
+
* ## Wiring
|
|
23
|
+
*
|
|
24
|
+
* Surface: wrap the region in `<OverlayOpenRegistryProvider onOpenChange={…}>`.
|
|
25
|
+
* Overlay: call `useReportOverlayOpen(open)`. No provider → the hook is inert,
|
|
26
|
+
* so overlays outside such a surface behave exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as React from 'react'
|
|
30
|
+
|
|
31
|
+
export interface OverlayOpenRegistry {
|
|
32
|
+
/** Register one open overlay; call the returned fn to release it. */
|
|
33
|
+
acquire: () => () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const OverlayOpenRegistryContext =
|
|
37
|
+
React.createContext<OverlayOpenRegistry | null>(null)
|
|
38
|
+
|
|
39
|
+
/** Read the surrounding registry (or `null` when there is no provider). */
|
|
40
|
+
export function useOverlayOpenRegistry(): OverlayOpenRegistry | null {
|
|
41
|
+
return React.useContext(OverlayOpenRegistryContext)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Report this overlay's open state to the surrounding surface. Safe to call
|
|
46
|
+
* unconditionally — with no provider it does nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function useReportOverlayOpen(open: boolean): void {
|
|
49
|
+
const registry = useOverlayOpenRegistry()
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (!open || !registry) return
|
|
52
|
+
return registry.acquire()
|
|
53
|
+
}, [open, registry])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OverlayOpenRegistryProviderProps {
|
|
57
|
+
/** Fires on 0 → 1 and 1 → 0 transitions only, not on every acquire. */
|
|
58
|
+
onOpenChange?: (hasOpenOverlay: boolean) => void
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Owns the count for one surface. The context value is referentially stable, so
|
|
64
|
+
* mounting this provider does not re-render overlays when the count changes —
|
|
65
|
+
* only the surface's own `onOpenChange` callback fires.
|
|
66
|
+
*/
|
|
67
|
+
export function OverlayOpenRegistryProvider({
|
|
68
|
+
onOpenChange,
|
|
69
|
+
children,
|
|
70
|
+
}: OverlayOpenRegistryProviderProps) {
|
|
71
|
+
const countRef = React.useRef(0)
|
|
72
|
+
const onOpenChangeRef = React.useRef(onOpenChange)
|
|
73
|
+
onOpenChangeRef.current = onOpenChange
|
|
74
|
+
|
|
75
|
+
const registry = React.useMemo<OverlayOpenRegistry>(
|
|
76
|
+
() => ({
|
|
77
|
+
acquire: () => {
|
|
78
|
+
countRef.current += 1
|
|
79
|
+
if (countRef.current === 1) onOpenChangeRef.current?.(true)
|
|
80
|
+
let released = false
|
|
81
|
+
return () => {
|
|
82
|
+
// Guard against a double release (StrictMode double-invokes effect
|
|
83
|
+
// cleanups in development) driving the count negative.
|
|
84
|
+
if (released) return
|
|
85
|
+
released = true
|
|
86
|
+
countRef.current -= 1
|
|
87
|
+
if (countRef.current === 0) onOpenChangeRef.current?.(false)
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
[],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<OverlayOpenRegistryContext.Provider value={registry}>
|
|
96
|
+
{children}
|
|
97
|
+
</OverlayOpenRegistryContext.Provider>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -26,3 +26,34 @@ export const PortalContainerContext = React.createContext<HTMLElement | null>(
|
|
|
26
26
|
export function usePortalContainer(): HTMLElement | null {
|
|
27
27
|
return React.useContext(PortalContainerContext)
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The element Radix overlays should treat as their COLLISION boundary — the
|
|
32
|
+
* surface they must stay inside of.
|
|
33
|
+
*
|
|
34
|
+
* Separate from {@link PortalContainerContext} on purpose: the portal target is
|
|
35
|
+
* a `display: contents` node (no box, so it can't be measured), while a
|
|
36
|
+
* collision boundary must be a real, laid-out element.
|
|
37
|
+
*
|
|
38
|
+
* Why it matters: Radix positions overlays `position: fixed` and, by default,
|
|
39
|
+
* flips/shifts them against the VIEWPORT. A menu opened near the top of a
|
|
40
|
+
* panel therefore happily grows upward past the panel's edge and lands on top
|
|
41
|
+
* of the app header — visually detached from the surface it belongs to. Handing
|
|
42
|
+
* Radix the panel element instead keeps flip/shift (and the
|
|
43
|
+
* `--radix-popper-available-height` a menu sizes itself by) inside that panel.
|
|
44
|
+
*
|
|
45
|
+
* Default `null` → viewport, i.e. Radix's own default, unchanged everywhere a
|
|
46
|
+
* provider isn't present.
|
|
47
|
+
*/
|
|
48
|
+
export const CollisionBoundaryContext = React.createContext<HTMLElement | null>(
|
|
49
|
+
null,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/** Read the active collision boundary (or `null` for the viewport). */
|
|
53
|
+
export function useCollisionBoundary(): HTMLElement | null {
|
|
54
|
+
return React.useContext(CollisionBoundaryContext)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Padding (px) kept between an overlay and its boundary edge. Matches the
|
|
58
|
+
* panel's own gutter so a shifted menu never sits flush against the edge. */
|
|
59
|
+
export const COLLISION_PADDING_PX = 8
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { extractSections } from '../markdown-section-extractor'
|
|
4
|
+
import { findAnchorElementByNormalizedId, normalizeAnchorId } from '../anchor-id'
|
|
5
|
+
import { getHashTargetElement } from '../same-page-hash-nav'
|
|
6
|
+
|
|
7
|
+
describe('normalizeAnchorId', () => {
|
|
8
|
+
it('drops the hyphen GitHub leaves behind a stripped emoji', () => {
|
|
9
|
+
expect(normalizeAnchorId('-getting-started')).toBe('getting-started')
|
|
10
|
+
expect(normalizeAnchorId('-architecture-diagrams')).toBe('architecture-diagrams')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('collapses the hyphen run GitHub emits around a dropped "&"', () => {
|
|
14
|
+
expect(normalizeAnchorId('-community--support')).toBe('community-support')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('is a no-op for an already-clean id', () => {
|
|
18
|
+
expect(normalizeAnchorId('table-of-contents')).toBe('table-of-contents')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('normalizes raw heading text too', () => {
|
|
22
|
+
expect(normalizeAnchorId('🚀 Getting Started')).toBe('getting-started')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('findAnchorElementByNormalizedId', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
document.body.innerHTML = `
|
|
29
|
+
<h1 id="openframe-cli-documentation">OpenFrame CLI — Documentation</h1>
|
|
30
|
+
<h2 id="table-of-contents">📚 Table of Contents</h2>
|
|
31
|
+
<h2 id="getting-started">🚀 Getting Started</h2>
|
|
32
|
+
<h2 id="community-support">💬 Community & Support</h2>
|
|
33
|
+
<h3 id="a---b">A - B</h3>
|
|
34
|
+
`
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('resolves a GitHub-style anchor onto our heading id', () => {
|
|
38
|
+
expect(findAnchorElementByNormalizedId('-getting-started', document)?.id).toBe('getting-started')
|
|
39
|
+
expect(findAnchorElementByNormalizedId('-table-of-contents', document)?.id).toBe(
|
|
40
|
+
'table-of-contents',
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('resolves the double-hyphen "&" form', () => {
|
|
45
|
+
expect(findAnchorElementByNormalizedId('-community--support', document)?.id).toBe(
|
|
46
|
+
'community-support',
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('matches an id whose own raw form normalizes differently', () => {
|
|
51
|
+
// `a---b` is not reachable by normalized lookup — only the heading scan finds it.
|
|
52
|
+
expect(findAnchorElementByNormalizedId('a-b', document)?.id).toBe('a---b')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns null for an anchor with no heading behind it', () => {
|
|
56
|
+
expect(findAnchorElementByNormalizedId('nope', document)).toBeNull()
|
|
57
|
+
expect(findAnchorElementByNormalizedId('', document)).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('duplicate headings — GitHub numbers from -1, our deduper from -2', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
document.body.innerHTML = `
|
|
64
|
+
<h2 id="setup">Setup</h2>
|
|
65
|
+
<h2 id="setup-2">Setup</h2>
|
|
66
|
+
<h2 id="setup-3">Setup</h2>
|
|
67
|
+
<h2 id="step-2">Step 2</h2>
|
|
68
|
+
`
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shifts a GitHub duplicate fragment onto our id', () => {
|
|
72
|
+
expect(getHashTargetElement('setup-1')?.id).toBe('setup-2')
|
|
73
|
+
expect(getHashTargetElement('setup-2')?.id).toBe('setup-2')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('an id that genuinely ends in a number is matched exactly, never shifted', () => {
|
|
77
|
+
// `step-2` exists, so the exact lookup wins and `step-3` is never consulted.
|
|
78
|
+
expect(getHashTargetElement('step-2')?.id).toBe('step-2')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not invent a match when the shifted id is absent too', () => {
|
|
82
|
+
expect(getHashTargetElement('missing-1')).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('getHashTargetElement — the fuzzy pass is wired into THE resolver', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
document.body.innerHTML = `
|
|
89
|
+
<h2 id="getting-started">🚀 Getting Started</h2>
|
|
90
|
+
<div id="ticket-a/b">encoded-id row</div>
|
|
91
|
+
`
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('exact ids still win, unchanged', () => {
|
|
95
|
+
expect(getHashTargetElement('getting-started')?.id).toBe('getting-started')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('percent-decoding still runs before the fuzzy pass', () => {
|
|
99
|
+
expect(getHashTargetElement('ticket-a%2Fb')?.id).toBe('ticket-a/b')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to a normalized match — the TOC bug, through the public entry point', () => {
|
|
103
|
+
expect(getHashTargetElement('-getting-started')?.id).toBe('getting-started')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('still returns null when nothing matches', () => {
|
|
107
|
+
expect(getHashTargetElement('nope')).toBeNull()
|
|
108
|
+
expect(getHashTargetElement('')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('the openframe-cli doc that reported the bug', () => {
|
|
113
|
+
// Verbatim shapes from
|
|
114
|
+
// GET /api/docs/sources/openframe-docs/content?path=openframe-cli
|
|
115
|
+
const headings = [
|
|
116
|
+
'# OpenFrame CLI — Documentation',
|
|
117
|
+
'## 📚 Table of Contents',
|
|
118
|
+
'## 🚀 Getting Started',
|
|
119
|
+
'## 🛠️ Development',
|
|
120
|
+
'## 💬 Community & Support',
|
|
121
|
+
].join('\n\n')
|
|
122
|
+
const tocHrefs = ['#-getting-started', '#-development', '#-community--support']
|
|
123
|
+
|
|
124
|
+
it('every TOC href resolves to the id the section extractor emits', () => {
|
|
125
|
+
const sections = extractSections(headings)
|
|
126
|
+
document.body.innerHTML = sections
|
|
127
|
+
.map((s) => `<h2 id="${s.id}">${s.title}</h2>`)
|
|
128
|
+
.join('')
|
|
129
|
+
|
|
130
|
+
const resolved = tocHrefs.map((href) => getHashTargetElement(href.slice(1))?.id ?? null)
|
|
131
|
+
expect(resolved).toEqual(['getting-started', 'development', 'community-support'])
|
|
132
|
+
// …and none of them resolved before this helper existed:
|
|
133
|
+
expect(tocHrefs.map((href) => document.getElementById(href.slice(1)))).toEqual([
|
|
134
|
+
null,
|
|
135
|
+
null,
|
|
136
|
+
null,
|
|
137
|
+
])
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -32,6 +32,80 @@ describe('makeComposeContentUrl (unified single-object seam)', () => {
|
|
|
32
32
|
})
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
+
it('canonicalizes rail-vocab aliases before matching suffixes', () => {
|
|
36
|
+
// `blog_post_existing` is the legacy ContentRef spelling `buildListUrl`
|
|
37
|
+
// accepts. Without canonicalization here it missed `suffixes` entirely and
|
|
38
|
+
// emitted `<hub>/blog_post_existing/<slug>` — the same post reachable at
|
|
39
|
+
// two different urls depending on which call site produced the link.
|
|
40
|
+
expect(compose({ type: 'blog_post_existing', identifier: 'hello' })).toEqual(
|
|
41
|
+
compose({ type: 'blog_post', identifier: 'hello' }),
|
|
42
|
+
)
|
|
43
|
+
expect(compose({ type: 'blog_post_existing', identifier: 'hello' })).toEqual({
|
|
44
|
+
href: `${HUB}/blog/hello`,
|
|
45
|
+
targetPlatform: null,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('non-hosted row resolves to the OWNING platform, not to contentOrigin', () => {
|
|
50
|
+
// The reported bug: an OpenMSP-owned blog post linked to flamingo.run.
|
|
51
|
+
expect(
|
|
52
|
+
compose({ type: 'blog_post', identifier: 'why-mdr', platforms: [{ name: 'openmsp' }] }),
|
|
53
|
+
).toEqual({ href: 'https://www.openmsp.ai/blog/why-mdr', targetPlatform: 'openmsp' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reads all three junction shapes the DALs produce', () => {
|
|
57
|
+
const shapes = [
|
|
58
|
+
[{ name: 'openmsp' }],
|
|
59
|
+
[{ platform_name: 'openmsp' } as unknown as { name?: string }],
|
|
60
|
+
[{ platforms: { name: 'openmsp' } } as unknown as { name?: string }],
|
|
61
|
+
]
|
|
62
|
+
for (const platforms of shapes) {
|
|
63
|
+
expect(compose({ type: 'blog_post', identifier: 'x', platforms }).href).toBe(
|
|
64
|
+
'https://www.openmsp.ai/blog/x',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('explicit targetPlatform wins over the junction', () => {
|
|
70
|
+
expect(
|
|
71
|
+
compose({
|
|
72
|
+
type: 'blog_post',
|
|
73
|
+
identifier: 'x',
|
|
74
|
+
targetPlatform: 'tmcg',
|
|
75
|
+
platforms: [{ name: 'openmsp' }],
|
|
76
|
+
}),
|
|
77
|
+
).toEqual({ href: 'https://www.tmcg.miami/blog/x', targetPlatform: 'tmcg' })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to contentOrigin for an unknown or absent platform', () => {
|
|
81
|
+
expect(compose({ type: 'blog_post', identifier: 'x' })).toEqual({
|
|
82
|
+
href: `${HUB}/blog/x`,
|
|
83
|
+
targetPlatform: null,
|
|
84
|
+
})
|
|
85
|
+
// An unresolvable owner reports `null`, NOT its own name: the href came
|
|
86
|
+
// from `contentOrigin`, and `decideNewTab` prefers `targetPlatform` over
|
|
87
|
+
// its origin check — so claiming a platform here forced a same-host link
|
|
88
|
+
// into a new tab.
|
|
89
|
+
expect(
|
|
90
|
+
compose({ type: 'blog_post', identifier: 'x', platforms: [{ name: 'not-a-platform' }] }),
|
|
91
|
+
).toEqual({ href: `${HUB}/blog/x`, targetPlatform: null })
|
|
92
|
+
expect(
|
|
93
|
+
compose({ type: 'blog_post', identifier: 'x', targetPlatform: 'not-a-platform' }),
|
|
94
|
+
).toEqual({ href: `${HUB}/blog/x`, targetPlatform: null })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('externalUrl still wins — the RAG url is authoritative', () => {
|
|
98
|
+
expect(
|
|
99
|
+
compose({
|
|
100
|
+
type: 'blog_post',
|
|
101
|
+
identifier: 'x',
|
|
102
|
+
externalUrl: 'https://www.openmsp.ai/blog/x',
|
|
103
|
+
targetPlatform: 'openmsp',
|
|
104
|
+
platforms: [{ name: 'flamingo' }],
|
|
105
|
+
}),
|
|
106
|
+
).toEqual({ href: 'https://www.openmsp.ai/blog/x', targetPlatform: 'openmsp' })
|
|
107
|
+
})
|
|
108
|
+
|
|
35
109
|
it('always returns a tuple (never null) — even for an unknown type', () => {
|
|
36
110
|
expect(compose({ type: 'totally_unknown', identifier: 'x' })).toEqual({
|
|
37
111
|
href: `${HUB}/totally_unknown/x`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { buildListUrl } from '../list-url'
|
|
2
|
+
import { buildListUrl, canonicalContentRefType } from '../list-url'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Byte-parity gate for the shared `buildListUrl`.
|
|
@@ -33,6 +33,16 @@ describe('buildListUrl — byte parity with the hub mappers', () => {
|
|
|
33
33
|
expect(buildListUrl(type, ['a', 'b'])).toBe(expected)
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
it('canonicalContentRefType: aliases resolve, prototype keys do not', () => {
|
|
37
|
+
expect(canonicalContentRefType('blog_post_existing')).toBe('blog_post')
|
|
38
|
+
expect(canonicalContentRefType('roadmap_item')).toBe('roadmap_item')
|
|
39
|
+
// A bare `ALIASES[key]` read returned `Object` here, which downstream
|
|
40
|
+
// interpolated into a URL path.
|
|
41
|
+
expect(canonicalContentRefType('constructor')).toBe('constructor')
|
|
42
|
+
expect(canonicalContentRefType('__proto__')).toBe('__proto__')
|
|
43
|
+
expect(canonicalContentRefType('toString')).toBe('toString')
|
|
44
|
+
})
|
|
45
|
+
|
|
36
46
|
it('un-aliases blog_post_existing → blog_post (mirrors hub LEGACY_TYPE_ALIASES)', () => {
|
|
37
47
|
expect(buildListUrl('blog_post_existing', ['a', 'b'])).toBe(BASELINE.blog_post)
|
|
38
48
|
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub-compatible anchor resolution for in-page `#hash` links.
|
|
3
|
+
*
|
|
4
|
+
* ## The mismatch this exists to bridge
|
|
5
|
+
*
|
|
6
|
+
* Doc authors write their table of contents against GitHub's slugger, which
|
|
7
|
+
* keeps the separator left behind by a stripped emoji:
|
|
8
|
+
*
|
|
9
|
+
* `## 📚 Table of Contents` → GitHub anchor `#-table-of-contents`
|
|
10
|
+
*
|
|
11
|
+
* Our heading ids come from the shared slug SSOT (`markdown-heading-id`),
|
|
12
|
+
* whose `slugifyHeadingBase` trims leading and trailing hyphens — so the same
|
|
13
|
+
* heading renders as `id="table-of-contents"`. The "On this page" rail
|
|
14
|
+
* navigates by those ids and works; the in-body TOC link points at an id that
|
|
15
|
+
* does not exist and the browser silently does nothing.
|
|
16
|
+
*
|
|
17
|
+
* Rather than change the id shape (it is already baked into shared URLs and
|
|
18
|
+
* the rail), resolution is made tolerant: compare a *normalized* form of both
|
|
19
|
+
* sides. This module owns ONLY that tolerance — the slug chain itself is
|
|
20
|
+
* imported, never re-implemented, so a change to the id shape lands in one
|
|
21
|
+
* place and both sides move together.
|
|
22
|
+
*
|
|
23
|
+
* Callers do not reach for this directly: `getHashTargetElement`
|
|
24
|
+
* (`utils/same-page-hash-nav`) is the ONE resolver every hash consumer goes
|
|
25
|
+
* through, and it applies this as its last pass.
|
|
26
|
+
*/
|
|
27
|
+
import { slugifyHeadingText } from './markdown-heading-id'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reduce an anchor id (ours or GitHub's) to a comparable form.
|
|
31
|
+
*
|
|
32
|
+
* Everything but the final hyphen-run collapse is the shared heading-slug
|
|
33
|
+
* chain: emoji strip → lowercase → drop non-word chars → spaces to hyphens →
|
|
34
|
+
* trim edge hyphens. The collapse is this module's own addition, covering
|
|
35
|
+
* GitHub's *interior* divergence — `## 💬 Community & Support` slugs to
|
|
36
|
+
* `#-community--support` (a double hyphen around the dropped `&`) against our
|
|
37
|
+
* `community-support`.
|
|
38
|
+
*
|
|
39
|
+
* `'-getting-started'` → `'getting-started'`
|
|
40
|
+
* `'-community--support'` → `'community-support'`
|
|
41
|
+
* `'Getting Started'` → `'getting-started'`
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeAnchorId(raw: string): string {
|
|
44
|
+
return slugifyHeadingText(raw).replace(/-+/g, '-').replace(/^-+|-+$/g, '')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Heading elements carrying an id — the candidate set for the fuzzy pass. */
|
|
48
|
+
const HEADING_SELECTOR = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
|
|
49
|
+
|
|
50
|
+
/** A `base-N` id, split into its base and ordinal. */
|
|
51
|
+
const DUPLICATE_SUFFIX_RE = /^(.+)-(\d+)$/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The two sides also disagree on where duplicate-heading numbering STARTS.
|
|
55
|
+
* For a document with two `## Setup` headings GitHub emits `setup` and
|
|
56
|
+
* `setup-1`, while `createHeadingIdDeduper` emits `setup` and `setup-2` (its
|
|
57
|
+
* suffix is the occurrence COUNT, not the offset). So a GitHub `#setup-1`
|
|
58
|
+
* needs our `setup-2` — every duplicate is off by exactly one.
|
|
59
|
+
*
|
|
60
|
+
* Runs LAST, after the exact lookup and the heading scan have both missed: a
|
|
61
|
+
* heading that genuinely slugs to `step-2` is resolved long before this, and
|
|
62
|
+
* the shift is the one pass that can match an id it was not asked for.
|
|
63
|
+
*/
|
|
64
|
+
function findDuplicateShifted(normalized: string, doc: Document): HTMLElement | null {
|
|
65
|
+
const parts = DUPLICATE_SUFFIX_RE.exec(normalized)
|
|
66
|
+
if (!parts) return null
|
|
67
|
+
const ordinal = Number(parts[2])
|
|
68
|
+
if (!Number.isSafeInteger(ordinal)) return null
|
|
69
|
+
return doc.getElementById(`${parts[1]}-${ordinal + 1}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a `#hash` id that no exact lookup matched, by comparing normalized
|
|
74
|
+
* forms. Returns `null` when nothing in the document matches, so the caller
|
|
75
|
+
* can leave the browser's default behavior alone rather than hijacking a
|
|
76
|
+
* click that was never ours to handle.
|
|
77
|
+
*
|
|
78
|
+
* Three passes, in order of how much they assume:
|
|
79
|
+
* 1. a single `getElementById` on the normalized id — catches the whole
|
|
80
|
+
* emoji-hyphen family without walking the DOM;
|
|
81
|
+
* 2. a heading scan — catches ids whose own raw form normalizes differently
|
|
82
|
+
* (e.g. `a---b`, from a title containing a literal ` - `);
|
|
83
|
+
* 3. the duplicate-heading shift (see `findDuplicateShifted`) — last,
|
|
84
|
+
* because it is the only pass that resolves to an id the fragment did
|
|
85
|
+
* not name.
|
|
86
|
+
*/
|
|
87
|
+
export function findAnchorElementByNormalizedId(
|
|
88
|
+
rawId: string,
|
|
89
|
+
doc: Document,
|
|
90
|
+
): HTMLElement | null {
|
|
91
|
+
const normalized = normalizeAnchorId(rawId)
|
|
92
|
+
if (!normalized) return null
|
|
93
|
+
|
|
94
|
+
if (normalized !== rawId) {
|
|
95
|
+
const direct = doc.getElementById(normalized)
|
|
96
|
+
if (direct) return direct
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const heading of Array.from(doc.querySelectorAll<HTMLElement>(HEADING_SELECTOR))) {
|
|
100
|
+
if (normalizeAnchorId(heading.id) === normalized) return heading
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Last: the shift would happily match an unrelated `x-3` for a `x-2` that
|
|
104
|
+
// has no heading at all, so it runs only once the scan has come up empty.
|
|
105
|
+
return findDuplicateShifted(normalized, doc)
|
|
106
|
+
}
|