@jhits/plugin-content 0.0.1

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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@jhits/plugin-content",
3
+ "version": "0.0.1",
4
+ "description": "Content management and localization plugin for the JHITS ecosystem",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./src/index.tsx",
9
+ "types": "./src/index.tsx",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.tsx",
13
+ "default": "./src/index.tsx"
14
+ },
15
+ "./server": {
16
+ "types": "./src/index.server.ts",
17
+ "default": "./src/index.server.ts"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "@jhits/plugin-core": "^0.0.1",
22
+ "framer-motion": "^12.23.26",
23
+ "lucide-react": "^0.562.0"
24
+ },
25
+ "peerDependencies": {
26
+ "next": ">=15.0.0",
27
+ "next-intl": ">=4.0.0",
28
+ "react": ">=18.0.0",
29
+ "react-dom": ">=18.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.19.27",
33
+ "@types/react": "^19",
34
+ "@types/react-dom": "^19",
35
+ "eslint": "^9",
36
+ "eslint-config-next": "16.1.1",
37
+ "next": "16.1.1",
38
+ "next-intl": "4.6.1",
39
+ "react": "19.2.3",
40
+ "react-dom": "19.2.3",
41
+ "typescript": "^5"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "package.json"
46
+ ]
47
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content API Handler
3
+ * RESTful API handler for content/translation management
4
+ * Compatible with Next.js API routes
5
+ *
6
+ * IMPORTANT: This file should ONLY be imported in server-side API routes.
7
+ * Do NOT import this in client-side code.
8
+ */
9
+
10
+ import { NextRequest, NextResponse } from 'next/server';
11
+ import * as fs from 'fs/promises';
12
+ import * as path from 'path';
13
+
14
+ export interface ContentApiConfig {
15
+ /** Directory where locale files are stored (default: 'data/locales') */
16
+ localesDir?: string;
17
+ }
18
+
19
+ /**
20
+ * POST /api/plugin-content/save - Save translations
21
+ */
22
+ export async function POST(req: NextRequest, config: ContentApiConfig): Promise<NextResponse> {
23
+ try {
24
+ const body = await req.json();
25
+ const { locale, messages } = body;
26
+
27
+ if (!locale || !messages) {
28
+ return NextResponse.json(
29
+ { error: 'Missing locale or messages' },
30
+ { status: 400 }
31
+ );
32
+ }
33
+
34
+ const localesDir = config.localesDir || path.join(process.cwd(), 'data/locales');
35
+ const currentLocaleDir = path.join(localesDir, locale);
36
+
37
+ // Ensure locale directory exists
38
+ await fs.mkdir(currentLocaleDir, { recursive: true });
39
+
40
+ // Save each namespace as a separate JSON file
41
+ for (const [namespace, content] of Object.entries(messages)) {
42
+ const filePath = path.join(currentLocaleDir, `${namespace}.json`);
43
+ await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf8');
44
+
45
+ // Ensure other locales have empty files for this namespace (if they don't exist)
46
+ try {
47
+ const otherLocales = await fs.readdir(localesDir);
48
+ for (const other of otherLocales) {
49
+ if (other === locale) continue;
50
+ const otherFilePath = path.join(localesDir, other, `${namespace}.json`);
51
+ try {
52
+ await fs.access(otherFilePath);
53
+ } catch {
54
+ // File doesn't exist, create empty one
55
+ await fs.writeFile(otherFilePath, JSON.stringify({}, null, 2), 'utf8');
56
+ }
57
+ }
58
+ } catch (err) {
59
+ // Ignore errors when reading other locales
60
+ console.warn('[ContentAPI] Could not sync other locales:', err);
61
+ }
62
+ }
63
+
64
+ return NextResponse.json({ success: true });
65
+ } catch (error: any) {
66
+ console.error('[ContentAPI] POST error:', error);
67
+ return NextResponse.json(
68
+ { error: 'Failed to save translations', detail: error.message },
69
+ { status: 500 }
70
+ );
71
+ }
72
+ }
73
+
74
+
@@ -0,0 +1,65 @@
1
+ 'use server';
2
+
3
+ /**
4
+ * Plugin Content API Router
5
+ * Centralized API handler for all content plugin routes
6
+ *
7
+ * This router handles requests to /api/plugin-content/*
8
+ * and routes them to the appropriate handler
9
+ */
10
+
11
+ import { NextRequest, NextResponse } from 'next/server';
12
+ import { POST as SaveHandler } from './handler';
13
+
14
+ export interface ContentApiRouterConfig {
15
+ /** Directory where locale files are stored (default: 'data/locales') */
16
+ localesDir?: string;
17
+ }
18
+
19
+ /**
20
+ * Handle content API requests
21
+ * Routes requests to appropriate handlers based on path
22
+ */
23
+ export async function handleContentApi(
24
+ req: NextRequest,
25
+ path: string[],
26
+ config: ContentApiRouterConfig
27
+ ): Promise<NextResponse> {
28
+ const method = req.method;
29
+ const safePath = Array.isArray(path) ? path : [];
30
+ const route = safePath.length > 0 ? safePath[0] : '';
31
+
32
+ console.log(`[ContentApiRouter] method=${method}, path=${JSON.stringify(safePath)}, route=${route}, url=${req.url}`);
33
+
34
+ try {
35
+ // Route: /api/plugin-content/save
36
+ if (route === 'save') {
37
+ if (method === 'POST') {
38
+ return await SaveHandler(req, config);
39
+ }
40
+ return NextResponse.json(
41
+ { error: `Method ${method} not allowed for route: save` },
42
+ { status: 405 }
43
+ );
44
+ }
45
+
46
+ // Route not found
47
+ return NextResponse.json(
48
+ { error: `Route not found: ${route || '/'}` },
49
+ { status: 404 }
50
+ );
51
+ } catch (error: any) {
52
+ console.error('[ContentApiRouter] Error:', error);
53
+ const status = error.message?.includes('not found') ? 404
54
+ : error.message?.includes('Unauthorized') ? 401
55
+ : error.message?.includes('already') ? 409
56
+ : error.message?.includes('too short') ? 400
57
+ : 500;
58
+ return NextResponse.json(
59
+ { error: error.message || 'Internal server error' },
60
+ { status }
61
+ );
62
+ }
63
+ }
64
+
65
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Plugin Content - API Server Entry Point
3
+ * Exports the API router for use in Next.js API routes
4
+ */
5
+
6
+ export { handleContentApi } from './api/router';
7
+ export type { ContentApiRouterConfig } from './api/router';
8
+
9
+
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import { parse } from '../utils/parser';
3
+ import { ParserConfig } from '../utils/parser-config';
4
+ import { useParserConfig } from '../context/ParserConfigContext';
5
+
6
+ export interface MultilineTextProps {
7
+ /**
8
+ * Text content - can be a string (multiline format) or an object (legacy format)
9
+ * String format: Use \n\n for paragraph breaks, \n for line breaks
10
+ * Object format: { "0": "paragraph 1", "1": "paragraph 2" }
11
+ */
12
+ text: string | Record<string, string> | null | undefined;
13
+
14
+ /**
15
+ * CSS classes to apply to each paragraph element
16
+ * @default "text-lg text-gray-700 leading-relaxed font-light whitespace-pre-line"
17
+ */
18
+ className?: string;
19
+
20
+ /**
21
+ * Wrapper element tag
22
+ * @default "div"
23
+ */
24
+ wrapper?: 'div' | 'span' | 'section';
25
+
26
+ /**
27
+ * CSS classes for the wrapper element
28
+ * @default "space-y-6"
29
+ */
30
+ wrapperClassName?: string;
31
+
32
+ /**
33
+ * Parser configuration for customizing formatting styles
34
+ * If not provided, uses the default configuration
35
+ */
36
+ config?: ParserConfig;
37
+ }
38
+
39
+ /**
40
+ * MultilineText Component
41
+ *
42
+ * Renders text content that can be either:
43
+ * - A multiline string (new format): splits by \n\n for paragraphs, preserves \n for line breaks
44
+ * - An object with keys (legacy format): renders each value as a paragraph
45
+ *
46
+ * Automatically applies formatting via the parse() function and handles whitespace.
47
+ *
48
+ * @example
49
+ * // Multiline string format
50
+ * <MultilineText text="First paragraph\n\nSecond paragraph\nWith line break" />
51
+ *
52
+ * @example
53
+ * // Legacy object format (backward compatible)
54
+ * <MultilineText text={{ "0": "First paragraph", "1": "Second paragraph" }} />
55
+ */
56
+ export default function MultilineText({
57
+ text,
58
+ className = "text-lg text-gray-700 leading-relaxed font-light whitespace-pre-line",
59
+ wrapper = 'div',
60
+ wrapperClassName = "space-y-6",
61
+ config: propConfig
62
+ }: MultilineTextProps) {
63
+ if (!text) return null;
64
+
65
+ // Use prop config if provided, otherwise use context config
66
+ const contextConfig = useParserConfig();
67
+ const config = propConfig || contextConfig;
68
+
69
+ const Wrapper = wrapper;
70
+
71
+ // Handle string format (new multiline format)
72
+ if (typeof text === 'string') {
73
+ const paragraphs = text.split('\n\n').filter(p => p.trim());
74
+
75
+ return (
76
+ <Wrapper className={wrapperClassName}>
77
+ {paragraphs.map((paragraph, index) => (
78
+ <p key={index} className={className}>
79
+ {parse(paragraph, false, config)}
80
+ </p>
81
+ ))}
82
+ </Wrapper>
83
+ );
84
+ }
85
+
86
+ // Handle object format (legacy format - backward compatibility)
87
+ if (typeof text === 'object' && text !== null) {
88
+ const keys = Object.keys(text);
89
+
90
+ return (
91
+ <Wrapper className={wrapperClassName}>
92
+ {keys.map((key) => (
93
+ <p key={key} className={className}>
94
+ {parse(text[key], false, config)}
95
+ </p>
96
+ ))}
97
+ </Wrapper>
98
+ );
99
+ }
100
+
101
+ return null;
102
+ }
103
+
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { parse } from '../utils/parser';
5
+ import { ParserConfig } from '../utils/parser-config';
6
+ import { useParserConfig } from '../context/ParserConfigContext';
7
+
8
+ export interface ParsedTextProps {
9
+ /**
10
+ * Text content to parse and render
11
+ */
12
+ text: string | null | undefined;
13
+
14
+ /**
15
+ * CSS classes to apply to the wrapper element
16
+ */
17
+ className?: string;
18
+
19
+ /**
20
+ * Wrapper element tag
21
+ * @default "span"
22
+ */
23
+ as?: 'span' | 'div' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
24
+
25
+ /**
26
+ * Whether to use editor mode (shows formatting markers)
27
+ * @default false
28
+ */
29
+ isEditor?: boolean;
30
+
31
+ /**
32
+ * Parser configuration for customizing formatting styles
33
+ * If not provided, uses the default configuration
34
+ */
35
+ config?: ParserConfig;
36
+ }
37
+
38
+ /**
39
+ * ParsedText Component
40
+ *
41
+ * Client component that parses and renders text with formatting support.
42
+ * Can be used in server components by passing the text as a prop.
43
+ *
44
+ * Supports:
45
+ * - _*text*_ -> Sage, Italic, Medium
46
+ * - **text** -> Forest, Black (Bold)
47
+ * - /text/ -> Sage (Standard)
48
+ *
49
+ * @example
50
+ * // In a server component
51
+ * <ParsedText text={t('hero.title')} as="h1" className="text-4xl" />
52
+ */
53
+ export default function ParsedText({
54
+ text,
55
+ className,
56
+ as: Component = 'span',
57
+ isEditor = false,
58
+ config: propConfig
59
+ }: ParsedTextProps) {
60
+ if (!text) return null;
61
+
62
+ // Use prop config if provided, otherwise use context config
63
+ const contextConfig = useParserConfig();
64
+ const config = propConfig || contextConfig;
65
+
66
+ const parsed = parse(text, isEditor, config);
67
+
68
+ return <Component className={className}>{parsed}</Component>;
69
+ }
70
+
@@ -0,0 +1,304 @@
1
+ 'use client';
2
+
3
+ import { useRouter, usePathname } from 'next/navigation';
4
+ import { useState, useEffect, useMemo, useRef } from 'react';
5
+ import { useLocale, useMessages } from 'next-intl';
6
+ import { motion, AnimatePresence } from 'framer-motion';
7
+ import { X, Globe, Search, ChevronRight, Plus, CornerDownRight, ShieldCheck, Lock, Bold, Italic, Type } from 'lucide-react';
8
+ import { parse } from '../utils/parser';
9
+
10
+ export default function TranslationEditor() {
11
+ const router = useRouter();
12
+ const pathname = usePathname();
13
+ const messages = useMessages();
14
+ const locale = useLocale();
15
+ const [open, setOpen] = useState(false);
16
+ const [userData, setUserData] = useState<any>(null);
17
+ const [loadingAuth, setLoadingAuth] = useState(true);
18
+
19
+ const [jsonData, setJsonData] = useState<any>(messages);
20
+ const [searchQuery, setSearchQuery] = useState('');
21
+ const [saving, setSaving] = useState(false);
22
+ const [activePath, setActivePath] = useState<string | null>(null);
23
+
24
+ const [showAddForm, setShowAddForm] = useState(false);
25
+ const [newKey, setNewKey] = useState('');
26
+ const [newValue, setNewValue] = useState('');
27
+
28
+ // Get all top-level keys for the dropdown (home, about, services, etc.)
29
+ const availableSections = useMemo(() => {
30
+ return Object.keys(jsonData).filter(key => typeof jsonData[key] === 'object');
31
+ }, [jsonData]);
32
+
33
+ const [selectedSection, setSelectedSection] = useState<string>('');
34
+
35
+ const textareaRefs = useRef<{ [key: string]: HTMLTextAreaElement | null }>({});
36
+
37
+ useEffect(() => {
38
+ async function checkAuth() {
39
+ try {
40
+ const res = await fetch('/api/plugin-dep/me');
41
+ const data = await res.json();
42
+ if (data.loggedIn) setUserData(data.user);
43
+ } catch (err) {
44
+ console.error("Auth check failed", err);
45
+ } finally {
46
+ setLoadingAuth(false);
47
+ }
48
+ }
49
+ checkAuth();
50
+ }, []);
51
+
52
+ const isAdmin = userData?.role === 'admin' || userData?.role === 'dev';
53
+ const isDev = userData?.role === 'dev';
54
+
55
+ const applyFormatting = (path: string, type: 'bold' | 'italic' | 'both') => {
56
+ const el = textareaRefs.current[path];
57
+ if (!el) return;
58
+ const start = el.selectionStart;
59
+ const end = el.selectionEnd;
60
+ const text = el.value;
61
+ const selectedText = text.substring(start, end);
62
+ if (!selectedText) return;
63
+
64
+ let prefix = '', suffix = '';
65
+ if (type === 'bold') { prefix = '**'; suffix = '**'; }
66
+ if (type === 'italic') { prefix = '/'; suffix = '/'; }
67
+ if (type === 'both') { prefix = '_*'; suffix = '*_'; }
68
+
69
+ let newText = '';
70
+ if (selectedText.startsWith(prefix) && selectedText.endsWith(suffix)) {
71
+ newText = text.substring(0, start) + selectedText.slice(prefix.length, -suffix.length) + text.substring(end);
72
+ } else {
73
+ newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
74
+ }
75
+
76
+ updateJsonData(path, newText);
77
+ setTimeout(() => {
78
+ el.focus();
79
+ el.setSelectionRange(start, start + (newText.length - text.length + selectedText.length));
80
+ }, 0);
81
+ };
82
+
83
+ const handleKeyDown = (e: React.KeyboardEvent, path: string) => {
84
+ if ((e.metaKey || e.ctrlKey)) {
85
+ if (e.key === 'b') { e.preventDefault(); applyFormatting(path, 'bold'); }
86
+ if (e.key === 'i') { e.preventDefault(); applyFormatting(path, 'italic'); }
87
+ if (e.key === 'u') { e.preventDefault(); applyFormatting(path, 'both'); }
88
+ }
89
+ };
90
+
91
+ const updateJsonData = (path: string, value: string) => {
92
+ const keys = path.split('.');
93
+ const newData = structuredClone(jsonData);
94
+ let curr = newData;
95
+ for (let i = 0; i < keys.length - 1; i++) curr = curr[keys[i]];
96
+ curr[keys[keys.length - 1]] = value;
97
+ setJsonData(newData);
98
+ };
99
+
100
+ const handleSave = async () => {
101
+ setSaving(true);
102
+ try {
103
+ const res = await fetch('/api/plugin-content/save', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ locale, messages: jsonData }),
107
+ });
108
+ if (res.ok) { router.refresh(); setShowAddForm(false); }
109
+ else { alert('Fout bij opslaan'); }
110
+ } catch (err) { alert('Netwerkfout bij opslaan'); }
111
+ finally { setSaving(false); }
112
+ };
113
+
114
+ const addNewEntry = () => {
115
+ if (!isDev || !newKey.includes('.')) return;
116
+ updateJsonData(newKey, newValue);
117
+ setNewKey(''); setNewValue(''); setShowAddForm(false);
118
+ };
119
+
120
+ // Set the initial section based on the URL, but only if it matches a JSON key
121
+ useEffect(() => {
122
+ const parts = pathname.split('/').filter(Boolean);
123
+ const cleanParts = (parts[0]?.length === 2) ? parts.slice(1) : parts;
124
+ const urlKey = cleanParts[0] || 'home';
125
+
126
+ // If "over-mij" isn't in JSON, but "about" is, this helps her pick
127
+ if (availableSections.includes(urlKey)) {
128
+ setSelectedSection(urlKey);
129
+ } else if (availableSections.length > 0 && !selectedSection) {
130
+ setSelectedSection(availableSections[0]);
131
+ }
132
+ }, [pathname, availableSections]);
133
+
134
+ const filteredItems = useMemo(() => {
135
+ const allPaths = getFlattenedPaths(jsonData);
136
+
137
+ const filtered = allPaths.filter(item => {
138
+ if (activePath === item.path) return true;
139
+ const matchesSearch = item.path.toLowerCase().includes(searchQuery.toLowerCase()) ||
140
+ item.value.toLowerCase().includes(searchQuery.toLowerCase());
141
+
142
+ if (searchQuery) return matchesSearch;
143
+ // Default view: Selected Section + Common
144
+ return item.path.startsWith(`${selectedSection}.`) || item.path.startsWith('common.');
145
+ });
146
+
147
+ return [...filtered].sort((a, b) => {
148
+ const getPriority = (path: string) => {
149
+ if (path.startsWith(`${selectedSection}.`)) return 1;
150
+ if (path.startsWith('common.')) return 3;
151
+ return 2;
152
+ };
153
+ const priorityA = getPriority(a.path);
154
+ const priorityB = getPriority(b.path);
155
+ return priorityA !== priorityB ? priorityA - priorityB : a.path.localeCompare(b.path);
156
+ });
157
+ }, [jsonData, searchQuery, selectedSection, activePath]);
158
+
159
+ if (loadingAuth || !isAdmin) return null;
160
+
161
+ return (
162
+ <>
163
+ <button onClick={() => setOpen(true)} className="fixed bottom-6 right-6 bg-neutral-950 dark:bg-white text-white dark:text-neutral-950 px-6 py-3 rounded-full flex items-center gap-3 shadow-2xl z-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 transition-all border border-neutral-800/20 dark:border-neutral-200/20">
164
+ <Globe size={18} /> <span className="font-bold">Editor</span>
165
+ </button>
166
+
167
+ <AnimatePresence>
168
+ {open && (
169
+ <>
170
+ <motion.div className="fixed inset-0 bg-neutral-950/50 dark:bg-neutral-950/70 z-40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setOpen(false)} />
171
+ <motion.div className="fixed right-0 top-0 bottom-0 bg-card text-foreground shadow-2xl z-50 flex flex-col border-l border-border w-[550px]" initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}>
172
+
173
+ {/* Header */}
174
+ <div className="p-6 bg-card border-b border-border flex justify-between items-center">
175
+ <div>
176
+ <h2 className="text-2xl font-serif flex items-center gap-2">
177
+ {isDev ? 'Dev Mode' : 'Content Editor'}
178
+ {isDev ? <ShieldCheck className="text-primary" size={20} /> : <Lock className="text-primary" size={16} />}
179
+ </h2>
180
+ <p className="text-[10px] uppercase font-bold text-neutral-500 dark:text-neutral-400">Welkom, {userData?.name}</p>
181
+ </div>
182
+ <div className="flex gap-2">
183
+ {isDev && (
184
+ <button onClick={() => setShowAddForm(!showAddForm)} className={`p-2 rounded-full transition-colors ${showAddForm ? 'bg-primary text-white' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400'}`}>
185
+ <Plus size={22} />
186
+ </button>
187
+ )}
188
+ <button onClick={() => setOpen(false)} className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full text-neutral-600 dark:text-neutral-400"><X size={24} /></button>
189
+ </div>
190
+ </div>
191
+
192
+ {/* Add Form */}
193
+ {isDev && showAddForm && (
194
+ <motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="bg-neutral-100/50 dark:bg-neutral-800/50 border-b border-border p-6 space-y-4 overflow-hidden">
195
+ <input value={newKey} onChange={e => setNewKey(e.target.value)} placeholder="pagina.sectie.sleutel" className="w-full p-3 rounded-xl border border-border bg-card text-foreground placeholder:text-neutral-500 dark:placeholder:text-neutral-400 text-sm font-mono" />
196
+ <textarea value={newValue} onChange={e => setNewValue(e.target.value)} placeholder="Nieuwe vertaling..." className="w-full p-3 rounded-xl border border-border bg-card text-foreground placeholder:text-neutral-500 dark:placeholder:text-neutral-400 text-sm min-h-[80px]" />
197
+ <button onClick={addNewEntry} className="w-full py-3 bg-primary text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2"><CornerDownRight size={16} /> Toevoegen</button>
198
+ </motion.div>
199
+ )}
200
+
201
+ {/* Search */}
202
+ <div className="px-6 py-4 bg-card border-b border-border">
203
+ <div className="relative">
204
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500 dark:text-neutral-400" />
205
+ <input type="text" placeholder="Zoek in teksten..." className="w-full pl-10 pr-4 py-2.5 bg-neutral-100/50 dark:bg-neutral-800/50 border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 text-foreground placeholder:text-neutral-500 dark:placeholder:text-neutral-400" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
206
+ </div>
207
+ </div>
208
+
209
+ {/* List */}
210
+ <div className="flex-1 overflow-y-auto p-6 space-y-10 bg-background pb-32">
211
+ {filteredItems.map((item) => (
212
+ <div key={item.path} className="group relative">
213
+ <div className="flex justify-between items-center mb-3">
214
+ <div className="flex items-center gap-2">
215
+ {item.path.split('.').map((part, i, arr) => (
216
+ <div key={i} className="flex items-center gap-2">
217
+ <span className={`px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider ${i === arr.length - 1 ? 'bg-primary text-white' : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 border border-border'}`}>
218
+ {part.replace(/_/g, ' ')}
219
+ </span>
220
+ {i !== arr.length - 1 && <ChevronRight size={10} className="text-neutral-400 dark:text-neutral-500" />}
221
+ </div>
222
+ ))}
223
+ </div>
224
+
225
+ {/* Toolbar */}
226
+ <AnimatePresence>
227
+ {activePath === item.path && (
228
+ <motion.div initial={{ opacity: 0, y: 5 }} animate={{ opacity: 1, y: 0 }} className="formatting-toolbar flex gap-1 bg-card border border-border rounded-lg p-1 shadow-sm">
229
+ <button type="button" tabIndex={-1} onClick={() => applyFormatting(item.path, 'bold')} className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded text-foreground"><Bold size={14} /></button>
230
+ <button type="button" tabIndex={-1} onClick={() => applyFormatting(item.path, 'italic')} className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded text-foreground"><Italic size={14} /></button>
231
+ <button type="button" tabIndex={-1} onClick={() => applyFormatting(item.path, 'both')} className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded text-foreground"><Type size={14} /></button>
232
+ </motion.div>
233
+ )}
234
+ </AnimatePresence>
235
+ </div>
236
+
237
+ {/* STYLED CONTAINER */}
238
+ <div className="relative w-full min-h-[60px]">
239
+ {/* The Background Visual Layer */}
240
+ <div
241
+ className="absolute inset-0 p-5 text-lg leading-relaxed pointer-events-none whitespace-pre-wrap break-words border-2 border-transparent text-foreground"
242
+ aria-hidden="true"
243
+ >
244
+ {parse(item.value, true)}
245
+ </div>
246
+
247
+ {/* The Interaction Layer (Textarea) */}
248
+ <textarea
249
+ ref={(el) => { textareaRefs.current[item.path] = el; }}
250
+ className={`
251
+ w-full p-5 bg-transparent border-2 rounded-2xl
252
+ text-transparent caret-primary leading-relaxed shadow-sm
253
+ transition-all outline-none resize-none overflow-hidden text-lg
254
+ relative z-10 whitespace-pre-wrap break-words
255
+ ${activePath === item.path ? 'border-primary ring-4 ring-primary/10' : 'border-border'}
256
+ `}
257
+ value={item.value}
258
+ rows={1}
259
+ spellCheck={false}
260
+ onFocus={() => setActivePath(item.path)}
261
+ onBlur={(e) => {
262
+ if (!e.relatedTarget?.closest('.formatting-toolbar')) {
263
+ setActivePath(null);
264
+ }
265
+ }}
266
+ onKeyDown={(e) => handleKeyDown(e, item.path)}
267
+ onChange={(e) => updateJsonData(item.path, e.target.value)}
268
+ onInput={(e: any) => {
269
+ e.target.style.height = 'auto';
270
+ e.target.style.height = e.target.scrollHeight + 'px';
271
+ }}
272
+ />
273
+ </div>
274
+ </div>
275
+ ))}
276
+ </div>
277
+
278
+ <div className="p-6 bg-card border-t border-border flex justify-end">
279
+ <button onClick={handleSave} disabled={saving} className="bg-primary text-white px-12 py-3.5 rounded-full font-bold shadow-lg disabled:opacity-50 transition-all hover:scale-105 active:scale-95">
280
+ {saving ? 'Bezig...' : 'Wijzigingen Opslaan'}
281
+ </button>
282
+ </div>
283
+ </motion.div>
284
+ </>
285
+ )}
286
+ </AnimatePresence>
287
+ </>
288
+ );
289
+ }
290
+
291
+ function getFlattenedPaths(obj: any, prefix = ''): { path: string; value: string }[] {
292
+ let paths: { path: string; value: string }[] = [];
293
+ for (const key in obj) {
294
+ const fullPath = prefix ? `${prefix}.${key}` : key;
295
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
296
+ paths = [...paths, ...getFlattenedPaths(obj[key], fullPath)];
297
+ } else {
298
+ paths.push({ path: fullPath, value: String(obj[key]) });
299
+ }
300
+ }
301
+ return paths;
302
+ }
303
+
304
+
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext } from 'react';
4
+ import { ParserConfig, defaultParserConfig } from '../utils/parser-config';
5
+
6
+ interface ParserConfigContextValue {
7
+ config: ParserConfig;
8
+ }
9
+
10
+ const ParserConfigContext = createContext<ParserConfigContextValue>({
11
+ config: defaultParserConfig
12
+ });
13
+
14
+ export interface ParserConfigProviderProps {
15
+ /**
16
+ * Parser configuration to use globally
17
+ * If not provided, uses the default configuration
18
+ */
19
+ config?: ParserConfig;
20
+ children: React.ReactNode;
21
+ }
22
+
23
+ /**
24
+ * ParserConfigProvider
25
+ *
26
+ * Provides a global parser configuration to all child components.
27
+ * Components can override this with their own config prop.
28
+ *
29
+ * @example
30
+ * <ParserConfigProvider config={{
31
+ * bold: { className: "font-bold text-blue-600" },
32
+ * italic: { className: "italic text-gray-500" }
33
+ * }}>
34
+ * <App />
35
+ * </ParserConfigProvider>
36
+ */
37
+ export function ParserConfigProvider({ config = defaultParserConfig, children }: ParserConfigProviderProps) {
38
+ return (
39
+ <ParserConfigContext.Provider value={{ config }}>
40
+ {children}
41
+ </ParserConfigContext.Provider>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Hook to get the current parser configuration from context
47
+ * Falls back to defaultParserConfig if no provider is found
48
+ */
49
+ export function useParserConfig(): ParserConfig {
50
+ const { config } = useContext(ParserConfigContext);
51
+ return config;
52
+ }
53
+
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+ import { parse as parseFunction } from '../utils/parser';
5
+ import { useParserConfig } from '../context/ParserConfigContext';
6
+ import type { ReactNode } from 'react';
7
+
8
+ /**
9
+ * Hook that returns a configured parse function
10
+ * Uses the parser configuration from context
11
+ *
12
+ * @example
13
+ * const parse = useParse();
14
+ * const formatted = parse('This is **bold** text');
15
+ */
16
+ export function useParse() {
17
+ const config = useParserConfig();
18
+
19
+ return useCallback((text: string, isEditor = false): ReactNode => {
20
+ return parseFunction(text, isEditor, config);
21
+ }, [config]);
22
+ }
23
+
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Plugin Content - Server-Only Entry Point
3
+ * This file exports only server-side API handlers
4
+ * Used by the dynamic plugin router via @jhits/plugin-content/server
5
+ *
6
+ * Note: This file is server-only (no 'use server' needed - that's only for Server Actions)
7
+ */
8
+
9
+ export { handleContentApi as handleApi } from './api/router';
10
+ export { handleContentApi } from './api/router'; // Keep original export for backward compatibility
11
+ export type { ContentApiRouterConfig } from './api/router';
12
+
package/src/index.tsx ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Plugin Content - Main Entry Point
3
+ * Provides content editing functionality for website translations
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import TranslationEditor from './components/TranslationEditor';
9
+
10
+ export interface ContentPluginProps {
11
+ /** Whether to show the editor (default: true) */
12
+ enabled?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Content Plugin Component
17
+ * Renders the translation editor for editing website content
18
+ */
19
+ function ContentPlugin({ enabled = true }: ContentPluginProps) {
20
+ if (!enabled) return null;
21
+
22
+ return <TranslationEditor />;
23
+ }
24
+
25
+ // Export as both default and named export for flexibility
26
+ export default ContentPlugin;
27
+ export { ContentPlugin };
28
+
29
+ // Export components
30
+ export { default as TranslationEditor } from './components/TranslationEditor';
31
+ export { default as MultilineText } from './components/MultilineText';
32
+ export type { MultilineTextProps } from './components/MultilineText';
33
+ export { default as ParsedText } from './components/ParsedText';
34
+ export type { ParsedTextProps } from './components/ParsedText';
35
+
36
+ // Export context
37
+ export { ParserConfigProvider, useParserConfig } from './context/ParserConfigContext';
38
+ export type { ParserConfigProviderProps } from './context/ParserConfigContext';
39
+
40
+ // Export hooks
41
+ export { useParse } from './hooks/useParse';
42
+
43
+ // Export utilities
44
+ // Note: parse() is a client-only function. Use ParsedText component in server components.
45
+ export { parse } from './utils/parser';
46
+
47
+ // Export parser configuration
48
+ export { defaultParserConfig } from './utils/parser-config';
49
+ export type { ParserConfig, FormatStyle } from './utils/parser-config';
50
+
51
+ // Note: API handlers are server-only and exported from ./index.ts (server entry point)
52
+ // They are NOT exported here to prevent client/server context mixing
53
+
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Style configuration for a formatting marker
5
+ */
6
+ export interface FormatStyle {
7
+ /** CSS classes to apply to the formatted text */
8
+ className?: string;
9
+ /** Inline styles to apply */
10
+ style?: React.CSSProperties;
11
+ }
12
+
13
+ /**
14
+ * Parser configuration
15
+ * Defines how different formatting markers are styled
16
+ */
17
+ export interface ParserConfig {
18
+ /** Style for _*text*_ (sage italic medium) */
19
+ sageItalic?: FormatStyle;
20
+ /** Style for **text** (bold forest) */
21
+ bold?: FormatStyle;
22
+ /** Style for /text/ (standard sage) */
23
+ italic?: FormatStyle;
24
+ }
25
+
26
+ /**
27
+ * Default parser configuration
28
+ * Matches the original styling from the client app
29
+ */
30
+ export const defaultParserConfig: ParserConfig = {
31
+ sageItalic: {
32
+ className: "text-sage italic font-medium brightness-130"
33
+ },
34
+ bold: {
35
+ className: "text-forest font-black"
36
+ },
37
+ italic: {
38
+ className: "text-sage"
39
+ }
40
+ };
41
+
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { ParserConfig, defaultParserConfig } from './parser-config';
3
+
4
+ /**
5
+ * Parses text with configurable formatting:
6
+ * _*text*_ -> Sage Italic (configurable via config.sageItalic)
7
+ * **text** -> Bold (configurable via config.bold)
8
+ * /text/ -> Italic (configurable via config.italic)
9
+ *
10
+ * @param text - The text to parse
11
+ * @param isEditor - Whether to show formatting markers (for editor mode)
12
+ * @param config - Optional parser configuration. Uses defaultParserConfig if not provided.
13
+ */
14
+ export function parse(
15
+ text: string,
16
+ isEditor = false,
17
+ config: ParserConfig = defaultParserConfig
18
+ ): React.ReactNode {
19
+ if (!text) return null;
20
+
21
+ // Merge with default config to ensure all styles are defined
22
+ const finalConfig: Required<ParserConfig> = {
23
+ sageItalic: { ...defaultParserConfig.sageItalic, ...config.sageItalic },
24
+ bold: { ...defaultParserConfig.bold, ...config.bold },
25
+ italic: { ...defaultParserConfig.italic, ...config.italic }
26
+ };
27
+
28
+ // The Regex must capture ALL your markers, otherwise they won't reach the 'if' statements
29
+ // We look for: _*...*_ OR **...** OR /.../
30
+ const parts = text.split(/(_\*.*?\*\_|\*\*.*?\*\*|\/.*?\/)/g);
31
+
32
+ const renderFormatted = (part: string, index: number) => {
33
+ // 1. Sage Italic: _*text*_ (Check first because it's the most specific)
34
+ if (part.startsWith('_*') && part.endsWith('*_')) {
35
+ const inner = part.slice(2, -2);
36
+ const style = finalConfig.sageItalic;
37
+ return (
38
+ <span
39
+ key={index}
40
+ className={style.className}
41
+ style={style.style}
42
+ >
43
+ {inner}
44
+ </span>
45
+ );
46
+ }
47
+
48
+ // 2. Bold: **text**
49
+ if (part.startsWith('**') && part.endsWith('**')) {
50
+ const inner = part.slice(2, -2);
51
+ const style = finalConfig.bold;
52
+ return (
53
+ <span
54
+ key={index}
55
+ className={style.className}
56
+ style={style.style}
57
+ >
58
+ {inner}
59
+ </span>
60
+ );
61
+ }
62
+
63
+ // 3. Italic: /text/
64
+ if (part.startsWith('/') && part.endsWith('/')) {
65
+ const inner = part.slice(1, -1);
66
+ const style = finalConfig.italic;
67
+ return (
68
+ <span
69
+ key={index}
70
+ className={style.className}
71
+ style={style.style}
72
+ >
73
+ {inner}
74
+ </span>
75
+ );
76
+ }
77
+
78
+ // 4. Plain text
79
+ return <React.Fragment key={index}>{part}</React.Fragment>;
80
+ };
81
+
82
+ return !isEditor ? parts.map(renderFormatted) : parts.map((part, index) => {
83
+ const ghost = (sym: string) => isEditor ? <span className="opacity-30 font-mono text-[0.8em]">{sym}</span> : null;
84
+
85
+ if (part.startsWith('_*') && part.endsWith('*_')) {
86
+ const style = finalConfig.sageItalic;
87
+ return (
88
+ <span
89
+ key={index}
90
+ className={style.className}
91
+ style={style.style}
92
+ >
93
+ {ghost('_*')}{part.slice(2, -2)}{ghost('*_')}
94
+ </span>
95
+ );
96
+ }
97
+ if (part.startsWith('**') && part.endsWith('**')) {
98
+ const style = finalConfig.bold;
99
+ return (
100
+ <span
101
+ key={index}
102
+ className={style.className}
103
+ style={style.style}
104
+ >
105
+ {ghost('**')}{part.slice(2, -2)}{ghost('**')}
106
+ </span>
107
+ );
108
+ }
109
+ if (part.startsWith('/') && part.endsWith('/')) {
110
+ const style = finalConfig.italic;
111
+ return (
112
+ <span
113
+ key={index}
114
+ className={style.className}
115
+ style={style.style}
116
+ >
117
+ {ghost('/')}{part.slice(1, -1)}{ghost('/')}
118
+ </span>
119
+ );
120
+ }
121
+ return <React.Fragment key={index}>{part}</React.Fragment>;
122
+ });
123
+ }
124
+
125
+