@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
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import type { AstroIntegrationLogger } from 'astro'
|
|
2
|
+
import { parse } from 'node-html-parser'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { getProjectRoot } from './config'
|
|
7
|
+
import { extractComponentName, processHtml } from './html-processor'
|
|
8
|
+
import type { ManifestWriter } from './manifest-writer'
|
|
9
|
+
import { generateComponentPreviews } from './preview-generator'
|
|
10
|
+
import {
|
|
11
|
+
clearSourceFinderCache,
|
|
12
|
+
extractOpeningTagWithLine,
|
|
13
|
+
findCollectionSource,
|
|
14
|
+
findImageSourceLocation,
|
|
15
|
+
findMarkdownSourceLocation,
|
|
16
|
+
findSourceLocation,
|
|
17
|
+
initializeSearchIndex,
|
|
18
|
+
parseMarkdownContent,
|
|
19
|
+
updateAttributeSources,
|
|
20
|
+
updateColorClassSources,
|
|
21
|
+
} from './source-finder'
|
|
22
|
+
import type { CmsMarkerOptions, CollectionEntry } from './types'
|
|
23
|
+
|
|
24
|
+
// Concurrency limit for parallel processing
|
|
25
|
+
const MAX_CONCURRENT = 10
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the page path from an HTML file path
|
|
29
|
+
* For example: /about/index.html -> /about
|
|
30
|
+
* /index.html -> /
|
|
31
|
+
* /blog/post.html -> /blog/post
|
|
32
|
+
*/
|
|
33
|
+
function getPagePath(htmlPath: string, outDir: string): string {
|
|
34
|
+
const relPath = path.relative(outDir, htmlPath)
|
|
35
|
+
const parts = relPath.split(path.sep)
|
|
36
|
+
|
|
37
|
+
// Handle index.html files
|
|
38
|
+
if (parts[parts.length - 1] === 'index.html') {
|
|
39
|
+
parts.pop()
|
|
40
|
+
return '/' + parts.join('/')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Handle other .html files (remove extension)
|
|
44
|
+
const last = parts[parts.length - 1]
|
|
45
|
+
if (last) {
|
|
46
|
+
parts[parts.length - 1] = last.replace('.html', '')
|
|
47
|
+
}
|
|
48
|
+
return '/' + parts.join('/')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cluster entries from the same source file into separate component instances.
|
|
53
|
+
* When a component is used multiple times on a page, its entries are in different
|
|
54
|
+
* subtrees. We partition by finding which direct child of the LCA each entry belongs to.
|
|
55
|
+
*/
|
|
56
|
+
export function clusterComponentEntries<T>(
|
|
57
|
+
elements: T[],
|
|
58
|
+
entryIds: string[],
|
|
59
|
+
findLCA: (els: T[]) => T | null,
|
|
60
|
+
): Array<{ clusterEntryIds: string[]; clusterElements: T[] }> {
|
|
61
|
+
if (elements.length <= 1) {
|
|
62
|
+
return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lca = findLCA(elements)
|
|
66
|
+
if (!lca) {
|
|
67
|
+
return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If any entry is a direct child of the LCA, the LCA is the component
|
|
71
|
+
// root itself — don't split its content into separate instances.
|
|
72
|
+
// Only split when ALL entries are behind intermediate wrapper elements.
|
|
73
|
+
const anyDirectChild = elements.some(
|
|
74
|
+
(el: any) => el.parentNode === lca,
|
|
75
|
+
)
|
|
76
|
+
if (anyDirectChild) {
|
|
77
|
+
return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Group entries by which direct child of the LCA they fall under.
|
|
81
|
+
// Entries under different intermediate subtrees belong to different instances.
|
|
82
|
+
const childGroups = new Map<unknown, { clusterEntryIds: string[]; clusterElements: T[] }>()
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < elements.length; i++) {
|
|
85
|
+
let current: any = elements[i]
|
|
86
|
+
while (current && current.parentNode !== lca) {
|
|
87
|
+
current = current.parentNode
|
|
88
|
+
}
|
|
89
|
+
if (!current) continue
|
|
90
|
+
|
|
91
|
+
const existing = childGroups.get(current)
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.clusterEntryIds.push(entryIds[i]!)
|
|
94
|
+
existing.clusterElements.push(elements[i]!)
|
|
95
|
+
} else {
|
|
96
|
+
childGroups.set(current, {
|
|
97
|
+
clusterEntryIds: [entryIds[i]!],
|
|
98
|
+
clusterElements: [elements[i]!],
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (childGroups.size > 1) {
|
|
104
|
+
// Multiple subtrees → each is a separate component instance
|
|
105
|
+
return Array.from(childGroups.values())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// All entries are in the same subtree → single instance
|
|
109
|
+
return [{ clusterEntryIds: [...entryIds], clusterElements: [...elements] }]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PageComponentInvocation {
|
|
113
|
+
componentName: string
|
|
114
|
+
sourceFile: string
|
|
115
|
+
/** Template offset for ordering invocations */
|
|
116
|
+
offset: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Find the .astro source file for a page given its URL path.
|
|
121
|
+
*/
|
|
122
|
+
async function findPageSource(pagePath: string): Promise<string | null> {
|
|
123
|
+
const projectRoot = getProjectRoot()
|
|
124
|
+
const candidates: string[] = []
|
|
125
|
+
|
|
126
|
+
if (pagePath === '/' || pagePath === '') {
|
|
127
|
+
candidates.push(path.join(projectRoot, 'src/pages/index.astro'))
|
|
128
|
+
} else {
|
|
129
|
+
const cleanPath = pagePath.replace(/^\//, '')
|
|
130
|
+
candidates.push(
|
|
131
|
+
path.join(projectRoot, `src/pages/${cleanPath}.astro`),
|
|
132
|
+
path.join(projectRoot, `src/pages/${cleanPath}/index.astro`),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
try {
|
|
138
|
+
await fs.access(candidate)
|
|
139
|
+
return candidate
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse an .astro page source file to find component invocations.
|
|
147
|
+
* Returns an ordered list of component usages (including duplicates).
|
|
148
|
+
*/
|
|
149
|
+
async function parseComponentInvocations(
|
|
150
|
+
pageSourcePath: string,
|
|
151
|
+
componentDirs: string[],
|
|
152
|
+
): Promise<PageComponentInvocation[]> {
|
|
153
|
+
const content = await fs.readFile(pageSourcePath, 'utf-8')
|
|
154
|
+
const projectRoot = getProjectRoot()
|
|
155
|
+
const pageDir = path.dirname(pageSourcePath)
|
|
156
|
+
|
|
157
|
+
// Split frontmatter from template
|
|
158
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
159
|
+
if (!fmMatch) return []
|
|
160
|
+
const frontmatter = fmMatch[1]!
|
|
161
|
+
const templateStart = fmMatch[0].length
|
|
162
|
+
const template = content.slice(templateStart)
|
|
163
|
+
|
|
164
|
+
// Parse import statements to map component names to source files
|
|
165
|
+
const imports = new Map<string, string>() // componentName -> relative source path
|
|
166
|
+
const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g
|
|
167
|
+
let match: RegExpMatchArray | null
|
|
168
|
+
while ((match = importRegex.exec(frontmatter)) !== null) {
|
|
169
|
+
const name = match[1]!
|
|
170
|
+
const importPath = match[2]!
|
|
171
|
+
|
|
172
|
+
// Resolve the import path relative to the page file
|
|
173
|
+
const resolved = path.resolve(pageDir, importPath)
|
|
174
|
+
const relToProject = path.relative(projectRoot, resolved)
|
|
175
|
+
|
|
176
|
+
// Check if it's in a component directory
|
|
177
|
+
const isComponent = componentDirs.some(dir => {
|
|
178
|
+
const d = dir.replace(/^\/+|\/+$/g, '')
|
|
179
|
+
return relToProject.startsWith(d + '/') || relToProject.startsWith(d + path.sep)
|
|
180
|
+
})
|
|
181
|
+
if (isComponent) {
|
|
182
|
+
imports.set(name, relToProject)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (imports.size === 0) return []
|
|
187
|
+
|
|
188
|
+
// Find component invocations in the template (both self-closing and paired tags)
|
|
189
|
+
const invocations: PageComponentInvocation[] = []
|
|
190
|
+
for (const [componentName, sourceFile] of imports) {
|
|
191
|
+
const tagRegex = new RegExp(`<${componentName}[\\s/>]`, 'g')
|
|
192
|
+
let tagMatch: RegExpExecArray | null
|
|
193
|
+
while ((tagMatch = tagRegex.exec(template)) !== null) {
|
|
194
|
+
invocations.push({
|
|
195
|
+
componentName,
|
|
196
|
+
sourceFile,
|
|
197
|
+
offset: tagMatch.index,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Sort by position in template (invocation order)
|
|
203
|
+
invocations.sort((a, b) => a.offset - b.offset)
|
|
204
|
+
|
|
205
|
+
return invocations
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Detect components that have no text entries by parsing the page source file.
|
|
210
|
+
* After entry-based components are detected, this finds any remaining component
|
|
211
|
+
* invocations and assigns them to unclaimed DOM elements using invocation order.
|
|
212
|
+
*/
|
|
213
|
+
async function detectEntrylessComponents(
|
|
214
|
+
pagePath: string,
|
|
215
|
+
root: ReturnType<typeof parse>,
|
|
216
|
+
components: Record<string, import('./types').ComponentInstance>,
|
|
217
|
+
componentDirs: string[],
|
|
218
|
+
relPath: string,
|
|
219
|
+
idGenerator: () => string,
|
|
220
|
+
markComponentRoot: (el: any, sourceFile: string, entryIds: string[]) => void,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const pageSourcePath = await findPageSource(pagePath)
|
|
223
|
+
if (!pageSourcePath) return
|
|
224
|
+
|
|
225
|
+
const invocations = await parseComponentInvocations(pageSourcePath, componentDirs)
|
|
226
|
+
if (invocations.length === 0) return
|
|
227
|
+
|
|
228
|
+
// Collect all detected component root elements in DOM order
|
|
229
|
+
const detectedRoots: Array<{ el: any; componentName: string }> = []
|
|
230
|
+
const compEls = root.querySelectorAll('[data-cms-component-id]')
|
|
231
|
+
for (const el of compEls) {
|
|
232
|
+
const compId = el.getAttribute('data-cms-component-id')
|
|
233
|
+
if (compId && components[compId]) {
|
|
234
|
+
detectedRoots.push({ el, componentName: components[compId].componentName })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (detectedRoots.length === 0 && invocations.length === 0) return
|
|
239
|
+
|
|
240
|
+
// Find the container: parent of all detected component roots
|
|
241
|
+
// If no components detected yet, we can't determine the container
|
|
242
|
+
if (detectedRoots.length === 0) return
|
|
243
|
+
|
|
244
|
+
const container = detectedRoots[0]?.el.parentNode
|
|
245
|
+
if (!container || !container.childNodes) return
|
|
246
|
+
|
|
247
|
+
// Verify all detected roots share the same parent
|
|
248
|
+
const allSameParent = detectedRoots.every(r => r.el.parentNode === container)
|
|
249
|
+
if (!allSameParent) return
|
|
250
|
+
|
|
251
|
+
// Get the container's element children in DOM order
|
|
252
|
+
const containerChildren: any[] = []
|
|
253
|
+
for (const child of container.childNodes) {
|
|
254
|
+
// Only consider element nodes (nodeType 1)
|
|
255
|
+
if (child.nodeType === 1) {
|
|
256
|
+
containerChildren.push(child)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Build a paired mapping between invocations and container children.
|
|
261
|
+
// Detected components serve as anchor points; undetected children between
|
|
262
|
+
// anchors are assigned to the corresponding unmatched invocations in order.
|
|
263
|
+
|
|
264
|
+
// First, find anchor points: container children that are already detected
|
|
265
|
+
const anchorMap = new Map<number, string>() // childIdx → componentName
|
|
266
|
+
for (let ci = 0; ci < containerChildren.length; ci++) {
|
|
267
|
+
const compId = containerChildren[ci].getAttribute?.('data-cms-component-id')
|
|
268
|
+
if (compId && components[compId]) {
|
|
269
|
+
anchorMap.set(ci, components[compId].componentName)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Walk both lists, using anchors to stay in sync
|
|
274
|
+
let invIdx = 0
|
|
275
|
+
for (let ci = 0; ci < containerChildren.length && invIdx < invocations.length; ci++) {
|
|
276
|
+
const anchorName = anchorMap.get(ci)
|
|
277
|
+
|
|
278
|
+
if (anchorName) {
|
|
279
|
+
// This child is a detected component. Find the matching invocation.
|
|
280
|
+
while (invIdx < invocations.length && invocations[invIdx]!.componentName !== anchorName) {
|
|
281
|
+
invIdx++
|
|
282
|
+
}
|
|
283
|
+
if (invIdx < invocations.length) {
|
|
284
|
+
invIdx++ // consume the matched invocation
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// Undetected child - assign it to the current invocation
|
|
288
|
+
const inv = invocations[invIdx]!
|
|
289
|
+
// Only assign if the invocation's component isn't already detected at a later anchor
|
|
290
|
+
markComponentRoot(containerChildren[ci], inv.sourceFile, [])
|
|
291
|
+
invIdx++
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Process a single HTML file
|
|
298
|
+
*/
|
|
299
|
+
async function processFile(
|
|
300
|
+
filePath: string,
|
|
301
|
+
outDir: string,
|
|
302
|
+
config: Required<CmsMarkerOptions>,
|
|
303
|
+
manifestWriter: ManifestWriter,
|
|
304
|
+
idCounter: { value: number },
|
|
305
|
+
): Promise<number> {
|
|
306
|
+
const relPath = path.relative(outDir, filePath)
|
|
307
|
+
const pagePath = getPagePath(filePath, outDir)
|
|
308
|
+
const html = await fs.readFile(filePath, 'utf-8')
|
|
309
|
+
|
|
310
|
+
// First, try to detect if this page is from a content collection
|
|
311
|
+
// We need to know this BEFORE processing HTML to skip marking markdown-rendered elements
|
|
312
|
+
const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
|
|
313
|
+
const isCollectionPage = !!collectionInfo
|
|
314
|
+
|
|
315
|
+
// Parse markdown content early if this is a collection page
|
|
316
|
+
// We need the body content to find the wrapper element during HTML processing
|
|
317
|
+
let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
|
|
318
|
+
if (collectionInfo) {
|
|
319
|
+
mdContent = await parseMarkdownContent(collectionInfo)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Get the first non-empty line of the markdown body for wrapper detection
|
|
323
|
+
const bodyFirstLine = mdContent?.body
|
|
324
|
+
?.split('\n')
|
|
325
|
+
.find((line) => line.trim().length > 0)
|
|
326
|
+
?.trim()
|
|
327
|
+
|
|
328
|
+
// Create ID generator - use atomic increment
|
|
329
|
+
const pageIdStart = idCounter.value
|
|
330
|
+
const idGenerator = () => `cms-${idCounter.value++}`
|
|
331
|
+
|
|
332
|
+
const result = await processHtml(
|
|
333
|
+
html,
|
|
334
|
+
relPath,
|
|
335
|
+
{
|
|
336
|
+
attributeName: config.attributeName,
|
|
337
|
+
includeTags: config.includeTags,
|
|
338
|
+
excludeTags: config.excludeTags,
|
|
339
|
+
includeEmptyText: config.includeEmptyText,
|
|
340
|
+
generateManifest: config.generateManifest,
|
|
341
|
+
markComponents: config.markComponents,
|
|
342
|
+
componentDirs: config.componentDirs,
|
|
343
|
+
// Skip marking markdown-rendered content on collection pages
|
|
344
|
+
// The markdown body is treated as a single editable unit
|
|
345
|
+
skipMarkdownContent: isCollectionPage,
|
|
346
|
+
// Pass collection info for wrapper element marking
|
|
347
|
+
collectionInfo: collectionInfo
|
|
348
|
+
? { name: collectionInfo.name, slug: collectionInfo.slug, bodyFirstLine, bodyText: mdContent?.body, contentPath: collectionInfo.file }
|
|
349
|
+
: undefined,
|
|
350
|
+
// Pass SEO options
|
|
351
|
+
seo: config.seo,
|
|
352
|
+
},
|
|
353
|
+
idGenerator,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
// During build, source location attributes are not injected by astro-transform.ts
|
|
357
|
+
// (disabled to avoid Vite parse errors). Use findSourceLocation to look up source files.
|
|
358
|
+
|
|
359
|
+
let collectionEntry: CollectionEntry | undefined
|
|
360
|
+
|
|
361
|
+
// Build collection entry if this is a collection page
|
|
362
|
+
if (collectionInfo && mdContent) {
|
|
363
|
+
collectionEntry = {
|
|
364
|
+
collectionName: mdContent.collectionName,
|
|
365
|
+
collectionSlug: mdContent.collectionSlug,
|
|
366
|
+
sourcePath: mdContent.file,
|
|
367
|
+
frontmatter: mdContent.frontmatter,
|
|
368
|
+
body: mdContent.body,
|
|
369
|
+
bodyStartLine: mdContent.bodyStartLine,
|
|
370
|
+
wrapperId: result.collectionWrapperId,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Process entries in parallel for better performance
|
|
375
|
+
const entryLookups = Object.values(result.entries).map(async (entry) => {
|
|
376
|
+
// Handle image entries specially - always search by image src
|
|
377
|
+
// The sourcePath from HTML attributes may point to a shared Image component
|
|
378
|
+
// rather than the file that actually uses the component with the src value
|
|
379
|
+
if (entry.imageMetadata?.src) {
|
|
380
|
+
const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
|
|
381
|
+
if (imageSource) {
|
|
382
|
+
entry.sourcePath = imageSource.file
|
|
383
|
+
entry.sourceLine = imageSource.line
|
|
384
|
+
entry.sourceSnippet = imageSource.snippet
|
|
385
|
+
}
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Skip entries that already have source info from component detection
|
|
390
|
+
if (entry.sourcePath && !entry.sourcePath.endsWith('.html')) {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Try to find source in collection markdown frontmatter first
|
|
395
|
+
if (collectionInfo) {
|
|
396
|
+
const mdSource = await findMarkdownSourceLocation(entry.text, collectionInfo)
|
|
397
|
+
if (mdSource) {
|
|
398
|
+
entry.sourcePath = mdSource.file
|
|
399
|
+
entry.sourceLine = mdSource.line
|
|
400
|
+
entry.sourceSnippet = mdSource.snippet
|
|
401
|
+
entry.variableName = mdSource.variableName
|
|
402
|
+
entry.collectionName = mdSource.collectionName
|
|
403
|
+
entry.collectionSlug = mdSource.collectionSlug
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Fall back to searching Astro files
|
|
409
|
+
const sourceLocation = await findSourceLocation(entry.text, entry.tag)
|
|
410
|
+
if (sourceLocation) {
|
|
411
|
+
entry.sourcePath = sourceLocation.file
|
|
412
|
+
entry.sourceLine = sourceLocation.line
|
|
413
|
+
entry.sourceSnippet = sourceLocation.snippet
|
|
414
|
+
entry.variableName = sourceLocation.variableName
|
|
415
|
+
|
|
416
|
+
// Update attribute and colorClasses source information if we have an opening tag
|
|
417
|
+
if (sourceLocation.openingTagSnippet) {
|
|
418
|
+
const filePath = path.isAbsolute(sourceLocation.file)
|
|
419
|
+
? sourceLocation.file
|
|
420
|
+
: path.join(getProjectRoot(), sourceLocation.file)
|
|
421
|
+
try {
|
|
422
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
423
|
+
const lines = content.split('\n')
|
|
424
|
+
const tagInfo = extractOpeningTagWithLine(lines, sourceLocation.line - 1, entry.tag)
|
|
425
|
+
const startLine = tagInfo ? tagInfo.startLine + 1 : undefined
|
|
426
|
+
|
|
427
|
+
if (entry.attributes) {
|
|
428
|
+
entry.attributes = await updateAttributeSources(
|
|
429
|
+
sourceLocation.openingTagSnippet,
|
|
430
|
+
entry.attributes,
|
|
431
|
+
sourceLocation.file,
|
|
432
|
+
startLine,
|
|
433
|
+
lines,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
if (entry.colorClasses) {
|
|
437
|
+
entry.colorClasses = updateColorClassSources(
|
|
438
|
+
sourceLocation.openingTagSnippet,
|
|
439
|
+
entry.colorClasses,
|
|
440
|
+
sourceLocation.file,
|
|
441
|
+
startLine,
|
|
442
|
+
lines,
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// Couldn't read file - still update without source lines
|
|
447
|
+
if (entry.attributes) {
|
|
448
|
+
entry.attributes = await updateAttributeSources(
|
|
449
|
+
sourceLocation.openingTagSnippet,
|
|
450
|
+
entry.attributes,
|
|
451
|
+
sourceLocation.file,
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
if (entry.colorClasses) {
|
|
455
|
+
entry.colorClasses = updateColorClassSources(
|
|
456
|
+
sourceLocation.openingTagSnippet,
|
|
457
|
+
entry.colorClasses,
|
|
458
|
+
sourceLocation.file,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
await Promise.all(entryLookups)
|
|
467
|
+
|
|
468
|
+
// Filter out entries without sourcePath - these can't be edited
|
|
469
|
+
const idsToRemove: string[] = []
|
|
470
|
+
for (const [id, entry] of Object.entries(result.entries)) {
|
|
471
|
+
// Keep collection wrapper entries even without sourcePath (they use contentPath)
|
|
472
|
+
if (entry.collectionName) continue
|
|
473
|
+
// Remove entries that don't have a resolved sourcePath
|
|
474
|
+
if (!entry.sourcePath) {
|
|
475
|
+
idsToRemove.push(id)
|
|
476
|
+
delete result.entries[id]
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Post-process: detect component roots from resolved entry source paths
|
|
481
|
+
// In production builds, data-astro-source-file is not available so processHtml
|
|
482
|
+
// cannot detect components. We infer them from the resolved sourcePath of entries.
|
|
483
|
+
const componentDirs = config.componentDirs ?? ['src/components']
|
|
484
|
+
const excludeComponentDirs = ['src/pages', 'src/layouts', 'src/layout']
|
|
485
|
+
|
|
486
|
+
if (config.markComponents) {
|
|
487
|
+
// Group entries by their source file (only component files)
|
|
488
|
+
const entriesBySourceFile = new Map<string, string[]>()
|
|
489
|
+
for (const [id, entry] of Object.entries(result.entries)) {
|
|
490
|
+
if (!entry.sourcePath) continue
|
|
491
|
+
const sp = entry.sourcePath
|
|
492
|
+
|
|
493
|
+
const isExcluded = excludeComponentDirs.some(dir => {
|
|
494
|
+
const d = dir.replace(/^\/+|\/+$/g, '')
|
|
495
|
+
return sp.startsWith(d + '/') || sp.includes('/' + d + '/')
|
|
496
|
+
})
|
|
497
|
+
if (isExcluded) continue
|
|
498
|
+
|
|
499
|
+
const isComponent = componentDirs.some(dir => {
|
|
500
|
+
const d = dir.replace(/^\/+|\/+$/g, '')
|
|
501
|
+
return sp.startsWith(d + '/') || sp.includes('/' + d + '/')
|
|
502
|
+
})
|
|
503
|
+
if (!isComponent) continue
|
|
504
|
+
|
|
505
|
+
const existing = entriesBySourceFile.get(sp)
|
|
506
|
+
if (existing) {
|
|
507
|
+
existing.push(id)
|
|
508
|
+
} else {
|
|
509
|
+
entriesBySourceFile.set(sp, [id])
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const root = parse(result.html, {
|
|
514
|
+
lowerCaseTagName: false,
|
|
515
|
+
comment: true,
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
// Helper: find lowest common ancestor of DOM elements
|
|
519
|
+
type HTMLNode = ReturnType<typeof root.querySelector>
|
|
520
|
+
const findLCA = (elements: NonNullable<HTMLNode>[]): HTMLNode => {
|
|
521
|
+
if (elements.length === 0) return null
|
|
522
|
+
if (elements.length === 1) return elements[0]!
|
|
523
|
+
|
|
524
|
+
const getAncestors = (el: HTMLNode): HTMLNode[] => {
|
|
525
|
+
const ancestors: HTMLNode[] = []
|
|
526
|
+
let current = el?.parentNode as HTMLNode
|
|
527
|
+
while (current) {
|
|
528
|
+
ancestors.unshift(current)
|
|
529
|
+
current = current.parentNode as HTMLNode
|
|
530
|
+
}
|
|
531
|
+
return ancestors
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const chains = elements.map(el => getAncestors(el))
|
|
535
|
+
const minLen = Math.min(...chains.map(c => c.length))
|
|
536
|
+
let lcaIdx = 0
|
|
537
|
+
for (let i = 0; i < minLen; i++) {
|
|
538
|
+
if (chains.every(chain => chain[i] === chains[0]![i])) {
|
|
539
|
+
lcaIdx = i
|
|
540
|
+
} else {
|
|
541
|
+
break
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return chains[0]![lcaIdx] ?? null
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Helper: mark an element as a component root and register the instance
|
|
548
|
+
const markComponentRoot = (
|
|
549
|
+
lca: NonNullable<HTMLNode>,
|
|
550
|
+
sourceFile: string,
|
|
551
|
+
instanceEntryIds: string[],
|
|
552
|
+
) => {
|
|
553
|
+
if (!('setAttribute' in lca) || !('getAttribute' in lca)) return
|
|
554
|
+
if (lca.getAttribute?.('data-cms-component-id')) return
|
|
555
|
+
|
|
556
|
+
const compId = idGenerator()
|
|
557
|
+
lca.setAttribute('data-cms-component-id', compId)
|
|
558
|
+
|
|
559
|
+
const componentName = extractComponentName(sourceFile)
|
|
560
|
+
const firstEntry = instanceEntryIds.length > 0 ? result.entries[instanceEntryIds[0]!] : undefined
|
|
561
|
+
|
|
562
|
+
result.components[compId] = {
|
|
563
|
+
id: compId,
|
|
564
|
+
componentName,
|
|
565
|
+
file: relPath,
|
|
566
|
+
sourcePath: sourceFile,
|
|
567
|
+
sourceLine: firstEntry?.sourceLine ?? 1,
|
|
568
|
+
props: {},
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (const eid of instanceEntryIds) {
|
|
572
|
+
const entry = result.entries[eid]
|
|
573
|
+
if (entry) {
|
|
574
|
+
entry.parentComponentId = compId
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// For each component source file, cluster entries into separate instances
|
|
580
|
+
// by partitioning them based on which subtree of their common ancestor they belong to
|
|
581
|
+
if (entriesBySourceFile.size > 0) {
|
|
582
|
+
for (const [sourceFile, entryIds] of entriesBySourceFile) {
|
|
583
|
+
const elements = entryIds
|
|
584
|
+
.map(id => root.querySelector(`[${config.attributeName}="${id}"]`))
|
|
585
|
+
.filter((el): el is NonNullable<HTMLNode> => el !== null)
|
|
586
|
+
|
|
587
|
+
if (elements.length === 0) continue
|
|
588
|
+
|
|
589
|
+
// Cluster entries into separate component instances
|
|
590
|
+
const clusters = clusterComponentEntries(elements, entryIds, findLCA)
|
|
591
|
+
|
|
592
|
+
for (const { clusterEntryIds, clusterElements } of clusters) {
|
|
593
|
+
let lca = findLCA(clusterElements)
|
|
594
|
+
|
|
595
|
+
// If the LCA is a text element itself (only one entry),
|
|
596
|
+
// use its parent so the component wraps the element
|
|
597
|
+
if (lca && clusterElements.length === 1 && lca === clusterElements[0]) {
|
|
598
|
+
lca = lca.parentNode as HTMLNode
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!lca) continue
|
|
602
|
+
markComponentRoot(lca, sourceFile, clusterEntryIds)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Detect components without text entries by parsing the page source file
|
|
608
|
+
await detectEntrylessComponents(
|
|
609
|
+
pagePath,
|
|
610
|
+
root,
|
|
611
|
+
result.components,
|
|
612
|
+
componentDirs,
|
|
613
|
+
relPath,
|
|
614
|
+
idGenerator,
|
|
615
|
+
markComponentRoot,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
// Re-serialize HTML with component markers
|
|
619
|
+
result.html = root.toString()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Remove CMS ID attributes from HTML for entries that were filtered out
|
|
623
|
+
let finalHtml = result.html
|
|
624
|
+
if (idsToRemove.length > 0) {
|
|
625
|
+
const root = parse(result.html, {
|
|
626
|
+
lowerCaseTagName: false,
|
|
627
|
+
comment: true,
|
|
628
|
+
})
|
|
629
|
+
for (const id of idsToRemove) {
|
|
630
|
+
const element = root.querySelector(`[${config.attributeName}="${id}"]`)
|
|
631
|
+
if (element) {
|
|
632
|
+
element.removeAttribute(config.attributeName)
|
|
633
|
+
// Also remove related CMS attributes
|
|
634
|
+
element.removeAttribute('data-cms-img')
|
|
635
|
+
element.removeAttribute('data-cms-markdown')
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
finalHtml = root.toString()
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Add to manifest writer (handles per-page manifest writes)
|
|
642
|
+
manifestWriter.addPage(pagePath, result.entries, result.components, collectionEntry, result.seo)
|
|
643
|
+
|
|
644
|
+
// Write transformed HTML back
|
|
645
|
+
await fs.writeFile(filePath, finalHtml, 'utf-8')
|
|
646
|
+
|
|
647
|
+
return Object.keys(result.entries).length
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Result of batch processing with error aggregation */
|
|
651
|
+
interface BatchProcessingResult {
|
|
652
|
+
totalEntries: number
|
|
653
|
+
errors: Array<{ file: string; error: Error }>
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Process HTML files in parallel with concurrency limit and error aggregation.
|
|
658
|
+
* Unlike Promise.all, this continues processing even if some files fail.
|
|
659
|
+
*/
|
|
660
|
+
async function processFilesInBatches(
|
|
661
|
+
files: string[],
|
|
662
|
+
outDir: string,
|
|
663
|
+
config: Required<CmsMarkerOptions>,
|
|
664
|
+
manifestWriter: ManifestWriter,
|
|
665
|
+
idCounter: { value: number },
|
|
666
|
+
): Promise<BatchProcessingResult> {
|
|
667
|
+
let totalEntries = 0
|
|
668
|
+
const errors: Array<{ file: string; error: Error }> = []
|
|
669
|
+
|
|
670
|
+
// Process files in batches of MAX_CONCURRENT
|
|
671
|
+
for (let i = 0; i < files.length; i += MAX_CONCURRENT) {
|
|
672
|
+
const batch = files.slice(i, i + MAX_CONCURRENT)
|
|
673
|
+
const results = await Promise.allSettled(
|
|
674
|
+
batch.map(file =>
|
|
675
|
+
processFile(file, outDir, config, manifestWriter, idCounter)
|
|
676
|
+
.then(count => ({ file, count }))
|
|
677
|
+
.catch(err => Promise.reject({ file, error: err }))
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
for (const result of results) {
|
|
682
|
+
if (result.status === 'fulfilled') {
|
|
683
|
+
totalEntries += result.value.count
|
|
684
|
+
} else {
|
|
685
|
+
const { file, error } = result.reason as { file: string; error: Error }
|
|
686
|
+
errors.push({ file, error })
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { totalEntries, errors }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Process build output - processes all HTML files in parallel.
|
|
696
|
+
* Uses error aggregation to continue processing even if some files fail.
|
|
697
|
+
*/
|
|
698
|
+
export async function processBuildOutput(
|
|
699
|
+
dir: URL,
|
|
700
|
+
config: Required<CmsMarkerOptions>,
|
|
701
|
+
manifestWriter: ManifestWriter,
|
|
702
|
+
idCounter: { value: number },
|
|
703
|
+
logger?: AstroIntegrationLogger,
|
|
704
|
+
): Promise<void> {
|
|
705
|
+
const outDir = fileURLToPath(dir)
|
|
706
|
+
manifestWriter.setOutDir(outDir)
|
|
707
|
+
|
|
708
|
+
// Clear caches from previous builds and initialize search index
|
|
709
|
+
clearSourceFinderCache()
|
|
710
|
+
|
|
711
|
+
const htmlFiles = await findHtmlFiles(outDir)
|
|
712
|
+
|
|
713
|
+
if (htmlFiles.length === 0) {
|
|
714
|
+
logger?.info('No HTML files found to process')
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const startTime = Date.now()
|
|
719
|
+
|
|
720
|
+
// Pre-build search index for fast source lookups (single pass through all source files)
|
|
721
|
+
await initializeSearchIndex()
|
|
722
|
+
|
|
723
|
+
// Process all files in parallel batches with error aggregation
|
|
724
|
+
const { totalEntries, errors } = await processFilesInBatches(htmlFiles, outDir, config, manifestWriter, idCounter)
|
|
725
|
+
|
|
726
|
+
// Report any errors that occurred during processing
|
|
727
|
+
if (errors.length > 0) {
|
|
728
|
+
const errorLog = logger?.error?.bind(logger) ?? console.error.bind(console)
|
|
729
|
+
errorLog(`[cms] ${errors.length} file(s) failed to process:`)
|
|
730
|
+
for (const { file, error } of errors) {
|
|
731
|
+
const relPath = path.relative(outDir, file)
|
|
732
|
+
errorLog(` - ${relPath}: ${error.message}`)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Generate component preview pages before finalizing manifest
|
|
737
|
+
// (preview URLs are written into componentDefinitions in-place)
|
|
738
|
+
await generateComponentPreviews(
|
|
739
|
+
outDir,
|
|
740
|
+
manifestWriter.getPageDataForPreviews(),
|
|
741
|
+
manifestWriter.getComponentDefinitions(),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
// Finalize manifest (writes global manifest and waits for all per-page writes)
|
|
745
|
+
const stats = await manifestWriter.finalize()
|
|
746
|
+
|
|
747
|
+
const duration = Date.now() - startTime
|
|
748
|
+
const successCount = htmlFiles.length - errors.length
|
|
749
|
+
const msg =
|
|
750
|
+
`Processed ${successCount}/${htmlFiles.length} pages with ${stats.totalEntries} entries and ${stats.totalComponents} components in ${duration}ms`
|
|
751
|
+
|
|
752
|
+
if (logger) {
|
|
753
|
+
if (errors.length > 0) {
|
|
754
|
+
logger.warn(msg)
|
|
755
|
+
} else {
|
|
756
|
+
logger.info(msg)
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
console.log(`[cms] ${msg}`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Recursively find all HTML files in a directory (parallel version)
|
|
765
|
+
*/
|
|
766
|
+
async function findHtmlFiles(dir: string): Promise<string[]> {
|
|
767
|
+
const result: string[] = []
|
|
768
|
+
|
|
769
|
+
async function scan(currentDir: string): Promise<void> {
|
|
770
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true })
|
|
771
|
+
|
|
772
|
+
await Promise.all(entries.map(async (entry) => {
|
|
773
|
+
const fullPath = path.join(currentDir, entry.name)
|
|
774
|
+
if (entry.isDirectory()) {
|
|
775
|
+
await scan(fullPath)
|
|
776
|
+
} else if (entry.isFile() && fullPath.endsWith('.html')) {
|
|
777
|
+
result.push(fullPath)
|
|
778
|
+
}
|
|
779
|
+
}))
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
await scan(dir)
|
|
783
|
+
return result
|
|
784
|
+
}
|