@vibecms/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+ import React, { useState, useEffect } from 'react';
3
+ import { VibeEngine } from '@/lib/cms/engine';
4
+ import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@vibecms/core';
5
+ import { Plus, X, Search, Link as LinkIcon } from 'lucide-react';
6
+
7
+ export function MultiReferencePicker({ value = [], onChange, label, collection }: { value: string[]; onChange: (v: string[]) => void; label: string; collection: string }) {
8
+ const [open, setOpen] = useState(false);
9
+ const [documents, setDocuments] = useState<any[]>([]);
10
+ const [loading, setLoading] = useState(false);
11
+
12
+ useEffect(() => {
13
+ if (open && documents.length === 0) {
14
+ setLoading(true);
15
+ VibeEngine.list(collection, { withMeta: true })
16
+ .then(setDocuments)
17
+ .catch(console.error)
18
+ .finally(() => setLoading(false));
19
+ }
20
+ }, [open, collection]);
21
+
22
+ const handleSelect = (slug: string) => {
23
+ if (value.includes(slug)) {
24
+ onChange(value.filter(v => v !== slug));
25
+ } else {
26
+ onChange([...value, slug]);
27
+ }
28
+ setOpen(false);
29
+ };
30
+
31
+ const handleRemove = (slug: string, e: React.MouseEvent) => {
32
+ e.stopPropagation();
33
+ onChange(value.filter(v => v !== slug));
34
+ };
35
+
36
+ return (
37
+ <div className="flex flex-col gap-2">
38
+ <div className="flex flex-wrap gap-2 mb-1">
39
+ {value.map((val) => {
40
+ const doc = documents.find(d => d.slug === val) || { title: val, slug: val };
41
+ return (
42
+ <div key={val} className="flex items-center gap-1.5 bg-indigo-50 border border-indigo-100 text-indigo-700 text-sm font-medium px-2.5 py-1 rounded-full shadow-sm">
43
+ <LinkIcon className="w-3 h-3 shrink-0 opacity-70" />
44
+ <span className="truncate max-w-[150px]">{doc.title || doc.name || val}</span>
45
+ <button onClick={(e) => handleRemove(val, e)} className="hover:bg-indigo-200/50 p-0.5 rounded-full transition-colors ml-1">
46
+ <X className="w-3 h-3 text-indigo-500 hover:text-indigo-700" />
47
+ </button>
48
+ </div>
49
+ );
50
+ })}
51
+ <button
52
+ className="flex items-center gap-1 bg-white border border-dashed border-neutral-300 hover:border-indigo-400 hover:text-indigo-600 text-neutral-500 text-sm font-medium px-3 py-1 rounded-full shadow-sm transition-all"
53
+ onClick={() => setOpen(true)}
54
+ >
55
+ <Plus className="w-3.5 h-3.5" /> Add
56
+ </button>
57
+ </div>
58
+
59
+ <CommandDialog open={open} onOpenChange={setOpen}>
60
+ <CommandInput placeholder={`Search ${collection}...`} />
61
+ <CommandList className="max-h-[250px] overflow-y-auto custom-scrollbar">
62
+ <CommandEmpty>{loading ? 'Loading documents...' : `No documents found in ${collection}.`}</CommandEmpty>
63
+ <CommandGroup>
64
+ {documents.filter(doc => !value.includes(doc.slug || doc)).map((doc) => {
65
+ const slug = doc.slug || doc.replace?.('.json', '');
66
+ const labelTxt = doc.title || doc.name || slug;
67
+ return (
68
+ <CommandItem
69
+ key={slug}
70
+ value={slug}
71
+ onSelect={(currentValue) => handleSelect(slug)}
72
+ className="cursor-pointer flex items-center gap-3 text-neutral-700 hover:text-neutral-900 hover:bg-neutral-100 data-[selected=true]:bg-indigo-50 data-[selected=true]:text-indigo-600"
73
+ >
74
+ {labelTxt}
75
+ </CommandItem>
76
+ );
77
+ })}
78
+ </CommandGroup>
79
+ </CommandList>
80
+ </CommandDialog>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect } from 'react';
4
+ import { Popover, PopoverContent, PopoverTrigger, Button, Label, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@vibecms/core';
5
+ import { VibeEngine } from '@/lib/cms/engine';
6
+ import { Link, Search } from 'lucide-react';
7
+
8
+ export function ReferencePicker({ value, onChange, label, collection }: { value: string; onChange: (v: string) => void; label: string; collection: string }) {
9
+ const [open, setOpen] = useState(false);
10
+ const [documents, setDocuments] = useState<any[]>([]);
11
+ const [loading, setLoading] = useState(false);
12
+
13
+ useEffect(() => {
14
+ if (open && documents.length === 0) {
15
+ setLoading(true);
16
+ VibeEngine.list(collection, { withMeta: true })
17
+ .then(setDocuments)
18
+ .catch(console.error)
19
+ .finally(() => setLoading(false));
20
+ }
21
+ }, [open, collection, documents.length]);
22
+
23
+ return (
24
+ <div className="flex flex-col gap-2 w-full">
25
+ <Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider">
26
+ {label} <span className="text-indigo-600/50 lowercase ml-1">({collection})</span>
27
+ </Label>
28
+ <Popover open={open} onOpenChange={setOpen}>
29
+ <PopoverTrigger className="w-full">
30
+ <div
31
+ role="button"
32
+ className="w-full flex items-center justify-between bg-white border border-neutral-300 text-neutral-900 hover:bg-neutral-50 transition-colors p-2.5 rounded-md cursor-pointer text-sm font-medium shadow-sm"
33
+ >
34
+ <div className="flex items-center gap-2 overflow-hidden">
35
+ <Link className="w-4 h-4 text-indigo-500 shrink-0" />
36
+ <span className="truncate">
37
+ {value
38
+ ? (documents.find(d => d.slug === value)?.title || value)
39
+ : `Select from ${collection}...`}
40
+ </span>
41
+ </div>
42
+ <Search className="w-4 h-4 text-neutral-400 shrink-0 ml-2" />
43
+ </div>
44
+ </PopoverTrigger>
45
+ <PopoverContent className="w-72 bg-white border-neutral-200 p-0 shadow-2xl">
46
+ <Command>
47
+ <CommandInput placeholder={`Search ${collection}...`} className="border-none text-neutral-900 placeholder:text-neutral-400" />
48
+ <CommandList className="max-h-[250px] overflow-y-auto custom-scrollbar">
49
+ <CommandEmpty>{loading ? 'Loading documents...' : `No documents found in ${collection}.`}</CommandEmpty>
50
+ <CommandGroup>
51
+ {documents.map((doc) => {
52
+ const slug = doc.slug || doc.replace?.('.json', ''); // fallback if still raw string
53
+ const label = doc.title || doc.name || slug;
54
+ return (
55
+ <CommandItem
56
+ key={slug}
57
+ value={slug}
58
+ onSelect={(currentValue: string) => {
59
+ onChange(currentValue === value ? '' : slug);
60
+ setOpen(false);
61
+ }}
62
+ className="cursor-pointer flex items-center gap-3 text-neutral-700 hover:text-neutral-900 hover:bg-neutral-100 data-[selected=true]:bg-indigo-50 data-[selected=true]:text-indigo-600"
63
+ >
64
+ {label}
65
+ </CommandItem>
66
+ );
67
+ })}
68
+ </CommandGroup>
69
+ </CommandList>
70
+ </Command>
71
+ </PopoverContent>
72
+ </Popover>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+ import { useEditor, EditorContent } from '@tiptap/react';
3
+ import { BubbleMenu } from '@tiptap/react/menus';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import { Bold, Italic, Strikethrough, List, ListOrdered, Heading2 } from 'lucide-react';
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@vibecms/core';
7
+ import { useEffect } from 'react';
8
+
9
+ interface RichTextProps {
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ label?: string;
13
+ }
14
+
15
+ export function RichText({ value, onChange, label }: RichTextProps) {
16
+ const editor = useEditor({
17
+ immediatelyRender: false,
18
+ extensions: [StarterKit],
19
+ content: value,
20
+ editorProps: {
21
+ attributes: {
22
+ class: 'prose prose-sm max-w-none focus:outline-none min-h-[150px] p-4 bg-transparent rounded-b-md border border-t-0 border-neutral-300 text-neutral-900 leading-relaxed focus-visible:ring-indigo-500/20 focus-visible:ring-2',
23
+ },
24
+ },
25
+ onUpdate: ({ editor }) => {
26
+ onChange(editor.getHTML());
27
+ },
28
+ });
29
+
30
+ useEffect(() => {
31
+ if (editor && editor.getHTML() !== value) {
32
+ editor.commands.setContent(value, { emitUpdate: false });
33
+ }
34
+ }, [value, editor]);
35
+
36
+ if (!editor) {
37
+ return null;
38
+ }
39
+
40
+ const toggleBtn = "p-1.5 rounded hover:bg-neutral-200 text-neutral-500 hover:text-neutral-900 transition-colors disabled:opacity-30";
41
+ const activeBtn = "bg-neutral-300 text-neutral-900 shadow-inner";
42
+
43
+ return (
44
+ <div className="flex flex-col gap-1.5 my-2">
45
+ <div className="flex justify-between items-center bg-white">
46
+ <span className="text-neutral-500 text-xs font-mono uppercase tracking-wider">{label}</span>
47
+ {editor && (
48
+ <Popover>
49
+ <PopoverTrigger title="Typography Tools" className="text-[10px] font-mono bg-neutral-100 hover:bg-neutral-200 text-neutral-500 px-2 py-0.5 rounded transition flex items-center gap-1.5 cursor-pointer">
50
+ <span className="font-serif italic font-bold text-[11px]">A</span> Typography
51
+ </PopoverTrigger>
52
+ <PopoverContent className="w-56 bg-white border-neutral-200 p-2 z-50 shadow-lg">
53
+ <div className="flex flex-col gap-1">
54
+ <button type="button" onClick={() => editor.chain().focus().insertContent('&shy;').run()} className="text-xs text-left text-neutral-700 hover:bg-neutral-50 hover:text-indigo-600 font-medium p-2 rounded transition">
55
+ Insert Soft Hyphen (&amp;shy;)
56
+ </button>
57
+ <button type="button" onClick={() => editor.chain().focus().setHardBreak().run()} className="text-xs text-left text-neutral-700 hover:bg-neutral-50 hover:text-indigo-600 font-medium p-2 rounded transition">
58
+ Insert Line Break (&lt;br&gt;)
59
+ </button>
60
+ </div>
61
+ </PopoverContent>
62
+ </Popover>
63
+ )}
64
+ </div>
65
+ <div className="flex flex-col rounded-md overflow-hidden border border-neutral-300 bg-white">
66
+ {editor && (
67
+ <BubbleMenu editor={editor} className="flex items-center gap-1 bg-white border border-neutral-200 p-1.5 rounded-lg shadow-xl shadow-neutral-200/50 backdrop-blur-md">
68
+ <button
69
+ onClick={() => editor.chain().focus().toggleBold().run()}
70
+ disabled={!editor.can().chain().focus().toggleBold().run()}
71
+ className={`${toggleBtn} ${editor.isActive('bold') ? activeBtn : ''}`}
72
+ title="Bold"
73
+ >
74
+ <Bold className="w-4 h-4" />
75
+ </button>
76
+ <button
77
+ onClick={() => editor.chain().focus().toggleItalic().run()}
78
+ disabled={!editor.can().chain().focus().toggleItalic().run()}
79
+ className={`${toggleBtn} ${editor.isActive('italic') ? activeBtn : ''}`}
80
+ title="Italic"
81
+ >
82
+ <Italic className="w-4 h-4" />
83
+ </button>
84
+ <button
85
+ onClick={() => editor.chain().focus().toggleStrike().run()}
86
+ disabled={!editor.can().chain().focus().toggleStrike().run()}
87
+ className={`${toggleBtn} ${editor.isActive('strike') ? activeBtn : ''}`}
88
+ title="Strikethrough"
89
+ >
90
+ <Strikethrough className="w-4 h-4" />
91
+ </button>
92
+ <div className="w-[1px] h-4 bg-neutral-200 mx-1" />
93
+ <button
94
+ onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
95
+ className={`${toggleBtn} ${editor.isActive('heading', { level: 2 }) ? activeBtn : ''}`}
96
+ title="Heading 2"
97
+ >
98
+ <Heading2 className="w-4 h-4" />
99
+ </button>
100
+ <div className="w-[1px] h-4 bg-neutral-200 mx-1" />
101
+ <button
102
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
103
+ className={`${toggleBtn} ${editor.isActive('bulletList') ? activeBtn : ''}`}
104
+ title="Bullet List"
105
+ >
106
+ <List className="w-4 h-4" />
107
+ </button>
108
+ <button
109
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
110
+ className={`${toggleBtn} ${editor.isActive('orderedList') ? activeBtn : ''}`}
111
+ title="Ordered List"
112
+ >
113
+ <ListOrdered className="w-4 h-4" />
114
+ </button>
115
+ </BubbleMenu>
116
+ )}
117
+ <EditorContent editor={editor} />
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,307 @@
1
+ import axe from 'axe-core';
2
+
3
+ export interface AuditIssue {
4
+ id: string;
5
+ type: 'seo' | 'accessibility';
6
+ severity: 'critical' | 'warning' | 'info';
7
+ message: string;
8
+ elementHtml?: string;
9
+ selector?: string;
10
+ fieldPath?: string;
11
+ failingColor?: string;
12
+ suggestedColor?: string;
13
+ }
14
+
15
+ // --- Phase 16 WCAG Contrast Math ---
16
+ function hexToRgb(hex: string) {
17
+ if (hex.startsWith('rgb')) {
18
+ const match = hex.match(/\d+/g);
19
+ if (!match || match.length < 3) return [0,0,0];
20
+ return [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
21
+ }
22
+ let h = hex.replace('#', '');
23
+ if (h.length === 3) h = h.split('').map(c => c+c).join('');
24
+ if (h.length !== 6) return [0,0,0];
25
+ return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16)];
26
+ }
27
+
28
+ function rgbToHex(r: number, g: number, b: number) {
29
+ return '#' + [r,g,b].map(x => {
30
+ const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
31
+ return hex.length === 1 ? '0' + hex : hex;
32
+ }).join('');
33
+ }
34
+
35
+ function getLuminance(r: number, g: number, b: number) {
36
+ const a = [r,g,b].map(function (v) {
37
+ v /= 255;
38
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
39
+ });
40
+ return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
41
+ }
42
+
43
+ function getContrastRatio(l1: number, l2: number) {
44
+ const lighter = Math.max(l1, l2);
45
+ const darker = Math.min(l1, l2);
46
+ return (lighter + 0.05) / (darker + 0.05);
47
+ }
48
+
49
+ function checkContrast(fgHex: string, bgHex: string): string | null {
50
+ const bgRgb = hexToRgb(bgHex);
51
+ const bgL = getLuminance(bgRgb[0], bgRgb[1], bgRgb[2]);
52
+
53
+ let currentFgRgb = hexToRgb(fgHex);
54
+ const initialFgL = getLuminance(currentFgRgb[0], currentFgRgb[1], currentFgRgb[2]);
55
+
56
+ if (getContrastRatio(initialFgL, bgL) >= 4.5) {
57
+ return null;
58
+ }
59
+
60
+ const isBgDark = bgL < 0.5;
61
+ const step = isBgDark ? 5 : -5;
62
+
63
+ for (let i = 0; i < 50; i++) {
64
+ currentFgRgb = currentFgRgb.map(c => Math.max(0, Math.min(255, c + step)));
65
+ const fgL = getLuminance(currentFgRgb[0], currentFgRgb[1], currentFgRgb[2]);
66
+ const ratio = getContrastRatio(fgL, bgL);
67
+ if (ratio >= 4.5) {
68
+ return rgbToHex(currentFgRgb[0], currentFgRgb[1], currentFgRgb[2]);
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export interface AuditReport {
75
+ score: number;
76
+ issues: AuditIssue[];
77
+ passed: number;
78
+ }
79
+
80
+ export async function runVibeAudit(documentHtml: string, containerElement: HTMLElement): Promise<AuditReport> {
81
+ const issues: AuditIssue[] = [];
82
+ let passedCount = 0;
83
+
84
+ // 1. Run Axe Core for Accessibility
85
+ try {
86
+ const axeResults = await axe.run(containerElement, {
87
+ resultTypes: ['violations', 'passes'],
88
+ rules: {
89
+ 'color-contrast': { enabled: true },
90
+ 'image-alt': { enabled: true },
91
+ 'heading-order': { enabled: true },
92
+ 'link-name': { enabled: true },
93
+ 'button-name': { enabled: true },
94
+ }
95
+ });
96
+
97
+ passedCount += axeResults.passes.length;
98
+
99
+ axeResults.violations.forEach(violation => {
100
+ violation.nodes.forEach(node => {
101
+ let suggestedColor: string | undefined;
102
+ let failingColor: string | undefined;
103
+ let fieldPath: string | undefined;
104
+
105
+ if (violation.id === 'color-contrast') {
106
+ const contrastData = node.any.find(a => a.id === 'color-contrast')?.data;
107
+ if (contrastData && contrastData.fgColor && contrastData.bgColor) {
108
+ failingColor = contrastData.fgColor;
109
+ suggestedColor = checkContrast(contrastData.fgColor, contrastData.bgColor) || undefined;
110
+ }
111
+ }
112
+
113
+ if (node.target && node.target.length > 0) {
114
+ try {
115
+ // Traverse upwards aiming to anchor raw Axe DOM items to precise VibeCMS Nanostore Draft indices
116
+ const el = containerElement.querySelector(node.target[0] as string);
117
+ if (el) {
118
+ const vibeEl = el.closest('[data-vibe-path]');
119
+ if (vibeEl) {
120
+ fieldPath = vibeEl.getAttribute('data-vibe-path') || undefined;
121
+ }
122
+ }
123
+ } catch (e) { /* Ignore parsing errors if Axe produces pseudo-selectors */ }
124
+ }
125
+
126
+ issues.push({
127
+ id: violation.id,
128
+ type: 'accessibility',
129
+ severity: violation.impact === 'critical' || violation.impact === 'serious' ? 'critical' : 'warning',
130
+ message: violation.help,
131
+ elementHtml: node.html,
132
+ selector: node.target.join(' > '),
133
+ fieldPath,
134
+ failingColor,
135
+ suggestedColor
136
+ });
137
+ });
138
+ });
139
+ } catch (e) {
140
+ console.error('Axe audit failed', e);
141
+ }
142
+
143
+ // 2. Custom SEO Heuristics
144
+ // Check for H1
145
+ const h1s = containerElement.querySelectorAll('h1');
146
+ if (h1s.length === 0) {
147
+ issues.push({
148
+ id: 'missing-h1',
149
+ type: 'seo',
150
+ severity: 'critical',
151
+ message: 'Page is missing an H1 tag. A single H1 tag is critical for SEO.',
152
+ });
153
+ } else if (h1s.length > 1) {
154
+ issues.push({
155
+ id: 'multiple-h1',
156
+ type: 'seo',
157
+ severity: 'warning',
158
+ message: 'Multiple H1 tags found. It is recommended to have exactly one H1 tag per page.',
159
+ });
160
+ } else {
161
+ passedCount++;
162
+ }
163
+
164
+ // Check Page Title Length (we check the actual document head)
165
+ const titleText = document.title;
166
+ if (!titleText || titleText.length === 0 || titleText === 'VibeCMS') {
167
+ issues.push({
168
+ id: 'missing-title',
169
+ type: 'seo',
170
+ severity: 'critical',
171
+ message: 'Page is missing a meta title.',
172
+ });
173
+ } else if (titleText.length > 60) {
174
+ issues.push({
175
+ id: 'title-too-long',
176
+ type: 'seo',
177
+ severity: 'warning',
178
+ message: `Meta title is ${titleText.length} characters. Keep it under 60 characters for optimal display in SERPs.`,
179
+ });
180
+ } else {
181
+ passedCount++;
182
+ }
183
+
184
+ // Check Description Length
185
+ const descMeta = document.querySelector('meta[name="description"]') as HTMLMetaElement;
186
+ if (!descMeta || !descMeta.content) {
187
+ issues.push({
188
+ id: 'missing-description',
189
+ type: 'seo',
190
+ severity: 'critical',
191
+ message: 'Page is missing a meta description.',
192
+ });
193
+ } else if (descMeta.content.length > 160) {
194
+ issues.push({
195
+ id: 'desc-too-long',
196
+ type: 'seo',
197
+ severity: 'warning',
198
+ message: `Meta description is ${descMeta.content.length} characters. Keep it under 160 characters.`,
199
+ });
200
+ } else {
201
+ passedCount++;
202
+ }
203
+
204
+ // Heading Hierarchy Check
205
+ const headings = Array.from(containerElement.querySelectorAll('h1, h2, h3, h4, h5, h6'));
206
+ let previousLevel = 1; // Assume we start logic near H1
207
+ headings.forEach((heading) => {
208
+ const currentLevel = parseInt(heading.tagName.substring(1));
209
+ if (currentLevel - previousLevel > 1) {
210
+ issues.push({
211
+ id: 'heading-hierarchy-skip',
212
+ type: 'seo',
213
+ severity: 'warning',
214
+ message: `Heading Hierarchy Skipped: found <H${currentLevel}> directly following <H${previousLevel}>.`,
215
+ elementHtml: heading.outerHTML,
216
+ fieldPath: heading.closest('[data-vibe-path]')?.getAttribute('data-vibe-path') || undefined,
217
+ });
218
+ }
219
+ previousLevel = currentLevel;
220
+ });
221
+
222
+ // Custom Image SEO & Performance Checks
223
+ const customImages = Array.from(containerElement.querySelectorAll('img'));
224
+ customImages.forEach((img, idx) => {
225
+ const fieldPath = img.closest('[data-vibe-path]')?.getAttribute('data-vibe-path') || undefined;
226
+
227
+ if (!img.hasAttribute('alt') || img.getAttribute('alt')?.trim() === '') {
228
+ // Axe catches missing alt for a11y, but we aggressively flag empty alt="" for SEO if it's large imagery
229
+ issues.push({
230
+ id: 'seo-missing-alt',
231
+ type: 'seo',
232
+ severity: 'warning',
233
+ message: 'Image is missing descriptive alt text. Ensure alt describes the image for SEO.',
234
+ elementHtml: img.outerHTML,
235
+ selector: 'img',
236
+ fieldPath
237
+ });
238
+ }
239
+
240
+ // Lazy loading (skip first 2 images assuming they are above the fold)
241
+ if (idx > 1 && img.getAttribute('loading') !== 'lazy') {
242
+ issues.push({
243
+ id: 'perf-missing-lazy',
244
+ type: 'seo',
245
+ severity: 'warning',
246
+ message: 'Image likely below the fold is missing loading="lazy" attribute.',
247
+ elementHtml: img.outerHTML,
248
+ selector: 'img',
249
+ fieldPath
250
+ });
251
+ }
252
+ });
253
+
254
+ // 3. Mobile Risk Heuristic (Long words)
255
+ const walkDOM = (node: Node) => {
256
+ if (node.nodeType === Node.TEXT_NODE) {
257
+ const text = node.textContent || '';
258
+ const words = text.split(/\s+/);
259
+ for (const word of words) {
260
+ const cleanWord = word.replace(/[^a-zA-ZäöüÄÖÜß0-9]/g, '');
261
+ if (cleanWord.length > 15) {
262
+ // If the word does not contain a soft hyphen character (\u00AD or \xAD)
263
+ if (!word.includes('\u00AD') && !word.includes('\xAD')) {
264
+ let fieldPath: string | undefined;
265
+ if (node.parentElement) {
266
+ const vibeEl = node.parentElement.closest('[data-vibe-path]');
267
+ if (vibeEl) {
268
+ fieldPath = vibeEl.getAttribute('data-vibe-path') || undefined;
269
+ }
270
+ }
271
+
272
+ issues.push({
273
+ id: 'mobile-risk-word',
274
+ type: 'accessibility',
275
+ severity: 'warning',
276
+ message: `Mobile Layout Risk: "${cleanWord.substring(0, 15)}..." exceeds 15 unbroken characters. This will shatter mobile layouts! Insert a Soft Hyphen (&shy;) via the CMS Typography tools.`,
277
+ fieldPath
278
+ });
279
+ }
280
+ }
281
+ }
282
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
283
+ const el = node as HTMLElement;
284
+ if (el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE') {
285
+ node.childNodes.forEach(walkDOM);
286
+ }
287
+ }
288
+ };
289
+
290
+ walkDOM(containerElement);
291
+
292
+ // Score Calculation (very naive for now)
293
+ const totalChecks = passedCount + issues.length;
294
+ const criticalCount = issues.filter(i => i.severity === 'critical').length;
295
+ const warningCount = issues.filter(i => i.severity === 'warning').length;
296
+
297
+ let score = 100;
298
+ if (totalChecks > 0) {
299
+ score = Math.max(0, 100 - (criticalCount * 15) - (warningCount * 5));
300
+ }
301
+
302
+ return {
303
+ score: Math.round(score),
304
+ issues,
305
+ passed: passedCount
306
+ };
307
+ }
@@ -0,0 +1,26 @@
1
+ // src/lib/cms/auth-nextauth.ts
2
+ import { AuthProvider, VibeUser } from '@vibecms/core';
3
+ import { getSession, signIn, signOut } from 'next-auth/react';
4
+
5
+ export class NextAuthProvider implements AuthProvider {
6
+ async getSession() {
7
+ const session = await getSession();
8
+ if (!session?.user) return null;
9
+ return {
10
+ user: {
11
+ name: session.user.name,
12
+ email: session.user.email,
13
+ image: session.user.image,
14
+ } as VibeUser,
15
+ accessToken: (session as any).accessToken // Keep for git push
16
+ };
17
+ }
18
+
19
+ async signIn() {
20
+ await signIn('github');
21
+ }
22
+
23
+ async signOut() {
24
+ await signOut();
25
+ }
26
+ }
@@ -0,0 +1,3 @@
1
+ // src/lib/cms/engine.ts
2
+ // @deprecated Use imports from '@vibecms/core/engine' instead.
3
+ export * from '@vibecms/core/engine';
@@ -0,0 +1,12 @@
1
+ // @ts-nocheck
2
+ import { z } from 'zod';
3
+ import vibeConfig from '../../../vibe.config';
4
+
5
+ export const schemaRegistry: Record<string, z.ZodTypeAny> = Object.fromEntries([
6
+ ...Object.entries(vibeConfig.collections || {}).map(([k, v]) => [k, v.schema]),
7
+ ...Object.entries(vibeConfig.singletons || {}).map(([k, v]) => [k, v.schema])
8
+ ]);
9
+
10
+ export function getSchemaForCollection(collection: string): z.ZodTypeAny | null {
11
+ return schemaRegistry[collection] || null;
12
+ }
@@ -0,0 +1,18 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ /**
4
+ * Sanitizes HTML content to prevent XSS attacks.
5
+ * Allows safe formatting tags only.
6
+ */
7
+ export function sanitizeHtml(dirty: string | undefined | null): string {
8
+ if (!dirty) return '';
9
+ return DOMPurify.sanitize(dirty, {
10
+ ALLOWED_TAGS: [
11
+ 'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'del',
12
+ 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
13
+ 'blockquote', 'code', 'pre', 'span', 'sub', 'sup', 'hr',
14
+ ],
15
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'id'],
16
+ ALLOW_DATA_ATTR: false,
17
+ });
18
+ }