@nuasite/notes 0.1.0

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.
Files changed (35) hide show
  1. package/README.md +211 -0
  2. package/dist/overlay.js +1367 -0
  3. package/package.json +51 -0
  4. package/src/apply/apply-suggestion.ts +157 -0
  5. package/src/dev/api-handlers.ts +215 -0
  6. package/src/dev/middleware.ts +65 -0
  7. package/src/dev/request-utils.ts +71 -0
  8. package/src/index.ts +2 -0
  9. package/src/integration.ts +168 -0
  10. package/src/overlay/App.tsx +434 -0
  11. package/src/overlay/components/CommentPopover.tsx +96 -0
  12. package/src/overlay/components/DiffPreview.tsx +29 -0
  13. package/src/overlay/components/ElementHighlight.tsx +33 -0
  14. package/src/overlay/components/SelectionTooltip.tsx +48 -0
  15. package/src/overlay/components/Sidebar.tsx +70 -0
  16. package/src/overlay/components/SidebarItem.tsx +104 -0
  17. package/src/overlay/components/StaleWarning.tsx +19 -0
  18. package/src/overlay/components/SuggestPopover.tsx +139 -0
  19. package/src/overlay/components/Toolbar.tsx +38 -0
  20. package/src/overlay/env.d.ts +4 -0
  21. package/src/overlay/index.tsx +71 -0
  22. package/src/overlay/lib/cms-bridge.ts +33 -0
  23. package/src/overlay/lib/dom-walker.ts +61 -0
  24. package/src/overlay/lib/manifest-fetch.ts +35 -0
  25. package/src/overlay/lib/notes-fetch.ts +121 -0
  26. package/src/overlay/lib/range-anchor.ts +87 -0
  27. package/src/overlay/lib/url-mode.ts +43 -0
  28. package/src/overlay/styles.css +526 -0
  29. package/src/overlay/types.ts +66 -0
  30. package/src/storage/id-gen.ts +32 -0
  31. package/src/storage/json-store.ts +196 -0
  32. package/src/storage/slug.ts +35 -0
  33. package/src/storage/types.ts +100 -0
  34. package/src/tsconfig.json +6 -0
  35. package/src/types.ts +50 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Read-only fetchers for `@nuasite/cms`'s public manifest endpoints.
