sekkei-preview 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/dist/cli.d.ts +2 -0
- package/dist/cli.js +116 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate-config.d.ts +22 -0
- package/dist/generate-config.js +138 -0
- package/dist/generate-config.js.map +1 -0
- package/dist/generate-index.d.ts +5 -0
- package/dist/generate-index.js +74 -0
- package/dist/generate-index.js.map +1 -0
- package/dist/resolve-docs-dir.d.ts +8 -0
- package/dist/resolve-docs-dir.js +46 -0
- package/dist/resolve-docs-dir.js.map +1 -0
- package/package.json +44 -0
- package/plugins/file-api-plugin.ts +198 -0
- package/plugins/frontmatter-utils.ts +22 -0
- package/plugins/safe-path.ts +35 -0
- package/theme/components/EditButton.vue +68 -0
- package/theme/components/EditorToolbar.vue +131 -0
- package/theme/components/Layout.vue +139 -0
- package/theme/components/MilkdownEditor.vue +78 -0
- package/theme/components/MilkdownWrapper.vue +94 -0
- package/theme/composables/use-edit-mode.ts +55 -0
- package/theme/composables/use-file-api.ts +46 -0
- package/theme/index.ts +16 -0
- package/theme/styles/custom.css +85 -0
- package/theme/styles/editor.css +31 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer } from 'vite'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { readFile, writeFile, readdir, realpath } from 'node:fs/promises'
|
|
4
|
+
import { join, relative, extname } from 'node:path'
|
|
5
|
+
import { safePath } from './safe-path.js'
|
|
6
|
+
import { splitFrontmatter, joinFrontmatter } from './frontmatter-utils.js'
|
|
7
|
+
|
|
8
|
+
type NextFn = () => void
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10 MB
|
|
11
|
+
|
|
12
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const chunks: Buffer[] = []
|
|
15
|
+
let size = 0
|
|
16
|
+
req.on('data', (chunk: Buffer) => {
|
|
17
|
+
size += chunk.length
|
|
18
|
+
if (size > MAX_BODY_SIZE) {
|
|
19
|
+
req.destroy()
|
|
20
|
+
reject(new Error('Request body too large'))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
chunks.push(chunk)
|
|
24
|
+
})
|
|
25
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
26
|
+
req.on('error', reject)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function json(res: ServerResponse, status: number, data: unknown): void {
|
|
31
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
32
|
+
res.end(JSON.stringify(data))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract title from first few lines of a markdown file content.
|
|
37
|
+
*/
|
|
38
|
+
function extractTitleFromContent(content: string): string {
|
|
39
|
+
const lines = content.split('\n')
|
|
40
|
+
// Check frontmatter
|
|
41
|
+
if (lines[0]?.trim() === '---') {
|
|
42
|
+
for (let i = 1; i < Math.min(lines.length, 20); i++) {
|
|
43
|
+
if (lines[i]?.trim() === '---') break
|
|
44
|
+
const match = lines[i]?.match(/^title:\s*["']?(.+?)["']?\s*$/)
|
|
45
|
+
if (match) return match[1]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Check first H1
|
|
49
|
+
for (const line of lines.slice(0, 30)) {
|
|
50
|
+
const h1 = line.match(/^#\s+(.+)/)
|
|
51
|
+
if (h1) return h1[1]
|
|
52
|
+
}
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Vite plugin that adds REST endpoints for file read/save/list.
|
|
58
|
+
* Only active when SEKKEI_EDIT=1 env var is set.
|
|
59
|
+
*/
|
|
60
|
+
export function sekkeiFileApiPlugin(docsRoot: string): Plugin {
|
|
61
|
+
let viteServer: ViteDevServer
|
|
62
|
+
let resolvedRoot = docsRoot
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: 'sekkei-file-api',
|
|
66
|
+
|
|
67
|
+
async configureServer(server) {
|
|
68
|
+
if (process.env.SEKKEI_EDIT !== '1') return
|
|
69
|
+
|
|
70
|
+
// Resolve symlinks (macOS /tmp → /private/tmp)
|
|
71
|
+
try { resolvedRoot = await realpath(docsRoot) } catch { /* keep original */ }
|
|
72
|
+
|
|
73
|
+
viteServer = server
|
|
74
|
+
|
|
75
|
+
server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next: NextFn) => {
|
|
76
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
|
|
77
|
+
const pathname = url.pathname
|
|
78
|
+
|
|
79
|
+
if (!pathname.startsWith('/__api/')) {
|
|
80
|
+
return next()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (req.method === 'GET' && pathname === '/__api/read') {
|
|
85
|
+
await handleRead(url, res, resolvedRoot)
|
|
86
|
+
} else if (req.method === 'POST' && pathname === '/__api/save') {
|
|
87
|
+
await handleSave(req, res, resolvedRoot, viteServer)
|
|
88
|
+
} else if (req.method === 'GET' && pathname === '/__api/list') {
|
|
89
|
+
await handleList(res, resolvedRoot)
|
|
90
|
+
} else {
|
|
91
|
+
next()
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const msg = (err as Error).message
|
|
95
|
+
if (msg === 'Path traversal detected') {
|
|
96
|
+
json(res, 403, { error: 'Forbidden' })
|
|
97
|
+
} else {
|
|
98
|
+
json(res, 500, { error: 'Internal error' })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handleRead(url: URL, res: ServerResponse, docsRoot: string): Promise<void> {
|
|
107
|
+
const filePath = url.searchParams.get('path')
|
|
108
|
+
if (!filePath) {
|
|
109
|
+
json(res, 400, { error: 'Missing path parameter' })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const absPath = safePath(filePath, docsRoot)
|
|
114
|
+
let raw: string
|
|
115
|
+
try {
|
|
116
|
+
raw = await readFile(absPath, 'utf8')
|
|
117
|
+
} catch {
|
|
118
|
+
json(res, 404, { error: 'Not found' })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { fm, body } = splitFrontmatter(raw)
|
|
123
|
+
json(res, 200, { content: body, frontmatter: fm, path: filePath })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function handleSave(
|
|
127
|
+
req: IncomingMessage,
|
|
128
|
+
res: ServerResponse,
|
|
129
|
+
docsRoot: string,
|
|
130
|
+
server: ViteDevServer
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const contentType = req.headers['content-type'] ?? ''
|
|
133
|
+
if (!contentType.includes('application/json')) {
|
|
134
|
+
json(res, 400, { error: 'Content-Type must be application/json' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let body: { path?: string; content?: string }
|
|
139
|
+
try {
|
|
140
|
+
body = JSON.parse(await readBody(req))
|
|
141
|
+
} catch {
|
|
142
|
+
json(res, 400, { error: 'Invalid JSON' })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!body.path || typeof body.content !== 'string') {
|
|
147
|
+
json(res, 400, { error: 'Missing path or content' })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const absPath = safePath(body.path, docsRoot)
|
|
152
|
+
|
|
153
|
+
// Read existing file to preserve frontmatter
|
|
154
|
+
let existingFm = ''
|
|
155
|
+
try {
|
|
156
|
+
const existing = await readFile(absPath, 'utf8')
|
|
157
|
+
existingFm = splitFrontmatter(existing).fm
|
|
158
|
+
} catch {
|
|
159
|
+
// New content without existing frontmatter is OK
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const merged = joinFrontmatter(existingFm, body.content)
|
|
163
|
+
await writeFile(absPath, merged, 'utf8')
|
|
164
|
+
|
|
165
|
+
// Trigger HMR
|
|
166
|
+
server.watcher.emit('change', absPath)
|
|
167
|
+
|
|
168
|
+
json(res, 200, { ok: true })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleList(res: ServerResponse, docsRoot: string): Promise<void> {
|
|
172
|
+
const files: Array<{ path: string; title: string }> = []
|
|
173
|
+
|
|
174
|
+
async function walk(dir: string): Promise<void> {
|
|
175
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const fullPath = join(dir, entry.name)
|
|
178
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
179
|
+
|
|
180
|
+
if (entry.isDirectory()) {
|
|
181
|
+
await walk(fullPath)
|
|
182
|
+
} else if (entry.isFile() && extname(entry.name) === '.md') {
|
|
183
|
+
const relPath = relative(docsRoot, fullPath)
|
|
184
|
+
try {
|
|
185
|
+
const content = await readFile(fullPath, 'utf8')
|
|
186
|
+
const title = extractTitleFromContent(content) || relPath
|
|
187
|
+
files.push({ path: relPath, title })
|
|
188
|
+
} catch {
|
|
189
|
+
files.push({ path: relPath, title: relPath })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await walk(docsRoot)
|
|
196
|
+
files.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }))
|
|
197
|
+
json(res, 200, { files })
|
|
198
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split markdown into frontmatter YAML and body content.
|
|
5
|
+
*/
|
|
6
|
+
export function splitFrontmatter(raw: string): { fm: string; body: string } {
|
|
7
|
+
const match = raw.match(FRONTMATTER_RE)
|
|
8
|
+
if (!match) {
|
|
9
|
+
return { fm: '', body: raw }
|
|
10
|
+
}
|
|
11
|
+
const fm = match[1]
|
|
12
|
+
const body = raw.slice(match[0].length)
|
|
13
|
+
return { fm, body }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Re-attach frontmatter YAML to body content.
|
|
18
|
+
*/
|
|
19
|
+
export function joinFrontmatter(fm: string, body: string): string {
|
|
20
|
+
if (!fm) return body
|
|
21
|
+
return `---\n${fm}\n---\n${body}`
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve, normalize } from 'node:path'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve user-supplied path safely within docsRoot.
|
|
6
|
+
* Prevents path traversal attacks.
|
|
7
|
+
* Uses realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp).
|
|
8
|
+
*/
|
|
9
|
+
export function safePath(userPath: string, root: string): string {
|
|
10
|
+
if (userPath.includes('\0')) {
|
|
11
|
+
throw new Error('Path traversal detected')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const cleaned = userPath.replace(/^\/+/, '')
|
|
15
|
+
const normalized = normalize(cleaned)
|
|
16
|
+
|
|
17
|
+
if (normalized.startsWith('..')) {
|
|
18
|
+
throw new Error('Path traversal detected')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let rootReal: string
|
|
22
|
+
try {
|
|
23
|
+
rootReal = realpathSync(root)
|
|
24
|
+
} catch {
|
|
25
|
+
rootReal = resolve(root)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const abs = resolve(rootReal, normalized)
|
|
29
|
+
|
|
30
|
+
if (abs !== rootReal && !abs.startsWith(rootReal + '/')) {
|
|
31
|
+
throw new Error('Path traversal detected')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return abs
|
|
35
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
isDirty?: boolean
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
const emit = defineEmits<{
|
|
7
|
+
edit: []
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<button
|
|
13
|
+
class="sekkei-edit-btn"
|
|
14
|
+
title="Edit this page"
|
|
15
|
+
@click="emit('edit')"
|
|
16
|
+
>
|
|
17
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
18
|
+
<path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.442l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.442-.757l8.613-8.607zm1.414 1.06a.25.25 0 00-.354 0L3.46 11.1a.25.25 0 00-.063.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.063l8.613-8.613a.25.25 0 000-.354l-1.086-1.086z" fill="currentColor"/>
|
|
19
|
+
</svg>
|
|
20
|
+
<span>Edit</span>
|
|
21
|
+
<span v-if="isDirty" class="sekkei-dirty-dot" />
|
|
22
|
+
</button>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.sekkei-edit-btn {
|
|
27
|
+
position: fixed;
|
|
28
|
+
bottom: 2rem;
|
|
29
|
+
right: 2rem;
|
|
30
|
+
z-index: 100;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 0.5rem;
|
|
34
|
+
padding: 0.6rem 1.2rem;
|
|
35
|
+
background: var(--vp-c-brand-1, #3451b2);
|
|
36
|
+
color: #fff;
|
|
37
|
+
border: none;
|
|
38
|
+
border-radius: 2rem;
|
|
39
|
+
font-size: 0.85rem;
|
|
40
|
+
font-weight: 500;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
43
|
+
transition: transform 0.15s, box-shadow 0.15s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.sekkei-edit-btn:hover {
|
|
47
|
+
transform: translateY(-1px);
|
|
48
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.sekkei-edit-btn:active {
|
|
52
|
+
transform: translateY(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.sekkei-dirty-dot {
|
|
56
|
+
width: 8px;
|
|
57
|
+
height: 8px;
|
|
58
|
+
border-radius: 50%;
|
|
59
|
+
background: #f59e0b;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@media (max-width: 768px) {
|
|
63
|
+
.sekkei-edit-btn {
|
|
64
|
+
bottom: 1rem;
|
|
65
|
+
right: 1rem;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
saving?: boolean
|
|
4
|
+
isDirty?: boolean
|
|
5
|
+
}>()
|
|
6
|
+
|
|
7
|
+
const emit = defineEmits<{
|
|
8
|
+
save: []
|
|
9
|
+
cancel: []
|
|
10
|
+
discard: []
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
function handleDiscard() {
|
|
14
|
+
if (window.confirm('Discard all changes? This will reload the original content.')) {
|
|
15
|
+
emit('discard')
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="sekkei-toolbar">
|
|
22
|
+
<div class="sekkei-toolbar-left">
|
|
23
|
+
<span class="sekkei-toolbar-label">Editing</span>
|
|
24
|
+
<span v-if="isDirty" class="sekkei-toolbar-dirty">Unsaved changes</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="sekkei-toolbar-right">
|
|
27
|
+
<button
|
|
28
|
+
class="sekkei-toolbar-btn sekkei-btn-discard"
|
|
29
|
+
:disabled="saving"
|
|
30
|
+
@click="handleDiscard"
|
|
31
|
+
>
|
|
32
|
+
Discard
|
|
33
|
+
</button>
|
|
34
|
+
<button
|
|
35
|
+
class="sekkei-toolbar-btn sekkei-btn-cancel"
|
|
36
|
+
:disabled="saving"
|
|
37
|
+
@click="emit('cancel')"
|
|
38
|
+
>
|
|
39
|
+
Cancel
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
class="sekkei-toolbar-btn sekkei-btn-save"
|
|
43
|
+
:disabled="saving"
|
|
44
|
+
@click="emit('save')"
|
|
45
|
+
>
|
|
46
|
+
{{ saving ? 'Saving...' : 'Save' }}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.sekkei-toolbar {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
padding: 0.5rem 1rem;
|
|
58
|
+
background: var(--vp-c-bg-soft, #f6f6f7);
|
|
59
|
+
border-bottom: 1px solid var(--vp-c-divider, #e2e8f0);
|
|
60
|
+
position: sticky;
|
|
61
|
+
top: var(--vp-nav-height, 64px);
|
|
62
|
+
z-index: 50;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.sekkei-toolbar-left {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 0.75rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.sekkei-toolbar-label {
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
font-size: 0.85rem;
|
|
74
|
+
color: var(--vp-c-text-1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.sekkei-toolbar-dirty {
|
|
78
|
+
font-size: 0.75rem;
|
|
79
|
+
color: #f59e0b;
|
|
80
|
+
font-weight: 500;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.sekkei-toolbar-right {
|
|
84
|
+
display: flex;
|
|
85
|
+
gap: 0.5rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.sekkei-toolbar-btn {
|
|
89
|
+
padding: 0.35rem 0.9rem;
|
|
90
|
+
border-radius: 6px;
|
|
91
|
+
font-size: 0.8rem;
|
|
92
|
+
font-weight: 500;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
border: 1px solid transparent;
|
|
95
|
+
transition: background 0.15s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.sekkei-btn-save {
|
|
99
|
+
background: var(--vp-c-brand-1, #3451b2);
|
|
100
|
+
color: #fff;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.sekkei-btn-save:hover:not(:disabled) {
|
|
104
|
+
background: var(--vp-c-brand-2, #3a5ccc);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.sekkei-btn-cancel {
|
|
108
|
+
background: var(--vp-c-bg, #fff);
|
|
109
|
+
color: var(--vp-c-text-1);
|
|
110
|
+
border-color: var(--vp-c-divider);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.sekkei-btn-cancel:hover:not(:disabled) {
|
|
114
|
+
background: var(--vp-c-bg-soft);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.sekkei-btn-discard {
|
|
118
|
+
background: transparent;
|
|
119
|
+
color: var(--vp-c-danger-1, #e53e3e);
|
|
120
|
+
border-color: var(--vp-c-danger-1, #e53e3e);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.sekkei-btn-discard:hover:not(:disabled) {
|
|
124
|
+
background: rgba(229, 62, 62, 0.08);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.sekkei-toolbar-btn:disabled {
|
|
128
|
+
opacity: 0.5;
|
|
129
|
+
cursor: not-allowed;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
import { useData } from 'vitepress'
|
|
4
|
+
import DefaultTheme from 'vitepress/theme'
|
|
5
|
+
import EditButton from './EditButton.vue'
|
|
6
|
+
import EditorToolbar from './EditorToolbar.vue'
|
|
7
|
+
import MilkdownWrapper from './MilkdownWrapper.vue'
|
|
8
|
+
import { provideEditMode } from '../composables/use-edit-mode'
|
|
9
|
+
|
|
10
|
+
const { page, theme } = useData()
|
|
11
|
+
const editEnabled = computed(() => (theme.value as any).editMode === true)
|
|
12
|
+
const currentPath = computed(() => page.value.relativePath)
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
state,
|
|
16
|
+
isDirty,
|
|
17
|
+
isEditing,
|
|
18
|
+
startEdit,
|
|
19
|
+
cancelEdit,
|
|
20
|
+
setSaving,
|
|
21
|
+
setView,
|
|
22
|
+
setEditing,
|
|
23
|
+
markDirty,
|
|
24
|
+
} = provideEditMode()
|
|
25
|
+
|
|
26
|
+
const editorRef = ref<InstanceType<typeof MilkdownWrapper> | null>(null)
|
|
27
|
+
|
|
28
|
+
// When startEdit triggers loading, fetch file then switch to editing
|
|
29
|
+
watch(state, async (newState) => {
|
|
30
|
+
if (newState === 'loading') {
|
|
31
|
+
setEditing()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Reset edit state when navigating to different page
|
|
36
|
+
watch(currentPath, () => {
|
|
37
|
+
if (isEditing.value) {
|
|
38
|
+
cancelEdit()
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
async function handleSave() {
|
|
43
|
+
setSaving()
|
|
44
|
+
try {
|
|
45
|
+
await editorRef.value?.save()
|
|
46
|
+
setView()
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Save failed:', err)
|
|
49
|
+
setEditing()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleCancel() {
|
|
54
|
+
cancelEdit()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleDiscard() {
|
|
58
|
+
cancelEdit()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onEditorChange() {
|
|
62
|
+
markDirty()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Keyboard shortcut: Ctrl+S / Cmd+S
|
|
66
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
67
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
if (isEditing.value && state.value === 'editing') {
|
|
70
|
+
handleSave()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Unsaved changes warning
|
|
76
|
+
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
77
|
+
if (isDirty.value) {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
e.returnValue = ''
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onMounted(() => {
|
|
84
|
+
document.addEventListener('keydown', handleKeydown)
|
|
85
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
onUnmounted(() => {
|
|
89
|
+
document.removeEventListener('keydown', handleKeydown)
|
|
90
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
91
|
+
})
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<DefaultTheme.Layout>
|
|
96
|
+
<template #doc-before>
|
|
97
|
+
<EditorToolbar
|
|
98
|
+
v-if="isEditing"
|
|
99
|
+
:saving="state === 'saving'"
|
|
100
|
+
:is-dirty="isDirty"
|
|
101
|
+
@save="handleSave"
|
|
102
|
+
@cancel="handleCancel"
|
|
103
|
+
@discard="handleDiscard"
|
|
104
|
+
/>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<template #doc-top>
|
|
108
|
+
<div v-if="isEditing" class="sekkei-editor-container">
|
|
109
|
+
<MilkdownWrapper
|
|
110
|
+
ref="editorRef"
|
|
111
|
+
:file-path="currentPath"
|
|
112
|
+
@change="onEditorChange"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<template #doc-after>
|
|
118
|
+
<EditButton
|
|
119
|
+
v-if="editEnabled && !isEditing"
|
|
120
|
+
:is-dirty="isDirty"
|
|
121
|
+
@edit="startEdit"
|
|
122
|
+
/>
|
|
123
|
+
</template>
|
|
124
|
+
</DefaultTheme.Layout>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<style>
|
|
128
|
+
/* Hide rendered VitePress content when editing (inline replace) */
|
|
129
|
+
.sekkei-editor-container + .vp-doc > div {
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
|
|
134
|
+
<style scoped>
|
|
135
|
+
.sekkei-editor-container {
|
|
136
|
+
margin: 0 -24px;
|
|
137
|
+
padding: 0 24px;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { MilkdownProvider, Milkdown, useEditor } from '@milkdown/vue'
|
|
3
|
+
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core'
|
|
4
|
+
import { commonmark } from '@milkdown/kit/preset/commonmark'
|
|
5
|
+
import { gfm } from '@milkdown/kit/preset/gfm'
|
|
6
|
+
import { history } from '@milkdown/kit/plugin/history'
|
|
7
|
+
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
|
|
8
|
+
import { getMarkdown } from '@milkdown/kit/utils'
|
|
9
|
+
import '@milkdown/theme-nord/style.css'
|
|
10
|
+
import { ref } from 'vue'
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
initialValue: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
change: [markdown: string]
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const editorRef = ref<Editor | null>(null)
|
|
21
|
+
|
|
22
|
+
useEditor((root) => {
|
|
23
|
+
return Editor.make()
|
|
24
|
+
.config((ctx) => {
|
|
25
|
+
ctx.set(rootCtx, root)
|
|
26
|
+
ctx.set(defaultValueCtx, props.initialValue)
|
|
27
|
+
ctx.set(listenerCtx, {
|
|
28
|
+
markdownUpdated: (_ctx, markdown) => {
|
|
29
|
+
emit('change', markdown)
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
.use(commonmark)
|
|
34
|
+
.use(gfm)
|
|
35
|
+
.use(history)
|
|
36
|
+
.use(listener)
|
|
37
|
+
.create()
|
|
38
|
+
.then((editor) => {
|
|
39
|
+
editorRef.value = editor
|
|
40
|
+
return editor
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function getEditorMarkdown(): string {
|
|
45
|
+
if (!editorRef.value) return props.initialValue
|
|
46
|
+
return editorRef.value.action(getMarkdown())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
defineExpose({ getMarkdown: getEditorMarkdown })
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<MilkdownProvider>
|
|
54
|
+
<Milkdown />
|
|
55
|
+
</MilkdownProvider>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
:deep(.milkdown) {
|
|
60
|
+
min-height: 400px;
|
|
61
|
+
padding: 1rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
:deep(.milkdown .editor) {
|
|
65
|
+
outline: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
:deep(.milkdown table) {
|
|
69
|
+
width: 100%;
|
|
70
|
+
border-collapse: collapse;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
:deep(.milkdown th),
|
|
74
|
+
:deep(.milkdown td) {
|
|
75
|
+
border: 1px solid var(--vp-c-divider, #e2e8f0);
|
|
76
|
+
padding: 0.5rem 0.75rem;
|
|
77
|
+
}
|
|
78
|
+
</style>
|