@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,567 @@
|
|
|
1
|
+
import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getProjectRoot } from './config'
|
|
5
|
+
import { findSourceLocation } from './source-finder/source-lookup'
|
|
6
|
+
import type { CanonicalUrl, JsonLdEntry, OpenGraphData, PageSeoData, SeoFavicon, SeoKeywords, SeoMetaTag, SeoTitle, TwitterCardData } from './types'
|
|
7
|
+
|
|
8
|
+
/** Type for parsed HTML element nodes from node-html-parser */
|
|
9
|
+
type HTMLNode = ParsedHTMLElement
|
|
10
|
+
|
|
11
|
+
export interface ProcessSeoOptions {
|
|
12
|
+
/** Whether to mark the page title with a CMS ID (default: true) */
|
|
13
|
+
markTitle?: boolean
|
|
14
|
+
/** Whether to parse JSON-LD structured data (default: true) */
|
|
15
|
+
parseJsonLd?: boolean
|
|
16
|
+
/** Path to source file for source tracking (fallback) */
|
|
17
|
+
sourcePath?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProcessSeoResult {
|
|
21
|
+
/** Extracted SEO data */
|
|
22
|
+
seo: PageSeoData
|
|
23
|
+
/** The modified HTML with title CMS ID if markTitle is enabled */
|
|
24
|
+
html: string
|
|
25
|
+
/** The CMS ID assigned to the title element */
|
|
26
|
+
titleId?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Process HTML to extract SEO metadata from the <head> section.
|
|
31
|
+
* Returns structured SEO data with source tracking information.
|
|
32
|
+
*/
|
|
33
|
+
export async function processSeoFromHtml(
|
|
34
|
+
html: string,
|
|
35
|
+
options: ProcessSeoOptions = {},
|
|
36
|
+
getNextId?: () => string,
|
|
37
|
+
): Promise<ProcessSeoResult> {
|
|
38
|
+
const { markTitle = true, parseJsonLd = true, sourcePath } = options
|
|
39
|
+
|
|
40
|
+
const root = parse(html, {
|
|
41
|
+
lowerCaseTagName: false,
|
|
42
|
+
comment: true,
|
|
43
|
+
blockTextElements: {
|
|
44
|
+
script: true,
|
|
45
|
+
noscript: true,
|
|
46
|
+
style: true,
|
|
47
|
+
pre: true,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const head = root.querySelector('head')
|
|
52
|
+
const seo: PageSeoData = {}
|
|
53
|
+
let titleId: string | undefined
|
|
54
|
+
|
|
55
|
+
// Extract title
|
|
56
|
+
const titleResult = await extractTitle(root, html, sourcePath, markTitle, getNextId)
|
|
57
|
+
if (titleResult) {
|
|
58
|
+
seo.title = titleResult.title
|
|
59
|
+
titleId = titleResult.id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Extract meta tags from head
|
|
63
|
+
if (head) {
|
|
64
|
+
const metaTags = await extractMetaTags(head, html, sourcePath, getNextId)
|
|
65
|
+
categorizeMetaTags(metaTags, seo)
|
|
66
|
+
|
|
67
|
+
// Extract canonical URL
|
|
68
|
+
const canonical = await extractCanonical(head, html, sourcePath, getNextId)
|
|
69
|
+
if (canonical) {
|
|
70
|
+
seo.canonical = canonical
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Extract favicons
|
|
74
|
+
const favicons = await extractFavicons(head, html, sourcePath, getNextId)
|
|
75
|
+
if (favicons.length > 0) {
|
|
76
|
+
seo.favicons = favicons
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract JSON-LD
|
|
80
|
+
if (parseJsonLd) {
|
|
81
|
+
const jsonLdEntries = await extractJsonLd(head, html, sourcePath, getNextId)
|
|
82
|
+
if (jsonLdEntries.length > 0) {
|
|
83
|
+
seo.jsonLd = jsonLdEntries
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
seo,
|
|
90
|
+
html: root.toString(),
|
|
91
|
+
titleId,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract the page title from HTML
|
|
97
|
+
*/
|
|
98
|
+
async function extractTitle(
|
|
99
|
+
root: HTMLNode,
|
|
100
|
+
html: string,
|
|
101
|
+
sourcePath?: string,
|
|
102
|
+
markTitle?: boolean,
|
|
103
|
+
getNextId?: () => string,
|
|
104
|
+
): Promise<{ title: SeoTitle; id?: string } | undefined> {
|
|
105
|
+
const titleElement = root.querySelector('title')
|
|
106
|
+
if (!titleElement) return undefined
|
|
107
|
+
|
|
108
|
+
const content = titleElement.textContent?.trim() || ''
|
|
109
|
+
if (!content) return undefined
|
|
110
|
+
|
|
111
|
+
// Use the same source finding logic as regular text entries
|
|
112
|
+
// This tracks through props, variables, and imports
|
|
113
|
+
const sourceLocation = await findSourceLocation(content, 'title')
|
|
114
|
+
|
|
115
|
+
// Fall back to rendered HTML location if source not found
|
|
116
|
+
const sourceInfo = sourceLocation
|
|
117
|
+
? {
|
|
118
|
+
sourcePath: sourceLocation.file,
|
|
119
|
+
sourceLine: sourceLocation.line,
|
|
120
|
+
sourceSnippet: sourceLocation.snippet || '',
|
|
121
|
+
}
|
|
122
|
+
: findElementSourceLocation(titleElement, html, sourcePath)
|
|
123
|
+
|
|
124
|
+
let id: string | undefined
|
|
125
|
+
if (markTitle && getNextId) {
|
|
126
|
+
id = getNextId()
|
|
127
|
+
titleElement.setAttribute('data-cms-id', id)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
title: {
|
|
132
|
+
content,
|
|
133
|
+
id,
|
|
134
|
+
...sourceInfo,
|
|
135
|
+
},
|
|
136
|
+
id,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extract all meta tags from the head
|
|
142
|
+
*/
|
|
143
|
+
async function extractMetaTags(
|
|
144
|
+
head: HTMLNode,
|
|
145
|
+
html: string,
|
|
146
|
+
sourcePath?: string,
|
|
147
|
+
getNextId?: () => string,
|
|
148
|
+
): Promise<SeoMetaTag[]> {
|
|
149
|
+
const metaTags: SeoMetaTag[] = []
|
|
150
|
+
const metas = head.querySelectorAll('meta')
|
|
151
|
+
|
|
152
|
+
for (const meta of metas) {
|
|
153
|
+
const name = meta.getAttribute('name')
|
|
154
|
+
const property = meta.getAttribute('property')
|
|
155
|
+
const content = meta.getAttribute('content')
|
|
156
|
+
|
|
157
|
+
// Skip meta tags without content or without name/property
|
|
158
|
+
if (!content || (!name && !property)) continue
|
|
159
|
+
|
|
160
|
+
// Use the same source finding logic as regular text entries
|
|
161
|
+
// This tracks through props, variables, and imports
|
|
162
|
+
const sourceLocation = await findSourceLocation(content, 'meta')
|
|
163
|
+
|
|
164
|
+
// Fall back to rendered HTML location if source not found
|
|
165
|
+
const sourceInfo = sourceLocation
|
|
166
|
+
? {
|
|
167
|
+
sourcePath: sourceLocation.file,
|
|
168
|
+
sourceLine: sourceLocation.line,
|
|
169
|
+
sourceSnippet: sourceLocation.snippet || '',
|
|
170
|
+
}
|
|
171
|
+
: findElementSourceLocation(meta, html, sourcePath)
|
|
172
|
+
|
|
173
|
+
// Mark meta tag with CMS ID for editing
|
|
174
|
+
let id: string | undefined
|
|
175
|
+
if (getNextId) {
|
|
176
|
+
id = getNextId()
|
|
177
|
+
meta.setAttribute('data-cms-id', id)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
metaTags.push({
|
|
181
|
+
id,
|
|
182
|
+
name: name || undefined,
|
|
183
|
+
property: property || undefined,
|
|
184
|
+
content,
|
|
185
|
+
...sourceInfo,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return metaTags
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Categorize meta tags into description, keywords, Open Graph and Twitter Card
|
|
194
|
+
*/
|
|
195
|
+
function categorizeMetaTags(metaTags: SeoMetaTag[], seo: PageSeoData): void {
|
|
196
|
+
const openGraph: OpenGraphData = {}
|
|
197
|
+
const twitterCard: TwitterCardData = {}
|
|
198
|
+
|
|
199
|
+
for (const meta of metaTags) {
|
|
200
|
+
const { name, property, content } = meta
|
|
201
|
+
|
|
202
|
+
// Description
|
|
203
|
+
if (name === 'description') {
|
|
204
|
+
seo.description = meta
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Keywords
|
|
209
|
+
if (name === 'keywords') {
|
|
210
|
+
const keywords = content.split(',').map(k => k.trim()).filter(Boolean)
|
|
211
|
+
seo.keywords = {
|
|
212
|
+
...meta,
|
|
213
|
+
keywords,
|
|
214
|
+
} as SeoKeywords
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Open Graph tags
|
|
219
|
+
if (property?.startsWith('og:')) {
|
|
220
|
+
const ogKey = property.replace('og:', '')
|
|
221
|
+
switch (ogKey) {
|
|
222
|
+
case 'title':
|
|
223
|
+
openGraph.title = meta
|
|
224
|
+
break
|
|
225
|
+
case 'description':
|
|
226
|
+
openGraph.description = meta
|
|
227
|
+
break
|
|
228
|
+
case 'image':
|
|
229
|
+
openGraph.image = meta
|
|
230
|
+
break
|
|
231
|
+
case 'url':
|
|
232
|
+
openGraph.url = meta
|
|
233
|
+
break
|
|
234
|
+
case 'type':
|
|
235
|
+
openGraph.type = meta
|
|
236
|
+
break
|
|
237
|
+
case 'site_name':
|
|
238
|
+
openGraph.siteName = meta
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Twitter Card tags
|
|
245
|
+
if (name?.startsWith('twitter:') || property?.startsWith('twitter:')) {
|
|
246
|
+
const twitterKey = (name || property || '').replace('twitter:', '')
|
|
247
|
+
switch (twitterKey) {
|
|
248
|
+
case 'card':
|
|
249
|
+
twitterCard.card = meta
|
|
250
|
+
break
|
|
251
|
+
case 'title':
|
|
252
|
+
twitterCard.title = meta
|
|
253
|
+
break
|
|
254
|
+
case 'description':
|
|
255
|
+
twitterCard.description = meta
|
|
256
|
+
break
|
|
257
|
+
case 'image':
|
|
258
|
+
twitterCard.image = meta
|
|
259
|
+
break
|
|
260
|
+
case 'site':
|
|
261
|
+
twitterCard.site = meta
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Only add if we found any OG tags
|
|
268
|
+
if (Object.keys(openGraph).length > 0) {
|
|
269
|
+
seo.openGraph = openGraph
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Only add if we found any Twitter tags
|
|
273
|
+
if (Object.keys(twitterCard).length > 0) {
|
|
274
|
+
seo.twitterCard = twitterCard
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Extract canonical URL from head
|
|
280
|
+
*/
|
|
281
|
+
async function extractCanonical(
|
|
282
|
+
head: HTMLNode,
|
|
283
|
+
html: string,
|
|
284
|
+
sourcePath?: string,
|
|
285
|
+
getNextId?: () => string,
|
|
286
|
+
): Promise<CanonicalUrl | undefined> {
|
|
287
|
+
const canonical = head.querySelector('link[rel="canonical"]')
|
|
288
|
+
if (!canonical) return undefined
|
|
289
|
+
|
|
290
|
+
const href = canonical.getAttribute('href')
|
|
291
|
+
if (!href) return undefined
|
|
292
|
+
|
|
293
|
+
// Use the same source finding logic as regular text entries
|
|
294
|
+
// This tracks through props, variables, and imports
|
|
295
|
+
const sourceLocation = await findSourceLocation(href, 'link')
|
|
296
|
+
|
|
297
|
+
// Fall back to rendered HTML location if source not found
|
|
298
|
+
const sourceInfo = sourceLocation
|
|
299
|
+
? {
|
|
300
|
+
sourcePath: sourceLocation.file,
|
|
301
|
+
sourceLine: sourceLocation.line,
|
|
302
|
+
sourceSnippet: sourceLocation.snippet || '',
|
|
303
|
+
}
|
|
304
|
+
: findElementSourceLocation(canonical, html, sourcePath)
|
|
305
|
+
|
|
306
|
+
// Mark canonical link with CMS ID for editing
|
|
307
|
+
let id: string | undefined
|
|
308
|
+
if (getNextId) {
|
|
309
|
+
id = getNextId()
|
|
310
|
+
canonical.setAttribute('data-cms-id', id)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id,
|
|
315
|
+
href,
|
|
316
|
+
...sourceInfo,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Extract favicon link elements from head
|
|
322
|
+
*/
|
|
323
|
+
async function extractFavicons(
|
|
324
|
+
head: HTMLNode,
|
|
325
|
+
html: string,
|
|
326
|
+
sourcePath?: string,
|
|
327
|
+
getNextId?: () => string,
|
|
328
|
+
): Promise<SeoFavicon[]> {
|
|
329
|
+
const favicons: SeoFavicon[] = []
|
|
330
|
+
const links = head.querySelectorAll('link')
|
|
331
|
+
|
|
332
|
+
for (const link of links) {
|
|
333
|
+
const rel = link.getAttribute('rel')?.toLowerCase()
|
|
334
|
+
if (!rel || !['icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed'].includes(rel)) continue
|
|
335
|
+
|
|
336
|
+
const href = link.getAttribute('href')
|
|
337
|
+
if (!href) continue
|
|
338
|
+
|
|
339
|
+
const sourceLocation = await findSourceLocation(href, 'link')
|
|
340
|
+
const sourceInfo = sourceLocation
|
|
341
|
+
? {
|
|
342
|
+
sourcePath: sourceLocation.file,
|
|
343
|
+
sourceLine: sourceLocation.line,
|
|
344
|
+
sourceSnippet: sourceLocation.snippet || '',
|
|
345
|
+
}
|
|
346
|
+
: findElementSourceLocation(link, html, sourcePath)
|
|
347
|
+
|
|
348
|
+
let id: string | undefined
|
|
349
|
+
if (getNextId) {
|
|
350
|
+
id = getNextId()
|
|
351
|
+
link.setAttribute('data-cms-id', id)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
favicons.push({
|
|
355
|
+
id,
|
|
356
|
+
href,
|
|
357
|
+
rel,
|
|
358
|
+
type: link.getAttribute('type') || undefined,
|
|
359
|
+
sizes: link.getAttribute('sizes') || undefined,
|
|
360
|
+
...sourceInfo,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return favicons
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Extract JSON-LD structured data from script tags
|
|
369
|
+
*/
|
|
370
|
+
async function extractJsonLd(
|
|
371
|
+
head: HTMLNode,
|
|
372
|
+
html: string,
|
|
373
|
+
sourcePath?: string,
|
|
374
|
+
getNextId?: () => string,
|
|
375
|
+
): Promise<JsonLdEntry[]> {
|
|
376
|
+
const entries: JsonLdEntry[] = []
|
|
377
|
+
|
|
378
|
+
// Also check body for JSON-LD scripts (some sites place them there)
|
|
379
|
+
const root = head.parentNode as HTMLNode
|
|
380
|
+
const scripts = root?.querySelectorAll('script[type="application/ld+json"]') || []
|
|
381
|
+
|
|
382
|
+
for (const script of scripts) {
|
|
383
|
+
const content = script.textContent?.trim()
|
|
384
|
+
if (!content) continue
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const data = JSON.parse(content)
|
|
388
|
+
const type = data['@type'] || 'Unknown'
|
|
389
|
+
|
|
390
|
+
// Search for JSON-LD script with this @type in source files
|
|
391
|
+
const sourceLocation = await findJsonLdSource(type)
|
|
392
|
+
|
|
393
|
+
// Fall back to rendered HTML location if source not found
|
|
394
|
+
const sourceInfo = sourceLocation || findElementSourceLocation(script, html, sourcePath)
|
|
395
|
+
|
|
396
|
+
// Mark JSON-LD script with CMS ID for editing
|
|
397
|
+
let id: string | undefined
|
|
398
|
+
if (getNextId) {
|
|
399
|
+
id = getNextId()
|
|
400
|
+
script.setAttribute('data-cms-id', id)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
entries.push({
|
|
404
|
+
id,
|
|
405
|
+
type,
|
|
406
|
+
data,
|
|
407
|
+
...sourceInfo,
|
|
408
|
+
})
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.warn('[astro-cms] Skipping malformed JSON-LD:', error instanceof Error ? error.message : String(error))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return entries
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Search for JSON-LD script with a specific @type in source files
|
|
419
|
+
*/
|
|
420
|
+
async function findJsonLdSource(
|
|
421
|
+
jsonLdType: string,
|
|
422
|
+
): Promise<{ sourcePath: string; sourceLine: number; sourceSnippet: string } | undefined> {
|
|
423
|
+
const srcDir = path.join(getProjectRoot(), 'src')
|
|
424
|
+
const searchDirs = [
|
|
425
|
+
path.join(srcDir, 'pages'),
|
|
426
|
+
path.join(srcDir, 'layouts'),
|
|
427
|
+
path.join(srcDir, 'components'),
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
for (const dir of searchDirs) {
|
|
431
|
+
try {
|
|
432
|
+
const result = await searchDirForJsonLd(dir, jsonLdType)
|
|
433
|
+
if (result) return result
|
|
434
|
+
} catch {
|
|
435
|
+
// Directory doesn't exist
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return undefined
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Recursively search a directory for JSON-LD scripts
|
|
444
|
+
*/
|
|
445
|
+
async function searchDirForJsonLd(
|
|
446
|
+
dir: string,
|
|
447
|
+
jsonLdType: string,
|
|
448
|
+
): Promise<{ sourcePath: string; sourceLine: number; sourceSnippet: string } | undefined> {
|
|
449
|
+
try {
|
|
450
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
451
|
+
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
const fullPath = path.join(dir, entry.name)
|
|
454
|
+
|
|
455
|
+
if (entry.isDirectory()) {
|
|
456
|
+
const result = await searchDirForJsonLd(fullPath, jsonLdType)
|
|
457
|
+
if (result) return result
|
|
458
|
+
} else if (entry.isFile() && (entry.name.endsWith('.astro') || entry.name.endsWith('.html'))) {
|
|
459
|
+
const result = await searchFileForJsonLd(fullPath, jsonLdType)
|
|
460
|
+
if (result) return result
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// Error reading directory
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return undefined
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Search a single file for JSON-LD with a specific @type
|
|
472
|
+
*/
|
|
473
|
+
async function searchFileForJsonLd(
|
|
474
|
+
filePath: string,
|
|
475
|
+
jsonLdType: string,
|
|
476
|
+
): Promise<{ sourcePath: string; sourceLine: number; sourceSnippet: string } | undefined> {
|
|
477
|
+
try {
|
|
478
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
479
|
+
const lines = content.split('\n')
|
|
480
|
+
|
|
481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
482
|
+
const line = lines[i] || ''
|
|
483
|
+
|
|
484
|
+
// Look for JSON-LD script opening
|
|
485
|
+
if (line.includes('application/ld+json')) {
|
|
486
|
+
// Check following lines for the @type
|
|
487
|
+
const snippetLines: string[] = []
|
|
488
|
+
let foundType = false
|
|
489
|
+
|
|
490
|
+
for (let j = i; j < Math.min(i + 30, lines.length); j++) {
|
|
491
|
+
const snippetLine = lines[j] || ''
|
|
492
|
+
snippetLines.push(snippetLine)
|
|
493
|
+
|
|
494
|
+
// Check if this JSON-LD contains the @type we're looking for
|
|
495
|
+
if (snippetLine.includes(`"@type"`) && snippetLine.includes(jsonLdType)) {
|
|
496
|
+
foundType = true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Check for closing script tag
|
|
500
|
+
if (snippetLine.includes('</script>')) {
|
|
501
|
+
break
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (foundType) {
|
|
506
|
+
return {
|
|
507
|
+
sourcePath: path.relative(getProjectRoot(), filePath),
|
|
508
|
+
sourceLine: i + 1,
|
|
509
|
+
sourceSnippet: snippetLines.join('\n'),
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// Error reading file
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return undefined
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Find the source location (line number and snippet) for an element in the rendered HTML.
|
|
523
|
+
* This is a fallback when the actual source file location cannot be found.
|
|
524
|
+
*/
|
|
525
|
+
function findElementSourceLocation(
|
|
526
|
+
element: HTMLNode,
|
|
527
|
+
html: string,
|
|
528
|
+
sourcePath?: string,
|
|
529
|
+
): { sourcePath: string; sourceLine: number; sourceSnippet: string } {
|
|
530
|
+
// Get the element's outer HTML as the source snippet
|
|
531
|
+
const sourceSnippet = element.toString()
|
|
532
|
+
|
|
533
|
+
// Find the line number by searching for the full element string in the original HTML
|
|
534
|
+
// Use the complete first line for more precise matching
|
|
535
|
+
let sourceLine = 1
|
|
536
|
+
const elementStr = sourceSnippet.split('\n')[0] || sourceSnippet
|
|
537
|
+
const lines = html.split('\n')
|
|
538
|
+
|
|
539
|
+
// Try exact match first (most reliable)
|
|
540
|
+
let found = false
|
|
541
|
+
for (let i = 0; i < lines.length; i++) {
|
|
542
|
+
const line = lines[i]
|
|
543
|
+
if (line?.includes(elementStr)) {
|
|
544
|
+
sourceLine = i + 1
|
|
545
|
+
found = true
|
|
546
|
+
break
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Fall back to progressively shorter prefix matching
|
|
551
|
+
if (!found) {
|
|
552
|
+
const minMatchLen = Math.min(80, elementStr.length)
|
|
553
|
+
for (let i = 0; i < lines.length; i++) {
|
|
554
|
+
const line = lines[i]
|
|
555
|
+
if (line?.includes(elementStr.substring(0, minMatchLen))) {
|
|
556
|
+
sourceLine = i + 1
|
|
557
|
+
break
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
sourcePath: sourcePath || '',
|
|
564
|
+
sourceLine,
|
|
565
|
+
sourceSnippet,
|
|
566
|
+
}
|
|
567
|
+
}
|