@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 +47 -0
- package/src/api/handler.ts +74 -0
- package/src/api/router.ts +65 -0
- package/src/api-server.ts +9 -0
- package/src/components/MultilineText.tsx +103 -0
- package/src/components/ParsedText.tsx +70 -0
- package/src/components/TranslationEditor.tsx +304 -0
- package/src/context/ParserConfigContext.tsx +53 -0
- package/src/hooks/useParse.ts +23 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +53 -0
- package/src/utils/parser-config.ts +41 -0
- package/src/utils/parser.tsx +125 -0
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,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
|
+
|