3
+ *
4
+ * The overlay uses the per-page manifest to map a `data-cms-id` element back
5
+ * to its source file + line + snippet at the moment a note is created. We
6
+ * intentionally do NOT pull from the global `/cms-manifest.json` here — the
7
+ * per-page file already contains everything we need and is much smaller.
8
+ */
9
+
10
+ import type { CmsPageManifest } from '../types'
11
+
12
+ /**
13
+ * Build the per-page manifest URL for a page path.
14
+ * / → /index.json
15
+ * /inspekce-nemovitosti → /inspekce-nemovitosti.json
16
+ * /blog/post → /blog/post.json
17
+ */
18
+ export function manifestUrlForPage(page: string): string {
19
+ if (page === '' || page === '/') return '/index.json'
20
+ const trimmed = page.replace(/^\/+|\/+$/g, '')
21
+ return `/${trimmed}.json`
22
+ }
23
+
24
+ export async function fetchPageManifest(page: string): Promise<CmsPageManifest | null> {
25
+ const url = manifestUrlForPage(page)
26
+ try {
27
+ const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
28
+ if (!res.ok) return null
29
+ const data = (await res.json()) as CmsPageManifest
30
+ return data
31
+ } catch (err) {
32
+ console.warn(`[nuasite-notes] Failed to fetch CMS manifest for "${page}":`, err instanceof Error ? err.message : err)
33
+ return null
34
+ }
35
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Thin wrapper around `/_nua/notes/*` endpoints.
3
+ *
4
+ * All methods return parsed JSON. Errors throw with the server-provided
5
+ * message so the overlay can surface them in a banner.
6
+ */
7
+
8
+ import type { NoteItem, NoteRange, NotesPageFile, NoteStatus, NoteType } from '../types'
9
+
10
+ const BASE = '/_nua/notes'
11
+ const TIMEOUT_MS = 10_000
12
+
13
+ async function postJson<T>(path: string, body: unknown): Promise<T> {
14
+ const res = await fetch(`${BASE}${path}`, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify(body),
18
+ signal: AbortSignal.timeout(TIMEOUT_MS),
19
+ })
20
+ const text = await res.text()
21
+ let parsed: any
22
+ try {
23
+ parsed = text ? JSON.parse(text) : {}
24
+ } catch {
25
+ throw new Error(`notes: invalid JSON response from ${path}`)
26
+ }
27
+ if (!res.ok) {
28
+ throw new Error(parsed?.error ?? `notes: ${path} failed (${res.status})`)
29
+ }
30
+ return parsed as T
31
+ }
32
+
33
+ export async function listNotes(page: string): Promise<NotesPageFile> {
34
+ const res = await fetch(`${BASE}/list?page=${encodeURIComponent(page)}`, {
35
+ headers: { Accept: 'application/json' },
36
+ signal: AbortSignal.timeout(TIMEOUT_MS),
37
+ })
38
+ if (!res.ok) throw new Error(`notes: list failed (${res.status})`)
39
+ return (await res.json()) as NotesPageFile
40
+ }
41
+
42
+ export interface CreateInput {
43
+ page: string
44
+ type: NoteType
45
+ targetCmsId: string
46
+ targetSourcePath?: string
47
+ targetSourceLine?: number
48
+ targetSnippet?: string
49
+ range?: NoteRange | null
50
+ body: string
51
+ author: string
52
+ }
53
+
54
+ export async function createNote(input: CreateInput): Promise<NoteItem> {
55
+ const res = await postJson<{ item: NoteItem }>('/create', input)
56
+ return res.item
57
+ }
58
+
59
+ export async function updateNote(
60
+ page: string,
61
+ id: string,
62
+ patch: Partial<Pick<NoteItem, 'body' | 'status' | 'targetSnippet'>> & { range?: NoteRange | null },
63
+ ): Promise<NoteItem> {
64
+ const res = await postJson<{ item: NoteItem }>('/update', { page, id, patch })
65
+ return res.item
66
+ }
67
+
68
+ export async function setNoteStatus(page: string, id: string, status: NoteStatus): Promise<NoteItem> {
69
+ if (status === 'resolved') {
70
+ const res = await postJson<{ item: NoteItem }>('/resolve', { page, id })
71
+ return res.item
72
+ }
73
+ if (status === 'open') {
74
+ const res = await postJson<{ item: NoteItem }>('/reopen', { page, id })
75
+ return res.item
76
+ }
77
+ return updateNote(page, id, { status })
78
+ }
79
+
80
+ export async function deleteNote(page: string, id: string): Promise<void> {
81
+ await postJson<{ ok: true }>('/delete', { page, id })
82
+ }
83
+
84
+ export interface ApplyResponse {
85
+ item: NoteItem
86
+ file?: string
87
+ before?: string
88
+ after?: string
89
+ error?: string
90
+ reason?: string
91
+ }
92
+
93
+ /**
94
+ * Apply a suggestion. Returns the updated item plus before/after snippets
95
+ * on success, or throws with the server error message. The 409 response
96
+ * (drift) is returned as a normal value because the server already updated
97
+ * the item to `stale` and the overlay should refresh accordingly.
98
+ */
99
+ export async function applyNote(page: string, id: string): Promise<ApplyResponse> {
100
+ const res = await fetch(`${BASE}/apply`, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({ page, id }),
104
+ signal: AbortSignal.timeout(TIMEOUT_MS),
105
+ })
106
+ const text = await res.text()
107
+ let parsed: any
108
+ try {
109
+ parsed = text ? JSON.parse(text) : {}
110
+ } catch {
111
+ throw new Error(`notes: invalid JSON response from /apply`)
112
+ }
113
+ if (res.status === 409) {
114
+ // Drift — server already marked as stale
115
+ return parsed as ApplyResponse
116
+ }
117
+ if (!res.ok) {
118
+ throw new Error(parsed?.error ?? `notes: /apply failed (${res.status})`)
119
+ }
120
+ return parsed as ApplyResponse
121
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Anchor-text re-attachment for range suggestions.
3
+ *
4
+ * Suggestions don't store character offsets — they store the original
5
+ * substring (`anchorText`). On reload we walk the target element's text
6
+ * nodes and look for that substring. If found, we return a DOM `Range`
7
+ * the overlay can use to draw highlights or compute coordinates. If not
8
+ * found, the suggestion is reported as stale so the sidebar can surface
9
+ * a re-attach CTA.
10
+ *
11
+ * The matching is intentionally simple: first exact match, then a
12
+ * collapsed-whitespace fallback (handles HTML re-flowing). Anything more
13
+ * sophisticated (fuzzy / Levenshtein) waits for a real-world failure.
14
+ */
15
+
16
+ import { collectTextNodes, rangeFromOffsets } from './dom-walker'
17
+
18
+ export interface AnchorMatch {
19
+ range: Range
20
+ rect: DOMRect
21
+ start: number
22
+ end: number
23
+ }
24
+
25
+ function collapseWhitespace(s: string): string {
26
+ return s.replace(/\s+/g, ' ').trim()
27
+ }
28
+
29
+ export function findAnchorRange(el: Element, anchorText: string): AnchorMatch | null {
30
+ if (!anchorText) return null
31
+ const { joined } = collectTextNodes(el)
32
+ if (!joined) return null
33
+
34
+ // 1. Exact substring match
35
+ const idx = joined.indexOf(anchorText)
36
+ if (idx >= 0) {
37
+ const range = rangeFromOffsets(el, idx, idx + anchorText.length)
38
+ if (range) return { range, rect: range.getBoundingClientRect(), start: idx, end: idx + anchorText.length }
39
+ }
40
+
41
+ // 2. Whitespace-collapsed fallback. Build a map from collapsed offsets back
42
+ // to original offsets so we can still produce a real DOM range.
43
+ const collapsedAnchor = collapseWhitespace(anchorText)
44
+ if (!collapsedAnchor) return null
45
+ const map: number[] = [] // collapsed index → original index
46
+ let collapsed = ''
47
+ let prevWs = false
48
+ for (let i = 0; i < joined.length; i++) {
49
+ const ch = joined[i]!
50
+ const isWs = /\s/.test(ch)
51
+ if (isWs) {
52
+ if (collapsed.length === 0) continue // leading
53
+ if (prevWs) continue
54
+ collapsed += ' '
55
+ map.push(i)
56
+ prevWs = true
57
+ } else {
58
+ collapsed += ch
59
+ map.push(i)
60
+ prevWs = false
61
+ }
62
+ }
63
+ const trimmed = collapsed.replace(/\s+$/, '')
64
+ const cIdx = trimmed.indexOf(collapsedAnchor)
65
+ if (cIdx < 0) return null
66
+ const startOrig = map[cIdx]
67
+ const endOrigInclusive = map[cIdx + collapsedAnchor.length - 1]
68
+ if (startOrig == null || endOrigInclusive == null) return null
69
+ const range = rangeFromOffsets(el, startOrig, endOrigInclusive + 1)
70
+ if (!range) return null
71
+ return { range, rect: range.getBoundingClientRect(), start: startOrig, end: endOrigInclusive + 1 }
72
+ }
73
+
74
+ /**
75
+ * Compute the substring of an element's joined text inside the user's
76
+ * current selection. Returns null if the selection is empty or not fully
77
+ * inside the element.
78
+ */
79
+ export function selectionInsideElement(el: Element, selection: Selection): { text: string; rect: DOMRect } | null {
80
+ if (selection.rangeCount === 0) return null
81
+ const range = selection.getRangeAt(0)
82
+ if (range.collapsed) return null
83
+ if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null
84
+ const text = range.toString()
85
+ if (!text.trim()) return null
86
+ return { text, rect: range.getBoundingClientRect() }
87
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * URL flag + cookie helpers controlling notes review mode.
3
+ *
4
+ * Review mode is on when EITHER the URL has the `?<urlFlag>` query param OR
5
+ * the `nua-notes-mode=1` cookie is set. Visiting a page with the flag the
6
+ * first time sets the cookie so subsequent navigation stays in review mode.
7
+ *
8
+ * The toggle button in the toolbar clears the cookie + reloads, which drops
9
+ * the user back into the regular CMS view.
10
+ */
11
+
12
+ const COOKIE = 'nua-notes-mode'
13
+
14
+ export function isReviewMode(urlFlag: string): boolean {
15
+ if (typeof window === 'undefined') return false
16
+ const url = new URL(window.location.href)
17
+ if (url.searchParams.has(urlFlag)) return true
18
+ return document.cookie.split('; ').some(c => c.startsWith(`${COOKIE}=1`))
19
+ }
20
+
21
+ export function setReviewModeCookie(): void {
22
+ // Session cookie — cleared when the browser closes. Good enough for v0.1.
23
+ document.cookie = `${COOKIE}=1; path=/; SameSite=Lax`
24
+ }
25
+
26
+ export function clearReviewModeCookie(): void {
27
+ document.cookie = `${COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`
28
+ }
29
+
30
+ export function exitReviewMode(urlFlag: string): void {
31
+ clearReviewModeCookie()
32
+ const url = new URL(window.location.href)
33
+ url.searchParams.delete(urlFlag)
34
+ window.location.href = url.pathname + (url.search || '') + url.hash
35
+ }
36
+
37
+ export function getCurrentPagePath(): string {
38
+ if (typeof window === 'undefined') return '/'
39
+ const p = window.location.pathname
40
+ if (p === '' || p === '/') return '/'
41
+ // Drop trailing slash for consistency with CMS / notes storage
42
+ return p.endsWith('/') ? p.slice(0, -1) : p
43
+ }