feat: Add blog post functionality with Markdown support

- Introduced BlogPostNode to render blog posts with metadata and content.
- Updated graph.json to include new blog post nodes.
- Enhanced MarkdownRenderer to support code block options for max height.
- Added CSS styles for improved code block scrolling and syntax highlighting.
- Created example blog posts: "Markdown Formatting Test", "Welcome to My Blog", and "Building Interactive Node Graphs".
- Implemented dynamic loading of blog data from blogs.json.
- Updated WorkspaceNavigator to allow smooth panning over blog nodes.
This commit is contained in:
2026-04-19 09:09:53 +10:00
parent 34c2aa4316
commit 40fd8e8e42
13 changed files with 930 additions and 42 deletions

180
build.js
View File

@@ -1,6 +1,179 @@
const fs = require('fs').promises;
const path = require('path');
/**
* Parse YAML front matter from markdown content
* @param {string} content - The markdown file content
* @returns {{metadata: Object, content: string}}
*/
function parseMarkdownWithFrontmatter(content) {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { metadata: {}, content: content };
}
const yamlContent = match[1];
const markdownContent = match[2];
// Simple YAML parser for basic key: value pairs
const metadata = {};
const lines = yamlContent.split(/\r?\n/);
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
// Remove quotes if present
metadata[key] = value.replace(/^["']|["']$/g, '');
}
}
return { metadata, content: markdownContent };
}
/**
* Process blog markdown files and generate blogs.json, also update graph.json
*/
async function processBlogPosts() {
const blogDir = path.join(__dirname, 'website', 'data', 'blog');
const outputPath = path.join(__dirname, 'website', 'data', 'blogs.json');
const graphPath = path.join(__dirname, 'website', 'data', 'graph.json');
try {
const files = await fs.readdir(blogDir);
const mdFiles = files.filter(f => f.endsWith('.md'));
const posts = [];
for (const file of mdFiles) {
const filePath = path.join(blogDir, file);
const content = await fs.readFile(filePath, 'utf-8');
const { metadata, content: markdown } = parseMarkdownWithFrontmatter(content);
const slug = path.basename(file, '.md');
// Extract and process tags separately
const tags = metadata.tags ? metadata.tags.split(',').map(t => t.trim()) : [];
// Remove processed fields from metadata
const { tags: _tags, title, date, author, ...extraMetadata } = metadata;
posts.push({
slug,
title: title || slug,
date: date || null,
author: author || null,
tags,
content: markdown,
...extraMetadata
});
}
// Sort by date descending (newest first)
posts.sort((a, b) => {
if (!a.date) return 1;
if (!b.date) return -1;
return new Date(b.date) - new Date(a.date);
});
await fs.writeFile(outputPath, JSON.stringify(posts, null, 2));
console.log(`✓ Processed ${posts.length} blog post(s)`);
// Update graph.json with blog nodes
if (posts.length > 0) {
await updateGraphWithBlogNodes(posts, graphPath);
}
} catch (err) {
if (err.code === 'ENOENT') {
console.log('⚠ No blog directory found, skipping blog processing');
} else {
throw err;
}
}
}
/**
* Update graph.json with blog post nodes
* @param {Array} posts - Array of blog post objects
* @param {string} graphPath - Path to graph.json
*/
async function updateGraphWithBlogNodes(posts, graphPath) {
const graphData = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
// Remove existing blog nodes and their connections
graphData.nodes = graphData.nodes.filter(node => node.type !== 'blog_post');
graphData.connections = graphData.connections.filter(conn => {
const fromNode = graphData.nodes.find(n => n.id === conn.from.nodeId);
const toNode = graphData.nodes.find(n => n.id === conn.to.nodeId);
return fromNode && toNode;
});
const startX = 1500;
const startY = 900;
const horizontalSpacing = 700;
const nodeWidth = 600;
const blogNodes = [];
const blogConnections = [];
posts.forEach((post, index) => {
const nodeId = `blog_${post.slug}`;
const x = startX + (index * horizontalSpacing);
const node = {
id: nodeId,
type: 'blog_post',
title: post.title,
blogSlug: post.slug,
position: {
x: x,
y: startY
},
width: nodeWidth,
inputs: index > 0 ? [{
id: 'exec_in',
name: 'Previous',
direction: 'input',
kind: 'exec'
}] : [],
outputs: index < posts.length - 1 ? [{
id: 'exec_out',
name: 'Next',
direction: 'output',
kind: 'exec'
}] : []
};
blogNodes.push(node);
// Create connection to next post
if (index < posts.length - 1) {
const nextNodeId = `blog_${posts[index + 1].slug}`;
blogConnections.push({
id: `blog_connection_${index}`,
from: {
nodeId: nodeId,
pinId: 'exec_out'
},
to: {
nodeId: nextNodeId,
pinId: 'exec_in'
},
kind: 'exec'
});
}
});
// Add blog nodes and connections to graph
graphData.nodes.push(...blogNodes);
graphData.connections.push(...blogConnections);
await fs.writeFile(graphPath, JSON.stringify(graphData, null, 2));
console.log(`✓ Added ${blogNodes.length} blog node(s) to graph`);
}
// Copy files recursively from website to build
async function copyWebsiteFiles() {
const websiteDir = path.join(__dirname, 'website');
@@ -43,4 +216,9 @@ async function copyWebsiteFiles() {
}
// Run the build
copyWebsiteFiles();
async function build() {
await processBlogPosts();
await copyWebsiteFiles();
}
build();

View File

@@ -0,0 +1,125 @@
---
title: Markdown Formatting Test
date: 2026-04-20
author: Test Author
tags: test, formatting, markdown
---
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
## Text Formatting
This is **bold text** and this is *italic text* and this is ***bold italic text***.
Here's some `inline code` in a sentence.
## Links
Here's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).
## Lists
Unordered list:
- First item
- Second item
- Third item
Ordered list:
1. First step
2. Second step
3. Third step
## Code Blocks
Standard JavaScript:
```javascript
const hello = "world";
console.log(hello);
function test() {
return true;
}
```
Python with syntax highlighting:
```python
def hello_world():
print("Hello, World!")
return True
```
Code with max height (200px):
```javascript {maxHeight: 200}
// This is a long code block that will scroll
const data = [1, 2, 3, 4, 5];
function processData(arr) {
return arr.map(x => x * 2);
}
console.log(processData(data));
// Adding more lines to demonstrate scrolling
for (let i = 0; i < 10; i++) {
console.log(`Iteration ${i}`);
}
// Even more content
const obj = {
name: "Test",
value: 42,
nested: {
deep: true
}
};
```
## Blockquotes
> This is a blockquote.
> It can span multiple lines.
> And continues here.
## Horizontal Rule
---
## Images
![Test Image](data/blog/media/test.png)
## Mixed Content
You can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.
### Nested Lists
- Top level item
- Another top level
- Nested item (if supported)
- Another nested
1. Numbered item
2. Another numbered
- Mixed with bullets
- More bullets
## Special Characters
Testing special chars: < > & " '
## Long Paragraph
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

View File

@@ -0,0 +1,52 @@
---
title: Building Interactive Node Graphs
date: 2026-04-15
author: Max Litruv Boonzaayer
tags: javascript, graph-systems, tutorial
---
# Building Interactive Node Graphs
In this post, I'll share some insights on building interactive node-based graph systems for the web.
## Why Node Graphs?
Node graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:
- **Visual Clarity**: See the flow of data and execution at a glance
- **Modularity**: Each node is a self-contained unit
- **Flexibility**: Easy to add new node types and behaviors
## The Architecture
The system is built on several key classes:
### NodeBase
The abstract base class that all nodes inherit from. It defines the blueprint interface:
```javascript
class NodeBase {
static NodeType = "";
static BlueprintPure_GetDefaultPins() { }
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }
async BlueprintCallable_Execute(ctx) { }
}
```
### Node Registry
Automatically registers node types and creates instances based on type strings.
### Execution Context
Handles the graph traversal and execution flow when nodes are triggered.
## Creating Custom Nodes
To create a new node type:
1. Extend `NodeBase`
2. Set a unique `NodeType`
3. Define pins with `BlueprintPure_GetDefaultPins()`
4. Implement rendering in `BlueprintNativeEvent_OnRender()`
5. Implement logic in `BlueprintCallable_Execute()`
That's the blueprint system in a nutshell!

View File

@@ -0,0 +1,46 @@
---
title: Welcome to My Blog
date: 2026-04-19
author: Max Litruv Boonzaayer
tags: welcome, meta, introduction
---
# Welcome to My Blog
This is my first blog post! I'm excited to share my thoughts and experiences with you.
## What This Blog Is About
This blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?
### Features
- 📝 Markdown support with YAML front matter
- 📅 Date-based organization
- 🏷️ Tag system for categorization
- 🎨 Interactive node-based visualization
- 📁 Media support in `data/blog/media/`
## Technical Details
The build system automatically:
1. Scans the `data/blog/` directory for `.md` files
2. Parses YAML front matter for metadata
3. Generates a `blogs.json` file
4. Makes posts available to the BlogPostNode
You can reference images like this:
![Example](data/blog/media/example.png)
## Code Examples
Here's some example code:
```javascript
const greeting = "Hello, World!";
console.log(greeting);
```
## Conclusion
Stay tuned for more posts! This is just the beginning of an exciting journey.

38
website/data/blogs.json Normal file
View File

@@ -0,0 +1,38 @@
[
{
"slug": "markdown-test",
"title": "Markdown Formatting Test",
"date": "2026-04-20",
"author": "Test Author",
"tags": [
"test",
"formatting",
"markdown"
],
"content": "\r\n# Heading 1\r\n\r\n## Heading 2\r\n\r\n### Heading 3\r\n\r\n#### Heading 4\r\n\r\n##### Heading 5\r\n\r\n###### Heading 6\r\n\r\n## Text Formatting\r\n\r\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\r\n\r\nHere's some `inline code` in a sentence.\r\n\r\n## Links\r\n\r\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\r\n\r\n## Lists\r\n\r\nUnordered list:\r\n- First item\r\n- Second item\r\n- Third item\r\n\r\nOrdered list:\r\n1. First step\r\n2. Second step\r\n3. Third step\r\n\r\n## Code Blocks\r\n\r\nStandard JavaScript:\r\n```javascript\r\nconst hello = \"world\";\r\nconsole.log(hello);\r\n\r\nfunction test() {\r\n return true;\r\n}\r\n```\r\n\r\nPython with syntax highlighting:\r\n```python\r\ndef hello_world():\r\n print(\"Hello, World!\")\r\n return True\r\n```\r\n\r\nCode with max height (200px):\r\n```javascript {maxHeight: 200}\r\n// This is a long code block that will scroll\r\nconst data = [1, 2, 3, 4, 5];\r\n\r\nfunction processData(arr) {\r\n return arr.map(x => x * 2);\r\n}\r\n\r\nconsole.log(processData(data));\r\n\r\n// Adding more lines to demonstrate scrolling\r\nfor (let i = 0; i < 10; i++) {\r\n console.log(`Iteration ${i}`);\r\n}\r\n\r\n// Even more content\r\nconst obj = {\r\n name: \"Test\",\r\n value: 42,\r\n nested: {\r\n deep: true\r\n }\r\n};\r\n```\r\n\r\n## Blockquotes\r\n\r\n> This is a blockquote.\r\n> It can span multiple lines.\r\n> And continues here.\r\n\r\n## Horizontal Rule\r\n\r\n---\r\n\r\n## Images\r\n\r\n![Test Image](data/blog/media/test.png)\r\n\r\n## Mixed Content\r\n\r\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.\r\n\r\n### Nested Lists\r\n\r\n- Top level item\r\n- Another top level\r\n - Nested item (if supported)\r\n - Another nested\r\n\r\n1. Numbered item\r\n2. Another numbered\r\n - Mixed with bullets\r\n - More bullets\r\n\r\n## Special Characters\r\n\r\nTesting special chars: < > & \" ' \r\n\r\n## Long Paragraph\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\r\n\r\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n"
},
{
"slug": "welcome",
"title": "Welcome to My Blog",
"date": "2026-04-19",
"author": "Max Litruv Boonzaayer",
"tags": [
"welcome",
"meta",
"introduction"
],
"content": "\r\n# Welcome to My Blog\r\n\r\nThis is my first blog post! I'm excited to share my thoughts and experiences with you.\r\n\r\n## What This Blog Is About\r\n\r\nThis blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?\r\n\r\n### Features\r\n\r\n- 📝 Markdown support with YAML front matter\r\n- 📅 Date-based organization\r\n- 🏷️ Tag system for categorization\r\n- 🎨 Interactive node-based visualization\r\n- 📁 Media support in `data/blog/media/`\r\n\r\n## Technical Details\r\n\r\nThe build system automatically:\r\n1. Scans the `data/blog/` directory for `.md` files\r\n2. Parses YAML front matter for metadata\r\n3. Generates a `blogs.json` file\r\n4. Makes posts available to the BlogPostNode\r\n\r\nYou can reference images like this:\r\n![Example](data/blog/media/example.png)\r\n\r\n## Code Examples\r\n\r\nHere's some example code:\r\n\r\n```javascript\r\nconst greeting = \"Hello, World!\";\r\nconsole.log(greeting);\r\n```\r\n\r\n## Conclusion\r\n\r\nStay tuned for more posts! This is just the beginning of an exciting journey.\r\n"
},
{
"slug": "node-graphs",
"title": "Building Interactive Node Graphs",
"date": "2026-04-15",
"author": "Max Litruv Boonzaayer",
"tags": [
"javascript",
"graph-systems",
"tutorial"
],
"content": "\r\n# Building Interactive Node Graphs\r\n\r\nIn this post, I'll share some insights on building interactive node-based graph systems for the web.\r\n\r\n## Why Node Graphs?\r\n\r\nNode graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:\r\n\r\n- **Visual Clarity**: See the flow of data and execution at a glance\r\n- **Modularity**: Each node is a self-contained unit\r\n- **Flexibility**: Easy to add new node types and behaviors\r\n\r\n## The Architecture\r\n\r\nThe system is built on several key classes:\r\n\r\n### NodeBase\r\nThe abstract base class that all nodes inherit from. It defines the blueprint interface:\r\n\r\n```javascript\r\nclass NodeBase {\r\n static NodeType = \"\";\r\n static BlueprintPure_GetDefaultPins() { }\r\n BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }\r\n async BlueprintCallable_Execute(ctx) { }\r\n}\r\n```\r\n\r\n### Node Registry\r\nAutomatically registers node types and creates instances based on type strings.\r\n\r\n### Execution Context\r\nHandles the graph traversal and execution flow when nodes are triggered.\r\n\r\n## Creating Custom Nodes\r\n\r\nTo create a new node type:\r\n\r\n1. Extend `NodeBase`\r\n2. Set a unique `NodeType`\r\n3. Define pins with `BlueprintPure_GetDefaultPins()`\r\n4. Implement rendering in `BlueprintNativeEvent_OnRender()`\r\n5. Implement logic in `BlueprintCallable_Execute()`\r\n\r\nThat's the blueprint system in a nutshell!\r\n"
}
]

View File

@@ -94,8 +94,8 @@
"type": "sequence",
"title": "Sequence",
"position": {
"x": 1180,
"y": 120
"x": 1080,
"y": 20
},
"focusOptions": {
"minWorldBox": {
@@ -137,8 +137,8 @@
"type": "info",
"title": "Who Am I",
"position": {
"x": 1780,
"y": -640
"x": 1600,
"y": -860
},
"width": 340,
"markdownSrc": "data/whoami.md",
@@ -186,8 +186,8 @@
"type": "info",
"title": "Find Me Online",
"position": {
"x": 2460,
"y": 140
"x": 2420,
"y": -280
},
"markdownSrc": "data/socials.md",
"focusOptions": {
@@ -211,8 +211,8 @@
"type": "pure",
"title": "String Node",
"position": {
"x": 2260,
"y": -280
"x": 2180,
"y": -500
},
"inputs": [],
"outputs": [
@@ -230,8 +230,8 @@
"type": "button",
"title": "Run",
"position": {
"x": 2180,
"y": -360
"x": 2100,
"y": -600
},
"inputs": [],
"outputs": [
@@ -248,8 +248,8 @@
"type": "timer",
"title": "Timer",
"position": {
"x": 2180,
"y": -460
"x": 2100,
"y": -700
},
"inputs": [
{
@@ -276,8 +276,8 @@
"type": "print",
"title": "Print String",
"position": {
"x": 2540,
"y": -460
"x": 2380,
"y": -680
},
"inputs": [
{
@@ -308,8 +308,8 @@
"type": "pure",
"title": "Homeserver",
"position": {
"x": 1520,
"y": 1020
"x": 1480,
"y": 340
},
"inputs": [],
"outputs": [
@@ -327,8 +327,8 @@
"type": "pure",
"title": "Room",
"position": {
"x": 1520,
"y": 1100
"x": 1480,
"y": 420
},
"inputs": [],
"outputs": [
@@ -346,8 +346,8 @@
"type": "chat",
"title": "Matrix Chat",
"position": {
"x": 1800,
"y": 920
"x": 1740,
"y": 220
},
"width": 400,
"focusOptions": {
@@ -420,8 +420,8 @@
"type": "random_name",
"title": "Random Name",
"position": {
"x": 1520,
"y": 1180
"x": 1480,
"y": 500
},
"inputs": [],
"outputs": [
@@ -438,8 +438,8 @@
"type": "button",
"title": "Connect Chat",
"position": {
"x": 1960,
"y": 820
"x": 1920,
"y": 120
},
"inputs": [],
"outputs": [
@@ -456,8 +456,8 @@
"type": "chat_connect",
"title": "Connect Chat",
"position": {
"x": 2220,
"y": 880
"x": 2260,
"y": 220
},
"inputs": [
{
@@ -487,8 +487,8 @@
"type": "get_matrix_chat",
"title": "Get MatrixChat",
"position": {
"x": 1520,
"y": 940
"x": 1500,
"y": 240
},
"inputs": [],
"outputs": [
@@ -506,8 +506,8 @@
"type": "bind_event",
"title": "Bind: Chat On Message",
"position": {
"x": 2460,
"y": 880
"x": 2540,
"y": 220
},
"inputs": [
{
@@ -557,8 +557,8 @@
"type": "append",
"title": "Append",
"position": {
"x": 2700,
"y": 960
"x": 2780,
"y": 280
},
"inputs": [
{
@@ -597,8 +597,8 @@
"type": "print",
"title": "Print Chat Message",
"position": {
"x": 2940,
"y": 900
"x": 3040,
"y": 220
},
"inputs": [
{
@@ -623,6 +623,73 @@
"kind": "exec"
}
]
},
{
"id": "blog_markdown-test",
"type": "blog_post",
"title": "Markdown Formatting Test",
"blogSlug": "markdown-test",
"position": {
"x": 1500,
"y": 900
},
"width": 600,
"inputs": [],
"outputs": [
{
"id": "exec_out",
"name": "Next",
"direction": "output",
"kind": "exec"
}
]
},
{
"id": "blog_welcome",
"type": "blog_post",
"title": "Welcome to My Blog",
"blogSlug": "welcome",
"position": {
"x": 2200,
"y": 900
},
"width": 600,
"inputs": [
{
"id": "exec_in",
"name": "Previous",
"direction": "input",
"kind": "exec"
}
],
"outputs": [
{
"id": "exec_out",
"name": "Next",
"direction": "output",
"kind": "exec"
}
]
},
{
"id": "blog_node-graphs",
"type": "blog_post",
"title": "Building Interactive Node Graphs",
"blogSlug": "node-graphs",
"position": {
"x": 2900,
"y": 900
},
"width": 600,
"inputs": [
{
"id": "exec_in",
"name": "Previous",
"direction": "input",
"kind": "exec"
}
],
"outputs": []
}
],
"connections": [
@@ -853,6 +920,30 @@
"pinId": "target"
},
"kind": "object"
},
{
"id": "blog_connection_0",
"from": {
"nodeId": "blog_markdown-test",
"pinId": "exec_out"
},
"to": {
"nodeId": "blog_welcome",
"pinId": "exec_in"
},
"kind": "exec"
},
{
"id": "blog_connection_1",
"from": {
"nodeId": "blog_welcome",
"pinId": "exec_out"
},
"to": {
"nodeId": "blog_node-graphs",
"pinId": "exec_in"
},
"kind": "exec"
}
]
}

View File

@@ -30,6 +30,15 @@
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
</head>
<body>

View File

@@ -14,7 +14,7 @@
*/
export class GraphNode {
/**
* @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, userSpawned?: boolean, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init
* @param {{ id: string, type: string, title: string, position: {x:number,y:number}, width?: number, inputs: PinDescriptor[], outputs: PinDescriptor[], markdownSrc?: string, imageSrc?: string, lottieSrc?: string, blogSlug?: string, userSpawned?: boolean, focusOptions?: { paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number } }} init
*/
constructor(init) {
this.id = init.id;
@@ -31,6 +31,8 @@ export class GraphNode {
this.imageSrc = init.imageSrc;
/** @type {string | undefined} */
this.lottieSrc = init.lottieSrc;
/** @type {string | undefined} */
this.blogSlug = init.blogSlug;
/** @type {boolean | undefined} */
this.userSpawned = init.userSpawned;
/** @type {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, anchorX?: number, anchorY?: number } | undefined} */

View File

@@ -37,9 +37,22 @@ export class MarkdownRenderer {
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Extract and protect code blocks first (prevents splitting on blank lines inside code)
const codeBlocks = [];
html = html.replace(/```[\s\S]*?```/g, (match) => {
const placeholder = `\n___CODEBLOCK_${codeBlocks.length}___\n`;
codeBlocks.push(match);
return placeholder;
});
// Split into blocks separated by blank lines
const blocks = html.split(/\n{2,}/);
const parts = blocks.map(block => MarkdownRenderer.#renderBlock(block.trim()));
const parts = blocks.map(block => {
block = block.trim();
// Restore code blocks
block = block.replace(/___CODEBLOCK_(\d+)___/g, (_, idx) => codeBlocks[parseInt(idx)]);
return MarkdownRenderer.#renderBlock(block);
});
return parts.filter(Boolean).join("\n");
}
@@ -53,11 +66,32 @@ export class MarkdownRenderer {
if (!block) return "";
// Fenced code block
const fenceMatch = block.match(/^```(\w*)\n([\s\S]*?)```$/);
const fenceMatch = block.match(/^```(\w*)(?:\s*\{([^}]+)\})?\s*\n([\s\S]*?)\n?```$/);
if (fenceMatch) {
const lang = MarkdownRenderer.#escape(fenceMatch[1] || "");
const code = MarkdownRenderer.#escape(fenceMatch[2]);
return `<pre class="md-pre"><code${lang ? ` class="lang-${lang}"` : ""}>${code}</code></pre>`;
const lang = fenceMatch[1] || "";
const options = fenceMatch[2] || "";
const code = fenceMatch[3];
// Parse options (e.g., {maxHeight: 300})
let maxHeight = null;
if (options) {
const maxHeightMatch = options.match(/maxHeight\s*:\s*(\d+)/);
if (maxHeightMatch) {
maxHeight = maxHeightMatch[1];
}
}
// Use Prism for syntax highlighting if available and language is specified
let highlightedCode = code;
if (lang && typeof Prism !== 'undefined' && Prism.languages[lang]) {
highlightedCode = Prism.highlight(code, Prism.languages[lang], lang);
} else {
highlightedCode = MarkdownRenderer.#escape(code);
}
const langClass = lang ? ` language-${lang}` : "";
const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : "";
return `<pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre>`;
}
// Horizontal rule
@@ -66,7 +100,7 @@ export class MarkdownRenderer {
}
// Headings
const headingMatch = block.match(/^(#{1,3})\s+(.+)$/m);
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m);
if (headingMatch && block.split("\n").length === 1) {
const level = headingMatch[1].length;
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;

View File

@@ -40,6 +40,12 @@ export class WorkspaceNavigator {
/** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
#smoothZoomFocus = null;
/** @type {number | null} */
#smoothPanRafId = null;
/** @type {{ x: number, y: number }} */
#targetPanOffset = { x: 0, y: 0 };
/** @type {{ pointerId: number, startX: number, startY: number, currentClientX: number, currentClientY: number, hasMoved: boolean, backgroundOrigin: { x: number, y: number }, lastDelta: { x: number, y: number } } | null} */
#panState = null;
@@ -436,6 +442,37 @@ export class WorkspaceNavigator {
if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return;
// Check if scrolling over a code block with max-height - let it scroll
const codeBlock = e.target.closest('.md-pre');
if (codeBlock && codeBlock.scrollHeight > codeBlock.clientHeight) {
const scrollTop = codeBlock.scrollTop;
const scrollHeight = codeBlock.scrollHeight;
const clientHeight = codeBlock.clientHeight;
const tolerance = 1; // Add tolerance for precision issues
const canScrollDown = scrollTop < (scrollHeight - clientHeight - tolerance);
const canScrollUp = scrollTop > tolerance;
// Allow scrolling if there's room to scroll in that direction
if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
return; // Don't prevent default, let the element scroll
}
// If at the edge, fall through to camera pan
}
// Check if scrolling over an info or blog_post node - pan the camera vertically
const blueprintNode = e.target.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
e.preventDefault();
const panAmount = e.deltaY * 0.5;
this.#pushSmoothPan(panAmount);
return;
}
}
// Not over a scrollable node or code block, proceed with zoom
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
@@ -608,6 +645,12 @@ export class WorkspaceNavigator {
// Do not start a pan while a node drag is actively in progress
if (this.#isDragInProgress?.()) return;
// Cancel any in-progress smooth pan
if (this.#smoothPanRafId !== null) {
cancelAnimationFrame(this.#smoothPanRafId);
this.#smoothPanRafId = null;
}
const state = {
pointerId: event.pointerId,
startX: event.clientX,
@@ -752,6 +795,54 @@ export class WorkspaceNavigator {
}
}
// ─── Smooth Pan ───────────────────────────────────────────────────────────
/**
* @param {number} deltaY
*/
#pushSmoothPan(deltaY) {
// Cancel any in-progress zoom or focus animation
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
}
if (this.#focusAnimRafId !== null) {
cancelAnimationFrame(this.#focusAnimRafId);
this.#focusAnimRafId = null;
this.#focusAnimGen++;
}
// Accumulate the pan delta
this.#targetPanOffset.y -= deltaY / this.#zoomLevel;
// Start animation loop if not already running
if (this.#smoothPanRafId === null) {
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
#tickSmoothPan() {
this.#smoothPanRafId = null;
const targetY = this.#targetPanOffset.y;
const currentY = this.#backgroundOffset.y;
const diffY = targetY - currentY;
const settled = Math.abs(diffY) < 0.01;
if (settled) {
// Snap to target and stop
this.#backgroundOffset.y = targetY;
this.#targetPanOffset.y = targetY;
this.#applyBackgroundOffset();
} else {
// Ease toward target
this.#backgroundOffset.y += diffY * 0.25;
this.#applyBackgroundOffset();
this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
}
}
// ─── Transform helpers ────────────────────────────────────────────────────
/** @param {{ x: number, y: number }} offset */
@@ -760,6 +851,8 @@ export class WorkspaceNavigator {
x: Number.isFinite(offset.x) ? offset.x : 0,
y: Number.isFinite(offset.y) ? offset.y : 0,
};
// Keep target pan offset in sync
this.#targetPanOffset = { ...this.#backgroundOffset };
this.#applyBackgroundOffset();
}

View File

@@ -0,0 +1,114 @@
/**
* @file BlogPostNode.js
* @UCLASS(BlueprintType, Blueprintable)
*/
import { NodeBase } from './NodeBase.js';
import { NodeRegistry } from './NodeRegistry.js';
/**
* @UCLASS(BlueprintType)
* Displays a blog post with metadata (title, date, author) and markdown content.
*/
export class BlogPostNode extends NodeBase {
/** @UCLASS(BlueprintType) */
static NodeType = "blog_post";
/**
* @UFUNCTION(BlueprintPure)
*/
static BlueprintPure_GetDefaultPins() {
return {
inputs: [],
outputs: []
};
}
/**
* @UFUNCTION(BlueprintNativeEvent)
* @param {HTMLElement} article
* @param {import('../GraphNode.js').GraphNode} graphNode
* @param {import('./NodeRenderContext.js').NodeRenderContext} renderCtx
*/
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) {
const body = document.createElement("div");
body.className = "node-body";
body.setAttribute("aria-live", "polite");
// Load blog data
const loadBlogData = async () => {
try {
const response = await fetch('data/blogs.json');
const blogs = await response.json();
// Find the blog post by slug
const post = blogs.find(b => b.slug === graphNode.blogSlug);
if (!post) {
body.innerHTML = `<p style="color: #ff6b6b;">Blog post "${graphNode.blogSlug}" not found.</p>`;
return;
}
// Render metadata header
const metaDiv = document.createElement("div");
metaDiv.className = "blog-meta";
metaDiv.style.cssText = "margin-bottom: 1em; padding-bottom: 0.5em; border-bottom: 1px solid rgba(255,255,255,0.1);";
if (post.title) {
const titleEl = document.createElement("h2");
titleEl.textContent = post.title;
titleEl.style.cssText = "margin: 0 0 0.5em 0; font-size: 1.5em;";
metaDiv.appendChild(titleEl);
}
const metaInfo = [];
if (post.date) {
const dateObj = new Date(post.date);
metaInfo.push(`📅 ${dateObj.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`);
}
if (post.author) {
metaInfo.push(`✍️ ${post.author}`);
}
if (metaInfo.length > 0) {
const infoEl = document.createElement("div");
infoEl.style.cssText = "color: rgba(255,255,255,0.6); font-size: 0.9em;";
infoEl.textContent = metaInfo.join(' • ');
metaDiv.appendChild(infoEl);
}
if (post.tags && post.tags.length > 0) {
const tagsDiv = document.createElement("div");
tagsDiv.style.cssText = "margin-top: 0.5em;";
post.tags.forEach(tag => {
const tagEl = document.createElement("span");
tagEl.textContent = `#${tag}`;
tagEl.style.cssText = "display: inline-block; margin-right: 0.5em; padding: 0.2em 0.5em; background: rgba(100,150,255,0.2); border-radius: 3px; font-size: 0.85em;";
tagsDiv.appendChild(tagEl);
});
metaDiv.appendChild(tagsDiv);
}
body.appendChild(metaDiv);
// Render markdown content directly
const contentDiv = document.createElement("div");
contentDiv.className = "blog-content";
body.appendChild(contentDiv);
// Import and use MarkdownRenderer directly
const { MarkdownRenderer } = await import('../MarkdownRenderer.js');
contentDiv.innerHTML = MarkdownRenderer.render(post.content);
} catch (error) {
body.innerHTML = `<p style="color: #ff6b6b;">Error loading blog post: ${error.message}</p>`;
console.error('Failed to load blog post:', error);
}
};
article.appendChild(body);
loadBlogData();
}
}
NodeRegistry.UCLASS_Register(BlogPostNode);

View File

@@ -16,6 +16,7 @@ export { CustomEventNode } from './CustomEventNode.js';
export { ButtonNode } from './ButtonNode.js';
export { TimerNode } from './TimerNode.js';
export { InfoNode } from './InfoNode.js';
export { BlogPostNode } from './BlogPostNode.js';
export { PureNode } from './PureNode.js';
export { MatrixChatNode } from './MatrixChatNode.js';
export { GetMatrixChatNode } from './GetMatrixChatNode.js';

View File

@@ -1332,6 +1332,26 @@ svg.world-image {
line-height: 1.6;
user-select: text;
cursor: text;
overflow-y: visible;
overflow-x: hidden;
}
.node-body::-webkit-scrollbar {
width: 8px;
}
.node-body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.node-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.node-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.node-body .md-h1,
@@ -1385,10 +1405,95 @@ svg.world-image {
border-radius: 5px;
padding: 0.5em 0.7em;
overflow-x: auto;
overflow-y: auto;
margin: 0.4em 0;
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace;
font-size: 0.76em;
color: #a8daff;
scroll-behavior: smooth;
}
.node-body .md-pre::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.node-body .md-pre::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
.node-body .md-pre::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.node-body .md-pre::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.node-body .md-pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
font-family: inherit;
}
/* Prism syntax highlighting overrides */
.node-body .md-pre .token.comment,
.node-body .md-pre .token.prolog,
.node-body .md-pre .token.doctype,
.node-body .md-pre .token.cdata {
color: #6a9955;
}
.node-body .md-pre .token.punctuation {
color: #d4d4d4;
}
.node-body .md-pre .token.property,
.node-body .md-pre .token.tag,
.node-body .md-pre .token.boolean,
.node-body .md-pre .token.number,
.node-body .md-pre .token.constant,
.node-body .md-pre .token.symbol,
.node-body .md-pre .token.deleted {
color: #b5cea8;
}
.node-body .md-pre .token.selector,
.node-body .md-pre .token.attr-name,
.node-body .md-pre .token.string,
.node-body .md-pre .token.char,
.node-body .md-pre .token.builtin,
.node-body .md-pre .token.inserted {
color: #ce9178;
}
.node-body .md-pre .token.operator,
.node-body .md-pre .token.entity,
.node-body .md-pre .token.url,
.node-body .md-pre .language-css .token.string,
.node-body .md-pre .style .token.string {
color: #d4d4d4;
}
.node-body .md-pre .token.atrule,
.node-body .md-pre .token.attr-value,
.node-body .md-pre .token.keyword {
color: #569cd6;
}
.node-body .md-pre .token.function,
.node-body .md-pre .token.class-name {
color: #dcdcaa;
}
.node-body .md-pre .token.regex,
.node-body .md-pre .token.important,
.node-body .md-pre .token.variable {
color: #9cdcfe;
}
.node-body .md-hr {