@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.
Files changed (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. 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
+ }