References, out of three general primitives

#9
by lvwerra HF Staff - opened
Files changed (6) hide show
  1. client/app.css +48 -0
  2. client/src/doc.js +149 -9
  3. client/src/fold.js +121 -0
  4. client/src/outline.js +89 -0
  5. server/api.js +12 -1
  6. test/browser.js +126 -0
client/app.css CHANGED
@@ -749,6 +749,54 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
749
  .card.thread .head .resolve-btn { color: var(--accent); }
750
 
751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  /* --- page-link autocomplete + links --- */
753
  #pagelink-menu { position: fixed; z-index: 70; }
754
  .tiptap a { cursor: pointer; }
 
749
  .card.thread .head .resolve-btn { color: var(--accent); }
750
 
751
 
752
+ /* --- collapsible headings ---------------------------------------------------
753
+ The arrow lives in the sheet's left margin so it never moves the text, and
754
+ stays invisible until the pointer is in the document (or the section is shut,
755
+ which has to be legible at a glance). Folding is a local view state: what you
756
+ collapse is collapsed for you only. */
757
+ .tiptap .cw-heading { position: relative; }
758
+ .fold-arrow {
759
+ position: absolute; left: -1.35em; top: 0.1em; width: 1.05em; height: 1.05em; padding: 0;
760
+ display: inline-flex; align-items: center; justify-content: center;
761
+ border: none; background: none; color: var(--doc-text-3); cursor: pointer;
762
+ opacity: 0; transition: opacity .12s, transform .12s; user-select: none;
763
+ touch-action: manipulation;
764
+ }
765
+ .fold-arrow .icon { width: 1em; height: 1em; stroke-width: 2; }
766
+ .tiptap:hover .fold-arrow, .fold-arrow:focus-visible { opacity: 0.55; }
767
+ .fold-arrow:hover { opacity: 1 !important; color: var(--doc-text); }
768
+ .fold-arrow.collapsed { opacity: 0.75; transform: rotate(-90deg); }
769
+ .tiptap .cw-hidden-by-fold { display: none; }
770
+ /* a shut section says so, so a short page and a folded one are not confused */
771
+ .tiptap .cw-heading.cw-folded::after {
772
+ content: '···'; margin-left: 0.5em; font-size: 0.7em; letter-spacing: 0.15em;
773
+ color: var(--doc-text-3); vertical-align: 0.15em;
774
+ }
775
+ /* arriving from a #anchor link: a brief wash, then out of the way */
776
+ @keyframes cw-jump-fade { from { background: var(--hl-active); } to { background: transparent; } }
777
+ .tiptap .cw-jumped { animation: cw-jump-fade 1.4s ease-out; border-radius: 3px; }
778
+ @media (prefers-reduced-motion: reduce) { .tiptap .cw-jumped { animation: none; } }
779
+ /* on a touch screen there is no hover to reveal them by */
780
+ @media (hover: none) { .fold-arrow { opacity: 0.5; } }
781
+
782
+ /* --- link preview ------------------------------------------------------------
783
+ What is on the other end of an internal link: the section's heading, its
784
+ source and the start of its summary. */
785
+ #link-preview {
786
+ position: fixed; z-index: 80; width: min(360px, calc(100vw - 16px));
787
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
788
+ box-shadow: var(--shadow-md); padding: 11px 13px; pointer-events: none;
789
+ font-family: var(--ui);
790
+ }
791
+ #link-preview.hidden { display: none; }
792
+ #link-preview .lp-title { font-weight: 600; font-size: 13.5px; line-height: 1.35; color: var(--text); }
793
+ #link-preview .lp-url { font-size: 11.5px; color: var(--accent); margin-top: 2px; word-break: break-all; }
794
+ #link-preview .lp-summary { font-size: 12.5px; color: var(--text-2); margin-top: 6px; line-height: 1.45; }
795
+ #link-preview .lp-page {
796
+ font-size: 11px; color: var(--text-3); margin-top: 8px; padding-top: 6px;
797
+ border-top: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.05em;
798
+ }
799
+
800
  /* --- page-link autocomplete + links --- */
801
  #pagelink-menu { position: fixed; z-index: 70; }
802
  .tiptap a { cursor: pointer; }
client/src/doc.js CHANGED
@@ -200,6 +200,8 @@ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
200
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
201
  import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme, wireDocStyle } from './common.js'
202
  import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
 
 
203
 
204
  const docId = location.pathname.split('/').filter(Boolean)[1]
205
  // current page — mutable: switching pages swaps the document client-side
