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;
|
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 {
|
.cursor-typing {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
@@ -1204,3 +1233,62 @@ svg.mermaid text {
|
|||||||
.reference-item > span.ml-2 {
|
.reference-item > span.ml-2 {
|
||||||
font-style: italic;
|
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: "🔒❌";
|
||||||
|
}
|
||||||
|
|||||||
19
src/app.tsx
19
src/app.tsx
@@ -5,8 +5,7 @@ import {
|
|||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
Suspense,
|
Suspense,
|
||||||
onMount,
|
onMount,
|
||||||
onCleanup,
|
onCleanup
|
||||||
Show
|
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { LeftBar, RightBar } from "./components/Bars";
|
import { LeftBar, RightBar } from "./components/Bars";
|
||||||
@@ -191,16 +190,18 @@ function AppLayout(props: { children: any }) {
|
|||||||
<LeftBar />
|
<LeftBar />
|
||||||
<div
|
<div
|
||||||
class="bg-base relative h-screen overflow-x-hidden overflow-y-scroll py-16"
|
class="bg-base relative h-screen overflow-x-hidden overflow-y-scroll py-16"
|
||||||
style={{
|
style={
|
||||||
width: `${centerWidth()}px`,
|
barsInitialized()
|
||||||
"margin-left": `${leftBarSize()}px`
|
? {
|
||||||
}}
|
width: `${centerWidth()}px`,
|
||||||
|
"margin-left": `${leftBarSize()}px`
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onMouseUp={handleCenterTapRelease}
|
onMouseUp={handleCenterTapRelease}
|
||||||
onTouchEnd={handleCenterTapRelease}
|
onTouchEnd={handleCenterTapRelease}
|
||||||
>
|
>
|
||||||
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
|
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
|
||||||
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<RightBar />
|
<RightBar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export function Typewriter(props: {
|
|||||||
const [isTyping, setIsTyping] = createSignal(false);
|
const [isTyping, setIsTyping] = createSignal(false);
|
||||||
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
||||||
const [shouldHide, setShouldHide] = createSignal(false);
|
const [shouldHide, setShouldHide] = createSignal(false);
|
||||||
|
const [animated, setAnimated] = createSignal(false);
|
||||||
const resolved = children(() => props.children);
|
const resolved = children(() => props.children);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!containerRef || !cursorRef) return;
|
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 }[] = [];
|
const textNodes: { node: Text; text: string; startIndex: number }[] = [];
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export function Typewriter(props: {
|
|||||||
text.split("").forEach((char, i) => {
|
text.split("").forEach((char, i) => {
|
||||||
const charSpan = document.createElement("span");
|
const charSpan = document.createElement("span");
|
||||||
charSpan.textContent = char;
|
charSpan.textContent = char;
|
||||||
charSpan.style.opacity = "0";
|
// Don't set opacity here - CSS will handle it based on data-typewriter state
|
||||||
charSpan.setAttribute(
|
charSpan.setAttribute(
|
||||||
"data-char-index",
|
"data-char-index",
|
||||||
String(totalChars - text.length + i)
|
String(totalChars - text.length + i)
|
||||||
@@ -54,6 +55,12 @@ export function Typewriter(props: {
|
|||||||
|
|
||||||
walkDOM(containerRef);
|
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
|
// Position cursor at the first character location
|
||||||
const firstChar = containerRef.querySelector(
|
const firstChar = containerRef.querySelector(
|
||||||
'[data-char-index="0"]'
|
'[data-char-index="0"]'
|
||||||
@@ -143,7 +150,11 @@ export function Typewriter(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} class={props.class}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class={props.class}
|
||||||
|
data-typewriter={!animated() ? "static" : "animated"}
|
||||||
|
>
|
||||||
{resolved()}
|
{resolved()}
|
||||||
<span ref={cursorRef} class={getCursorClass()}></span>
|
<span ref={cursorRef} class={getCursorClass()}></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import DetailsContent from "@tiptap/extension-details-content";
|
|||||||
import { Node } from "@tiptap/core";
|
import { Node } from "@tiptap/core";
|
||||||
import { createLowlight, common } from "lowlight";
|
import { createLowlight, common } from "lowlight";
|
||||||
import { Mermaid } from "./extensions/Mermaid";
|
import { Mermaid } from "./extensions/Mermaid";
|
||||||
|
import { ConditionalBlock } from "./extensions/ConditionalBlock";
|
||||||
|
import { ConditionalInline } from "./extensions/ConditionalInline";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
import Superscript from "@tiptap/extension-superscript";
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
import Subscript from "@tiptap/extension-subscript";
|
import Subscript from "@tiptap/extension-subscript";
|
||||||
@@ -380,6 +382,24 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
|
|
||||||
const [showKeyboardHelp, setShowKeyboardHelp] = createSignal(false);
|
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 [isFullscreen, setIsFullscreen] = createSignal(false);
|
||||||
const [keyboardVisible, setKeyboardVisible] = createSignal(false);
|
const [keyboardVisible, setKeyboardVisible] = createSignal(false);
|
||||||
const [keyboardHeight, setKeyboardHeight] = createSignal(0);
|
const [keyboardHeight, setKeyboardHeight] = createSignal(0);
|
||||||
@@ -434,6 +454,8 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Mermaid,
|
Mermaid,
|
||||||
|
ConditionalBlock,
|
||||||
|
ConditionalInline,
|
||||||
TextAlign.configure({
|
TextAlign.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
alignments: ["left", "center", "right", "justify"],
|
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 showMermaidSelector = (e: MouseEvent) => {
|
||||||
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const buttonRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
setMermaidMenuPosition({
|
setMermaidMenuPosition({
|
||||||
@@ -1022,6 +1065,108 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
setShowMermaidTemplates(false);
|
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
|
// Toggle fullscreen mode
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
setIsFullscreen(!isFullscreen());
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -1475,6 +1814,19 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 */}
|
{/* Main Toolbar - Pinned at top in fullscreen */}
|
||||||
<div
|
<div
|
||||||
class="border-surface2 bg-base border-b"
|
class="border-surface2 bg-base border-b"
|
||||||
@@ -1782,6 +2134,19 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
>
|
>
|
||||||
📊 Diagram
|
📊 Diagram
|
||||||
</button>
|
</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>
|
<div class="border-surface2 mx-1 border-l"></div>
|
||||||
<button
|
<button
|
||||||
type="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}
|
{assets}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<div id="app">{children}</div>
|
||||||
{scripts}
|
{scripts}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const getPostByTitle = query(
|
|||||||
"use server";
|
"use server";
|
||||||
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
||||||
await import("~/server/utils");
|
await import("~/server/utils");
|
||||||
|
const { parseConditionals, getSafeEnvVariables } =
|
||||||
|
await import("~/server/conditional-parser");
|
||||||
|
const { getFeatureFlags } = await import("~/server/feature-flags");
|
||||||
const event = getRequestEvent()!;
|
const event = getRequestEvent()!;
|
||||||
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
||||||
const userID = await getUserID(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
|
// Fetch comments with sorting
|
||||||
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||||
|
|
||||||
@@ -274,10 +297,10 @@ export default function PostPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer to push content down */}
|
{/* 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 */}
|
{/* 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="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="flex justify-center italic md:justify-start md:pl-24">
|
<div class="flex justify-center italic md:justify-start md:pl-24">
|
||||||
@@ -287,7 +310,7 @@ export default function PostPage() {
|
|||||||
By Michael Freno
|
By Michael Freno
|
||||||
</div>
|
</div>
|
||||||
</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[]}>
|
<For each={postData.tags as any[]}>
|
||||||
{(tag) => (
|
{(tag) => (
|
||||||
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
|
<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">
|
<div class="relative pt-12 md:pt-24">
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
<Show when={error()}>
|
<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"}>
|
<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!
|
Passwords did not match!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={error() === "duplicate"}>
|
<Show when={error() === "duplicate"}>
|
||||||
<div class="text-lg font-semibold text-red-500">
|
<div class="text-red text-lg font-semibold">
|
||||||
Email Already Exists!
|
Email Already Exists!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -354,7 +354,7 @@ export default function LoginPage() {
|
|||||||
error() !== "duplicate"
|
error() !== "duplicate"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="text-sm text-red-500">{error()}</div>
|
<div class="text-red text-sm">{error()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -519,7 +519,7 @@ export default function LoginPage() {
|
|||||||
passwordConfRef.value.length >= 6
|
passwordConfRef.value.length >= 6
|
||||||
? ""
|
? ""
|
||||||
: "opacity-0 select-none"
|
: "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!
|
Passwords do not match!
|
||||||
</div>
|
</div>
|
||||||
@@ -535,11 +535,11 @@ export default function LoginPage() {
|
|||||||
<div
|
<div
|
||||||
class={`${
|
class={`${
|
||||||
showPasswordError()
|
showPasswordError()
|
||||||
? "text-red-500"
|
? "text-red"
|
||||||
: showPasswordSuccess()
|
: showPasswordSuccess()
|
||||||
? "text-green-500"
|
? "text-green"
|
||||||
: "opacity-0 select-none"
|
: "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()}>
|
<Show when={showPasswordError()}>
|
||||||
Credentials did not match any record
|
Credentials did not match any record
|
||||||
@@ -576,7 +576,7 @@ export default function LoginPage() {
|
|||||||
initialRemainingTime={countDown()}
|
initialRemainingTime={countDown()}
|
||||||
size={48}
|
size={48}
|
||||||
strokeWidth={6}
|
strokeWidth={6}
|
||||||
colors="#60a5fa"
|
colors="var(--color-blue)"
|
||||||
>
|
>
|
||||||
{renderTime}
|
{renderTime}
|
||||||
</CountdownCircleTimer>
|
</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