@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,196 @@
1
+ /**
2
+ * JSON file store for `@nuasite/notes`.
3
+ *
4
+ * One file per page lives at `<notesDir>/pages/<slug>.json`. Reads tolerate a
5
+ * missing file (returns an empty page). Writes are atomic (write to a `.tmp`
6
+ * file, then rename) and serialized per slug via an in-memory mutex so
7
+ * concurrent POSTs don't clobber each other.
8
+ *
9
+ * The store is intentionally simple — no caching, no in-memory index, no
10
+ * background flush. Each request reads the file from disk, mutates, writes
11
+ * back. This is fine for the local-dev usage profile and matches how
12
+ * `@nuasite/cms` writes back to source files.
13
+ */
14
+
15
+ import fs from 'node:fs/promises'
16
+ import path from 'node:path'
17
+ import { generateNoteId } from './id-gen'
18
+ import { normalizePagePath, pageToSlug } from './slug'
19
+ import type { NoteItem, NoteItemPatch, NotesPageFile } from './types'
20
+
21
+ const FILE_VERSION_HEADER = '// @nuasite/notes v1\n'
22
+
23
+ function nowIso(): string {
24
+ return new Date().toISOString()
25
+ }
26
+
27
+ /**
28
+ * Per-slug write mutex. Concurrent POSTs to the same page are serialized;
29
+ * different pages can write in parallel. Cleared after each lock release.
30
+ */
31
+ const locks = new Map<string, Promise<unknown>>()
32
+
33
+ async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
34
+ const prev = locks.get(key) ?? Promise.resolve()
35
+ let release!: () => void
36
+ const next = new Promise<void>((resolve) => {
37
+ release = resolve
38
+ })
39
+ locks.set(key, prev.then(() => next))
40
+ try {
41
+ await prev
42
+ return await fn()
43
+ } finally {
44
+ release()
45
+ // If we're the tail of the chain, clean up so the map doesn't grow unbounded
46
+ if (locks.get(key) === prev.then(() => next)) {
47
+ locks.delete(key)
48
+ }
49
+ }
50
+ }
51
+
52
+ export interface JsonStoreOptions {
53
+ /** Absolute path to the project root. Required so the store can resolve `notesDir`. */
54
+ projectRoot: string
55
+ /** Project-relative directory where note JSON files live. */
56
+ notesDir: string
57
+ }
58
+
59
+ export class NotesJsonStore {
60
+ private readonly pagesDir: string
61
+
62
+ constructor(private readonly options: JsonStoreOptions) {
63
+ this.pagesDir = path.resolve(options.projectRoot, options.notesDir, 'pages')
64
+ }
65
+
66
+ /** Resolve the absolute path of the JSON file for a given page. */
67
+ private fileFor(page: string): string {
68
+ return path.join(this.pagesDir, `${pageToSlug(page)}.json`)
69
+ }
70
+
71
+ /** Read a page's notes file from disk, or return an empty page if missing. */
72
+ async readPage(page: string): Promise<NotesPageFile> {
73
+ const normalized = normalizePagePath(page)
74
+ const filePath = this.fileFor(normalized)
75
+ try {
76
+ const raw = await fs.readFile(filePath, 'utf-8')
77
+ // Tolerate the optional version header
78
+ const stripped = raw.startsWith('//') ? raw.slice(raw.indexOf('\n') + 1) : raw
79
+ const parsed = JSON.parse(stripped) as NotesPageFile
80
+ // Normalize legacy / partial files
81
+ return {
82
+ page: parsed.page ?? normalized,
83
+ lastUpdated: parsed.lastUpdated ?? nowIso(),
84
+ items: Array.isArray(parsed.items) ? parsed.items : [],
85
+ }
86
+ } catch (err) {
87
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
88
+ return { page: normalized, lastUpdated: nowIso(), items: [] }
89
+ }
90
+ throw err
91
+ }
92
+ }
93
+
94
+ /** Write a page's notes file to disk atomically. Creates parents as needed. */
95
+ private async writePageFile(page: string, file: NotesPageFile): Promise<void> {
96
+ const filePath = this.fileFor(page)
97
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
98
+ const body = FILE_VERSION_HEADER + JSON.stringify(file, null, '\t') + '\n'
99
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`
100
+ await fs.writeFile(tmp, body, 'utf-8')
101
+ await fs.rename(tmp, filePath)
102
+ }
103
+
104
+ /** Add a new item. Generates the id, createdAt, and defaults status to "open". */
105
+ async addItem(
106
+ page: string,
107
+ input: Omit<NoteItem, 'id' | 'createdAt' | 'status' | 'replies'> & {
108
+ id?: string
109
+ createdAt?: string
110
+ status?: NoteItem['status']
111
+ replies?: NoteItem['replies']
112
+ },
113
+ ): Promise<NoteItem> {
114
+ const normalized = normalizePagePath(page)
115
+ return withLock(normalized, async () => {
116
+ const file = await this.readPage(normalized)
117
+ const item: NoteItem = {
118
+ id: input.id ?? generateNoteId(),
119
+ type: input.type,
120
+ targetCmsId: input.targetCmsId,
121
+ targetSourcePath: input.targetSourcePath,
122
+ targetSourceLine: input.targetSourceLine,
123
+ targetSnippet: input.targetSnippet,
124
+ range: input.range ?? null,
125
+ body: input.body,
126
+ author: input.author,
127
+ createdAt: input.createdAt ?? nowIso(),
128
+ status: input.status ?? 'open',
129
+ replies: input.replies ?? [],
130
+ }
131
+ file.items.push(item)
132
+ file.page = normalized
133
+ file.lastUpdated = nowIso()
134
+ await this.writePageFile(normalized, file)
135
+ return item
136
+ })
137
+ }
138
+
139
+ /** Patch an existing item. Returns the updated item, or null if not found. */
140
+ async updateItem(page: string, id: string, patch: NoteItemPatch): Promise<NoteItem | null> {
141
+ const normalized = normalizePagePath(page)
142
+ return withLock(normalized, async () => {
143
+ const file = await this.readPage(normalized)
144
+ const idx = file.items.findIndex(it => it.id === id)
145
+ if (idx === -1) return null
146
+ const existing = file.items[idx]!
147
+ const updated: NoteItem = {
148
+ ...existing,
149
+ ...patch,
150
+ // `range: null` is a meaningful patch (clearing a range), preserve it
151
+ range: 'range' in patch ? (patch.range ?? null) : existing.range,
152
+ updatedAt: nowIso(),
153
+ }
154
+ file.items[idx] = updated
155
+ file.lastUpdated = nowIso()
156
+ await this.writePageFile(normalized, file)
157
+ return updated
158
+ })
159
+ }
160
+
161
+ /** Delete an item by id. Returns true if it existed. */
162
+ async deleteItem(page: string, id: string): Promise<boolean> {
163
+ const normalized = normalizePagePath(page)
164
+ return withLock(normalized, async () => {
165
+ const file = await this.readPage(normalized)
166
+ const idx = file.items.findIndex(it => it.id === id)
167
+ if (idx === -1) return false
168
+ file.items.splice(idx, 1)
169
+ file.lastUpdated = nowIso()
170
+ await this.writePageFile(normalized, file)
171
+ return true
172
+ })
173
+ }
174
+
175
+ /** List all pages that have at least one note item. Used by the agency inbox (Phase 5). */
176
+ async listAllPages(): Promise<NotesPageFile[]> {
177
+ try {
178
+ const files = await fs.readdir(this.pagesDir)
179
+ const pages: NotesPageFile[] = []
180
+ for (const f of files) {
181
+ if (!f.endsWith('.json')) continue
182
+ try {
183
+ const raw = await fs.readFile(path.join(this.pagesDir, f), 'utf-8')
184
+ const stripped = raw.startsWith('//') ? raw.slice(raw.indexOf('\n') + 1) : raw
185
+ pages.push(JSON.parse(stripped) as NotesPageFile)
186
+ } catch (err) {
187
+ console.warn(`[nuasite-notes] Skipping malformed notes file ${f}:`, err instanceof Error ? err.message : err)
188
+ }
189
+ }
190
+ return pages
191
+ } catch (err) {
192
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
193
+ throw err
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Slug helpers for mapping page paths to JSON file names.
3
+ *
4
+ * The notes overlay anchors items to page paths like `/inspekce-nemovitosti`,
5
+ * `/blog/post-name`, or `/`. We need a stable filesystem-safe representation
6
+ * of those paths so the JSON store can write one file per page.
7
+ *
8
+ * Rules:
9
+ * - Leading slash is stripped.
10
+ * - The root page `/` becomes `index`.
11
+ * - Trailing slashes are stripped.
12
+ * - Path separators become double underscores so nested paths stay readable.
13
+ * - Anything outside `[a-z0-9._-]` is replaced with a single dash.
14
+ */
15
+
16
+ const SAFE_RE = /[^a-z0-9._-]+/g
17
+
18
+ export function pageToSlug(page: string): string {
19
+ const trimmed = page.trim().replace(/^\/+|\/+$/g, '')
20
+ if (!trimmed) return 'index'
21
+ return trimmed
22
+ .toLowerCase()
23
+ .split('/')
24
+ .map(seg => seg.replace(SAFE_RE, '-').replace(/^-+|-+$/g, '') || '-')
25
+ .join('__')
26
+ }
27
+
28
+ export function normalizePagePath(page: string): string {
29
+ if (!page) return '/'
30
+ let p = page.trim()
31
+ if (!p.startsWith('/')) p = '/' + p
32
+ // Drop trailing slash except for root
33
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
34
+ return p
35
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Storage types for `@nuasite/notes`.
3
+ *
4
+ * One JSON file per page lives at `<notesDir>/pages/<slug>.json` and contains
5
+ * a flat list of items. Each item is either an element-level `comment` or a
6
+ * range-level `suggestion` (Google Docs style strikethrough/insertion diff).
7
+ *
8
+ * Both item types share the same target metadata so the sidebar can render
9
+ * them in a unified list ordered by creation time.
10
+ */
11
+
12
+ /**
13
+ * Item lifecycle. Notes start as `open`. Reviewers (or the agency) move them
14
+ * through the other states. `applied` is reserved for suggestions that have
15
+ * been written back to the source file via the apply flow (Phase 4).
16
+ */
17
+ export type NoteStatus = 'open' | 'resolved' | 'applied' | 'rejected' | 'stale'
18
+
19
+ export type NoteType = 'comment' | 'suggestion'
20
+
21
+ /**
22
+ * Range payload for `type: "suggestion"` items.
23
+ *
24
+ * We intentionally store the original substring (`anchorText`) instead of
25
+ * character offsets so the suggestion can survive small edits to surrounding
26
+ * text. On load, the overlay walks text nodes inside the target element
27
+ * looking for `anchorText`. If not found, the suggestion is marked stale.
28
+ */
29
+ export interface NoteRange {
30
+ /** Exact substring used as the search anchor when re-attaching on load. */
31
+ anchorText: string
32
+ /** Original text the reviewer wants to replace. Usually equal to anchorText for v0.1. */
33
+ originalText: string
34
+ /** The reviewer's proposed replacement text. */
35
+ suggestedText: string
36
+ /** Optional reasoning the reviewer leaves for the agency. */
37
+ rationale?: string
38
+ }
39
+
40
+ export interface NoteReply {
41
+ id: string
42
+ author: string
43
+ body: string
44
+ createdAt: string
45
+ }
46
+
47
+ /**
48
+ * One note item — either an element comment or a range suggestion.
49
+ *
50
+ * `targetCmsId` is the anchor: it points at a `data-cms-id` element from the
51
+ * `@nuasite/cms` manifest, which knows how to map back to file + line + snippet.
52
+ * The other `target*` fields are denormalized copies of the manifest entry at
53
+ * the time the note was created so the sidebar has something to render even
54
+ * if the source file has drifted.
55
+ */
56
+ export interface NoteItem {
57
+ id: string
58
+ type: NoteType
59
+
60
+ /** Anchor element from the CMS manifest. */
61
+ targetCmsId: string
62
+ targetSourcePath?: string
63
+ targetSourceLine?: number
64
+ targetSnippet?: string
65
+
66
+ /** Only set when type === 'suggestion'. */
67
+ range: NoteRange | null
68
+
69
+ body: string
70
+ author: string
71
+ createdAt: string
72
+ updatedAt?: string
73
+
74
+ status: NoteStatus
75
+ replies: NoteReply[]
76
+ }
77
+
78
+ /**
79
+ * Shape of one `<notesDir>/pages/<slug>.json` file on disk.
80
+ */
81
+ export interface NotesPageFile {
82
+ /** Page path the items are anchored to, e.g. `/inspekce-nemovitosti` or `/`. */
83
+ page: string
84
+ /** ISO timestamp of the most recent mutation. */
85
+ lastUpdated: string
86
+ items: NoteItem[]
87
+ }
88
+
89
+ /**
90
+ * Patch payload for `updateItem`. All fields are optional; only the provided
91
+ * ones are merged onto the existing item.
92
+ */
93
+ export interface NoteItemPatch {
94
+ body?: string
95
+ status?: NoteStatus
96
+ range?: NoteRange | null
97
+ targetSnippet?: string
98
+ targetSourcePath?: string
99
+ targetSourceLine?: number
100
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../tsconfig.settings.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/types"
5
+ }
6
+ }
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Public types for `@nuasite/notes`.
3
+ *
4
+ * Phase 0: only the integration options live here. Item types
5
+ * (`NoteItem`, `NoteRange`, `NoteStatus`) and storage types are added
6
+ * in Phase 1 alongside the JSON store.
7
+ */
8
+
9
+ export interface NuaNotesOptions {
10
+ /**
11
+ * Master switch. Defaults to `true` in dev, ignored in build.
12
+ */
13
+ enabled?: boolean
14
+
15
+ /**
16
+ * Directory (relative to project root) where note JSON files are stored,
17
+ * one file per page at `<notesDir>/pages/<slug>.json`.
18
+ *
19
+ * Default: `data/notes`
20
+ */
21
+ notesDir?: string
22
+
23
+ /**
24
+ * Hide the `@nuasite/cms` editor chrome when notes mode is active
25
+ * (URL flag `?nua-notes` or cookie `nua-notes-mode=1`).
26
+ *
27
+ * When `false`, both UIs may render simultaneously and pointer events
28
+ * collide. Only set to `false` if you know what you're doing.
29
+ *
30
+ * Default: `true`
31
+ */
32
+ hideCmsInReviewMode?: boolean
33
+
34
+ /**
35
+ * URL query flag that activates notes review mode. Reviewers append
36
+ * this to any page URL to switch from CMS edit mode to notes review mode.
37
+ *
38
+ * Default: `nua-notes`
39
+ */
40
+ urlFlag?: string
41
+
42
+ /**
43
+ * Forward `/_nua/notes/*` requests through this proxy target. Mirrors the
44
+ * pattern used by `@nuasite/cms` for sandbox/hosted dev. The target backend
45
+ * must implement the matching `/notes/*` endpoints.
46
+ *
47
+ * Example: `http://localhost:8787`
48
+ */
49
+ proxy?: string
50
+ }