@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.
- package/README.md +211 -0
- package/dist/overlay.js +1367 -0
- package/package.json +51 -0
- package/src/apply/apply-suggestion.ts +157 -0
- package/src/dev/api-handlers.ts +215 -0
- package/src/dev/middleware.ts +65 -0
- package/src/dev/request-utils.ts +71 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +168 -0
- package/src/overlay/App.tsx +434 -0
- package/src/overlay/components/CommentPopover.tsx +96 -0
- package/src/overlay/components/DiffPreview.tsx +29 -0
- package/src/overlay/components/ElementHighlight.tsx +33 -0
- package/src/overlay/components/SelectionTooltip.tsx +48 -0
- package/src/overlay/components/Sidebar.tsx +70 -0
- package/src/overlay/components/SidebarItem.tsx +104 -0
- package/src/overlay/components/StaleWarning.tsx +19 -0
- package/src/overlay/components/SuggestPopover.tsx +139 -0
- package/src/overlay/components/Toolbar.tsx +38 -0
- package/src/overlay/env.d.ts +4 -0
- package/src/overlay/index.tsx +71 -0
- package/src/overlay/lib/cms-bridge.ts +33 -0
- package/src/overlay/lib/dom-walker.ts +61 -0
- package/src/overlay/lib/manifest-fetch.ts +35 -0
- package/src/overlay/lib/notes-fetch.ts +121 -0
- package/src/overlay/lib/range-anchor.ts +87 -0
- package/src/overlay/lib/url-mode.ts +43 -0
- package/src/overlay/styles.css +526 -0
- package/src/overlay/types.ts +66 -0
- package/src/storage/id-gen.ts +32 -0
- package/src/storage/json-store.ts +196 -0
- package/src/storage/slug.ts +35 -0
- package/src/storage/types.ts +100 -0
- package/src/tsconfig.json +6 -0
- 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
|
+
}
|
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
|
+
}
|