working on making nojs workable
This commit is contained in:
88
src/app.css
88
src/app.css
@@ -230,6 +230,35 @@ body {
|
||||
transition: background-color 500ms ease-in-out;
|
||||
}
|
||||
|
||||
[data-typewriter="animated"] [data-char-index] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-typewriter-ready="true"] [data-char-index] {
|
||||
transition: opacity 0.05s ease-in;
|
||||
}
|
||||
|
||||
.bg-base.relative.h-screen {
|
||||
width: 100vw;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bg-base.relative.h-screen {
|
||||
width: calc(100vw - 600px);
|
||||
margin-left: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
nav.fixed.z-50[class*="border-r-2"] {
|
||||
/* Left sidebar starts off-screen on mobile */
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Note: JS will add inline styles and reactive classList that override these defaults */
|
||||
|
||||
.cursor-typing {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
@@ -1204,3 +1233,62 @@ svg.mermaid text {
|
||||
.reference-item > span.ml-2 {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Conditional Block Styling in Editor */
|
||||
.ProseMirror .conditional-block {
|
||||
border: 2px dashed rgba(69, 112, 122, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
background: rgba(69, 112, 122, 0.05);
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-block::before {
|
||||
content: "🔒 " attr(data-condition-type) ": " attr(data-condition-value);
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 8px;
|
||||
background: var(--color-blue);
|
||||
color: var(--color-base);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-block[data-show-when="false"]::before {
|
||||
content: "🔒 NOT " attr(data-condition-type) ": " attr(data-condition-value);
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Inline conditional styling */
|
||||
.ProseMirror .conditional-inline {
|
||||
display: inline;
|
||||
background: rgba(69, 112, 122, 0.15);
|
||||
border-bottom: 2px dotted rgba(69, 112, 122, 0.6);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-inline::after {
|
||||
content: "🔒";
|
||||
font-size: 10px;
|
||||
margin-left: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-inline[data-show-when="false"] {
|
||||
background: rgba(193, 74, 74, 0.15);
|
||||
border-bottom-color: rgba(193, 74, 74, 0.6);
|
||||
}
|
||||
|
||||
.ProseMirror .conditional-inline[data-show-when="false"]::after {
|
||||
content: "🔒❌";
|
||||
}
|
||||
|
||||
13
src/app.tsx
13
src/app.tsx
@@ -5,8 +5,7 @@ import {
|
||||
ErrorBoundary,
|
||||
Suspense,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Show
|
||||
onCleanup
|
||||
} from "solid-js";
|
||||
import "./app.css";
|
||||
import { LeftBar, RightBar } from "./components/Bars";
|
||||
@@ -191,16 +190,18 @@ function AppLayout(props: { children: any }) {
|
||||
<LeftBar />
|
||||
<div
|
||||
class="bg-base relative h-screen overflow-x-hidden overflow-y-scroll py-16"
|
||||
style={{
|
||||
style={
|
||||
barsInitialized()
|
||||
? {
|
||||
width: `${centerWidth()}px`,
|
||||
"margin-left": `${leftBarSize()}px`
|
||||
}}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseUp={handleCenterTapRelease}
|
||||
onTouchEnd={handleCenterTapRelease}
|
||||
>
|
||||
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
|
||||
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
<RightBar />
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,13 @@ export function Typewriter(props: {
|
||||
const [isTyping, setIsTyping] = createSignal(false);
|
||||
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
||||
const [shouldHide, setShouldHide] = createSignal(false);
|
||||
const [animated, setAnimated] = createSignal(false);
|
||||
const resolved = children(() => props.children);
|
||||
|
||||
onMount(() => {
|
||||
if (!containerRef || !cursorRef) return;
|
||||
|
||||
// FIRST: Walk DOM and hide all text immediately
|
||||
// FIRST: Walk DOM and split text into character spans
|
||||
const textNodes: { node: Text; text: string; startIndex: number }[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
@@ -38,7 +39,7 @@ export function Typewriter(props: {
|
||||
text.split("").forEach((char, i) => {
|
||||
const charSpan = document.createElement("span");
|
||||
charSpan.textContent = char;
|
||||
charSpan.style.opacity = "0";
|
||||
// Don't set opacity here - CSS will handle it based on data-typewriter state
|
||||
charSpan.setAttribute(
|
||||
"data-char-index",
|
||||
String(totalChars - text.length + i)
|
||||
@@ -54,6 +55,12 @@ export function Typewriter(props: {
|
||||
|
||||
walkDOM(containerRef);
|
||||
|
||||
// Mark as animated AFTER DOM manipulation - this triggers CSS to hide characters
|
||||
setAnimated(true);
|
||||
|
||||
// Mark container as ready for animation
|
||||
containerRef.setAttribute("data-typewriter-ready", "true");
|
||||
|
||||
// Position cursor at the first character location
|
||||
const firstChar = containerRef.querySelector(
|
||||
'[data-char-index="0"]'
|
||||
@@ -143,7 +150,11 @@ export function Typewriter(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} class={props.class}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={props.class}
|
||||
data-typewriter={!animated() ? "static" : "animated"}
|
||||
>
|
||||
{resolved()}
|
||||
<span ref={cursorRef} class={getCursorClass()}></span>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,8 @@ import DetailsContent from "@tiptap/extension-details-content";
|
||||
import { Node } from "@tiptap/core";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
import { Mermaid } from "./extensions/Mermaid";
|
||||
import { ConditionalBlock } from "./extensions/ConditionalBlock";
|
||||
import { ConditionalInline } from "./extensions/ConditionalInline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Superscript from "@tiptap/extension-superscript";
|
||||
import Subscript from "@tiptap/extension-subscript";
|
||||
@@ -380,6 +382,24 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false);
|
||||
|
||||
const [showConditionalConfig, setShowConditionalConfig] = createSignal(false);
|
||||
const [conditionalConfigPosition, setConditionalConfigPosition] =
|
||||
createSignal({
|
||||
top: 0,
|
||||
left: 0
|
||||
});
|
||||
const [conditionalForm, setConditionalForm] = createSignal<{
|
||||
conditionType: "auth" | "privilege" | "date" | "feature" | "env";
|
||||
conditionValue: string;
|
||||
showWhen: "true" | "false";
|
||||
inline: boolean; // New field for inline vs block
|
||||
}>({
|
||||
conditionType: "auth",
|
||||
conditionValue: "authenticated",
|
||||
showWhen: "true",
|
||||
inline: false
|
||||
});
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = createSignal(false);
|
||||
const [keyboardVisible, setKeyboardVisible] = createSignal(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = createSignal(0);
|
||||
@@ -434,6 +454,8 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
}
|
||||
}),
|
||||
Mermaid,
|
||||
ConditionalBlock,
|
||||
ConditionalInline,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
alignments: ["left", "center", "right", "justify"],
|
||||
@@ -1005,6 +1027,27 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Close conditional config on outside click
|
||||
createEffect(() => {
|
||||
if (showConditionalConfig()) {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
!target.closest(".conditional-config") &&
|
||||
!target.closest("[data-conditional-trigger]")
|
||||
) {
|
||||
setShowConditionalConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
const showMermaidSelector = (e: MouseEvent) => {
|
||||
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setMermaidMenuPosition({
|
||||
@@ -1022,6 +1065,108 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
setShowMermaidTemplates(false);
|
||||
};
|
||||
|
||||
// Conditional block functions
|
||||
const showConditionalConfigurator = (e: MouseEvent) => {
|
||||
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setConditionalConfigPosition({
|
||||
top: buttonRect.bottom + 5,
|
||||
left: buttonRect.left
|
||||
});
|
||||
|
||||
// If cursor is in existing conditional, load its values
|
||||
const instance = editor();
|
||||
if (instance?.isActive("conditionalBlock")) {
|
||||
const attrs = instance.getAttributes("conditionalBlock");
|
||||
setConditionalForm({
|
||||
conditionType: attrs.conditionType || "auth",
|
||||
conditionValue: attrs.conditionValue || "authenticated",
|
||||
showWhen: attrs.showWhen || "true",
|
||||
inline: false
|
||||
});
|
||||
} else if (instance?.isActive("conditionalInline")) {
|
||||
const attrs = instance.getAttributes("conditionalInline");
|
||||
setConditionalForm({
|
||||
conditionType: attrs.conditionType || "auth",
|
||||
conditionValue: attrs.conditionValue || "authenticated",
|
||||
showWhen: attrs.showWhen || "true",
|
||||
inline: true
|
||||
});
|
||||
} else {
|
||||
// Reset to defaults for new conditional
|
||||
setConditionalForm({
|
||||
conditionType: "auth",
|
||||
conditionValue: "authenticated",
|
||||
showWhen: "true",
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
setShowConditionalConfig(!showConditionalConfig());
|
||||
};
|
||||
|
||||
const insertConditionalBlock = () => {
|
||||
const instance = editor();
|
||||
if (!instance) return;
|
||||
|
||||
const { conditionType, conditionValue, showWhen, inline } =
|
||||
conditionalForm();
|
||||
|
||||
if (inline) {
|
||||
// Handle inline conditionals (Mark)
|
||||
if (instance.isActive("conditionalInline")) {
|
||||
// Update existing inline conditional
|
||||
instance
|
||||
.chain()
|
||||
.focus()
|
||||
.unsetConditionalInline()
|
||||
.setConditionalInline({
|
||||
conditionType,
|
||||
conditionValue,
|
||||
showWhen
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
// Apply inline conditional to selection
|
||||
instance
|
||||
.chain()
|
||||
.focus()
|
||||
.setConditionalInline({
|
||||
conditionType,
|
||||
conditionValue,
|
||||
showWhen
|
||||
})
|
||||
.run();
|
||||
}
|
||||
} else {
|
||||
// Handle block conditionals (Node)
|
||||
if (instance.isActive("conditionalBlock")) {
|
||||
// Update existing conditional
|
||||
instance
|
||||
.chain()
|
||||
.focus()
|
||||
.updateConditionalBlock({
|
||||
conditionType,
|
||||
conditionValue,
|
||||
showWhen
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
// Wrap selection in new conditional
|
||||
instance
|
||||
.chain()
|
||||
.focus()
|
||||
.setConditionalBlock({
|
||||
conditionType,
|
||||
conditionValue,
|
||||
showWhen
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
setShowConditionalConfig(false);
|
||||
};
|
||||
|
||||
// Toggle fullscreen mode
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen());
|
||||
@@ -1123,6 +1268,200 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// Conditional Configurator Component
|
||||
const ConditionalConfigurator = () => {
|
||||
return (
|
||||
<div class="bg-mantle border-surface2 w-80 rounded border p-4 shadow-lg">
|
||||
<h3 class="text-text mb-3 font-semibold">Conditional Block</h3>
|
||||
|
||||
{/* Condition Type Selector */}
|
||||
<label class="text-subtext0 mb-2 block text-xs">Condition Type</label>
|
||||
<select
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionType}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionType: e.currentTarget.value as any
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="auth">User Authentication</option>
|
||||
<option value="privilege">Privilege Level</option>
|
||||
<option value="date">Date Range</option>
|
||||
<option value="feature">Feature Flag</option>
|
||||
<option value="env">Environment Variable</option>
|
||||
</select>
|
||||
|
||||
{/* Dynamic Condition Value Input based on type */}
|
||||
<Show when={conditionalForm().conditionType === "auth"}>
|
||||
<label class="text-subtext0 mb-2 block text-xs">User State</label>
|
||||
<select
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionValue}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionValue: e.currentTarget.value
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="authenticated">Authenticated</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
</select>
|
||||
</Show>
|
||||
|
||||
<Show when={conditionalForm().conditionType === "privilege"}>
|
||||
<label class="text-subtext0 mb-2 block text-xs">
|
||||
Privilege Level
|
||||
</label>
|
||||
<select
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionValue}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionValue: e.currentTarget.value
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
</select>
|
||||
</Show>
|
||||
|
||||
<Show when={conditionalForm().conditionType === "date"}>
|
||||
<label class="text-subtext0 mb-2 block text-xs">Date Condition</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="before:2026-01-01 or after:2025-01-01"
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionValue}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionValue: e.currentTarget.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div class="text-subtext0 mb-3 text-xs">
|
||||
Format: before:YYYY-MM-DD, after:YYYY-MM-DD, or
|
||||
between:YYYY-MM-DD,YYYY-MM-DD
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={conditionalForm().conditionType === "feature"}>
|
||||
<label class="text-subtext0 mb-2 block text-xs">
|
||||
Feature Flag Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="feature-name"
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionValue}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionValue: e.currentTarget.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={conditionalForm().conditionType === "env"}>
|
||||
<label class="text-subtext0 mb-2 block text-xs">
|
||||
Environment Variable
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="env-variables"
|
||||
placeholder="NODE_ENV:production"
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().conditionValue}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
conditionValue: e.currentTarget.value
|
||||
})
|
||||
}
|
||||
/>
|
||||
<datalist id="env-variables">
|
||||
<option value="NODE_ENV:development">
|
||||
Development environment
|
||||
</option>
|
||||
<option value="NODE_ENV:production">Production environment</option>
|
||||
<option value="NODE_ENV:test">Test environment</option>
|
||||
<option value="VERCEL_ENV:preview">
|
||||
Vercel preview deployment
|
||||
</option>
|
||||
<option value="VERCEL_ENV:production">Vercel production</option>
|
||||
<option value="VITE_DOMAIN:*">Any domain configured</option>
|
||||
<option value="VITE_AWS_BUCKET_STRING:*">
|
||||
S3 bucket configured
|
||||
</option>
|
||||
<option value="VITE_GOOGLE_CLIENT_ID:*">Google auth enabled</option>
|
||||
<option value="VITE_GITHUB_CLIENT_ID:*">GitHub auth enabled</option>
|
||||
<option value="VITE_WEBSOCKET:*">WebSocket configured</option>
|
||||
</datalist>
|
||||
<div class="text-subtext0 mb-3 text-xs">
|
||||
Format: VAR_NAME:value or VAR_NAME:* for any truthy value
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Show When Toggle */}
|
||||
<label class="text-subtext0 mb-2 block text-xs">Show When</label>
|
||||
<select
|
||||
class="bg-surface0 text-text border-surface2 mb-3 w-full rounded border px-2 py-1"
|
||||
value={conditionalForm().showWhen}
|
||||
onInput={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
showWhen: e.currentTarget.value as "true" | "false"
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="true">Condition is TRUE</option>
|
||||
<option value="false">Condition is FALSE</option>
|
||||
</select>
|
||||
|
||||
{/* Inline Toggle */}
|
||||
<label class="text-subtext0 mb-3 flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={conditionalForm().inline}
|
||||
onChange={(e) =>
|
||||
setConditionalForm({
|
||||
...conditionalForm(),
|
||||
inline: e.currentTarget.checked
|
||||
})
|
||||
}
|
||||
class="rounded"
|
||||
/>
|
||||
<span>Inline (no line break)</span>
|
||||
</label>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertConditionalBlock}
|
||||
class="bg-blue rounded px-3 py-1 text-sm hover:brightness-125"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConditionalConfig(false)}
|
||||
class="hover:bg-surface1 rounded px-3 py-1 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -1475,6 +1814,19 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Conditional Configurator */}
|
||||
<Show when={showConditionalConfig()}>
|
||||
<div
|
||||
class="conditional-config fixed z-110"
|
||||
style={{
|
||||
top: `${conditionalConfigPosition().top}px`,
|
||||
left: `${conditionalConfigPosition().left}px`
|
||||
}}
|
||||
>
|
||||
<ConditionalConfigurator />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Main Toolbar - Pinned at top in fullscreen */}
|
||||
<div
|
||||
class="border-surface2 bg-base border-b"
|
||||
@@ -1782,6 +2134,19 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
>
|
||||
📊 Diagram
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showConditionalConfigurator}
|
||||
data-conditional-trigger
|
||||
class={`${
|
||||
instance().isActive("conditionalBlock")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Insert Conditional Block"
|
||||
>
|
||||
🔒 Conditional
|
||||
</button>
|
||||
<div class="border-surface2 mx-1 border-l"></div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
103
src/components/blog/extensions/ConditionalBlock.ts
Normal file
103
src/components/blog/extensions/ConditionalBlock.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
export interface ConditionalBlockOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ConditionalBlockAttributes {
|
||||
conditionType: "auth" | "privilege" | "date" | "feature" | "env";
|
||||
conditionValue: string;
|
||||
showWhen: "true" | "false";
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
conditionalBlock: {
|
||||
setConditionalBlock: (
|
||||
attributes: ConditionalBlockAttributes
|
||||
) => ReturnType;
|
||||
updateConditionalBlock: (
|
||||
attributes: Partial<ConditionalBlockAttributes>
|
||||
) => ReturnType;
|
||||
removeConditionalBlock: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ConditionalBlock = Node.create<ConditionalBlockOptions>({
|
||||
name: "conditionalBlock",
|
||||
group: "block",
|
||||
content: "block+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: "conditional-block"
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
conditionType: {
|
||||
default: "auth",
|
||||
parseHTML: (element) => element.getAttribute("data-condition-type"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-condition-type": attributes.conditionType
|
||||
})
|
||||
},
|
||||
conditionValue: {
|
||||
default: "authenticated",
|
||||
parseHTML: (element) => element.getAttribute("data-condition-value"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-condition-value": attributes.conditionValue
|
||||
})
|
||||
},
|
||||
showWhen: {
|
||||
default: "true",
|
||||
parseHTML: (element) => element.getAttribute("data-show-when"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-show-when": attributes.showWhen
|
||||
})
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div.conditional-block[data-condition-type]"
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
["div", { class: "conditional-content" }, 0]
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setConditionalBlock:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.wrapIn(this.name, attributes);
|
||||
},
|
||||
updateConditionalBlock:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.updateAttributes(this.name, attributes);
|
||||
},
|
||||
removeConditionalBlock:
|
||||
() =>
|
||||
({ commands }) => {
|
||||
return commands.lift(this.name);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
101
src/components/blog/extensions/ConditionalInline.ts
Normal file
101
src/components/blog/extensions/ConditionalInline.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
export interface ConditionalInlineOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ConditionalInlineAttributes {
|
||||
conditionType: "auth" | "privilege" | "date" | "feature" | "env";
|
||||
conditionValue: string;
|
||||
showWhen: "true" | "false";
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
conditionalInline: {
|
||||
setConditionalInline: (
|
||||
attributes: ConditionalInlineAttributes
|
||||
) => ReturnType;
|
||||
toggleConditionalInline: (
|
||||
attributes: ConditionalInlineAttributes
|
||||
) => ReturnType;
|
||||
unsetConditionalInline: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ConditionalInline = Mark.create<ConditionalInlineOptions>({
|
||||
name: "conditionalInline",
|
||||
priority: 1000,
|
||||
keepOnSplit: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: "conditional-inline"
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
conditionType: {
|
||||
default: "auth",
|
||||
parseHTML: (element) => element.getAttribute("data-condition-type"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-condition-type": attributes.conditionType
|
||||
})
|
||||
},
|
||||
conditionValue: {
|
||||
default: "authenticated",
|
||||
parseHTML: (element) => element.getAttribute("data-condition-value"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-condition-value": attributes.conditionValue
|
||||
})
|
||||
},
|
||||
showWhen: {
|
||||
default: "true",
|
||||
parseHTML: (element) => element.getAttribute("data-show-when"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-show-when": attributes.showWhen
|
||||
})
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span.conditional-inline[data-condition-type]"
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setConditionalInline:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.setMark(this.name, attributes);
|
||||
},
|
||||
toggleConditionalInline:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.toggleMark(this.name, attributes);
|
||||
},
|
||||
unsetConditionalInline:
|
||||
() =>
|
||||
({ commands }) => {
|
||||
return commands.unsetMark(this.name);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,11 @@ export default createHandler(() => (
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; z-index: 9999; background-color: var(--color-yellow); color: var(--color-crust); padding: 1rem; text-align: center; font-weight: 600; border-bottom: 2px solid var(--color-text);">
|
||||
JavaScript is disabled. Features will be limited.
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
|
||||
@@ -26,6 +26,9 @@ const getPostByTitle = query(
|
||||
"use server";
|
||||
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
||||
await import("~/server/utils");
|
||||
const { parseConditionals, getSafeEnvVariables } =
|
||||
await import("~/server/conditional-parser");
|
||||
const { getFeatureFlags } = await import("~/server/feature-flags");
|
||||
const event = getRequestEvent()!;
|
||||
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
||||
const userID = await getUserID(event.nativeEvent);
|
||||
@@ -78,6 +81,26 @@ const getPostByTitle = query(
|
||||
};
|
||||
}
|
||||
|
||||
// Build conditional evaluation context
|
||||
const conditionalContext = {
|
||||
isAuthenticated: userID !== null,
|
||||
privilegeLevel: privilegeLevel,
|
||||
userId: userID,
|
||||
currentDate: new Date(),
|
||||
featureFlags: getFeatureFlags(),
|
||||
env: getSafeEnvVariables()
|
||||
};
|
||||
|
||||
// Parse conditionals in post body
|
||||
if (post.body) {
|
||||
try {
|
||||
post.body = parseConditionals(post.body, conditionalContext);
|
||||
} catch (error) {
|
||||
console.error("Error parsing conditionals in post body:", error);
|
||||
// Fall back to showing original content
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch comments with sorting
|
||||
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||
|
||||
@@ -274,10 +297,10 @@ export default function PostPage() {
|
||||
</div>
|
||||
|
||||
{/* Spacer to push content down */}
|
||||
<div class="h-80 sm:h-96 md:h-[50vh]"></div>
|
||||
<div class="-mt-[10vh] h-80 sm:h-96 md:h-[50vh]" />
|
||||
|
||||
{/* Content that slides over the fixed image */}
|
||||
<div class="bg-surface0 relative z-40 pb-24">
|
||||
<div class="bg-base relative z-40 pb-24">
|
||||
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
|
||||
<div class="">
|
||||
<div class="flex justify-center italic md:justify-start md:pl-24">
|
||||
@@ -287,7 +310,7 @@ export default function PostPage() {
|
||||
By Michael Freno
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex max-w-[420px] flex-wrap justify-center italic md:justify-start md:pl-24">
|
||||
<div class="flex max-w-105 flex-wrap justify-center italic md:justify-start md:pl-24">
|
||||
<For each={postData.tags as any[]}>
|
||||
{(tag) => (
|
||||
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
|
||||
|
||||
@@ -336,14 +336,14 @@ export default function LoginPage() {
|
||||
<div class="relative pt-12 md:pt-24">
|
||||
{/* Error message */}
|
||||
<Show when={error()}>
|
||||
<div class="mb-4 w-full max-w-md rounded-lg border border-red-500 bg-red-500/10 px-4 py-3 text-center">
|
||||
<div class="border-maroon bg-red mb-4 w-full max-w-md rounded-lg border px-4 py-3 text-center">
|
||||
<Show when={error() === "passwordMismatch"}>
|
||||
<div class="text-lg font-semibold text-red-500">
|
||||
<div class="text-red text-lg font-semibold">
|
||||
Passwords did not match!
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() === "duplicate"}>
|
||||
<div class="text-lg font-semibold text-red-500">
|
||||
<div class="text-red text-lg font-semibold">
|
||||
Email Already Exists!
|
||||
</div>
|
||||
</Show>
|
||||
@@ -354,7 +354,7 @@ export default function LoginPage() {
|
||||
error() !== "duplicate"
|
||||
}
|
||||
>
|
||||
<div class="text-sm text-red-500">{error()}</div>
|
||||
<div class="text-red text-sm">{error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -519,7 +519,7 @@ export default function LoginPage() {
|
||||
passwordConfRef.value.length >= 6
|
||||
? ""
|
||||
: "opacity-0 select-none"
|
||||
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
|
||||
} text-red text-center transition-opacity duration-200 ease-in-out`}
|
||||
>
|
||||
Passwords do not match!
|
||||
</div>
|
||||
@@ -535,11 +535,11 @@ export default function LoginPage() {
|
||||
<div
|
||||
class={`${
|
||||
showPasswordError()
|
||||
? "text-red-500"
|
||||
? "text-red"
|
||||
: showPasswordSuccess()
|
||||
? "text-green-500"
|
||||
? "text-green"
|
||||
: "opacity-0 select-none"
|
||||
} flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`}
|
||||
} flex min-h-4 justify-center italic transition-opacity duration-300 ease-in-out`}
|
||||
>
|
||||
<Show when={showPasswordError()}>
|
||||
Credentials did not match any record
|
||||
@@ -576,7 +576,7 @@ export default function LoginPage() {
|
||||
initialRemainingTime={countDown()}
|
||||
size={48}
|
||||
strokeWidth={6}
|
||||
colors="#60a5fa"
|
||||
colors="var(--color-blue)"
|
||||
>
|
||||
{renderTime}
|
||||
</CountdownCircleTimer>
|
||||
|
||||
288
src/server/conditional-parser.test.ts
Normal file
288
src/server/conditional-parser.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
parseConditionals,
|
||||
type ConditionalContext
|
||||
} from "./conditional-parser";
|
||||
|
||||
describe("parseConditionals", () => {
|
||||
const baseContext: ConditionalContext = {
|
||||
isAuthenticated: true,
|
||||
privilegeLevel: "user",
|
||||
userId: "test-user",
|
||||
currentDate: new Date("2025-06-01"),
|
||||
featureFlags: { "beta-feature": true },
|
||||
env: { NODE_ENV: "development", VERCEL_ENV: "development" }
|
||||
};
|
||||
|
||||
it("should show content for authenticated users", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">
|
||||
<div class="conditional-content"><p>Secret content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Secret content");
|
||||
expect(result).not.toContain("conditional-block");
|
||||
});
|
||||
|
||||
it("should hide content for anonymous users when condition is authenticated", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">
|
||||
<div class="conditional-content"><p>Secret content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const anonContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
isAuthenticated: false,
|
||||
privilegeLevel: "anonymous"
|
||||
};
|
||||
const result = parseConditionals(html, anonContext);
|
||||
expect(result).not.toContain("Secret content");
|
||||
});
|
||||
|
||||
it("should evaluate admin-only content", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="privilege" data-condition-value="admin" data-show-when="true">
|
||||
<div class="conditional-content"><p>Admin panel</p></div>
|
||||
</div>
|
||||
`;
|
||||
const userResult = parseConditionals(html, baseContext);
|
||||
expect(userResult).not.toContain("Admin panel");
|
||||
|
||||
const adminContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
privilegeLevel: "admin"
|
||||
};
|
||||
const adminResult = parseConditionals(html, adminContext);
|
||||
expect(adminResult).toContain("Admin panel");
|
||||
});
|
||||
|
||||
it("should handle date before condition", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="date" data-condition-value="before:2026-01-01" data-show-when="true">
|
||||
<div class="conditional-content"><p>Available until 2026</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Available until 2026");
|
||||
});
|
||||
|
||||
it("should handle date after condition", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="date" data-condition-value="after:2024-01-01" data-show-when="true">
|
||||
<div class="conditional-content"><p>Available after 2024</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Available after 2024");
|
||||
});
|
||||
|
||||
it("should handle date between condition", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="date" data-condition-value="between:2025-01-01,2025-12-31" data-show-when="true">
|
||||
<div class="conditional-content"><p>2025 content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("2025 content");
|
||||
});
|
||||
|
||||
it("should handle feature flag conditions", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="feature" data-condition-value="beta-feature" data-show-when="true">
|
||||
<div class="conditional-content"><p>Beta content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Beta content");
|
||||
});
|
||||
|
||||
it("should hide content when feature flag is false", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="feature" data-condition-value="disabled-feature" data-show-when="true">
|
||||
<div class="conditional-content"><p>Disabled content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Disabled content");
|
||||
});
|
||||
|
||||
it("should handle showWhen=false (inverted logic)", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="auth" data-condition-value="authenticated" data-show-when="false">
|
||||
<div class="conditional-content"><p>Not authenticated content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Not authenticated content");
|
||||
|
||||
const anonContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
isAuthenticated: false,
|
||||
privilegeLevel: "anonymous"
|
||||
};
|
||||
const anonResult = parseConditionals(html, anonContext);
|
||||
expect(anonResult).toContain("Not authenticated content");
|
||||
});
|
||||
|
||||
it("should handle multiple conditional blocks", () => {
|
||||
const html = `
|
||||
<p>Public content</p>
|
||||
<div class="conditional-block" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">
|
||||
<div class="conditional-content"><p>Auth content</p></div>
|
||||
</div>
|
||||
<p>More public</p>
|
||||
<div class="conditional-block" data-condition-type="privilege" data-condition-value="admin" data-show-when="true">
|
||||
<div class="conditional-content"><p>Admin content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Public content");
|
||||
expect(result).toContain("Auth content");
|
||||
expect(result).toContain("More public");
|
||||
expect(result).not.toContain("Admin content");
|
||||
});
|
||||
|
||||
it("should handle empty HTML", () => {
|
||||
const result = parseConditionals("", baseContext);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should handle HTML with no conditionals", () => {
|
||||
const html = "<p>Regular content</p>";
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("should default to hiding unknown condition types", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="unknown" data-condition-value="something" data-show-when="true">
|
||||
<div class="conditional-content"><p>Unknown type content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Unknown type content");
|
||||
});
|
||||
|
||||
it("should handle complex nested HTML in conditional content", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">
|
||||
<div class="conditional-content">
|
||||
<h2>Title</h2>
|
||||
<ul><li>Item 1</li><li>Item 2</li></ul>
|
||||
<pre><code>console.log('test');</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("<h2>Title</h2>");
|
||||
expect(result).toContain("<ul><li>Item 1</li>");
|
||||
expect(result).toContain("<code>console.log('test');</code>");
|
||||
});
|
||||
|
||||
it("should handle env condition with exact match", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="NODE_ENV:development" data-show-when="true">
|
||||
<div class="conditional-content"><p>Dev mode content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Dev mode content");
|
||||
});
|
||||
|
||||
it("should hide env condition when value doesn't match", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="NODE_ENV:production" data-show-when="true">
|
||||
<div class="conditional-content"><p>Prod content</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Prod content");
|
||||
});
|
||||
|
||||
it("should handle env condition with wildcard (*) for any truthy value", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="NODE_ENV:*" data-show-when="true">
|
||||
<div class="conditional-content"><p>Any env set</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Any env set");
|
||||
});
|
||||
|
||||
it("should hide env condition when variable is undefined", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="NONEXISTENT_VAR:*" data-show-when="true">
|
||||
<div class="conditional-content"><p>Should not show</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Should not show");
|
||||
});
|
||||
|
||||
it("should handle env condition with inverted logic", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="NODE_ENV:production" data-show-when="false">
|
||||
<div class="conditional-content"><p>Not production</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Not production");
|
||||
});
|
||||
|
||||
it("should handle malformed env condition format", () => {
|
||||
const html = `
|
||||
<div class="conditional-block" data-condition-type="env" data-condition-value="INVALID_FORMAT" data-show-when="true">
|
||||
<div class="conditional-content"><p>Invalid format</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).not.toContain("Invalid format");
|
||||
});
|
||||
|
||||
// Inline conditional tests
|
||||
it("should handle inline conditional span for authenticated users", () => {
|
||||
const html = `<p>The domain is <span class="conditional-inline" data-condition-type="env" data-condition-value="NODE_ENV:development" data-show-when="true">localhost</span>.</p>`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("The domain is localhost.");
|
||||
expect(result).not.toContain("conditional-inline");
|
||||
expect(result).not.toContain("data-condition-type");
|
||||
});
|
||||
|
||||
it("should hide inline conditional when condition is false", () => {
|
||||
const html = `<p>The domain is <span class="conditional-inline" data-condition-type="env" data-condition-value="NODE_ENV:production" data-show-when="true">freno.me</span>.</p>`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toBe("<p>The domain is .</p>");
|
||||
});
|
||||
|
||||
it("should handle inline auth conditionals", () => {
|
||||
const html = `<p>Welcome <span class="conditional-inline" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">back</span>!</p>`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Welcome back!");
|
||||
});
|
||||
|
||||
it("should handle multiple inline conditionals in same paragraph", () => {
|
||||
const html = `<p>Domain: <span class="conditional-inline" data-condition-type="env" data-condition-value="NODE_ENV:development" data-show-when="true">localhost</span>, User: <span class="conditional-inline" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">logged in</span></p>`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Domain: localhost");
|
||||
expect(result).toContain("User: logged in");
|
||||
});
|
||||
|
||||
it("should handle mixed block and inline conditionals", () => {
|
||||
const html = `
|
||||
<p>Text with <span class="conditional-inline" data-condition-type="auth" data-condition-value="authenticated" data-show-when="true">inline</span> conditional.</p>
|
||||
<div class="conditional-block" data-condition-type="privilege" data-condition-value="admin" data-show-when="true">
|
||||
<div class="conditional-content"><p>Block conditional</p></div>
|
||||
</div>
|
||||
`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Text with inline conditional.");
|
||||
expect(result).not.toContain("Block conditional"); // user is not admin
|
||||
});
|
||||
|
||||
it("should handle inline conditional with showWhen=false", () => {
|
||||
const html = `<p>Status: <span class="conditional-inline" data-condition-type="env" data-condition-value="NODE_ENV:production" data-show-when="false">not production</span></p>`;
|
||||
const result = parseConditionals(html, baseContext);
|
||||
expect(result).toContain("Status: not production");
|
||||
});
|
||||
});
|
||||
309
src/server/conditional-parser.ts
Normal file
309
src/server/conditional-parser.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Server-side conditional parser for blog content
|
||||
* Evaluates conditional blocks and returns processed HTML
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get safe environment variables for conditional evaluation
|
||||
* Only exposes non-sensitive variables that are safe to use in content conditionals
|
||||
*/
|
||||
export function getSafeEnvVariables(): Record<string, string | undefined> {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
VERCEL_ENV: process.env.VERCEL_ENV
|
||||
// Add other safe, non-sensitive env vars here as needed
|
||||
// DO NOT expose API keys, secrets, database URLs, etc.
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConditionalContext {
|
||||
isAuthenticated: boolean;
|
||||
privilegeLevel: "admin" | "user" | "anonymous";
|
||||
userId: string | null;
|
||||
currentDate: Date;
|
||||
featureFlags: Record<string, boolean>;
|
||||
env: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
interface ConditionalBlock {
|
||||
fullMatch: string;
|
||||
conditionType: string;
|
||||
conditionValue: string;
|
||||
showWhen: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML and evaluate conditional blocks (both block and inline)
|
||||
* @param html - Raw HTML from database
|
||||
* @param context - Evaluation context (user, date, features)
|
||||
* @returns Processed HTML with conditionals evaluated
|
||||
*/
|
||||
export function parseConditionals(
|
||||
html: string,
|
||||
context: ConditionalContext
|
||||
): string {
|
||||
if (!html) return html;
|
||||
|
||||
let processedHtml = html;
|
||||
|
||||
// First, process block-level conditionals (div elements)
|
||||
processedHtml = processBlockConditionals(processedHtml, context);
|
||||
|
||||
// Then, process inline conditionals (span elements)
|
||||
processedHtml = processInlineConditionals(processedHtml, context);
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process block-level conditional divs
|
||||
*/
|
||||
function processBlockConditionals(
|
||||
html: string,
|
||||
context: ConditionalContext
|
||||
): string {
|
||||
// Regex to match conditional blocks
|
||||
// Matches: <div class="conditional-block" data-condition-type="..." data-condition-value="..." data-show-when="...">...</div>
|
||||
const conditionalRegex =
|
||||
/<div\s+[^>]*class="[^"]*conditional-block[^"]*"[^>]*data-condition-type="([^"]+)"[^>]*data-condition-value="([^"]+)"[^>]*data-show-when="(true|false)"[^>]*>([\s\S]*?)<\/div>/gi;
|
||||
|
||||
let processedHtml = html;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex lastIndex
|
||||
conditionalRegex.lastIndex = 0;
|
||||
|
||||
// Collect all matches first to avoid regex state issues
|
||||
const matches: ConditionalBlock[] = [];
|
||||
while ((match = conditionalRegex.exec(html)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
conditionType: match[1],
|
||||
conditionValue: match[2],
|
||||
showWhen: match[3],
|
||||
content: match[4]
|
||||
});
|
||||
}
|
||||
|
||||
// Process each conditional block
|
||||
for (const block of matches) {
|
||||
const shouldShow = evaluateCondition(
|
||||
block.conditionType,
|
||||
block.conditionValue,
|
||||
block.showWhen === "true",
|
||||
context
|
||||
);
|
||||
|
||||
if (shouldShow) {
|
||||
// Keep content, but remove conditional wrapper
|
||||
// Extract content from inner <div class="conditional-content">
|
||||
const innerContentRegex =
|
||||
/<div\s+class="conditional-content">([\s\S]*?)<\/div>/i;
|
||||
const innerMatch = block.fullMatch.match(innerContentRegex);
|
||||
const innerContent = innerMatch ? innerMatch[1] : block.content;
|
||||
|
||||
processedHtml = processedHtml.replace(block.fullMatch, innerContent);
|
||||
} else {
|
||||
// Remove entire block
|
||||
processedHtml = processedHtml.replace(block.fullMatch, "");
|
||||
}
|
||||
}
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process inline conditional spans
|
||||
*/
|
||||
function processInlineConditionals(
|
||||
html: string,
|
||||
context: ConditionalContext
|
||||
): string {
|
||||
// Regex to match inline conditionals
|
||||
// Matches: <span class="conditional-inline" data-condition-type="..." data-condition-value="..." data-show-when="...">...</span>
|
||||
const inlineRegex =
|
||||
/<span\s+[^>]*class="[^"]*conditional-inline[^"]*"[^>]*data-condition-type="([^"]+)"[^>]*data-condition-value="([^"]+)"[^>]*data-show-when="(true|false)"[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
|
||||
let processedHtml = html;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex lastIndex
|
||||
inlineRegex.lastIndex = 0;
|
||||
|
||||
// Collect all matches first
|
||||
const matches: ConditionalBlock[] = [];
|
||||
while ((match = inlineRegex.exec(html)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
conditionType: match[1],
|
||||
conditionValue: match[2],
|
||||
showWhen: match[3],
|
||||
content: match[4]
|
||||
});
|
||||
}
|
||||
|
||||
// Process each inline conditional
|
||||
for (const inline of matches) {
|
||||
const shouldShow = evaluateCondition(
|
||||
inline.conditionType,
|
||||
inline.conditionValue,
|
||||
inline.showWhen === "true",
|
||||
context
|
||||
);
|
||||
|
||||
if (shouldShow) {
|
||||
// Keep content, remove span wrapper
|
||||
processedHtml = processedHtml.replace(inline.fullMatch, inline.content);
|
||||
} else {
|
||||
// Remove entire inline span
|
||||
processedHtml = processedHtml.replace(inline.fullMatch, "");
|
||||
}
|
||||
}
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single condition
|
||||
*/
|
||||
function evaluateCondition(
|
||||
conditionType: string,
|
||||
conditionValue: string,
|
||||
showWhen: boolean,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
let conditionMet = false;
|
||||
|
||||
switch (conditionType) {
|
||||
case "auth":
|
||||
conditionMet = evaluateAuthCondition(conditionValue, context);
|
||||
break;
|
||||
case "privilege":
|
||||
conditionMet = evaluatePrivilegeCondition(conditionValue, context);
|
||||
break;
|
||||
case "date":
|
||||
conditionMet = evaluateDateCondition(conditionValue, context);
|
||||
break;
|
||||
case "feature":
|
||||
conditionMet = evaluateFeatureCondition(conditionValue, context);
|
||||
break;
|
||||
case "env":
|
||||
conditionMet = evaluateEnvCondition(conditionValue, context);
|
||||
break;
|
||||
default:
|
||||
// Unknown condition type - default to hiding content for safety
|
||||
conditionMet = false;
|
||||
}
|
||||
|
||||
// Apply showWhen logic: if showWhen is true, show when condition is met
|
||||
// If showWhen is false, show when condition is NOT met
|
||||
return showWhen ? conditionMet : !conditionMet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate authentication condition
|
||||
*/
|
||||
function evaluateAuthCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
switch (value) {
|
||||
case "authenticated":
|
||||
return context.isAuthenticated;
|
||||
case "anonymous":
|
||||
return !context.isAuthenticated;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate privilege level condition
|
||||
*/
|
||||
function evaluatePrivilegeCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
return context.privilegeLevel === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate date-based condition
|
||||
* Supports: "before:YYYY-MM-DD", "after:YYYY-MM-DD", "between:YYYY-MM-DD,YYYY-MM-DD"
|
||||
*/
|
||||
function evaluateDateCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
try {
|
||||
const now = context.currentDate.getTime();
|
||||
|
||||
if (value.startsWith("before:")) {
|
||||
const dateStr = value.substring(7);
|
||||
const targetDate = new Date(dateStr).getTime();
|
||||
return now < targetDate;
|
||||
}
|
||||
|
||||
if (value.startsWith("after:")) {
|
||||
const dateStr = value.substring(6);
|
||||
const targetDate = new Date(dateStr).getTime();
|
||||
return now > targetDate;
|
||||
}
|
||||
|
||||
if (value.startsWith("between:")) {
|
||||
const dateRange = value.substring(8).split(",");
|
||||
if (dateRange.length !== 2) return false;
|
||||
|
||||
const startDate = new Date(dateRange[0].trim()).getTime();
|
||||
const endDate = new Date(dateRange[1].trim()).getTime();
|
||||
return now >= startDate && now <= endDate;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date condition:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate feature flag condition
|
||||
*/
|
||||
function evaluateFeatureCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
return context.featureFlags[value] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate environment variable condition
|
||||
* Format: "ENV_VAR_NAME:expected_value" or "ENV_VAR_NAME:*" for any truthy value
|
||||
*/
|
||||
function evaluateEnvCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
try {
|
||||
// Parse format: "VAR_NAME:expected_value"
|
||||
const colonIndex = value.indexOf(":");
|
||||
if (colonIndex === -1) return false;
|
||||
|
||||
const varName = value.substring(0, colonIndex).trim();
|
||||
const expectedValue = value.substring(colonIndex + 1).trim();
|
||||
|
||||
const actualValue = context.env[varName];
|
||||
|
||||
// If expected value is "*", check if variable exists and is truthy
|
||||
if (expectedValue === "*") {
|
||||
return !!actualValue;
|
||||
}
|
||||
|
||||
// Otherwise, check for exact match
|
||||
return actualValue === expectedValue;
|
||||
} catch (error) {
|
||||
console.error("Error parsing env condition:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
24
src/server/feature-flags.ts
Normal file
24
src/server/feature-flags.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Feature flag system for conditional content
|
||||
* Centralized configuration for feature toggles
|
||||
*/
|
||||
|
||||
export interface FeatureFlags {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export function getFeatureFlags(): FeatureFlags {
|
||||
return {
|
||||
// TODO: Add feature flags here
|
||||
"beta-features": process.env.ENABLE_BETA_FEATURES === "true",
|
||||
"new-editor": false,
|
||||
"premium-content": true,
|
||||
"seasonal-event": false,
|
||||
"maintenance-mode": false
|
||||
};
|
||||
}
|
||||
|
||||
export function isFeatureEnabled(featureName: string): boolean {
|
||||
const flags = getFeatureFlags();
|
||||
return flags[featureName] === true;
|
||||
}
|
||||
Reference in New Issue
Block a user