@nuasite/cms 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 +237 -0
- package/dist/src/build-processor.d.ts +20 -0
- package/dist/src/build-processor.d.ts.map +1 -0
- package/dist/src/collection-scanner.d.ts +6 -0
- package/dist/src/collection-scanner.d.ts.map +1 -0
- package/dist/src/component-registry.d.ts +63 -0
- package/dist/src/component-registry.d.ts.map +1 -0
- package/dist/src/config.d.ts +24 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/dev-middleware.d.ts +20 -0
- package/dist/src/dev-middleware.d.ts.map +1 -0
- package/dist/src/editor/ai.d.ts +60 -0
- package/dist/src/editor/ai.d.ts.map +1 -0
- package/dist/src/editor/api.d.ts +140 -0
- package/dist/src/editor/api.d.ts.map +1 -0
- package/dist/src/editor/color-utils.d.ts +106 -0
- package/dist/src/editor/color-utils.d.ts.map +1 -0
- package/dist/src/editor/components/ai-chat.d.ts +11 -0
- package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
- package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
- package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
- package/dist/src/editor/components/attribute-editor.d.ts +5 -0
- package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
- package/dist/src/editor/components/block-editor.d.ts +12 -0
- package/dist/src/editor/components/block-editor.d.ts.map +1 -0
- package/dist/src/editor/components/collections-browser.d.ts +2 -0
- package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
- package/dist/src/editor/components/color-toolbar.d.ts +12 -0
- package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
- package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
- package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
- package/dist/src/editor/components/create-page-modal.d.ts +2 -0
- package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
- package/dist/src/editor/components/editable-highlights.d.ts +9 -0
- package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
- package/dist/src/editor/components/error-boundary.d.ts +32 -0
- package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
- package/dist/src/editor/components/fields.d.ts +75 -0
- package/dist/src/editor/components/fields.d.ts.map +1 -0
- package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
- package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
- package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
- package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/image-overlay.d.ts +12 -0
- package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
- package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
- package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
- package/dist/src/editor/components/media-library.d.ts +2 -0
- package/dist/src/editor/components/media-library.d.ts.map +1 -0
- package/dist/src/editor/components/outline.d.ts +21 -0
- package/dist/src/editor/components/outline.d.ts.map +1 -0
- package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
- package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
- package/dist/src/editor/components/seo-editor.d.ts +2 -0
- package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
- package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
- package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
- package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
- package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
- package/dist/src/editor/components/toast/toast.d.ts +7 -0
- package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
- package/dist/src/editor/components/toast/types.d.ts +7 -0
- package/dist/src/editor/components/toast/types.d.ts.map +1 -0
- package/dist/src/editor/components/toolbar.d.ts +21 -0
- package/dist/src/editor/components/toolbar.d.ts.map +1 -0
- package/dist/src/editor/config.d.ts +4 -0
- package/dist/src/editor/config.d.ts.map +1 -0
- package/dist/src/editor/constants.d.ts +101 -0
- package/dist/src/editor/constants.d.ts.map +1 -0
- package/dist/src/editor/context.d.ts +14 -0
- package/dist/src/editor/context.d.ts.map +1 -0
- package/dist/src/editor/dom.d.ts +77 -0
- package/dist/src/editor/dom.d.ts.map +1 -0
- package/dist/src/editor/editor.d.ts +64 -0
- package/dist/src/editor/editor.d.ts.map +1 -0
- package/dist/src/editor/history.d.ts +20 -0
- package/dist/src/editor/history.d.ts.map +1 -0
- package/dist/src/editor/hooks/index.d.ts +14 -0
- package/dist/src/editor/hooks/index.d.ts.map +1 -0
- package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
- package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
- package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
- package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
- package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
- package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
- package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
- package/dist/src/editor/hooks/utils.d.ts +32 -0
- package/dist/src/editor/hooks/utils.d.ts.map +1 -0
- package/dist/src/editor/index.d.ts +12 -0
- package/dist/src/editor/index.d.ts.map +1 -0
- package/dist/src/editor/lib/cn.d.ts +3 -0
- package/dist/src/editor/lib/cn.d.ts.map +1 -0
- package/dist/src/editor/manifest.d.ts +19 -0
- package/dist/src/editor/manifest.d.ts.map +1 -0
- package/dist/src/editor/markdown-api.d.ts +36 -0
- package/dist/src/editor/markdown-api.d.ts.map +1 -0
- package/dist/src/editor/signals.d.ts +242 -0
- package/dist/src/editor/signals.d.ts.map +1 -0
- package/dist/src/editor/storage.d.ts +27 -0
- package/dist/src/editor/storage.d.ts.map +1 -0
- package/dist/src/editor/text-styling.d.ts +350 -0
- package/dist/src/editor/text-styling.d.ts.map +1 -0
- package/dist/src/editor/themes.d.ts +38 -0
- package/dist/src/editor/themes.d.ts.map +1 -0
- package/dist/src/editor/types.d.ts +454 -0
- package/dist/src/editor/types.d.ts.map +1 -0
- package/dist/src/error-collector.d.ts +56 -0
- package/dist/src/error-collector.d.ts.map +1 -0
- package/dist/src/handlers/component-ops.d.ts +34 -0
- package/dist/src/handlers/component-ops.d.ts.map +1 -0
- package/dist/src/handlers/markdown-ops.d.ts +41 -0
- package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
- package/dist/src/handlers/request-utils.d.ts +20 -0
- package/dist/src/handlers/request-utils.d.ts.map +1 -0
- package/dist/src/handlers/source-writer.d.ts +51 -0
- package/dist/src/handlers/source-writer.d.ts.map +1 -0
- package/dist/src/html-processor.d.ts +63 -0
- package/dist/src/html-processor.d.ts.map +1 -0
- package/dist/src/index.d.ts +41 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/manifest-writer.d.ts +111 -0
- package/dist/src/manifest-writer.d.ts.map +1 -0
- package/dist/src/media/contember.d.ts +15 -0
- package/dist/src/media/contember.d.ts.map +1 -0
- package/dist/src/media/local.d.ts +9 -0
- package/dist/src/media/local.d.ts.map +1 -0
- package/dist/src/media/s3.d.ts +12 -0
- package/dist/src/media/s3.d.ts.map +1 -0
- package/dist/src/media/types.d.ts +40 -0
- package/dist/src/media/types.d.ts.map +1 -0
- package/dist/src/preview-generator.d.ts +19 -0
- package/dist/src/preview-generator.d.ts.map +1 -0
- package/dist/src/seo-processor.d.ts +23 -0
- package/dist/src/seo-processor.d.ts.map +1 -0
- package/dist/src/source-finder/ast-extractors.d.ts +35 -0
- package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
- package/dist/src/source-finder/ast-parser.d.ts +16 -0
- package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
- package/dist/src/source-finder/cache.d.ts +18 -0
- package/dist/src/source-finder/cache.d.ts.map +1 -0
- package/dist/src/source-finder/collection-finder.d.ts +29 -0
- package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
- package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
- package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
- package/dist/src/source-finder/element-finder.d.ts +42 -0
- package/dist/src/source-finder/element-finder.d.ts.map +1 -0
- package/dist/src/source-finder/image-finder.d.ts +24 -0
- package/dist/src/source-finder/image-finder.d.ts.map +1 -0
- package/dist/src/source-finder/index.d.ts +9 -0
- package/dist/src/source-finder/index.d.ts.map +1 -0
- package/dist/src/source-finder/search-index.d.ts +27 -0
- package/dist/src/source-finder/search-index.d.ts.map +1 -0
- package/dist/src/source-finder/snippet-utils.d.ts +90 -0
- package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
- package/dist/src/source-finder/source-lookup.d.ts +16 -0
- package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
- package/dist/src/source-finder/types.d.ts +167 -0
- package/dist/src/source-finder/types.d.ts.map +1 -0
- package/dist/src/source-finder/variable-extraction.d.ts +37 -0
- package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
- package/dist/src/tailwind-colors.d.ts +54 -0
- package/dist/src/tailwind-colors.d.ts.map +1 -0
- package/dist/src/tsconfig.tsbuildinfo +1 -0
- package/dist/src/types.d.ts +367 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/utils.d.ts +61 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/vite-plugin.d.ts +14 -0
- package/dist/src/vite-plugin.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +80 -0
- package/src/build-processor.ts +784 -0
- package/src/collection-scanner.ts +304 -0
- package/src/component-registry.ts +393 -0
- package/src/config.ts +74 -0
- package/src/dev-middleware.ts +525 -0
- package/src/dist/src/tsconfig.tsbuildinfo +1 -0
- package/src/editor/ai.ts +185 -0
- package/src/editor/api.ts +513 -0
- package/src/editor/color-utils.ts +556 -0
- package/src/editor/components/ai-chat.tsx +632 -0
- package/src/editor/components/ai-tooltip.tsx +179 -0
- package/src/editor/components/attribute-editor.tsx +596 -0
- package/src/editor/components/block-editor.tsx +546 -0
- package/src/editor/components/collections-browser.tsx +248 -0
- package/src/editor/components/color-toolbar.tsx +314 -0
- package/src/editor/components/confirm-dialog.tsx +69 -0
- package/src/editor/components/create-page-modal.tsx +163 -0
- package/src/editor/components/editable-highlights.tsx +260 -0
- package/src/editor/components/error-boundary.tsx +87 -0
- package/src/editor/components/fields.tsx +387 -0
- package/src/editor/components/frontmatter-fields.tsx +469 -0
- package/src/editor/components/highlight-overlay.ts +229 -0
- package/src/editor/components/image-overlay.tsx +230 -0
- package/src/editor/components/markdown-editor-overlay.tsx +505 -0
- package/src/editor/components/markdown-inline-editor.tsx +780 -0
- package/src/editor/components/media-library.tsx +297 -0
- package/src/editor/components/outline.tsx +402 -0
- package/src/editor/components/redirect-countdown.tsx +45 -0
- package/src/editor/components/seo-editor.tsx +498 -0
- package/src/editor/components/text-style-toolbar.tsx +362 -0
- package/src/editor/components/toast/toast-container.tsx +15 -0
- package/src/editor/components/toast/toast.tsx +49 -0
- package/src/editor/components/toast/types.ts +7 -0
- package/src/editor/components/toolbar.tsx +366 -0
- package/src/editor/config.ts +12 -0
- package/src/editor/constants.ts +106 -0
- package/src/editor/context.tsx +38 -0
- package/src/editor/dom.ts +357 -0
- package/src/editor/editor.ts +1510 -0
- package/src/editor/env.d.ts +4 -0
- package/src/editor/history.ts +355 -0
- package/src/editor/hooks/index.ts +19 -0
- package/src/editor/hooks/useAIHandlers.ts +345 -0
- package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
- package/src/editor/hooks/useElementDetection.ts +284 -0
- package/src/editor/hooks/useImageHoverDetection.ts +102 -0
- package/src/editor/hooks/useTextSelection.ts +187 -0
- package/src/editor/hooks/useTooltipState.ts +126 -0
- package/src/editor/hooks/utils.ts +101 -0
- package/src/editor/index.tsx +481 -0
- package/src/editor/lib/cn.ts +4 -0
- package/src/editor/manifest.ts +25 -0
- package/src/editor/markdown-api.ts +209 -0
- package/src/editor/signals.ts +1351 -0
- package/src/editor/storage.ts +266 -0
- package/src/editor/styles.css +465 -0
- package/src/editor/text-styling.ts +773 -0
- package/src/editor/themes.ts +210 -0
- package/src/editor/types.ts +591 -0
- package/src/error-collector.ts +106 -0
- package/src/handlers/component-ops.ts +463 -0
- package/src/handlers/markdown-ops.ts +202 -0
- package/src/handlers/request-utils.ts +151 -0
- package/src/handlers/source-writer.ts +649 -0
- package/src/html-processor.ts +1108 -0
- package/src/index.ts +284 -0
- package/src/manifest-writer.ts +371 -0
- package/src/media/contember.ts +84 -0
- package/src/media/local.ts +114 -0
- package/src/media/s3.ts +133 -0
- package/src/media/types.ts +33 -0
- package/src/preview-generator.ts +293 -0
- package/src/seo-processor.ts +567 -0
- package/src/source-finder/ast-extractors.ts +185 -0
- package/src/source-finder/ast-parser.ts +150 -0
- package/src/source-finder/cache.ts +76 -0
- package/src/source-finder/collection-finder.ts +335 -0
- package/src/source-finder/cross-file-tracker.ts +741 -0
- package/src/source-finder/element-finder.ts +387 -0
- package/src/source-finder/image-finder.ts +283 -0
- package/src/source-finder/index.ts +37 -0
- package/src/source-finder/search-index.ts +525 -0
- package/src/source-finder/snippet-utils.ts +668 -0
- package/src/source-finder/source-lookup.ts +200 -0
- package/src/source-finder/types.ts +210 -0
- package/src/source-finder/variable-extraction.ts +406 -0
- package/src/tailwind-colors.ts +874 -0
- package/src/tsconfig.json +25 -0
- package/src/types.ts +406 -0
- package/src/utils.ts +186 -0
- package/src/vite-plugin.ts +42 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global configuration for cms-marker.
|
|
3
|
+
* This allows overriding the project root for testing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
let projectRootOverride: string | null = null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get the current project root directory.
|
|
13
|
+
* Returns the override if set, otherwise process.cwd().
|
|
14
|
+
*/
|
|
15
|
+
export function getProjectRoot(): string {
|
|
16
|
+
return projectRootOverride ?? process.cwd()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set the project root directory override.
|
|
21
|
+
* Call this to use a specific directory instead of process.cwd().
|
|
22
|
+
*/
|
|
23
|
+
export function setProjectRoot(root: string): void {
|
|
24
|
+
projectRootOverride = root
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reset the project root to use process.cwd() again.
|
|
29
|
+
*/
|
|
30
|
+
export function resetProjectRoot(): void {
|
|
31
|
+
projectRootOverride = null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the validation root for path traversal checks.
|
|
36
|
+
* In a monorepo/workspace, this returns the workspace root so that
|
|
37
|
+
* files in sibling packages are considered valid.
|
|
38
|
+
* Falls back to getProjectRoot() when not in a workspace.
|
|
39
|
+
*/
|
|
40
|
+
let validationRootCache: string | undefined
|
|
41
|
+
export function getValidationRoot(): string {
|
|
42
|
+
if (validationRootCache !== undefined) return validationRootCache
|
|
43
|
+
const wsRoot = findWorkspaceRoot(getProjectRoot())
|
|
44
|
+
validationRootCache = wsRoot ?? getProjectRoot()
|
|
45
|
+
return validationRootCache
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reset the cached validation root (call when project root changes).
|
|
50
|
+
*/
|
|
51
|
+
export function resetValidationRoot(): void {
|
|
52
|
+
validationRootCache = undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walk up from startDir to find the nearest package.json with a "workspaces" field.
|
|
57
|
+
* Returns the workspace root directory, or null if not found.
|
|
58
|
+
*/
|
|
59
|
+
function findWorkspaceRoot(startDir: string): string | null {
|
|
60
|
+
let dir = path.resolve(startDir)
|
|
61
|
+
while (true) {
|
|
62
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
63
|
+
if (existsSync(pkgPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
66
|
+
if (pkg.workspaces) return dir
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
const parent = path.dirname(dir)
|
|
70
|
+
if (parent === dir) break
|
|
71
|
+
dir = parent
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { handleInsertComponent, handleRemoveComponent } from './handlers/component-ops'
|
|
6
|
+
import {
|
|
7
|
+
handleCreateMarkdown,
|
|
8
|
+
handleGetMarkdownContent,
|
|
9
|
+
handleUpdateMarkdown,
|
|
10
|
+
} from './handlers/markdown-ops'
|
|
11
|
+
import {
|
|
12
|
+
handleCors,
|
|
13
|
+
parseJsonBody,
|
|
14
|
+
parseMultipartFile,
|
|
15
|
+
readBody,
|
|
16
|
+
sendError,
|
|
17
|
+
sendJson,
|
|
18
|
+
} from './handlers/request-utils'
|
|
19
|
+
import { handleUpdate } from './handlers/source-writer'
|
|
20
|
+
import { processHtml } from './html-processor'
|
|
21
|
+
import type { ManifestWriter } from './manifest-writer'
|
|
22
|
+
import type { MediaStorageAdapter } from './media/types'
|
|
23
|
+
import { clearSourceFinderCache, findCollectionSource, findImageSourceLocation, initializeSearchIndex, parseMarkdownContent } from './source-finder'
|
|
24
|
+
import type { CmsMarkerOptions, CollectionEntry, ComponentDefinition, PageSeoData } from './types'
|
|
25
|
+
import { normalizePagePath } from './utils'
|
|
26
|
+
|
|
27
|
+
/** Minimal ViteDevServer interface to avoid version conflicts between Astro's bundled Vite and root Vite */
|
|
28
|
+
interface ViteDevServerLike {
|
|
29
|
+
middlewares: {
|
|
30
|
+
use: (middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => void) => void
|
|
31
|
+
}
|
|
32
|
+
transformIndexHtml: (url: string, html: string) => Promise<string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DevMiddlewareOptions {
|
|
36
|
+
enableCmsApi?: boolean
|
|
37
|
+
mediaAdapter?: MediaStorageAdapter
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDevMiddleware(
|
|
41
|
+
server: ViteDevServerLike,
|
|
42
|
+
config: Required<CmsMarkerOptions>,
|
|
43
|
+
manifestWriter: ManifestWriter,
|
|
44
|
+
componentDefinitions: Record<string, ComponentDefinition>,
|
|
45
|
+
idCounter: { value: number },
|
|
46
|
+
options: DevMiddlewareOptions = {},
|
|
47
|
+
) {
|
|
48
|
+
// Serve uploaded media files directly from disk.
|
|
49
|
+
// Vite's public dir middleware caches file listings, so newly uploaded files
|
|
50
|
+
// may not be available immediately. This middleware bypasses that cache.
|
|
51
|
+
if (options.mediaAdapter?.staticFiles) {
|
|
52
|
+
const { urlPrefix, dir } = options.mediaAdapter.staticFiles
|
|
53
|
+
const prefix = urlPrefix.endsWith('/') ? urlPrefix : `${urlPrefix}/`
|
|
54
|
+
|
|
55
|
+
server.middlewares.use((req, res, next) => {
|
|
56
|
+
const pathname = (req.url || '').split('?')[0] || ''
|
|
57
|
+
if (!pathname.startsWith(prefix)) {
|
|
58
|
+
next()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filename = path.basename(pathname)
|
|
63
|
+
if (!filename || filename.includes('..')) {
|
|
64
|
+
next()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const filePath = path.join(dir, filename)
|
|
69
|
+
fs.readFile(filePath)
|
|
70
|
+
.then((data) => {
|
|
71
|
+
const ext = path.extname(filename).toLowerCase()
|
|
72
|
+
res.setHeader('Content-Type', mediaMimeFromExt(ext))
|
|
73
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
74
|
+
res.end(data)
|
|
75
|
+
})
|
|
76
|
+
.catch(() => next())
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CMS API endpoints (local dev server backend)
|
|
81
|
+
if (options.enableCmsApi) {
|
|
82
|
+
server.middlewares.use((req, res, next) => {
|
|
83
|
+
const url = req.url || ''
|
|
84
|
+
if (!url.startsWith('/_nua/cms/')) {
|
|
85
|
+
next()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (handleCors(req, res)) return
|
|
90
|
+
|
|
91
|
+
const route = url.replace('/_nua/cms/', '').split('?')[0]!
|
|
92
|
+
|
|
93
|
+
handleCmsApiRoute(route, req, res, manifestWriter, options.mediaAdapter).catch(
|
|
94
|
+
(error) => {
|
|
95
|
+
console.error('[astro-cms] API error:', error)
|
|
96
|
+
sendError(res, 'Internal server error', 500)
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Serve global CMS manifest (component definitions, available colors, collection definitions, and settings)
|
|
103
|
+
server.middlewares.use((req, res, next) => {
|
|
104
|
+
const pathname = (req.url || '').split('?')[0]
|
|
105
|
+
if (pathname === '/cms-manifest.json') {
|
|
106
|
+
res.setHeader('Content-Type', 'application/json')
|
|
107
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
108
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
109
|
+
const manifest: Record<string, unknown> = {
|
|
110
|
+
componentDefinitions,
|
|
111
|
+
availableColors: manifestWriter.getAvailableColors(),
|
|
112
|
+
availableTextStyles: manifestWriter.getAvailableTextStyles(),
|
|
113
|
+
}
|
|
114
|
+
const collectionDefs = manifestWriter.getCollectionDefinitions()
|
|
115
|
+
if (Object.keys(collectionDefs).length > 0) {
|
|
116
|
+
manifest.collectionDefinitions = collectionDefs
|
|
117
|
+
}
|
|
118
|
+
res.end(JSON.stringify(manifest, null, 2))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
next()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Serve per-page manifest endpoints (e.g., /about.json for /about page)
|
|
125
|
+
server.middlewares.use((req, res, next) => {
|
|
126
|
+
const url = (req.url || '').split('?')[0]!
|
|
127
|
+
|
|
128
|
+
// Match /*.json pattern (but not files that actually exist)
|
|
129
|
+
const match = url.match(/^\/(.*)\.json$/)
|
|
130
|
+
if (match) {
|
|
131
|
+
// Convert manifest path to page path
|
|
132
|
+
// e.g., /about.json -> /about
|
|
133
|
+
// /index.json -> /
|
|
134
|
+
// /blog/post.json -> /blog/post
|
|
135
|
+
let pagePath = '/' + match[1]
|
|
136
|
+
if (pagePath === '/index') {
|
|
137
|
+
pagePath = '/'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pageData = manifestWriter.getPageManifest(pagePath)
|
|
141
|
+
|
|
142
|
+
// Only serve if we have manifest data for this page
|
|
143
|
+
if (pageData) {
|
|
144
|
+
res.setHeader('Content-Type', 'application/json')
|
|
145
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
146
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
147
|
+
const responseData: Record<string, unknown> = {
|
|
148
|
+
page: pagePath,
|
|
149
|
+
entries: pageData.entries,
|
|
150
|
+
components: pageData.components,
|
|
151
|
+
componentDefinitions,
|
|
152
|
+
}
|
|
153
|
+
if (pageData.collection) {
|
|
154
|
+
responseData.collection = pageData.collection
|
|
155
|
+
}
|
|
156
|
+
if (pageData.seo) {
|
|
157
|
+
responseData.seo = pageData.seo
|
|
158
|
+
}
|
|
159
|
+
res.end(JSON.stringify(responseData, null, 2))
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
next()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Transform HTML responses — only buffer when Content-Type is text/html
|
|
167
|
+
server.middlewares.use((req, res, next) => {
|
|
168
|
+
const originalWrite = res.write
|
|
169
|
+
const originalEnd = res.end
|
|
170
|
+
const requestUrl = req.url || 'unknown'
|
|
171
|
+
let chunks: Buffer[] | null = null
|
|
172
|
+
let isHtml: boolean | null = null
|
|
173
|
+
|
|
174
|
+
const checkIfHtml = (): boolean => {
|
|
175
|
+
if (isHtml !== null) return isHtml
|
|
176
|
+
const contentType = res.getHeader('content-type')
|
|
177
|
+
isHtml = !!(contentType && typeof contentType === 'string' && contentType.includes('text/html'))
|
|
178
|
+
if (isHtml) {
|
|
179
|
+
chunks = []
|
|
180
|
+
}
|
|
181
|
+
return isHtml
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Intercept response chunks — only buffer for HTML
|
|
185
|
+
res.write = ((chunk: any, encodingOrCb?: any, cb?: any) => {
|
|
186
|
+
if (!checkIfHtml()) {
|
|
187
|
+
// Not HTML — pass through immediately, preserving backpressure
|
|
188
|
+
return originalWrite.call(res, chunk, encodingOrCb, cb)
|
|
189
|
+
}
|
|
190
|
+
if (chunk) {
|
|
191
|
+
chunks!.push(typeof chunk === 'string' ? Buffer.from(chunk, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8') : Buffer.from(chunk))
|
|
192
|
+
}
|
|
193
|
+
if (typeof encodingOrCb === 'function') encodingOrCb()
|
|
194
|
+
else if (typeof cb === 'function') cb()
|
|
195
|
+
return true
|
|
196
|
+
}) as any
|
|
197
|
+
|
|
198
|
+
res.end = ((chunk: any, ...args: any[]) => {
|
|
199
|
+
if (!checkIfHtml()) {
|
|
200
|
+
// Not HTML — pass through
|
|
201
|
+
res.write = originalWrite
|
|
202
|
+
res.end = originalEnd
|
|
203
|
+
return res.end(chunk, ...args)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (chunk) {
|
|
207
|
+
chunks!.push(Buffer.from(chunk))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const html = Buffer.concat(chunks!).toString('utf8')
|
|
211
|
+
const pagePath = normalizePagePath(requestUrl)
|
|
212
|
+
|
|
213
|
+
// Process HTML asynchronously
|
|
214
|
+
processHtmlForDev(html, pagePath, config, idCounter)
|
|
215
|
+
.then(({ html: transformed, entries, components, collection, seo }) => {
|
|
216
|
+
manifestWriter.addPage(pagePath, entries, components, collection, seo)
|
|
217
|
+
|
|
218
|
+
res.write = originalWrite
|
|
219
|
+
res.end = originalEnd
|
|
220
|
+
if (!res.headersSent) {
|
|
221
|
+
res.removeHeader('content-length')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return res.end(transformed, ...args)
|
|
225
|
+
})
|
|
226
|
+
.catch((error) => {
|
|
227
|
+
console.error('[cms] Error transforming HTML:', error)
|
|
228
|
+
|
|
229
|
+
res.write = originalWrite
|
|
230
|
+
res.end = originalEnd
|
|
231
|
+
|
|
232
|
+
if (chunks!.length > 0) {
|
|
233
|
+
return res.end(Buffer.concat(chunks!), ...args)
|
|
234
|
+
}
|
|
235
|
+
return res.end(...args)
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}) as any
|
|
239
|
+
|
|
240
|
+
next()
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handleCmsApiRoute(
|
|
245
|
+
route: string,
|
|
246
|
+
req: IncomingMessage,
|
|
247
|
+
res: ServerResponse,
|
|
248
|
+
manifestWriter: ManifestWriter,
|
|
249
|
+
mediaAdapter?: MediaStorageAdapter,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
// POST /_nua/cms/update
|
|
252
|
+
if (route === 'update' && req.method === 'POST') {
|
|
253
|
+
const body = await parseJsonBody<Parameters<typeof handleUpdate>[0]>(req)
|
|
254
|
+
const result = await handleUpdate(body, manifestWriter)
|
|
255
|
+
sendJson(res, result)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// POST /_nua/cms/insert-component
|
|
260
|
+
if (route === 'insert-component' && req.method === 'POST') {
|
|
261
|
+
const body = await parseJsonBody<Parameters<typeof handleInsertComponent>[0]>(req)
|
|
262
|
+
const result = await handleInsertComponent(body, manifestWriter)
|
|
263
|
+
sendJson(res, result)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// POST /_nua/cms/remove-component
|
|
268
|
+
if (route === 'remove-component' && req.method === 'POST') {
|
|
269
|
+
const body = await parseJsonBody<Parameters<typeof handleRemoveComponent>[0]>(req)
|
|
270
|
+
const result = await handleRemoveComponent(body, manifestWriter)
|
|
271
|
+
sendJson(res, result)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// GET /_nua/cms/markdown/content?filePath=...
|
|
276
|
+
if (route === 'markdown/content' && req.method === 'GET') {
|
|
277
|
+
const urlObj = new URL(req.url!, `http://${req.headers.host}`)
|
|
278
|
+
const filePath = urlObj.searchParams.get('filePath')
|
|
279
|
+
if (!filePath) {
|
|
280
|
+
sendError(res, 'filePath query parameter required')
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const result = await handleGetMarkdownContent(filePath)
|
|
284
|
+
if (!result) {
|
|
285
|
+
sendError(res, 'File not found', 404)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
sendJson(res, result)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// POST /_nua/cms/markdown/update
|
|
293
|
+
if (route === 'markdown/update' && req.method === 'POST') {
|
|
294
|
+
const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
|
|
295
|
+
const result = await handleUpdateMarkdown(body)
|
|
296
|
+
sendJson(res, result)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// POST /_nua/cms/markdown/create
|
|
301
|
+
if (route === 'markdown/create' && req.method === 'POST') {
|
|
302
|
+
const body = await parseJsonBody<Parameters<typeof handleCreateMarkdown>[0]>(req)
|
|
303
|
+
const result = await handleCreateMarkdown(body)
|
|
304
|
+
sendJson(res, result, result.success ? 200 : 400)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// GET /_nua/cms/media/list
|
|
309
|
+
if (route === 'media/list' && req.method === 'GET') {
|
|
310
|
+
if (!mediaAdapter) {
|
|
311
|
+
sendError(res, 'Media storage not configured', 501)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
const urlObj = new URL(req.url!, `http://${req.headers.host}`)
|
|
315
|
+
const parsedLimit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10)
|
|
316
|
+
const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 50 : Math.min(parsedLimit, 1000)
|
|
317
|
+
const cursor = urlObj.searchParams.get('cursor') ?? undefined
|
|
318
|
+
const result = await mediaAdapter.list({ limit, cursor })
|
|
319
|
+
sendJson(res, result)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// POST /_nua/cms/media/upload
|
|
324
|
+
if (route === 'media/upload' && req.method === 'POST') {
|
|
325
|
+
if (!mediaAdapter) {
|
|
326
|
+
sendError(res, 'Media storage not configured', 501)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const contentType = req.headers['content-type'] ?? ''
|
|
330
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
331
|
+
sendError(res, 'Expected multipart/form-data')
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
// 50 MB limit for file uploads
|
|
335
|
+
const body = await readBody(req, 50 * 1024 * 1024)
|
|
336
|
+
const file = parseMultipartFile(body, contentType)
|
|
337
|
+
if (!file) {
|
|
338
|
+
sendError(res, 'No file found in request')
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Validate file content type — allow images, videos, PDFs, and common web assets
|
|
343
|
+
const allowedTypes = [
|
|
344
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/x-icon',
|
|
345
|
+
'video/mp4', 'video/webm',
|
|
346
|
+
'application/pdf',
|
|
347
|
+
]
|
|
348
|
+
// Block SVG (can contain scripts) unless explicitly served with safe headers
|
|
349
|
+
if (!allowedTypes.includes(file.contentType)) {
|
|
350
|
+
sendError(res, `File type not allowed: ${file.contentType}`)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const result = await mediaAdapter.upload(file.buffer, file.filename, file.contentType)
|
|
355
|
+
sendJson(res, result)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// DELETE /_nua/cms/media/<id> — only match paths with an actual ID segment
|
|
360
|
+
if (route.startsWith('media/') && req.method === 'DELETE') {
|
|
361
|
+
if (!mediaAdapter) {
|
|
362
|
+
sendError(res, 'Media storage not configured', 501)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
const id = route.slice('media/'.length)
|
|
366
|
+
// Don't match known sub-routes like 'list' or 'upload'
|
|
367
|
+
if (!id || id === 'list' || id === 'upload') {
|
|
368
|
+
sendError(res, 'Not found', 404)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
const result = await mediaAdapter.delete(decodeURIComponent(id))
|
|
372
|
+
sendJson(res, result)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// GET /_nua/cms/deployment/status
|
|
377
|
+
if (route === 'deployment/status' && req.method === 'GET') {
|
|
378
|
+
sendJson(res, { currentDeployment: null, pendingCount: 0 })
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
sendError(res, 'Not found', 404)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function processHtmlForDev(
|
|
386
|
+
html: string,
|
|
387
|
+
pagePath: string,
|
|
388
|
+
config: Required<CmsMarkerOptions>,
|
|
389
|
+
idCounter: { value: number },
|
|
390
|
+
) {
|
|
391
|
+
// Clear cached parsed files so variable definitions reflect the latest source
|
|
392
|
+
clearSourceFinderCache()
|
|
393
|
+
|
|
394
|
+
// In dev mode, reset counter per page for consistent IDs during HMR
|
|
395
|
+
let pageCounter = 0
|
|
396
|
+
const idGenerator = () => `cms-${pageCounter++}`
|
|
397
|
+
|
|
398
|
+
// Check if this is a collection page (e.g., /services/example -> services collection, example slug)
|
|
399
|
+
const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
|
|
400
|
+
const isCollectionPage = !!collectionInfo
|
|
401
|
+
|
|
402
|
+
// Parse markdown content if this is a collection page
|
|
403
|
+
let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
|
|
404
|
+
if (collectionInfo) {
|
|
405
|
+
mdContent = await parseMarkdownContent(collectionInfo)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Get the first non-empty line of the markdown body for wrapper detection
|
|
409
|
+
const bodyFirstLine = mdContent?.body
|
|
410
|
+
?.split('\n')
|
|
411
|
+
.find((line) => line.trim().length > 0)
|
|
412
|
+
?.trim()
|
|
413
|
+
|
|
414
|
+
const result = await processHtml(
|
|
415
|
+
html,
|
|
416
|
+
pagePath,
|
|
417
|
+
{
|
|
418
|
+
attributeName: config.attributeName,
|
|
419
|
+
includeTags: config.includeTags,
|
|
420
|
+
excludeTags: config.excludeTags,
|
|
421
|
+
includeEmptyText: config.includeEmptyText,
|
|
422
|
+
generateManifest: config.generateManifest,
|
|
423
|
+
markComponents: config.markComponents,
|
|
424
|
+
componentDirs: config.componentDirs,
|
|
425
|
+
// Skip marking markdown-rendered content on collection pages
|
|
426
|
+
// The markdown body is treated as a single editable unit
|
|
427
|
+
skipMarkdownContent: isCollectionPage,
|
|
428
|
+
// Pass collection info for wrapper element marking
|
|
429
|
+
collectionInfo: collectionInfo
|
|
430
|
+
? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine, bodyText: mdContent?.body, contentPath: collectionInfo.file }
|
|
431
|
+
: undefined,
|
|
432
|
+
// Pass SEO options
|
|
433
|
+
seo: config.seo,
|
|
434
|
+
},
|
|
435
|
+
idGenerator,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
// Build collection entry if this is a collection page
|
|
439
|
+
let collectionEntry: CollectionEntry | undefined
|
|
440
|
+
if (collectionInfo && mdContent) {
|
|
441
|
+
collectionEntry = {
|
|
442
|
+
collectionName: mdContent.collectionName,
|
|
443
|
+
collectionSlug: mdContent.collectionSlug,
|
|
444
|
+
sourcePath: mdContent.file,
|
|
445
|
+
frontmatter: mdContent.frontmatter,
|
|
446
|
+
body: mdContent.body,
|
|
447
|
+
bodyStartLine: mdContent.bodyStartLine,
|
|
448
|
+
wrapperId: result.collectionWrapperId,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Ensure the search index is initialized for image source lookups
|
|
453
|
+
// (idempotent - only scans files on first call)
|
|
454
|
+
await initializeSearchIndex()
|
|
455
|
+
|
|
456
|
+
// In dev mode, we use the source info from Astro compiler attributes
|
|
457
|
+
// which is already extracted by html-processor
|
|
458
|
+
// Always search for image source by src value - the sourcePath from HTML attributes
|
|
459
|
+
// may point to a shared Image component rather than the actual usage site
|
|
460
|
+
for (const entry of Object.values(result.entries)) {
|
|
461
|
+
if (entry.imageMetadata?.src) {
|
|
462
|
+
const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
|
|
463
|
+
if (imageSource) {
|
|
464
|
+
entry.sourcePath = imageSource.file
|
|
465
|
+
entry.sourceLine = imageSource.line
|
|
466
|
+
entry.sourceSnippet = imageSource.snippet
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Filter out entries without sourcePath - these can't be edited
|
|
472
|
+
const idsToRemove: string[] = []
|
|
473
|
+
for (const [id, entry] of Object.entries(result.entries)) {
|
|
474
|
+
// Keep collection wrapper entries even without sourcePath (they use contentPath)
|
|
475
|
+
if (entry.collectionName) continue
|
|
476
|
+
// Remove entries that don't have a resolved sourcePath
|
|
477
|
+
if (!entry.sourcePath) {
|
|
478
|
+
idsToRemove.push(id)
|
|
479
|
+
delete result.entries[id]
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Remove CMS ID attributes from HTML for entries that were filtered out
|
|
484
|
+
let finalHtml = result.html
|
|
485
|
+
if (idsToRemove.length > 0) {
|
|
486
|
+
const root = parse(result.html, {
|
|
487
|
+
lowerCaseTagName: false,
|
|
488
|
+
comment: true,
|
|
489
|
+
})
|
|
490
|
+
for (const id of idsToRemove) {
|
|
491
|
+
const element = root.querySelector(`[${config.attributeName}="${id}"]`)
|
|
492
|
+
if (element) {
|
|
493
|
+
element.removeAttribute(config.attributeName)
|
|
494
|
+
// Also remove related CMS attributes
|
|
495
|
+
element.removeAttribute('data-cms-img')
|
|
496
|
+
element.removeAttribute('data-cms-markdown')
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
finalHtml = root.toString()
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
html: finalHtml,
|
|
504
|
+
entries: result.entries,
|
|
505
|
+
components: result.components,
|
|
506
|
+
collection: collectionEntry,
|
|
507
|
+
seo: result.seo,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function mediaMimeFromExt(ext: string): string {
|
|
512
|
+
const map: Record<string, string> = {
|
|
513
|
+
'.jpg': 'image/jpeg',
|
|
514
|
+
'.jpeg': 'image/jpeg',
|
|
515
|
+
'.png': 'image/png',
|
|
516
|
+
'.gif': 'image/gif',
|
|
517
|
+
'.webp': 'image/webp',
|
|
518
|
+
'.avif': 'image/avif',
|
|
519
|
+
'.ico': 'image/x-icon',
|
|
520
|
+
'.mp4': 'video/mp4',
|
|
521
|
+
'.webm': 'video/webm',
|
|
522
|
+
'.pdf': 'application/pdf',
|
|
523
|
+
}
|
|
524
|
+
return map[ext] ?? 'application/octet-stream'
|
|
525
|
+
}
|