@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
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuasite/notes",
|
|
3
|
+
"description": "Astro integration adding a Pastel-style comment + Google Docs-style suggestion overlay alongside @nuasite/cms.",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist/**",
|
|
6
|
+
"src/**",
|
|
7
|
+
"README.md",
|
|
8
|
+
"package.json"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/nuasite/nuasite/blob/main/packages/notes/README.md",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/nuasite/nuasite.git",
|
|
14
|
+
"directory": "packages/notes"
|
|
15
|
+
},
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"module": "src/index.ts",
|
|
19
|
+
"types": "src/index.ts",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./src/index.ts",
|
|
24
|
+
"import": "./src/index.ts",
|
|
25
|
+
"default": "./src/index.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "1.3.11",
|
|
31
|
+
"astro": "6.1.4",
|
|
32
|
+
"preact": "^10.29.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"astro": "6.1.4",
|
|
36
|
+
"typescript": "^6.0.2"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "vite build --config vite.config.overlay.ts",
|
|
40
|
+
"prepack": "bun run build && bun run ../../scripts/workspace-deps/resolve-deps.ts"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"astro",
|
|
44
|
+
"cms",
|
|
45
|
+
"devtools",
|
|
46
|
+
"nuasite",
|
|
47
|
+
"review",
|
|
48
|
+
"comments",
|
|
49
|
+
"suggestions"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a suggestion's text replacement back to the source file.
|
|
3
|
+
*
|
|
4
|
+
* Each suggestion item already carries the source location it was created
|
|
5
|
+
* against (`targetSourcePath`, `targetSourceLine`) — both populated from the
|
|
6
|
+
* `@nuasite/cms` per-page manifest at create time. That gives us everything
|
|
7
|
+
* we need to perform the apply locally without peer-importing CMS internals:
|
|
8
|
+
*
|
|
9
|
+
* 1. Read the source file at `targetSourcePath`.
|
|
10
|
+
* 2. Find `range.originalText` in the file.
|
|
11
|
+
* - If it appears exactly once → replace it.
|
|
12
|
+
* - If it appears multiple times → use `targetSourceLine` to pick the
|
|
13
|
+
* nearest occurrence (within a small window). This handles repeated
|
|
14
|
+
* words / boilerplate inside large files.
|
|
15
|
+
* - If it doesn't appear → drift detected, return `stale`.
|
|
16
|
+
* 3. Atomically write the file back (write `.tmp`, rename).
|
|
17
|
+
*
|
|
18
|
+
* The Vite watcher inside CMS picks up the file write and triggers HMR,
|
|
19
|
+
* which reloads the page and shows the applied text. The notes API handler
|
|
20
|
+
* also fires its own HMR full-reload to be safe.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs/promises'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import type { NoteItem } from '../storage/types'
|
|
26
|
+
|
|
27
|
+
export type ApplyResult =
|
|
28
|
+
| { ok: true; file: string; before: string; after: string }
|
|
29
|
+
| { ok: false; reason: 'not-suggestion' | 'missing-source' | 'file-error' | 'not-found' | 'ambiguous'; message: string }
|
|
30
|
+
|
|
31
|
+
interface ApplyOptions {
|
|
32
|
+
projectRoot: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LINE_WINDOW = 8
|
|
36
|
+
|
|
37
|
+
function* findAllOccurrences(haystack: string, needle: string): Generator<number> {
|
|
38
|
+
if (!needle) return
|
|
39
|
+
let from = 0
|
|
40
|
+
while (true) {
|
|
41
|
+
const idx = haystack.indexOf(needle, from)
|
|
42
|
+
if (idx < 0) return
|
|
43
|
+
yield idx
|
|
44
|
+
from = idx + needle.length
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lineOfOffset(content: string, offset: number): number {
|
|
49
|
+
let line = 1
|
|
50
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
51
|
+
if (content[i] === '\n') line++
|
|
52
|
+
}
|
|
53
|
+
return line
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve `targetSourcePath` to an absolute path inside the project root.
|
|
58
|
+
* Defends against path traversal: the resolved path must stay inside the
|
|
59
|
+
* project root.
|
|
60
|
+
*/
|
|
61
|
+
function resolveSafe(projectRoot: string, sourcePath: string): string | null {
|
|
62
|
+
const root = path.resolve(projectRoot)
|
|
63
|
+
const candidate = path.resolve(root, sourcePath)
|
|
64
|
+
if (!candidate.startsWith(root + path.sep) && candidate !== root) return null
|
|
65
|
+
return candidate
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function applySuggestion(item: NoteItem, options: ApplyOptions): Promise<ApplyResult> {
|
|
69
|
+
if (item.type !== 'suggestion' || !item.range) {
|
|
70
|
+
return { ok: false, reason: 'not-suggestion', message: 'Only suggestion items can be applied' }
|
|
71
|
+
}
|
|
72
|
+
if (!item.targetSourcePath) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: 'missing-source',
|
|
76
|
+
message: 'Suggestion is missing targetSourcePath. Cannot resolve which file to edit.',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const abs = resolveSafe(options.projectRoot, item.targetSourcePath)
|
|
80
|
+
if (!abs) {
|
|
81
|
+
return { ok: false, reason: 'missing-source', message: `Refusing to write outside project root: ${item.targetSourcePath}` }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let content: string
|
|
85
|
+
try {
|
|
86
|
+
content = await fs.readFile(abs, 'utf-8')
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: 'file-error',
|
|
91
|
+
message: `Could not read ${item.targetSourcePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const original = item.range.originalText
|
|
96
|
+
const replacement = item.range.suggestedText
|
|
97
|
+
if (!original) {
|
|
98
|
+
return { ok: false, reason: 'not-found', message: 'Suggestion has empty originalText' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const occurrences = Array.from(findAllOccurrences(content, original))
|
|
102
|
+
if (occurrences.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: 'not-found',
|
|
106
|
+
message: `Original text not found in ${item.targetSourcePath}. Source may have drifted since the suggestion was made.`,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let chosenOffset: number
|
|
111
|
+
if (occurrences.length === 1) {
|
|
112
|
+
chosenOffset = occurrences[0]!
|
|
113
|
+
} else {
|
|
114
|
+
// Pick the occurrence closest to targetSourceLine (within a small window).
|
|
115
|
+
const targetLine = item.targetSourceLine ?? 0
|
|
116
|
+
let best: { offset: number; dist: number } | null = null
|
|
117
|
+
for (const off of occurrences) {
|
|
118
|
+
const ln = lineOfOffset(content, off)
|
|
119
|
+
const dist = Math.abs(ln - targetLine)
|
|
120
|
+
if (!best || dist < best.dist) best = { offset: off, dist }
|
|
121
|
+
}
|
|
122
|
+
if (!best || best.dist > LINE_WINDOW) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: 'ambiguous',
|
|
126
|
+
message:
|
|
127
|
+
`Original text appears ${occurrences.length} times in ${item.targetSourcePath} and none are near targetSourceLine ${targetLine}. Refusing to apply.`,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
chosenOffset = best.offset
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const before = content.slice(Math.max(0, chosenOffset - 40), chosenOffset + original.length + 40)
|
|
134
|
+
const newContent = content.slice(0, chosenOffset) + replacement + content.slice(chosenOffset + original.length)
|
|
135
|
+
const after = newContent.slice(Math.max(0, chosenOffset - 40), chosenOffset + replacement.length + 40)
|
|
136
|
+
|
|
137
|
+
// Atomic write — same pattern the JSON store uses.
|
|
138
|
+
const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`
|
|
139
|
+
try {
|
|
140
|
+
await fs.writeFile(tmp, newContent, 'utf-8')
|
|
141
|
+
await fs.rename(tmp, abs)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Clean up tmp on failure
|
|
144
|
+
try {
|
|
145
|
+
await fs.unlink(tmp)
|
|
146
|
+
} catch (cleanupErr) {
|
|
147
|
+
console.warn(`[nuasite-notes] Failed to clean up temp file ${tmp}:`, cleanupErr instanceof Error ? cleanupErr.message : cleanupErr)
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: 'file-error',
|
|
152
|
+
message: `Could not write ${item.targetSourcePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { ok: true, file: item.targetSourcePath, before, after }
|
|
157
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev API route handlers for `/_nua/notes/*`.
|
|
3
|
+
*
|
|
4
|
+
* All handlers operate on a single `NotesJsonStore` and follow the same
|
|
5
|
+
* shape as `@nuasite/cms`'s api-routes: parse query/body, mutate, send JSON.
|
|
6
|
+
*
|
|
7
|
+
* Routes (all mounted under `/_nua/notes/`):
|
|
8
|
+
*
|
|
9
|
+
* GET /list?page=/<page> → list items for one page
|
|
10
|
+
* GET /inbox → list items across all pages (Phase 5 use)
|
|
11
|
+
* POST /create → create a comment or suggestion
|
|
12
|
+
* POST /update → patch an existing item
|
|
13
|
+
* POST /resolve → mark item as resolved
|
|
14
|
+
* POST /reopen → reopen a resolved item
|
|
15
|
+
* POST /delete → delete an item
|
|
16
|
+
* POST /apply → write a suggestion's replacement back to source
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
20
|
+
import { applySuggestion } from '../apply/apply-suggestion'
|
|
21
|
+
import type { NotesJsonStore } from '../storage/json-store'
|
|
22
|
+
import type { NoteItem, NoteItemPatch, NoteRange, NoteStatus, NoteType } from '../storage/types'
|
|
23
|
+
import { parseJsonBody, sendError, sendJson } from './request-utils'
|
|
24
|
+
|
|
25
|
+
export interface NotesApiContext {
|
|
26
|
+
store: NotesJsonStore
|
|
27
|
+
projectRoot: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CreateBody {
|
|
31
|
+
page: string
|
|
32
|
+
type: NoteType
|
|
33
|
+
targetCmsId: string
|
|
34
|
+
targetSourcePath?: string
|
|
35
|
+
targetSourceLine?: number
|
|
36
|
+
targetSnippet?: string
|
|
37
|
+
range?: NoteRange | null
|
|
38
|
+
body: string
|
|
39
|
+
author: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UpdateBody {
|
|
43
|
+
page: string
|
|
44
|
+
id: string
|
|
45
|
+
patch: NoteItemPatch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IdBody {
|
|
49
|
+
page: string
|
|
50
|
+
id: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getQuery(url: string): URLSearchParams {
|
|
54
|
+
const q = url.indexOf('?')
|
|
55
|
+
return new URLSearchParams(q >= 0 ? url.slice(q + 1) : '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function handleNotesApiRoute(
|
|
59
|
+
route: string,
|
|
60
|
+
req: IncomingMessage,
|
|
61
|
+
res: ServerResponse,
|
|
62
|
+
ctx: NotesApiContext,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const { store } = ctx
|
|
65
|
+
const method = req.method ?? 'GET'
|
|
66
|
+
|
|
67
|
+
// GET /list?page=/some-page
|
|
68
|
+
if (method === 'GET' && route === 'list') {
|
|
69
|
+
const params = getQuery(req.url ?? '')
|
|
70
|
+
const page = params.get('page')
|
|
71
|
+
if (!page) {
|
|
72
|
+
sendError(res, 'Missing required query param: page', 400, req)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const file = await store.readPage(page)
|
|
76
|
+
sendJson(res, file, 200, req)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// GET /inbox — all items across all pages
|
|
81
|
+
if (method === 'GET' && route === 'inbox') {
|
|
82
|
+
const pages = await store.listAllPages()
|
|
83
|
+
sendJson(res, { pages }, 200, req)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// POST /create
|
|
88
|
+
if (method === 'POST' && route === 'create') {
|
|
89
|
+
const body = await parseJsonBody<CreateBody>(req)
|
|
90
|
+
if (!body.page || !body.type || !body.targetCmsId || !body.author) {
|
|
91
|
+
sendError(res, 'Missing required fields: page, type, targetCmsId, author', 400, req)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (body.type !== 'comment' && body.type !== 'suggestion') {
|
|
95
|
+
sendError(res, `Invalid type: ${body.type}`, 400, req)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (body.type === 'suggestion' && !body.range) {
|
|
99
|
+
sendError(res, 'Suggestion items require a range payload', 400, req)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
// Comments must have a body; suggestions may have an empty body since
|
|
103
|
+
// the diff itself communicates the change.
|
|
104
|
+
if (body.type === 'comment' && !body.body?.trim()) {
|
|
105
|
+
sendError(res, 'Comment items require a non-empty body', 400, req)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const item = await store.addItem(body.page, {
|
|
109
|
+
type: body.type,
|
|
110
|
+
targetCmsId: body.targetCmsId,
|
|
111
|
+
targetSourcePath: body.targetSourcePath,
|
|
112
|
+
targetSourceLine: body.targetSourceLine,
|
|
113
|
+
targetSnippet: body.targetSnippet,
|
|
114
|
+
range: body.range ?? null,
|
|
115
|
+
body: body.body ?? '',
|
|
116
|
+
author: body.author,
|
|
117
|
+
})
|
|
118
|
+
sendJson(res, { item }, 201, req)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// POST /update
|
|
123
|
+
if (method === 'POST' && route === 'update') {
|
|
124
|
+
const body = await parseJsonBody<UpdateBody>(req)
|
|
125
|
+
if (!body.page || !body.id || !body.patch) {
|
|
126
|
+
sendError(res, 'Missing required fields: page, id, patch', 400, req)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const updated = await store.updateItem(body.page, body.id, body.patch)
|
|
130
|
+
if (!updated) {
|
|
131
|
+
sendError(res, `Item not found: ${body.id}`, 404, req)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
sendJson(res, { item: updated }, 200, req)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// POST /resolve and POST /reopen — convenience wrappers around update
|
|
139
|
+
if (method === 'POST' && (route === 'resolve' || route === 'reopen')) {
|
|
140
|
+
const body = await parseJsonBody<IdBody>(req)
|
|
141
|
+
if (!body.page || !body.id) {
|
|
142
|
+
sendError(res, 'Missing required fields: page, id', 400, req)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const status: NoteStatus = route === 'resolve' ? 'resolved' : 'open'
|
|
146
|
+
const updated = await store.updateItem(body.page, body.id, { status })
|
|
147
|
+
if (!updated) {
|
|
148
|
+
sendError(res, `Item not found: ${body.id}`, 404, req)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
sendJson(res, { item: updated }, 200, req)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// POST /delete
|
|
156
|
+
if (method === 'POST' && route === 'delete') {
|
|
157
|
+
const body = await parseJsonBody<IdBody>(req)
|
|
158
|
+
if (!body.page || !body.id) {
|
|
159
|
+
sendError(res, 'Missing required fields: page, id', 400, req)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const ok = await store.deleteItem(body.page, body.id)
|
|
163
|
+
if (!ok) {
|
|
164
|
+
sendError(res, `Item not found: ${body.id}`, 404, req)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
sendJson(res, { ok: true }, 200, req)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// POST /apply — write the suggestion's replacement back to the source file
|
|
172
|
+
if (method === 'POST' && route === 'apply') {
|
|
173
|
+
const body = await parseJsonBody<IdBody>(req)
|
|
174
|
+
if (!body.page || !body.id) {
|
|
175
|
+
sendError(res, 'Missing required fields: page, id', 400, req)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const file = await store.readPage(body.page)
|
|
179
|
+
const item = file.items.find(it => it.id === body.id)
|
|
180
|
+
if (!item) {
|
|
181
|
+
sendError(res, `Item not found: ${body.id}`, 404, req)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (item.type !== 'suggestion' || !item.range) {
|
|
185
|
+
sendError(res, 'Only suggestion items can be applied', 400, req)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = await applySuggestion(item, { projectRoot: ctx.projectRoot })
|
|
190
|
+
|
|
191
|
+
if (!result.ok) {
|
|
192
|
+
// Drift detected — mark the item as stale so the sidebar can warn
|
|
193
|
+
// the agency without losing the suggestion.
|
|
194
|
+
if (result.reason === 'not-found' || result.reason === 'ambiguous') {
|
|
195
|
+
const updated = await store.updateItem(body.page, body.id, { status: 'stale' })
|
|
196
|
+
sendJson(res, { item: updated, error: result.message, reason: result.reason }, 409, req)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
sendError(res, result.message, 400, req)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Successful write — flip the item to `applied`. The middleware will
|
|
204
|
+
// fire a Vite full-reload after this returns; CMS's own watcher also
|
|
205
|
+
// notices the source-file change and triggers HMR.
|
|
206
|
+
const updated = await store.updateItem(body.page, body.id, { status: 'applied' })
|
|
207
|
+
sendJson(res, { item: updated, file: result.file, before: result.before, after: result.after }, 200, req)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
sendError(res, `Unknown notes route: ${method} ${route}`, 404, req)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Re-export for tests / external use
|
|
215
|
+
export type { NoteItem }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite dev middleware mounting `/_nua/notes/*`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the structure of `@nuasite/cms`'s `createDevMiddleware`:
|
|
5
|
+
* - Filters by URL prefix and short-circuits other requests
|
|
6
|
+
* - Handles CORS preflight before dispatching
|
|
7
|
+
* - Catches handler errors and surfaces them as 500 JSON responses
|
|
8
|
+
* - Triggers a Vite HMR full-reload after content-modifying POSTs so
|
|
9
|
+
* pages currently open in a browser reflect the new note immediately
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
|
+
import type { NotesJsonStore } from '../storage/json-store'
|
|
14
|
+
import type { NotesApiContext } from './api-handlers'
|
|
15
|
+
import { handleNotesApiRoute } from './api-handlers'
|
|
16
|
+
import { handleCors, sendError } from './request-utils'
|
|
17
|
+
|
|
18
|
+
/** Minimal ViteDevServer interface — same shape used by `@nuasite/cms`. */
|
|
19
|
+
export interface ViteDevServerLike {
|
|
20
|
+
middlewares: {
|
|
21
|
+
use: (middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => void) => void
|
|
22
|
+
}
|
|
23
|
+
ws?: {
|
|
24
|
+
send: (payload: { type: string; path?: string }) => void
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ROUTE_PREFIX = '/_nua/notes/'
|
|
29
|
+
|
|
30
|
+
export function createNotesDevMiddleware(
|
|
31
|
+
server: ViteDevServerLike,
|
|
32
|
+
store: NotesJsonStore,
|
|
33
|
+
projectRoot: string,
|
|
34
|
+
): void {
|
|
35
|
+
const ctx: NotesApiContext = { store, projectRoot }
|
|
36
|
+
|
|
37
|
+
server.middlewares.use((req, res, next) => {
|
|
38
|
+
const url = req.url ?? ''
|
|
39
|
+
if (!url.startsWith(ROUTE_PREFIX)) {
|
|
40
|
+
next()
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (handleCors(req, res)) return
|
|
45
|
+
|
|
46
|
+
const route = url.replace(ROUTE_PREFIX, '').split('?')[0] ?? ''
|
|
47
|
+
|
|
48
|
+
handleNotesApiRoute(route, req, res, ctx)
|
|
49
|
+
.then(() => {
|
|
50
|
+
// Mirror CMS: explicitly trigger full-reload after content-modifying
|
|
51
|
+
// routes. In sandboxed dev environments (E2B etc.) chokidar events
|
|
52
|
+
// may not fire reliably for note JSON files, so we send the HMR
|
|
53
|
+
// event directly. The overlay re-fetches `/list` on reload.
|
|
54
|
+
if (req.method === 'POST' && server.ws) {
|
|
55
|
+
server.ws.send({ type: 'full-reload' })
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
console.error('[nuasite-notes] API error:', error)
|
|
60
|
+
if (!res.headersSent) {
|
|
61
|
+
sendError(res, error instanceof Error ? error.message : 'Internal server error', 500, req)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny HTTP helpers for the notes dev API. Mirrors `@nuasite/cms`'s
|
|
3
|
+
* `handlers/request-utils.ts` but kept local so notes has no runtime
|
|
4
|
+
* dependency on CMS internals (only on the published source-finder
|
|
5
|
+
* surface, used in Phase 4).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_SIZE = 2 * 1024 * 1024 // 2 MB — notes are text, no uploads
|
|
11
|
+
|
|
12
|
+
export function readBody(req: IncomingMessage, maxSize: number = MAX_BODY_SIZE): Promise<Buffer> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const chunks: Buffer[] = []
|
|
15
|
+
let totalSize = 0
|
|
16
|
+
req.on('data', (chunk: Buffer) => {
|
|
17
|
+
totalSize += chunk.length
|
|
18
|
+
if (totalSize > maxSize) {
|
|
19
|
+
req.destroy()
|
|
20
|
+
reject(new Error(`Request body exceeds maximum size of ${maxSize} bytes`))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
chunks.push(chunk)
|
|
24
|
+
})
|
|
25
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
26
|
+
req.on('error', reject)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
31
|
+
const buf = await readBody(req)
|
|
32
|
+
if (buf.length === 0) return {} as T
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(buf.toString('utf-8')) as T
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error('Invalid JSON in request body')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getCorsOrigin(req: IncomingMessage): string {
|
|
41
|
+
return req.headers.origin ?? '*'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sendJson(res: ServerResponse, data: unknown, status = 200, req?: IncomingMessage): void {
|
|
45
|
+
res.writeHead(status, {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
'Cache-Control': 'no-store',
|
|
48
|
+
'Access-Control-Allow-Origin': req ? getCorsOrigin(req) : '*',
|
|
49
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
50
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
51
|
+
})
|
|
52
|
+
res.end(JSON.stringify(data))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sendError(res: ServerResponse, message: string, status = 400, req?: IncomingMessage): void {
|
|
56
|
+
sendJson(res, { error: message }, status, req)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function handleCors(req: IncomingMessage, res: ServerResponse): boolean {
|
|
60
|
+
if (req.method === 'OPTIONS') {
|
|
61
|
+
res.writeHead(204, {
|
|
62
|
+
'Access-Control-Allow-Origin': getCorsOrigin(req),
|
|
63
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
64
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
65
|
+
'Access-Control-Max-Age': '86400',
|
|
66
|
+
})
|
|
67
|
+
res.end()
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
package/src/index.ts
ADDED