@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511
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/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
- package/dist/chunk-27OKD7ZV.js.map +1 -0
- package/dist/{chunk-2RLI5XVI.cjs → chunk-2CE725NN.cjs} +16 -16
- package/dist/{chunk-2RLI5XVI.cjs.map → chunk-2CE725NN.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-RJ3D3PE2.cjs → chunk-4SG55XVA.cjs} +9 -9
- package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-4SG55XVA.cjs.map} +1 -1
- 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-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
- package/dist/{chunk-6TL5XIAW.cjs → chunk-7YV67K3I.cjs} +119 -119
- package/dist/{chunk-6TL5XIAW.cjs.map → chunk-7YV67K3I.cjs.map} +1 -1
- package/dist/{chunk-5CDPRCPS.cjs → chunk-A5YZUV6B.cjs} +23 -23
- package/dist/{chunk-5CDPRCPS.cjs.map → chunk-A5YZUV6B.cjs.map} +1 -1
- package/dist/{chunk-WSAPKKDU.cjs → chunk-C4EUXZSA.cjs} +9 -9
- package/dist/{chunk-WSAPKKDU.cjs.map → chunk-C4EUXZSA.cjs.map} +1 -1
- package/dist/{chunk-5AZZQQYS.cjs → chunk-DPLXHG7H.cjs} +40 -40
- package/dist/{chunk-5AZZQQYS.cjs.map → chunk-DPLXHG7H.cjs.map} +1 -1
- package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
- package/dist/chunk-FS3YSNSU.js.map +1 -0
- package/dist/{chunk-RB2EW6IZ.cjs → chunk-GHXTF35X.cjs} +3231 -3165
- package/dist/chunk-GHXTF35X.cjs.map +1 -0
- 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-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
- package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
- package/dist/{chunk-JMBIGH4H.cjs → chunk-IUZMWMVX.cjs} +83 -83
- package/dist/{chunk-JMBIGH4H.cjs.map → chunk-IUZMWMVX.cjs.map} +1 -1
- package/dist/{chunk-XJVRAKEX.cjs → chunk-IXCBVJRU.cjs} +56 -33
- package/dist/chunk-IXCBVJRU.cjs.map +1 -0
- package/dist/{chunk-TBGSQTJK.js → chunk-J4VB4EHW.js} +6 -6
- package/dist/{chunk-UWPT23SA.js → chunk-JXMNLH7F.js} +3 -3
- package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
- package/dist/chunk-KI4EGKNV.cjs.map +1 -0
- package/dist/{chunk-RHQ7BJKK.cjs → chunk-LEWQRAZ4.cjs} +73 -73
- package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-LEWQRAZ4.cjs.map} +1 -1
- package/dist/{chunk-DBR5JLX3.js → chunk-LVAJK2U2.js} +46 -23
- package/dist/chunk-LVAJK2U2.js.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-HCZJZMU7.js → chunk-NKA3O4L6.js} +3 -3
- package/dist/{chunk-SRYV4H3M.js → chunk-NNWSGOUP.js} +3 -3
- package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
- package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
- package/dist/{chunk-EJ3EBYNV.cjs → chunk-OMN7A6RP.cjs} +32 -32
- package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-OMN7A6RP.cjs.map} +1 -1
- package/dist/{chunk-WNOGR5AA.js → chunk-PRMR6WKC.js} +3 -3
- package/dist/{chunk-J2JUAS4C.cjs → chunk-PXQQFRUD.cjs} +11 -11
- package/dist/{chunk-J2JUAS4C.cjs.map → chunk-PXQQFRUD.cjs.map} +1 -1
- package/dist/{chunk-VDJF4ZAU.js → chunk-RU3SCECX.js} +3 -3
- package/dist/{chunk-53JKCFFE.cjs → chunk-RXFBW2EJ.cjs} +12 -12
- package/dist/{chunk-53JKCFFE.cjs.map → chunk-RXFBW2EJ.cjs.map} +1 -1
- package/dist/{chunk-JOCFPAS6.js → chunk-TNZOUHHN.js} +6 -6
- package/dist/{chunk-HX4CJYXL.js → chunk-TR7AMHGB.js} +10 -10
- package/dist/{chunk-MLGGF24T.js → chunk-TYVHM67X.js} +8 -8
- package/dist/{chunk-Z35QWWJH.cjs → chunk-U4WPFX5K.cjs} +34 -34
- package/dist/{chunk-Z35QWWJH.cjs.map → chunk-U4WPFX5K.cjs.map} +1 -1
- package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
- package/dist/{chunk-FEGEC6FY.js → chunk-X7AJ3J42.js} +5170 -5104
- package/dist/chunk-X7AJ3J42.js.map +1 -0
- package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
- package/dist/{chunk-CTMLY2GN.js → chunk-YGS7BLSN.js} +5 -5
- package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
- package/dist/{chunk-2AASLY6D.js → chunk-ZBQV7WH7.js} +3 -3
- package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
- package/dist/{chunk-LBOWSKGM.js → chunk-ZSIEM7CL.js} +5 -5
- 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/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/dispatch.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/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/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/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
- package/src/components/chat/chat-message-enhanced.tsx +3 -2
- 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/dispatch.tsx +65 -4
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +5 -3
- package/src/components/chat/remark-mention-chips.ts +6 -3
- 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/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-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
- /package/dist/{chunk-TBGSQTJK.js.map → chunk-J4VB4EHW.js.map} +0 -0
- /package/dist/{chunk-UWPT23SA.js.map → chunk-JXMNLH7F.js.map} +0 -0
- /package/dist/{chunk-HCZJZMU7.js.map → chunk-NKA3O4L6.js.map} +0 -0
- /package/dist/{chunk-SRYV4H3M.js.map → chunk-NNWSGOUP.js.map} +0 -0
- /package/dist/{chunk-WNOGR5AA.js.map → chunk-PRMR6WKC.js.map} +0 -0
- /package/dist/{chunk-VDJF4ZAU.js.map → chunk-RU3SCECX.js.map} +0 -0
- /package/dist/{chunk-JOCFPAS6.js.map → chunk-TNZOUHHN.js.map} +0 -0
- /package/dist/{chunk-HX4CJYXL.js.map → chunk-TR7AMHGB.js.map} +0 -0
- /package/dist/{chunk-MLGGF24T.js.map → chunk-TYVHM67X.js.map} +0 -0
- /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
- /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
- /package/dist/{chunk-CTMLY2GN.js.map → chunk-YGS7BLSN.js.map} +0 -0
- /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
- /package/dist/{chunk-2AASLY6D.js.map → chunk-ZBQV7WH7.js.map} +0 -0
- /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
- /package/dist/{chunk-LBOWSKGM.js.map → chunk-ZSIEM7CL.js.map} +0 -0
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
* })
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { canonicalContentRefType } from './list-url'
|
|
30
|
+
import { byKey, getPlatformProductionUrl } from '../platform-domains'
|
|
31
|
+
|
|
29
32
|
/**
|
|
30
33
|
* Type → in-app route suffix. The public-hostable subset of the hub's
|
|
31
34
|
* `PUBLIC_URL_PATHS` (`lib/utils/content-url-builder.ts`); the hub keeps
|
|
@@ -56,14 +59,32 @@ export const DEFAULT_CONTENT_SUFFIXES: Record<string, string> = {
|
|
|
56
59
|
* `identifier` (the primary-key id) + `externalUrl` (the canonical hub URL,
|
|
57
60
|
* from which the slug is recovered for in-app routing). */
|
|
58
61
|
export interface ComposeContentUrlInput {
|
|
59
|
-
/** The content's documentType (e.g. `'product_release'`,
|
|
62
|
+
/** The content's CANONICAL documentType (e.g. `'product_release'`,
|
|
63
|
+
* `'blog_post'`).
|
|
64
|
+
*
|
|
65
|
+
* Rail-vocab aliases (`'blog_post_existing'`) must be canonicalized by the
|
|
66
|
+
* caller via `canonicalContentRefType` BEFORE they reach the seam: a host
|
|
67
|
+
* writes its `hostedTypes` / `suffixes` / `overrides` against the canonical
|
|
68
|
+
* vocabulary, so an alias silently misses every one of them and falls
|
|
69
|
+
* through to the default `<contentOrigin>/<type>/<id>` branch — which is how
|
|
70
|
+
* the same blog post reached `/blog/<slug>` from one path and
|
|
71
|
+
* `/blog_post_existing/<slug>` from another. */
|
|
60
72
|
type: string
|
|
61
73
|
/** Content identifier. Page views pass the slug; chat rows pass the
|
|
62
74
|
* primary-key id (the slug is recovered from `externalUrl` when hosted). */
|
|
63
75
|
identifier: string
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
76
|
+
/** Preferred path segment for HOSTED types when there is no `externalUrl`
|
|
77
|
+
* to recover the slug from — the Mingo/NATS transport ships bare
|
|
78
|
+
* `[card://type:id]` markers with no ref metadata, so a fetch-mode card
|
|
79
|
+
* knows the row's slug only AFTER it loads the row. `identifier` must stay
|
|
80
|
+
* the primary key (that is what `overrides` deep-link on), hence a separate
|
|
81
|
+
* field rather than overloading it. Ignored for non-hosted types. */
|
|
82
|
+
slug?: string | null
|
|
83
|
+
/** Hydrated platform junction from the list APIs. `hostedTypes` membership
|
|
84
|
+
* still decides in-app vs out; this decides WHICH origin an out-link points
|
|
85
|
+
* at — an OpenMSP-owned row resolves to openmsp.ai, not to the embedder's
|
|
86
|
+
* `contentOrigin`. Read only when `targetPlatform` is absent. Accepts the
|
|
87
|
+
* three junction shapes the DALs produce (see `primaryPlatformOf`). */
|
|
67
88
|
platforms?: Array<{ name?: string }>
|
|
68
89
|
/** The canonical hub URL when the caller already has it (chat entity rows
|
|
69
90
|
* carry it from the RAG mapper). Hosted types relativize it to an in-app
|
|
@@ -78,8 +99,11 @@ export interface ContentHrefOptions {
|
|
|
78
99
|
/** Types THIS host serves in-app → relative href (soft-nav). Everything
|
|
79
100
|
* else resolves to the row's `externalUrl` / `contentOrigin` (opens out). */
|
|
80
101
|
hostedTypes: ReadonlySet<string>
|
|
81
|
-
/**
|
|
82
|
-
* `https://openframe.app`).
|
|
102
|
+
/** Fallback origin for non-hosted types with no `externalUrl` AND no
|
|
103
|
+
* resolvable owning platform (e.g. `https://openframe.app`). When the row
|
|
104
|
+
* DOES name its platform, that platform's canonical origin wins — otherwise
|
|
105
|
+
* cross-platform content (an OpenMSP blog post) would be linked to the
|
|
106
|
+
* wrong site. */
|
|
83
107
|
contentOrigin: string
|
|
84
108
|
/** Per-type route suffix. Defaults to {@link DEFAULT_CONTENT_SUFFIXES}. */
|
|
85
109
|
suffixes?: Record<string, string>
|
|
@@ -95,6 +119,35 @@ export type ComposeContentUrl = (
|
|
|
95
119
|
input: ComposeContentUrlInput,
|
|
96
120
|
) => { href: string; targetPlatform: string | null }
|
|
97
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Owning platform of a content row, from the hydrated junction the list APIs
|
|
124
|
+
* return. Mirrors the hub's `extractPrimaryPlatform` (`lib/utils/content-url-
|
|
125
|
+
* builder.ts`): the same junction arrives in three shapes depending on which
|
|
126
|
+
* DAL produced it, so all three are read.
|
|
127
|
+
*
|
|
128
|
+
* 1. `{ platforms: { name } }` — raw Supabase row (rag-mappers)
|
|
129
|
+
* 2. `{ platform_name }` — legacy pre-flattened admin endpoints
|
|
130
|
+
* 3. `{ name }` — modern flattened shape (blog / case-study utils)
|
|
131
|
+
*
|
|
132
|
+
* Returns the FIRST resolvable name; `null` when the junction is absent or
|
|
133
|
+
* carries no usable platform (caller then falls back to `contentOrigin`).
|
|
134
|
+
*/
|
|
135
|
+
function primaryPlatformOf(
|
|
136
|
+
platforms: ComposeContentUrlInput['platforms'],
|
|
137
|
+
): string | null {
|
|
138
|
+
if (!Array.isArray(platforms)) return null
|
|
139
|
+
for (const entry of platforms) {
|
|
140
|
+
const row = entry as {
|
|
141
|
+
name?: unknown
|
|
142
|
+
platform_name?: unknown
|
|
143
|
+
platforms?: { name?: unknown }
|
|
144
|
+
}
|
|
145
|
+
const name = row?.platforms?.name ?? row?.platform_name ?? row?.name
|
|
146
|
+
if (typeof name === 'string' && name.trim()) return name.trim()
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
98
151
|
/** Last non-empty path segment of a URL (relative or absolute) — the content
|
|
99
152
|
* slug of a canonical detail URL like `https://hub/releases/my-release`.
|
|
100
153
|
* Returns `null` on a malformed URL or empty path. */
|
|
@@ -127,7 +180,19 @@ function lastPathSegment(url: string): string | null {
|
|
|
127
180
|
*/
|
|
128
181
|
export function makeComposeContentUrl(opts: ContentHrefOptions): ComposeContentUrl {
|
|
129
182
|
const suffixes = opts.suffixes ?? DEFAULT_CONTENT_SUFFIXES
|
|
130
|
-
return ({
|
|
183
|
+
return ({
|
|
184
|
+
type: rawType,
|
|
185
|
+
identifier,
|
|
186
|
+
slug: slugHint,
|
|
187
|
+
externalUrl,
|
|
188
|
+
targetPlatform,
|
|
189
|
+
platforms,
|
|
190
|
+
}) => {
|
|
191
|
+
// Defence in depth: callers are expected to hand over a canonical
|
|
192
|
+
// documentType (see `ComposeContentUrlInput.type`), but a stray alias must
|
|
193
|
+
// not silently produce a `<origin>/blog_post_existing/<slug>` URL. Same
|
|
194
|
+
// canonicalizer `buildListUrl` uses — one alias table, both paths.
|
|
195
|
+
const type = canonicalContentRefType(rawType)
|
|
131
196
|
const override =
|
|
132
197
|
opts.overrides && Object.prototype.hasOwnProperty.call(opts.overrides, type)
|
|
133
198
|
? opts.overrides[type]
|
|
@@ -143,15 +208,50 @@ export function makeComposeContentUrl(opts: ContentHrefOptions): ComposeContentU
|
|
|
143
208
|
// segment is the type's OWN suffix (e.g. a malformed/list externalUrl like
|
|
144
209
|
// `https://hub/releases/` → `'releases'`), fall back to `identifier` instead
|
|
145
210
|
// of emitting a nonsensical `/releases/releases`.
|
|
211
|
+
//
|
|
212
|
+
// `slug` (the explicit hint) sits between the two: a Mingo fetch-mode card
|
|
213
|
+
// has NO externalUrl to recover from but DOES know the row's slug once it
|
|
214
|
+
// loads, and its `identifier` must stay the primary key so `overrides`
|
|
215
|
+
// still deep-link correctly.
|
|
146
216
|
const recovered = externalUrl ? lastPathSegment(externalUrl) : null
|
|
147
|
-
const slug =
|
|
217
|
+
const slug =
|
|
218
|
+
recovered && recovered !== seg ? recovered : (slugHint?.trim() || identifier)
|
|
148
219
|
return { href: `/${seg}/${slug}`, targetPlatform: null }
|
|
149
220
|
}
|
|
150
|
-
// Not hosted → opens out. Prefer the RAG-authoritative `externalUrl` (chat)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
221
|
+
// Not hosted → opens out. Prefer the RAG-authoritative `externalUrl` (chat).
|
|
222
|
+
if (externalUrl) return { href: externalUrl, targetPlatform: targetPlatform ?? null }
|
|
223
|
+
|
|
224
|
+
// No externalUrl → compose it. The destination is the origin of the
|
|
225
|
+
// platform that OWNS the row, not a fixed hub: an OpenMSP blog post lives
|
|
226
|
+
// on openmsp.ai, and pinning every non-hosted type to `contentOrigin` sent
|
|
227
|
+
// it to flamingo.run (a 404 / wrong site). Owner comes from the explicit
|
|
228
|
+
// `targetPlatform`, else the hydrated platforms junction — mirroring the
|
|
229
|
+
// hub's `composeContentUrlFromPlatforms` + `extractPrimaryPlatform`.
|
|
230
|
+
// Origins resolve through the platform-domain SSOT (env override → default
|
|
231
|
+
// production URL), so a per-deploy `NEXT_PUBLIC_*_URL` is honoured here too.
|
|
232
|
+
//
|
|
233
|
+
// `contentOrigin` stays the fallback for rows with no resolvable owner and
|
|
234
|
+
// for platform names the registry doesn't know — an embedder's explicit
|
|
235
|
+
// configuration must win over the registry's flamingo default.
|
|
236
|
+
// Only a REGISTRY-RESOLVED owner counts. An unknown name (a platform the
|
|
237
|
+
// registry doesn't carry) falls back to `contentOrigin` for the href, and
|
|
238
|
+
// reporting it as the `targetPlatform` anyway made the two disagree:
|
|
239
|
+
// `decideNewTab` prefers `targetPlatform` over its origin check, so a link
|
|
240
|
+
// pointing at the CURRENT host opened in a new tab.
|
|
241
|
+
const claimedOwner = targetPlatform ?? primaryPlatformOf(platforms)
|
|
242
|
+
const owner = claimedOwner && byKey(claimedOwner) ? claimedOwner : null
|
|
243
|
+
const origin = owner ? getPlatformProductionUrl(owner) : opts.contentOrigin
|
|
244
|
+
// The hub's public detail routes are SLUG-based, so the `slug` hint wins
|
|
245
|
+
// over `identifier` here too — a Mingo fetch-mode card knows the row's slug
|
|
246
|
+
// but its `identifier` is the marker's primary key, which would 404.
|
|
247
|
+
return {
|
|
248
|
+
href: `${origin.replace(/\/+$/, '')}/${seg}/${slugHint?.trim() || identifier}`,
|
|
249
|
+
// Report the owner so `decideNewTab` can compare it against the current
|
|
250
|
+
// app instead of falling back to an origin guess (which is unreliable in
|
|
251
|
+
// dev, where every platform shares localhost). `null` whenever the href
|
|
252
|
+
// came from `contentOrigin`, so the two never disagree.
|
|
253
|
+
targetPlatform: owner,
|
|
254
|
+
}
|
|
155
255
|
}
|
|
156
256
|
}
|
|
157
257
|
|
package/src/utils/index.ts
CHANGED
|
@@ -299,6 +299,14 @@ export {
|
|
|
299
299
|
type NavigateSamePageHashOptions,
|
|
300
300
|
} from './same-page-hash-nav'
|
|
301
301
|
|
|
302
|
+
// GitHub-compatible anchor resolution. Doc TOCs are authored against
|
|
303
|
+
// GitHub's slugger (`## 📚 Table of Contents` → `#-table-of-contents`)
|
|
304
|
+
// while our heading ids trim the emoji's leftover hyphen — this bridges
|
|
305
|
+
// that without changing the id shape the "On this page" rail and every
|
|
306
|
+
// shared URL already depend on. Consumers resolving a hash want
|
|
307
|
+
// `getHashTargetElement` (above), which applies this as its last pass.
|
|
308
|
+
export { findAnchorElementByNormalizedId, normalizeAnchorId } from './anchor-id'
|
|
309
|
+
|
|
302
310
|
// Shared list-API URL builder — the single source for the per-type chat
|
|
303
311
|
// entity-card fetch shapes. The hub's 12 RAG mapper `listApi` closures
|
|
304
312
|
// delegate here (byte-parity test guards the migration); embedders wire
|
package/src/utils/list-url.ts
CHANGED
|
@@ -43,7 +43,12 @@ const ALIASES: Record<string, string> = { blog_post_existing: 'blog_post' }
|
|
|
43
43
|
* registry entityTypes (e.g. the related-content rail's same-type-first
|
|
44
44
|
* ordering) share THIS alias map instead of re-declaring it. */
|
|
45
45
|
export function canonicalContentRefType(contentRefType: string): string {
|
|
46
|
-
|
|
46
|
+
// `hasOwnProperty` guard, same as `buildListUrl` below: a bare index read
|
|
47
|
+
// resolves prototype keys, so `canonicalContentRefType('constructor')`
|
|
48
|
+
// returned `Object` itself — which then got interpolated into a URL path.
|
|
49
|
+
return Object.prototype.hasOwnProperty.call(ALIASES, contentRefType)
|
|
50
|
+
? ALIASES[contentRefType]
|
|
51
|
+
: contentRefType
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scrollElementIntoView } from './scroll-into-view'
|
|
2
|
+
import { findAnchorElementByNormalizedId } from './anchor-id'
|
|
2
3
|
|
|
3
4
|
/** Pages with a section-nav STRIP on top of the global hub header
|
|
4
5
|
* (dev-center roadmap/delivery/tickets, FAQ category-pill nav).
|
|
@@ -24,24 +25,42 @@ export function normalizeHashFragment(hash: string): string {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
* Resolve the DOM element a hash fragment id points at
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* Resolve the DOM element a hash fragment id points at — THE hash resolver;
|
|
29
|
+
* `navigateSamePageHash`, `useScrollToHash` and the markdown link renderer
|
|
30
|
+
* all go through this one, so a fragment that resolves for a deep link
|
|
31
|
+
* resolves for an in-body click too.
|
|
32
|
+
*
|
|
33
|
+
* Three passes, cheapest first:
|
|
34
|
+
* 1. the raw id (ordinary anchors — the overwhelming common case);
|
|
35
|
+
* 2. the percent-decoded form — href composers like `buildTicketOpenHref`
|
|
36
|
+
* encode the id into the fragment while rows render the raw id, so
|
|
37
|
+
* `#ticket-a%2Fb` must still find `id="ticket-a/b"`. A malformed escape
|
|
38
|
+
* sequence just falls through;
|
|
39
|
+
* 3. a NORMALIZED match against the document's headings — doc bodies are
|
|
40
|
+
* authored on GitHub, whose slugger keeps the hyphen left behind by a
|
|
41
|
+
* stripped emoji (`## 📚 Table of Contents` → `#-table-of-contents`)
|
|
42
|
+
* while our ids trim it. Without this every in-body TOC link in every
|
|
43
|
+
* GitHub-authored doc resolves to nothing and clicking one does nothing.
|
|
44
|
+
* See `utils/anchor-id`.
|
|
33
45
|
*/
|
|
34
46
|
export function getHashTargetElement(id: string): HTMLElement | null {
|
|
35
47
|
if (!id) return null
|
|
36
48
|
const direct = document.getElementById(id)
|
|
37
49
|
if (direct) return direct
|
|
50
|
+
|
|
51
|
+
let decoded = id
|
|
38
52
|
try {
|
|
39
|
-
|
|
40
|
-
if (decoded !== id)
|
|
53
|
+
decoded = decodeURIComponent(id)
|
|
54
|
+
if (decoded !== id) {
|
|
55
|
+
const decodedEl = document.getElementById(decoded)
|
|
56
|
+
if (decodedEl) return decodedEl
|
|
57
|
+
}
|
|
41
58
|
} catch {
|
|
42
59
|
// malformed escape — the raw lookup above already covered it
|
|
60
|
+
decoded = id
|
|
43
61
|
}
|
|
44
|
-
|
|
62
|
+
|
|
63
|
+
return findAnchorElementByNormalizedId(decoded, document)
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
export interface NavigateSamePageHashOptions {
|