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.
@@ -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>