From 8f7b4cb6ea2bad0dab3901f6b3a02931b364354a Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 22 Dec 2025 15:10:13 -0500 Subject: [PATCH] working on making nojs workable --- src/app.css | 88 +++++ src/app.tsx | 19 +- src/components/Typewriter.tsx | 17 +- src/components/blog/TextEditor.tsx | 365 ++++++++++++++++++ .../blog/extensions/ConditionalBlock.ts | 103 +++++ .../blog/extensions/ConditionalInline.ts | 101 +++++ src/entry-server.tsx | 5 + src/routes/blog/[title]/index.tsx | 29 +- src/routes/login/index.tsx | 18 +- src/server/conditional-parser.test.ts | 288 ++++++++++++++ src/server/conditional-parser.ts | 309 +++++++++++++++ src/server/feature-flags.ts | 24 ++ 12 files changed, 1342 insertions(+), 24 deletions(-) create mode 100644 src/components/blog/extensions/ConditionalBlock.ts create mode 100644 src/components/blog/extensions/ConditionalInline.ts create mode 100644 src/server/conditional-parser.test.ts create mode 100644 src/server/conditional-parser.ts create mode 100644 src/server/feature-flags.ts diff --git a/src/app.css b/src/app.css index e6f1f35..5b39aab 100644 --- a/src/app.css +++ b/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: "🔒❌"; +} diff --git a/src/app.tsx b/src/app.tsx index 7b9ecb5..4bf476c 100644 --- a/src/app.tsx +++ b/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 }) {
- }> - }>{props.children} - + }>{props.children}
diff --git a/src/components/Typewriter.tsx b/src/components/Typewriter.tsx index 6e42da1..c5d91ce 100644 --- a/src/components/Typewriter.tsx +++ b/src/components/Typewriter.tsx @@ -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 ( -
+
{resolved()}
diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 523b3a6..b7f2e27 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -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 ( +
+

Conditional Block

+ + {/* Condition Type Selector */} + + + + {/* Dynamic Condition Value Input based on type */} + + + + + + + + + + + + + + setConditionalForm({ + ...conditionalForm(), + conditionValue: e.currentTarget.value + }) + } + /> +
+ Format: before:YYYY-MM-DD, after:YYYY-MM-DD, or + between:YYYY-MM-DD,YYYY-MM-DD +
+
+ + + + + setConditionalForm({ + ...conditionalForm(), + conditionValue: e.currentTarget.value + }) + } + /> + + + + + + setConditionalForm({ + ...conditionalForm(), + conditionValue: e.currentTarget.value + }) + } + /> + + + + + + + + + + + + +
+ Format: VAR_NAME:value or VAR_NAME:* for any truthy value +
+
+ + {/* Show When Toggle */} + + + + {/* Inline Toggle */} + + + {/* Action Buttons */} +
+ + +
+
+ ); + }; + return (
+ {/* Conditional Configurator */} + +
+ +
+
+ {/* Main Toolbar - Pinned at top in fullscreen */}
📊 Diagram +
{/* Spacer to push content down */} -
+
{/* Content that slides over the fixed image */} -
+
@@ -287,7 +310,7 @@ export default function PostPage() { By Michael Freno
-
+
{(tag) => (
diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index b0d4ffc..80af48a 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -336,14 +336,14 @@ export default function LoginPage() {
{/* Error message */} -
+
-
+
Passwords did not match!
-
+
Email Already Exists!
@@ -354,7 +354,7 @@ export default function LoginPage() { error() !== "duplicate" } > -
{error()}
+
{error()}
@@ -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!
@@ -535,11 +535,11 @@ export default function LoginPage() {
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} diff --git a/src/server/conditional-parser.test.ts b/src/server/conditional-parser.test.ts new file mode 100644 index 0000000..9f6314b --- /dev/null +++ b/src/server/conditional-parser.test.ts @@ -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 = ` +
+

Secret content

+
+ `; + 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 = ` +
+

Secret content

+
+ `; + 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 = ` +
+

Admin panel

+
+ `; + 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 = ` +
+

Available until 2026

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Available until 2026"); + }); + + it("should handle date after condition", () => { + const html = ` +
+

Available after 2024

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Available after 2024"); + }); + + it("should handle date between condition", () => { + const html = ` +
+

2025 content

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("2025 content"); + }); + + it("should handle feature flag conditions", () => { + const html = ` +
+

Beta content

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Beta content"); + }); + + it("should hide content when feature flag is false", () => { + const html = ` +
+

Disabled content

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).not.toContain("Disabled content"); + }); + + it("should handle showWhen=false (inverted logic)", () => { + const html = ` +
+

Not authenticated content

+
+ `; + 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 = ` +

Public content

+
+

Auth content

+
+

More public

+
+

Admin content

+
+ `; + 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 = "

Regular content

"; + const result = parseConditionals(html, baseContext); + expect(result).toBe(html); + }); + + it("should default to hiding unknown condition types", () => { + const html = ` +
+

Unknown type content

+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).not.toContain("Unknown type content"); + }); + + it("should handle complex nested HTML in conditional content", () => { + const html = ` +
+
+

Title

+
  • Item 1
  • Item 2
+
console.log('test');
+
+
+ `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("

Title

"); + expect(result).toContain("
  • Item 1
  • "); + expect(result).toContain("console.log('test');"); + }); + + it("should handle env condition with exact match", () => { + const html = ` +
    +

    Dev mode content

    +
    + `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Dev mode content"); + }); + + it("should hide env condition when value doesn't match", () => { + const html = ` +
    +

    Prod content

    +
    + `; + const result = parseConditionals(html, baseContext); + expect(result).not.toContain("Prod content"); + }); + + it("should handle env condition with wildcard (*) for any truthy value", () => { + const html = ` +
    +

    Any env set

    +
    + `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Any env set"); + }); + + it("should hide env condition when variable is undefined", () => { + const html = ` +
    +

    Should not show

    +
    + `; + const result = parseConditionals(html, baseContext); + expect(result).not.toContain("Should not show"); + }); + + it("should handle env condition with inverted logic", () => { + const html = ` +
    +

    Not production

    +
    + `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Not production"); + }); + + it("should handle malformed env condition format", () => { + const html = ` +
    +

    Invalid format

    +
    + `; + 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 = `

    The domain is localhost.

    `; + 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 = `

    The domain is freno.me.

    `; + const result = parseConditionals(html, baseContext); + expect(result).toBe("

    The domain is .

    "); + }); + + it("should handle inline auth conditionals", () => { + const html = `

    Welcome back!

    `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Welcome back!"); + }); + + it("should handle multiple inline conditionals in same paragraph", () => { + const html = `

    Domain: localhost, User: logged in

    `; + 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 = ` +

    Text with inline conditional.

    +
    +

    Block conditional

    +
    + `; + 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 = `

    Status: not production

    `; + const result = parseConditionals(html, baseContext); + expect(result).toContain("Status: not production"); + }); +}); diff --git a/src/server/conditional-parser.ts b/src/server/conditional-parser.ts new file mode 100644 index 0000000..110cf01 --- /dev/null +++ b/src/server/conditional-parser.ts @@ -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 { + 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; + env: Record; +} + +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:
    ...
    + const conditionalRegex = + /]*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
    + const innerContentRegex = + /([\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: ... + const inlineRegex = + /]*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; + } +} diff --git a/src/server/feature-flags.ts b/src/server/feature-flags.ts new file mode 100644 index 0000000..81941c0 --- /dev/null +++ b/src/server/feature-flags.ts @@ -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; +}