@@ -293,11 +295,23 @@ async function main() {
293
  // back/forward navigates between pages client-side
294
  window.addEventListener('popstate', () => {
295
  const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
296
- if (slug !== pageSlug) openPage(slug, { push: false })
 
 
297
  })
298
 
 
 
 
 
 
 
 
 
299
  loadStructure() // in flight while the document syncs, not after it
300
  await initPage()
 
 
301
 
302
  setInterval(loadStructure, 15000)
303
  window.addEventListener('focus', () => loadStructure())
@@ -470,6 +484,7 @@ function teardownPage() {
470
  state.chainPos = {}
471
  state.composerAnchors = null
472
  state.suggestAnchors = null
 
473
  document.getElementById('composer').classList.add('hidden')
474
  document.getElementById('suggest-composer').classList.add('hidden')
475
  document.getElementById('selection-menu').classList.add('hidden')
@@ -477,22 +492,45 @@ function teardownPage() {
477
  }
478
 
479
  // switch pages client-side: swap the document, keep header + sidebar
480
- async function openPage(slug, { push = true } = {}) {
481
  closeDrawerOnPhone() // the drawer covers the document it just navigated to
 
482
  if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) {
483
  // leaving a proposal preview back to the same page
484
  } else if (slug === pageSlug) {
 
 
 
 
 
485
  return
486
  }
487
  exitProposalPreview()
488
  teardownPage()
489
  setPage(slug)
490
- if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
491
  showDocSkeleton({ delay: 180 })
492
  await initPage()
 
493
  loadStructure()
494
  }
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  // Is this tab's collaboration socket actually delivering changes? Everything
497
  // typed or accepted while it is down never reaches the server, so actions that
498
  // must persist check this first.
@@ -578,6 +616,11 @@ function pageLinkKey(key) {
578
  return pageLinkController ? pageLinkController(key) : false
579
  }
580
 
 
 
 
 
 
581
  function wirePageLinkMenu(editor) {
582
  document.getElementById('pagelink-menu')?.remove()
583
  const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' })
@@ -594,7 +637,8 @@ function wirePageLinkMenu(editor) {
594
 
595
  const apply = item => {
596
  if (!range) return
597
- const href = item.slug === 'home' ? `/d/${docId}` : `/d/${docId}/${item.slug}`
 
598
  editor
599
  .chain()
600
  .focus()
@@ -612,7 +656,7 @@ function wirePageLinkMenu(editor) {
612
  ...items.map((item, i) => {
613
  const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
614
  row.appendChild(el('span', {}, item.title))
615
- row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.slug))
616
  row.addEventListener('mousedown', e => {
617
  e.preventDefault()
618
  apply(item)
@@ -626,10 +670,35 @@ function wirePageLinkMenu(editor) {
626
  const { state: pmState } = editor
627
  const { from, empty } = pmState.selection
628
  if (!empty) return hide()
629
- const before = pmState.doc.textBetween(Math.max(0, from - 42), from, '\n', '\n')
630
- const m = before.match(/\[\[([^\[\]]{0,32})$/)
631
  if (!m) return hide()
632
  const q = m[1].toLowerCase()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  items = Object.entries(state.pageTitles || {})
634
  .filter(([slug]) => slug !== '_structure' && slug !== pageSlug)
635
  .map(([slug, title]) => ({ slug, title }))
@@ -672,6 +741,76 @@ function wirePageLinkMenu(editor) {
672
  }
673
  }
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  // --- table controls -----------------------------------------------------------
676
  // A floating bar that appears above the table your cursor is in, with add/remove
677
  // row & column and delete-table actions (TipTap has no default table UI).
@@ -1254,6 +1393,7 @@ function buildEditor(provider) {
1254
  user: { name: state.me.username, color: colorFor(state.me.username) },
1255
  }),
1256
  Highlights,
 
1257
  ],
1258
  editorProps: {
1259
  handleClick: (view, pos, event) => {
@@ -1263,8 +1403,8 @@ function buildEditor(provider) {
1263
  const touch = window.matchMedia('(hover: none)').matches
1264
  if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey || touch)) return false
1265
  const url = new URL(a.getAttribute('href'), location.origin)
1266
- const internal = url.origin === location.origin && url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?$`))
1267
- if (internal) openPage(internal[1] || 'home')
1268
  else if (url.origin === location.origin) location.href = url.pathname + url.search
1269
  else window.open(url.href, '_blank', 'noopener')
1270
  return true
 
200
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
201
  import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme, wireDocStyle } from './common.js'
202
  import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
203
+ import { HeadingFold, outlineOf, resetFolds, unfold } from './fold.js'
204
+ import { markdownSections, parseInternalLink } from './outline.js'
205
 
206
  const docId = location.pathname.split('/').filter(Boolean)[1]
207
  // current page — mutable: switching pages swaps the document client-side
 
295
  // back/forward navigates between pages client-side
296
  window.addEventListener('popstate', () => {
297
  const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
298
+ const anchor = decodeURIComponent(location.hash.replace(/^#/, '')) || null
299
+ if (slug !== pageSlug) openPage(slug, { push: false, anchor })
300
+ else if (anchor) revealSection(anchor)
301
  })
302
 
303
+ // a hash arriving any other way (pasted, edited in the bar, an in-page anchor)
304
+ window.addEventListener('hashchange', () => {
305
+ const anchor = decodeURIComponent(location.hash.replace(/^#/, ''))
306
+ if (anchor) revealSection(anchor)
307
+ })
308
+
309
+ wireLinkPreview()
310
+
311
  loadStructure() // in flight while the document syncs, not after it
312
  await initPage()
313
+ // a link into a section, pasted or followed from outside, lands on the section
314
+ if (location.hash) revealSection(decodeURIComponent(location.hash.slice(1)))
315
 
316
  setInterval(loadStructure, 15000)
317
  window.addEventListener('focus', () => loadStructure())
 
484
  state.chainPos = {}
485
  state.composerAnchors = null
486
  state.suggestAnchors = null
487
+ resetFolds() // which sections are shut is a fact about the page you were on
488
  document.getElementById('composer').classList.add('hidden')
489
  document.getElementById('suggest-composer').classList.add('hidden')
490
  document.getElementById('selection-menu').classList.add('hidden')
 
492
  }
493
 
494
  // switch pages client-side: swap the document, keep header + sidebar
495
+ async function openPage(slug, { push = true, anchor = null } = {}) {
496
  closeDrawerOnPhone() // the drawer covers the document it just navigated to
497
+ const hash = anchor ? `#${anchor}` : ''
498
  if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) {
499
  // leaving a proposal preview back to the same page
500
  } else if (slug === pageSlug) {
501
+ // already here: a link into a section of this page is just a jump
502
+ if (anchor) {
503
+ if (push) history.replaceState({}, '', location.pathname + hash)
504
+ revealSection(anchor)
505
+ }
506
  return
507
  }
508
  exitProposalPreview()
509
  teardownPage()
510
  setPage(slug)
511
+ if (push) history.pushState({}, '', (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) + hash)
512
  showDocSkeleton({ delay: 180 })
513
  await initPage()
514
+ if (anchor) revealSection(anchor)
515
  loadStructure()
516
  }
517
 
518
+ // Jump to a heading by its slug — unfolding it first if it is collapsed, since
519
+ // scrolling to something that is hidden is no answer at all.
520
+ function revealSection(anchor, tries = 0) {
521
+ if (!anchor) return
522
+ unfold(anchor)
523
+ const target = document.querySelector(`.tiptap [data-anchor="${CSS.escape(anchor)}"]`)
524
+ if (!target) {
525
+ // the page may still be syncing; a few frames of patience, then give up
526
+ if (tries < 20) setTimeout(() => revealSection(anchor, tries + 1), 100)
527
+ return
528
+ }
529
+ target.scrollIntoView({ block: 'start', behavior: 'smooth' })
530
+ target.classList.add('cw-jumped')
531
+ setTimeout(() => target.classList.remove('cw-jumped'), 1400)
532
+ }
533
+
534
  // Is this tab's collaboration socket actually delivering changes? Everything
535
  // typed or accepted while it is down never reaches the server, so actions that
536
  // must persist check this first.
 
616
  return pageLinkController ? pageLinkController(key) : false
617
  }
618
 
619
+ // the headings of the page in front of you, in the shape sectionsFor() returns
620
+ function pageOutline(editor) {
621
+ return outlineOf(editor.state.doc).map(h => ({ slug: h.slug, title: h.node.textContent, summary: '', url: '' }))
622
+ }
623
+
624
  function wirePageLinkMenu(editor) {
625
  document.getElementById('pagelink-menu')?.remove()
626
  const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' })
 
637
 
638
  const apply = item => {
639
  if (!range) return
640
+ const path = item.slug === 'home' ? `/d/${docId}` : `/d/${docId}/${item.slug}`
641
+ const href = item.anchor ? `${path}#${item.anchor}` : path
642
  editor
643
  .chain()
644
  .focus()
 
656
  ...items.map((item, i) => {
657
  const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
658
  row.appendChild(el('span', {}, item.title))
659
+ row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.anchor ? `#${item.anchor}` : item.slug))
660
  row.addEventListener('mousedown', e => {
661
  e.preventDefault()
662
  apply(item)
 
670
  const { state: pmState } = editor
671
  const { from, empty } = pmState.selection
672
  if (!empty) return hide()
673
+ const before = pmState.doc.textBetween(Math.max(0, from - 80), from, '\n', '\n')
674
+ const m = before.match(/\[\[([^\[\]]{0,64})$/)
675
  if (!m) return hide()
676
  const q = m[1].toLowerCase()
677
+ // "[[page#" switches to that page's headings — how you cite one entry of a
678
+ // references page rather than the whole list
679
+ const deep = q.match(/^(.*?)#(.*)$/)
680
+ if (deep) {
681
+ const [, pageQ, headQ] = deep
682
+ const page = Object.entries(state.pageTitles || {})
683
+ .map(([slug, title]) => ({ slug, title }))
684
+ .find(p => p.slug === pageQ || p.title.toLowerCase() === pageQ)
685
+ if (!page) return hide()
686
+ // the target page's own headings, unless it IS this page (already in hand)
687
+ const load = page.slug === pageSlug ? Promise.resolve(pageOutline(editor)) : sectionsFor(page.slug)
688
+ Promise.resolve(load).then(sections => {
689
+ if (!range) return
690
+ items = sections
691
+ .filter(s => s.slug.includes(headQ) || s.title.toLowerCase().includes(headQ))
692
+ .slice(0, 8)
693
+ .map(s => ({ slug: page.slug, title: s.title, anchor: s.slug }))
694
+ if (!items.length) return hide()
695
+ sel = Math.min(sel, items.length - 1)
696
+ render()
697
+ menu.classList.remove('hidden')
698
+ })
699
+ range = { from: from - m[0].length, to: from }
700
+ return
701
+ }
702
  items = Object.entries(state.pageTitles || {})
703
  .filter(([slug]) => slug !== '_structure' && slug !== pageSlug)
704
  .map(([slug, title]) => ({ slug, title }))
 
741
  }
742
  }
743
 
744
+ // --- link previews --------------------------------------------------------------
745
+ // Hovering a link to another page in this project shows what is on the other
746
+ // end: for "…/references#attention-is-all-you-need" that is the section's
747
+ // heading, its source URL and the start of its summary. Nothing here knows what
748
+ // a reference IS — it previews any internal link, and a references page is just
749
+ // the page where that happens to be most useful.
750
+
751
+ const pageCache = new Map() // slug -> { at, sections: Promise }
752
+ const PREVIEW_TTL = 30000 // a page being edited next door should not stay stale
753
+
754
+ function sectionsFor(slug) {
755
+ const hit = pageCache.get(slug)
756
+ if (hit && Date.now() - hit.at < PREVIEW_TTL) return hit.sections
757
+ const sections = api(`/api/docs/${docId}${slug === 'home' ? '' : `?page=${slug}`}`)
758
+ .then(res => markdownSections(res?.markdown || ''))
759
+ .catch(() => [])
760
+ pageCache.set(slug, { at: Date.now(), sections })
761
+ return sections
762
+ }
763
+
764
+ function wireLinkPreview() {
765
+ const pop = el('div', { id: 'link-preview', class: 'hidden' })
766
+ document.body.appendChild(pop)
767
+ let over = null
768
+ let timer = null
769
+
770
+ const hide = () => {
771
+ clearTimeout(timer)
772
+ over = null
773
+ pop.classList.add('hidden')
774
+ }
775
+
776
+ const show = async (a, target) => {
777
+ const sections = await sectionsFor(target.slug)
778
+ if (over !== a) return // the pointer moved on while we were fetching
779
+ const section = target.anchor ? sections.find(s => s.slug === target.anchor) : sections[0]
780
+ const title = section?.title || state.pageTitles?.[target.slug] || target.slug
781
+ const summary = section?.summary || ''
782
+ if (!title && !summary) return
783
+ pop.replaceChildren(
784
+ el('div', { class: 'lp-title' }, title),
785
+ ...(section?.url ? [el('div', { class: 'lp-url' }, section.url.replace(/^https?:\/\//, ''))] : []),
786
+ ...(summary ? [el('div', { class: 'lp-summary' }, summary.slice(0, 220) + (summary.length > 220 ? '…' : ''))] : []),
787
+ el('div', { class: 'lp-page' }, state.pageTitles?.[target.slug] || target.slug)
788
+ )
789
+ pop.classList.remove('hidden')
790
+ // place it under the link, kept inside the window
791
+ const r = a.getBoundingClientRect()
792
+ const w = pop.offsetWidth
793
+ pop.style.left = `${Math.max(8, Math.min(window.innerWidth - w - 8, r.left))}px`
794
+ const below = r.bottom + 8
795
+ pop.style.top = `${below + pop.offsetHeight > window.innerHeight - 8 ? Math.max(8, r.top - pop.offsetHeight - 8) : below}px`
796
+ }
797
+
798
+ document.addEventListener('mouseover', e => {
799
+ const a = e.target?.closest?.('.tiptap a, .proposal-content a, #margin-items a')
800
+ if (!a) {
801
+ if (over && !e.target?.closest?.('#link-preview')) hide()
802
+ return
803
+ }
804
+ if (a === over) return
805
+ const target = parseInternalLink(a.getAttribute('href'), docId)
806
+ if (!target) return hide()
807
+ hide()
808
+ over = a
809
+ timer = setTimeout(() => show(a, target), 280) // not on every passing pointer
810
+ })
811
+ document.addEventListener('scroll', hide, true)
812
+ }
813
+
814
  // --- table controls -----------------------------------------------------------
815
  // A floating bar that appears above the table your cursor is in, with add/remove
816
  // row & column and delete-table actions (TipTap has no default table UI).
 
1393
  user: { name: state.me.username, color: colorFor(state.me.username) },
1394
  }),
1395
  Highlights,
1396
+ HeadingFold,
1397
  ],
1398
  editorProps: {
1399
  handleClick: (view, pos, event) => {
 
1403
  const touch = window.matchMedia('(hover: none)').matches
1404
  if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey || touch)) return false
1405
  const url = new URL(a.getAttribute('href'), location.origin)
1406
+ const internal = parseInternalLink(a.getAttribute('href'), docId)
1407
+ if (internal) openPage(internal.slug, { anchor: internal.anchor })
1408
  else if (url.origin === location.origin) location.href = url.pathname + url.search
1409
  else window.open(url.href, '_blank', 'noopener')
1410
  return true
client/src/fold.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Collapsible headings.
2
+ //
3
+ // A long page — a references list above all — is only navigable if its sections
4
+ // can be shut. Folding is a VIEW state, not document content: it lives in this
5
+ // module, never in the Yjs doc, so collapsing a section on your screen does not
6
+ // collapse it on everyone else's, and nothing about it is persisted or synced.
7
+ //
8
+ // Sections are keyed by heading slug (the same slug a "#anchor" link uses), so
9
+ // the fold survives edits elsewhere in the page and a link can open a folded
10
+ // section by name.
11
+ import { Extension } from '@tiptap/core'
12
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
13
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
14
+ import { makeSlugger } from './outline.js'
15
+
16
+ export const foldKey = new PluginKey('headingFold')
17
+
18
+ const folded = new Set()
19
+ let onToggle = () => {}
20
+
21
+ export function resetFolds() {
22
+ folded.clear()
23
+ }
24
+
25
+ export function isFolded(slug) {
26
+ return folded.has(slug)
27
+ }
28
+
29
+ export function unfold(slug) {
30
+ if (!folded.delete(slug)) return false
31
+ onToggle()
32
+ return true
33
+ }
34
+
35
+ // The page's headings in document order, each with the slug everything else
36
+ // addresses it by.
37
+ export function outlineOf(doc) {
38
+ const slugOf = makeSlugger()
39
+ const out = []
40
+ doc.forEach((node, offset) => {
41
+ if (node.type.name !== 'heading') return
42
+ out.push({ slug: slugOf(node.textContent), level: node.attrs.level || 1, pos: offset, node })
43
+ })
44
+ return out
45
+ }
46
+
47
+ // Walk the top level once: every heading gets its slug and a fold arrow, and
48
+ // everything under a folded heading — up to the next heading of the same or a
49
+ // higher rank — gets hidden.
50
+ function decorate(doc) {
51
+ const decos = []
52
+ const headings = outlineOf(doc)
53
+ const hidden = []
54
+ headings.forEach((h, i) => {
55
+ if (!folded.has(h.slug)) return
56
+ const next = headings.slice(i + 1).find(n => n.level <= h.level)
57
+ hidden.push([h.pos + h.node.nodeSize, next ? next.pos : doc.content.size])
58
+ })
59
+
60
+ let hi = 0
61
+ doc.forEach((node, offset) => {
62
+ const end = offset + node.nodeSize
63
+ const heading = headings[hi]
64
+ if (heading && heading.pos === offset) {
65
+ hi++
66
+ const collapsed = folded.has(heading.slug)
67
+ decos.push(
68
+ Decoration.node(offset, end, {
69
+ class: `cw-heading${collapsed ? ' cw-folded' : ''}`,
70
+ 'data-anchor': heading.slug,
71
+ })
72
+ )
73
+ decos.push(
74
+ Decoration.widget(offset + 1, () => foldArrow(heading.slug, collapsed), {
75
+ side: -1,
76
+ key: `fold-${heading.slug}-${collapsed}`,
77
+ })
78
+ )
79
+ return
80
+ }
81
+ if (hidden.some(([from, to]) => offset >= from && end <= to)) {
82
+ decos.push(Decoration.node(offset, end, { class: 'cw-hidden-by-fold' }))
83
+ }
84
+ })
85
+ return DecorationSet.create(doc, decos)
86
+ }
87
+
88
+ function foldArrow(slug, collapsed) {
89
+ const btn = document.createElement('button')
90
+ btn.className = 'fold-arrow' + (collapsed ? ' collapsed' : '')
91
+ btn.type = 'button'
92
+ btn.contentEditable = 'false'
93
+ btn.setAttribute('aria-expanded', String(!collapsed))
94
+ btn.title = collapsed ? 'Expand section' : 'Collapse section'
95
+ btn.innerHTML = '<svg viewBox="0 0 22 22" class="icon"><path d="M6 9l5 5 5-5"/></svg>'
96
+ // mousedown, not click: ProseMirror would otherwise move the caret into the
97
+ // heading first and scroll the section you are trying to shut
98
+ btn.addEventListener('mousedown', e => {
99
+ e.preventDefault()
100
+ e.stopPropagation()
101
+ if (!folded.delete(slug)) folded.add(slug)
102
+ onToggle()
103
+ })
104
+ return btn
105
+ }
106
+
107
+ export const HeadingFold = Extension.create({
108
+ name: 'headingFold',
109
+ addProseMirrorPlugins() {
110
+ const editor = this.editor
111
+ onToggle = () => editor.view.dispatch(editor.state.tr.setMeta('fold', true))
112
+ return [
113
+ new Plugin({
114
+ key: foldKey,
115
+ props: {
116
+ decorations: state => decorate(state.doc),
117
+ },
118
+ }),
119
+ ]
120
+ },
121
+ })
client/src/outline.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Headings as addressable sections.
2
+ //
3
+ // One slug rule, used in three places that have to agree: the fold state, the
4
+ // "#anchor" at the end of a page link, and the hover preview that reads the
5
+ // target section. Anything that can name a heading names it this way.
6
+
7
+ export function headingSlug(text) {
8
+ return String(text || '')
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ .slice(0, 64)
13
+ }
14
+
15
+ // Slugs are unique within a page: repeats take -2, -3 … in document order, so
16
+ // two sections called "Method" stay separately addressable.
17
+ export function makeSlugger() {
18
+ const seen = new Map()
19
+ return text => {
20
+ const base = headingSlug(text) || 'section'
21
+ const n = (seen.get(base) || 0) + 1
22
+ seen.set(base, n)
23
+ return n === 1 ? base : `${base}-${n}`
24
+ }
25
+ }
26
+
27
+ // Split page markdown into sections: the heading, its slug, the prose under it
28
+ // and the first link in that prose. For a references page that is exactly the
29
+ // source's title, its URL and the start of its summary — but nothing here knows
30
+ // what a reference is, so it works for any page.
31
+ export function markdownSections(markdown) {
32
+ const slugOf = makeSlugger()
33
+ const sections = []
34
+ let fenced = false
35
+ for (const line of String(markdown || '').split('\n')) {
36
+ if (/^\s*```/.test(line)) fenced = !fenced
37
+ const head = !fenced && line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/)
38
+ if (head) {
39
+ sections.push({ level: head[1].length, title: stripMarkup(head[2]), slug: slugOf(head[2]), body: [] })
40
+ } else if (sections.length) {
41
+ sections[sections.length - 1].body.push(line)
42
+ }
43
+ }
44
+ return sections.map(s => {
45
+ const text = s.body.join('\n').trim()
46
+ return { ...s, summary: stripMarkup(firstProse(text)), url: firstUrl(text) }
47
+ })
48
+ }
49
+
50
+ // The first paragraph that is prose rather than a bare link line — a reference
51
+ // usually leads with its URL, and "https://…" is a poor preview of a source.
52
+ function firstProse(text) {
53
+ for (const para of text.split(/\n\s*\n/)) {
54
+ const p = para.trim()
55
+ if (!p || /^\[?https?:\/\//.test(p) || /^<https?:\/\//.test(p)) continue
56
+ return p.replace(/\s+/g, ' ')
57
+ }
58
+ return ''
59
+ }
60
+
61
+ function firstUrl(text) {
62
+ return text.match(/https?:\/\/[^\s)>\]]+/)?.[0] || ''
63
+ }
64
+
65
+ // Markdown left in a heading or a summary should read as text in a preview
66
+ // card, not as syntax.
67
+ function stripMarkup(s) {
68
+ return String(s || '')
69
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
70
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
71
+ .replace(/[*_`]+/g, '')
72
+ .trim()
73
+ }
74
+
75
+ // "/d/<doc>/<page>#<anchor>" → the parts, for any link that points inside this
76
+ // project. Returns null for anything else (external links, other documents).
77
+ export function parseInternalLink(href, docId) {
78
+ if (!href) return null
79
+ let url
80
+ try {
81
+ url = new URL(href, location.origin)
82
+ } catch {
83
+ return null
84
+ }
85
+ if (url.origin !== location.origin) return null
86
+ const m = url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?/?$`))
87
+ if (!m) return null
88
+ return { slug: m[1] || 'home', anchor: decodeURIComponent(url.hash.replace(/^#/, '')) || null }
89
+ }
server/api.js CHANGED
@@ -483,7 +483,18 @@ Work loop — repeat until the user tells you to stop:
483
  the page with your content and adds it to the structure) or rejects it. To reorganize
484
  EXISTING pages, suggest an edit to the _structure page's YAML as an ordinary suggestion.
485
  Removing a page's line never deletes it — it just moves to "unfiled" in the sidebar.
486
- Cross-reference pages in any markdown you write as [Title](/d/<doc_id>/<slug>).
 
 
 
 
 
 
 
 
 
 
 
487
 
488
  3. Do the task the instruction asks for, using your own tools and judgment.
489
 
 
483
  the page with your content and adds it to the structure) or rejects it. To reorganize
484
  EXISTING pages, suggest an edit to the _structure page's YAML as an ordinary suggestion.
485
  Removing a page's line never deletes it — it just moves to "unfiled" in the sidebar.
486
+ Cross-reference pages in any markdown you write as [Title](/d/<doc_id>/<slug>),
487
+ or a single section as [Title](/d/<doc_id>/<slug>#<heading-slug>) — the slug of a
488
+ heading is its text lowercased with every run of non-alphanumerics turned into a
489
+ hyphen ("Attention Is All You Need" -> attention-is-all-you-need). Readers get a
490
+ preview of the section when they hover the link.
491
+
492
+ Sources: when you cite anything, keep a "references" page and cite INTO it.
493
+ Create it if it does not exist (a new-page suggestion titled "References").
494
+ One "### Source title" section per source, and under it the URL on its own line
495
+ followed by a 1-3 sentence summary of what it says. Then cite it from the text as
496
+ [Source title](/d/<doc_id>/references#source-title). Never paste bare URLs into
497
+ the prose, and never repeat a source's summary outside its section.
498
 
499
  3. Do the task the instruction asks for, using your own tools and judgment.
500
 
test/browser.js CHANGED
@@ -1035,6 +1035,132 @@ async function main() {
1035
  await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
1036
  console.log('✓ [[ page cross-references')
1037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
  // ghost page: visiting a slug that does not exist creates it, titled by slug
1039
  const projId = page.url().split('/d/')[1].split('/')[0]
1040
  await page.goto(`${BASE}/d/${projId}/road-map`)
 
1035
  await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
1036
  console.log('✓ [[ page cross-references')
1037
 
1038
+ // --- references: sections are addressable, previewable and collapsible ---
1039
+ // No references machinery exists: an agent writes an ordinary page with one
1040
+ // heading per source, and these three primitives make it work as a citation
1041
+ // system. The test therefore builds that page the way an agent would.
1042
+ const refsMd = [
1043
+ '# References',
1044
+ '',
1045
+ '### Attention Is All You Need',
1046
+ '',
1047
+ 'https://arxiv.org/abs/1706.03762',
1048
+ '',
1049
+ 'Introduces the transformer: attention replaces recurrence entirely.',
1050
+ '',
1051
+ '### Deep Residual Learning',
1052
+ '',
1053
+ 'https://arxiv.org/abs/1512.03385',
1054
+ '',
1055
+ 'Residual connections let very deep networks train at all.',
1056
+ '',
1057
+ ].join('\n')
1058
+ const refsDocId = page.url().split('/d/')[1].split('/')[0]
1059
+ const refsPage = await page.evaluate(
1060
+ async ([id, md]) => {
1061
+ const post = (path, body) =>
1062
+ fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json())
1063
+ const proposed = await post(`/api/docs/${id}/page-suggestions`, { title: 'References', content_markdown: md })
1064
+ return post(`/api/docs/${id}/page-suggestions/${proposed.page_suggestion_id}/accept`, {})
1065
+ },
1066
+ [refsDocId, refsMd]
1067
+ )
1068
+ assert.ok(refsPage.ok, 'references page created: ' + JSON.stringify(refsPage))
1069
+ // the sidebar polls the structure; the autocomplete reads the same list
1070
+ await waitFor(
1071
+ async () => page.evaluate(() => [...document.querySelectorAll('#page-tree a')].some(a => a.textContent.includes('References'))),
1072
+ 'the references page reaches the page tree',
1073
+ 20000
1074
+ )
1075
+
1076
+ // cite it: "[[references#att" offers the section, and links straight into it
1077
+ await focusDocEnd(page)
1078
+ await page.keyboard.press('Enter')
1079
+ await page.keyboard.type('As shown in [[references#att')
1080
+ await waitFor(
1081
+ async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))),
1082
+ 'the heading autocomplete offers sections of the target page'
1083
+ )
1084
+ await page.keyboard.press('Enter')
1085
+ const citationHref = () =>
1086
+ page.evaluate(() =>
1087
+ [...document.querySelectorAll('.tiptap a')].map(a => a.getAttribute('href')).find(h => h.includes('/references#'))
1088
+ )
1089
+ await waitFor(citationHref, 'a citation links into the section')
1090
+ const citation = await citationHref()
1091
+ assert.ok(citation.endsWith('#attention-is-all-you-need'), 'the anchor is the heading slug: ' + citation)
1092
+
1093
+ // hovering it shows what is on the other end
1094
+ await page.hover(`.tiptap a[href$="#attention-is-all-you-need"]`)
1095
+ await waitFor(async () => !(await page.evaluate(() => document.getElementById('link-preview').classList.contains('hidden'))), 'a preview appears')
1096
+ const preview = await page.evaluate(() => ({
1097
+ title: document.querySelector('#link-preview .lp-title')?.textContent,
1098
+ url: document.querySelector('#link-preview .lp-url')?.textContent,
1099
+ summary: document.querySelector('#link-preview .lp-summary')?.textContent,
1100
+ page: document.querySelector('#link-preview .lp-page')?.textContent,
1101
+ }))
1102
+ assert.strictEqual(preview.title, 'Attention Is All You Need', 'the preview names the source')
1103
+ assert.ok(preview.url?.includes('arxiv.org/abs/1706.03762'), 'and carries its link: ' + preview.url)
1104
+ assert.ok(preview.summary?.startsWith('Introduces the transformer'), 'and the start of the summary: ' + preview.summary)
1105
+ assert.strictEqual(preview.page, 'References', 'and says which page it is on')
1106
+
1107
+ // following it lands on that section, not merely on the page
1108
+ await page.click(`.tiptap a[href$="#attention-is-all-you-need"]`, { modifiers: ['Control'] })
1109
+ await waitFor(async () => (await page.evaluate(() => location.hash)) === '#attention-is-all-you-need', 'the link carries the anchor')
1110
+ await waitFor(
1111
+ async () => page.evaluate(() => !!document.querySelector('.tiptap [data-anchor="attention-is-all-you-need"]')),
1112
+ 'the references page is open with its sections addressable'
1113
+ )
1114
+ const jumped = await page.evaluate(() => {
1115
+ const h = document.querySelector('.tiptap [data-anchor="attention-is-all-you-need"]')
1116
+ return { onScreen: h.getBoundingClientRect().top < innerHeight, marked: h.classList.contains('cw-jumped') }
1117
+ })
1118
+ assert.ok(jumped.onScreen, 'the section was scrolled into view')
1119
+
1120
+ // and a section can be shut, locally
1121
+ const folding = async () =>
1122
+ page.evaluate(() => ({
1123
+ hidden: document.querySelectorAll('.tiptap .cw-hidden-by-fold').length,
1124
+ folded: [...document.querySelectorAll('.tiptap .cw-folded')].map(n => n.dataset.anchor),
1125
+ bodyVisible: document.querySelector('.tiptap').innerText.includes('attention replaces recurrence'),
1126
+ }))
1127
+ const clickArrow = anchor =>
1128
+ page.evaluate(
1129
+ a =>
1130
+ document
1131
+ .querySelector(`.tiptap [data-anchor="${a}"] .fold-arrow`)
1132
+ .dispatchEvent(new MouseEvent('mousedown', { bubbles: true })),
1133
+ anchor
1134
+ )
1135
+ assert.ok((await folding()).bodyVisible, 'the section reads before folding')
1136
+ await clickArrow('attention-is-all-you-need')
1137
+ await page.waitForTimeout(250)
1138
+ const shut = await folding()
1139
+ assert.deepStrictEqual(shut.folded, ['attention-is-all-you-need'], 'the heading shows as folded')
1140
+ assert.ok(shut.hidden > 0 && !shut.bodyVisible, 'its body is hidden, and only its body')
1141
+ assert.ok(
1142
+ await page.evaluate(() => document.querySelector('.tiptap').innerText.includes('Deep Residual Learning')),
1143
+ 'the next section is untouched'
1144
+ )
1145
+ await clickArrow('attention-is-all-you-need')
1146
+ await page.waitForTimeout(250)
1147
+ assert.ok((await folding()).bodyVisible, 'unfolding brings it back')
1148
+
1149
+ // a link INTO a folded section has to open it, or the jump lands on nothing
1150
+ await clickArrow('attention-is-all-you-need')
1151
+ await page.waitForTimeout(250)
1152
+ await page.evaluate(() => {
1153
+ location.hash = ''
1154
+ location.hash = 'attention-is-all-you-need'
1155
+ })
1156
+ await page.waitForTimeout(600)
1157
+ assert.ok((await folding()).bodyVisible, 'following an anchor unfolds the section it points at')
1158
+ console.log('✓ references: #section links, hover previews, collapsible headings')
1159
+
1160
+ await page.goto(`${BASE}/d/${refsDocId}`)
1161
+ await page.waitForSelector('.tiptap', { timeout: 10000 })
1162
+ await page.waitForTimeout(800)
1163
+
1164
  // ghost page: visiting a slug that does not exist creates it, titled by slug
1165
  const projId = page.url().split('/d/')[1].split('/')[0]
1166
  await page.goto(`${BASE}/d/${projId}/road-map`)