chore: 初始化 BlockFlow Workbench 仓库

建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。

补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
2026-05-29 17:23:43 +08:00
commit 589ff15213
88 changed files with 31656 additions and 0 deletions

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
* text=auto eol=lf
*.ico binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.pdf binary

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
# Frontend build output
dist/
dist-ssr/
# Tauri and Rust build output
src-tauri/target/
# Local run artifacts and smoke-test scratch data
artifacts/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment files
.env
.env.*
!.env.example
# Editor and OS files
.vscode/
.idea/
.DS_Store
Thumbs.db
# Test and coverage output
coverage/
.nyc_output/
# Package manager caches
.npm/
.pnpm-store/

2944
Design_Spec.md Normal file

File diff suppressed because it is too large Load Diff

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlockFlow Workbench</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4246
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "blockflow-workbench",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.7"
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tiptap/core": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"zustand": "^5.0.13"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

4726
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "blockflow-workbench"
version = "0.1.0"
description = "BlockFlow Workbench desktop shell"
authors = ["BlockFlow Workbench"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the BlockFlow Workbench desktop window.",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"dialog:allow-open",
"fs:default",
"fs:allow-exists",
"fs:allow-lstat",
"fs:allow-mkdir",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Permissions for the BlockFlow Workbench desktop window.","local":true,"windows":["main"],"permissions":["core:default","dialog:default","dialog:allow-open","fs:default","fs:allow-exists","fs:allow-lstat","fs:allow-mkdir","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

19
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,19 @@
use std::path::PathBuf;
use tauri_plugin_fs::FsExt;
#[tauri::command]
fn allow_project_directory(app: tauri::AppHandle, path: String) -> Result<(), String> {
app.fs_scope()
.allow_directory(PathBuf::from(path), true)
.map_err(|error| error.to_string())
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![allow_project_directory])
.run(tauri::generate_context!())
.expect("error while running BlockFlow Workbench");
}

31
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BlockFlow Workbench",
"version": "0.1.0",
"identifier": "com.blockflow.workbench",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "BlockFlow Workbench",
"width": 1280,
"height": 800,
"minWidth": 960,
"minHeight": 640
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all"
}
}

5
src/app/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { MainLayout } from "./layout/MainLayout"
export function App() {
return <MainLayout />
}

256
src/app/demo/demoProject.ts Normal file
View File

@@ -0,0 +1,256 @@
import { buildReferenceIndex, renderTemplate } from "../../core"
import type {
BlockNode,
DataSetDocument,
FragmentDocument,
InlineNode,
ProjectResourceNode,
ProjectSnapshot,
TemplateDocument
} from "../../core/types"
const mainTemplate: TemplateDocument = {
kind: "template",
id: "template_main",
name: "用户通知模板",
filePath: "D:/DemoBlockFlow/templates/main.json",
tags: ["demo", "notice"],
outputConfig: {
format: "markdown"
},
children: [
paragraph("block_title", [{ type: "text", content: "# 用户通知模板" }]),
paragraph("block_hello", [
{ type: "text", content: "你好," },
{ type: "variable", path: "user.name", required: true }
]),
{
id: "block_vip",
type: "condition",
expression: "user.isVip",
children: [
paragraph("block_vip_text", [
{ type: "text", content: "感谢你作为 VIP 用户长期支持我们。" }
])
]
},
{
id: "block_items",
type: "loop",
source: "items",
itemName: "item",
indexName: "index",
children: [
paragraph("block_item_row", [
{ type: "text", content: "- " },
{ type: "variable", path: "item.title" },
{ type: "text", content: ": " },
{ type: "variable", path: "item.price" }
])
]
},
{
id: "block_footer",
type: "fragmentRef",
fragmentId: "fragment_common_footer"
},
{
id: "block_missing",
type: "fragmentRef",
fragmentId: "fragment_signature"
}
]
}
const footerFragment: FragmentDocument = {
kind: "fragment",
id: "fragment_common_footer",
name: "通用结尾",
filePath: "D:/DemoBlockFlow/fragments/common-footer.json",
tags: ["common", "footer"],
children: [
paragraph("footer_line", [{ type: "text", content: "以上。" }])
]
}
const defaultDataset: DataSetDocument = {
kind: "dataset",
id: "dataset_default",
name: "default",
filePath: "D:/DemoBlockFlow/datasets/default.json",
tags: ["demo"],
data: {
user: {
name: "张三",
isVip: true
},
items: [
{ title: "产品 A", price: 99 },
{ title: "产品 B", price: 199 }
]
}
}
const emptyDataset: DataSetDocument = {
kind: "dataset",
id: "dataset_empty",
name: "empty",
filePath: "D:/DemoBlockFlow/datasets/empty.json",
data: {
user: {},
items: []
}
}
const resourceTreeChildren: ProjectResourceNode[] = [
{
type: "file",
name: "project.json",
path: "D:/DemoBlockFlow/project.json",
relativePath: "project.json",
resourceKind: "project"
},
{
type: "directory",
name: "templates",
path: "D:/DemoBlockFlow/templates",
relativePath: "templates",
children: [
{
type: "file",
name: "main.json",
path: "D:/DemoBlockFlow/templates/main.json",
relativePath: "templates/main.json",
resourceKind: "template",
documentId: mainTemplate.id
}
]
},
{
type: "directory",
name: "fragments",
path: "D:/DemoBlockFlow/fragments",
relativePath: "fragments",
children: [
{
type: "file",
name: "common-footer.json",
path: "D:/DemoBlockFlow/fragments/common-footer.json",
relativePath: "fragments/common-footer.json",
resourceKind: "fragment",
documentId: footerFragment.id
}
]
},
{
type: "directory",
name: "datasets",
path: "D:/DemoBlockFlow/datasets",
relativePath: "datasets",
children: [
{
type: "file",
name: "default.json",
path: "D:/DemoBlockFlow/datasets/default.json",
relativePath: "datasets/default.json",
resourceKind: "dataset",
documentId: defaultDataset.id
},
{
type: "file",
name: "empty.json",
path: "D:/DemoBlockFlow/datasets/empty.json",
relativePath: "datasets/empty.json",
resourceKind: "dataset",
documentId: emptyDataset.id
}
]
},
{
type: "directory",
name: "schemas",
path: "D:/DemoBlockFlow/schemas",
relativePath: "schemas",
children: [
{
type: "file",
name: "common-inputs.json",
path: "D:/DemoBlockFlow/schemas/common-inputs.json",
relativePath: "schemas/common-inputs.json",
resourceKind: "schema"
}
]
},
{
type: "directory",
name: "exports",
path: "D:/DemoBlockFlow/exports",
relativePath: "exports",
children: [
{
type: "file",
name: "latest-output.md",
path: "D:/DemoBlockFlow/exports/latest-output.md",
relativePath: "exports/latest-output.md",
resourceKind: "export"
}
]
}
]
export const demoSnapshot: ProjectSnapshot = {
project: {
id: "project_demo",
name: "Demo BlockFlow Project",
version: "0.1.0",
createdAt: "2026-05-24T00:00:00.000Z",
updatedAt: "2026-05-24T00:00:00.000Z",
entryTemplateId: mainTemplate.id,
paths: {
root: "D:/DemoBlockFlow",
templatesDir: "D:/DemoBlockFlow/templates",
fragmentsDir: "D:/DemoBlockFlow/fragments",
datasetsDir: "D:/DemoBlockFlow/datasets",
exportsDir: "D:/DemoBlockFlow/exports"
}
},
templates: {
[mainTemplate.id]: mainTemplate
},
fragments: {
[footerFragment.id]: footerFragment
},
datasets: {
[defaultDataset.id]: defaultDataset,
[emptyDataset.id]: emptyDataset
},
resourceTree: {
root: "D:/DemoBlockFlow",
children: resourceTreeChildren
},
referenceIndex: buildReferenceIndex([mainTemplate, footerFragment]),
diagnostics: [
{
code: "MISSING_FRAGMENT",
severity: "error",
message: "Missing fragment reference: fragment_signature",
filePath: "D:/DemoBlockFlow/templates/main.json",
documentId: mainTemplate.id,
fragmentId: "fragment_signature"
}
]
}
export const demoPreview = renderTemplate({
template: mainTemplate,
fragments: demoSnapshot.fragments,
dataset: defaultDataset
})
function paragraph(id: string, inlines: InlineNode[]): BlockNode {
return {
id,
type: "paragraph",
inlines
}
}

View File

@@ -0,0 +1,71 @@
import { invoke } from "@tauri-apps/api/core"
import { open } from "@tauri-apps/plugin-dialog"
import type { ProjectSnapshot } from "../../core/types"
import { openProject } from "../../project/openProject"
import { writeDocument } from "../../project/writeDocument"
import { tauriProjectFileSystem } from "../../project/tauriFileSystem"
export type ProjectPersistence = {
isAvailable: () => boolean
openProjectFromDisk: () => Promise<ProjectSnapshot | null>
saveProjectChanges: (snapshot: ProjectSnapshot, dirtyDocumentIds: string[]) => Promise<ProjectSnapshot>
}
export function createTauriProjectPersistence(): ProjectPersistence {
return {
isAvailable: isTauriRuntime,
async openProjectFromDisk() {
if (!isTauriRuntime()) {
return null
}
const selected = await open({
directory: true,
multiple: false,
title: "Open BlockFlow Project"
})
if (typeof selected !== "string") {
return null
}
await allowProjectDirectory(selected)
return openProject(selected, { fs: tauriProjectFileSystem })
},
async saveProjectChanges(snapshot, dirtyDocumentIds) {
const root = snapshot.project.paths.root
for (const documentId of dirtyDocumentIds) {
const document =
snapshot.templates[documentId] ??
snapshot.fragments[documentId] ??
snapshot.datasets[documentId]
if (document !== undefined) {
await writeDocument(root, document, { fs: tauriProjectFileSystem })
}
}
return openProject(root, { fs: tauriProjectFileSystem })
}
}
}
async function allowProjectDirectory(projectPath: string): Promise<void> {
await invoke("allow_project_directory", { path: projectPath })
}
function isTauriRuntime(): boolean {
if (typeof window === "undefined") {
return false
}
const runtime = window as Window & {
__TAURI__?: unknown
__TAURI_INTERNALS__?: unknown
}
return runtime.__TAURI__ !== undefined || runtime.__TAURI_INTERNALS__ !== undefined
}

View File

@@ -0,0 +1,181 @@
import { ChevronDown } from "lucide-react"
import type { BlockNode, InlineNode } from "../../core/types"
import { getCurrentBlocks, getCurrentDocument, useWorkbenchStore } from "../store/workbenchStore"
export function CenterEditorPlaceholder() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const blocks = getCurrentBlocks({ snapshot, selectedDocumentId })
return (
<section className="min-w-0 bg-workbench-bg" data-testid="editor-placeholder">
<div className="flex h-9 items-center gap-2 border-b border-workbench-line bg-workbench-panel px-3 text-[12px]">
<ChevronDown className="h-3.5 w-3.5 text-workbench-muted" aria-hidden="true" />
<span className="truncate font-medium">{document.name}</span>
<span className="text-workbench-faint">{document.filePath}</span>
</div>
<div className="thin-scrollbar h-[calc(100%-2.25rem)] overflow-auto p-4 font-mono text-[13px] leading-6">
<div className="mx-auto max-w-4xl">
{blocks.map((block) => (
<BlockRow key={block.id} block={block} depth={0} />
))}
</div>
</div>
</section>
)
}
function BlockRow({ block, depth }: { block: BlockNode; depth: number }) {
if (block.enabled === false) {
return null
}
const paddingLeft = depth * 18
if (block.type === "paragraph") {
return (
<div className="min-h-6 whitespace-pre-wrap" style={{ paddingLeft }}>
{block.inlines.map((inline, index) => (
<InlinePart inline={inline} key={`${inline.type}-${index}`} />
))}
</div>
)
}
if (block.type === "condition") {
const props: ContainerRowsProps = {
depth,
label: `if ${block.expression}`,
childrenBlocks: block.children
}
if (block.elseChildren !== undefined) {
props.elseBlocks = block.elseChildren
}
return (
<ContainerRows {...props} />
)
}
if (block.type === "loop") {
const props: ContainerRowsProps = {
depth,
label: `for ${block.itemName} in ${block.source}`,
childrenBlocks: block.children
}
if (block.indexName !== undefined) {
props.footer = `index ${block.indexName}`
}
return (
<ContainerRows {...props} />
)
}
if (block.type === "container") {
return (
<ContainerRows
depth={depth}
label={block.name}
childrenBlocks={block.children}
/>
)
}
if (block.type === "fragmentRef") {
return (
<div className="flex min-h-6 items-center gap-2 text-workbench-good" style={{ paddingLeft }}>
<span className="text-workbench-faint">use</span>
<span>{block.fragmentId}</span>
</div>
)
}
if (block.type === "comment") {
return (
<div className="min-h-6 text-workbench-faint" style={{ paddingLeft }}>
# {block.content}
</div>
)
}
if (block.type === "output") {
return (
<div className="min-h-6 text-workbench-muted" style={{ paddingLeft }}>
output {block.config.format}
</div>
)
}
return null
}
type ContainerRowsProps = {
depth: number
label: string
childrenBlocks: BlockNode[]
elseBlocks?: BlockNode[]
footer?: string
}
function ContainerRows({
depth,
label,
childrenBlocks,
elseBlocks,
footer
}: ContainerRowsProps) {
return (
<div className="my-1">
<div
className="flex min-h-6 items-center gap-1.5 border-l border-workbench-line text-workbench-accent"
style={{ paddingLeft: depth * 18 }}
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
<span>{label}</span>
{footer ? <span className="text-workbench-faint">{footer}</span> : null}
</div>
{childrenBlocks.map((child) => (
<BlockRow key={child.id} block={child} depth={depth + 1} />
))}
{elseBlocks !== undefined && elseBlocks.length > 0 ? (
<>
<div className="min-h-6 text-workbench-warn" style={{ paddingLeft: (depth + 1) * 18 }}>
else
</div>
{elseBlocks.map((child) => (
<BlockRow key={child.id} block={child} depth={depth + 1} />
))}
</>
) : null}
</div>
)
}
function InlinePart({ inline }: { inline: InlineNode }) {
if (inline.type === "text") {
return <>{inline.content}</>
}
if (inline.type === "variable") {
return (
<span className="mx-0.5 inline-flex h-5 items-center border border-workbench-line bg-workbench-panel2 px-1.5 text-[12px] text-workbench-accent">
{inline.path}
{inline.required === true ? <span className="ml-1 text-workbench-warn">!</span> : null}
</span>
)
}
if (inline.type === "expression") {
return (
<span className="mx-0.5 inline-flex h-5 items-center border border-workbench-line bg-workbench-panel2 px-1.5 text-[12px] text-workbench-good">
{inline.expression}
</span>
)
}
if (inline.type === "inlineFragment") {
return <span className="text-workbench-good">use {inline.fragmentId}</span>
}
return <span className="text-workbench-faint">{inline.name}</span>
}

View File

@@ -0,0 +1,138 @@
import {
Braces,
ChevronDown,
Database,
FileJson,
FileOutput,
FileText,
Folder,
Layers,
Plus
} from "lucide-react"
import type { ProjectResourceFile, ProjectResourceNode, ProjectSnapshot } from "../../core/types"
import { useWorkbenchStore } from "../store/workbenchStore"
export function LeftResourcePanel() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedResourcePath = useWorkbenchStore((state) => state.selectedResourcePath)
const selectResource = useWorkbenchStore((state) => state.selectResource)
const createFragment = useWorkbenchStore((state) => state.createFragment)
const referenceCounts = fragmentReferenceCounts(snapshot.referenceIndex)
return (
<aside className="min-w-0 border-r border-workbench-line bg-workbench-panel text-[12px]" data-testid="resource-panel">
<div className="flex h-9 items-center justify-between border-b border-workbench-line px-3">
<span className="font-medium text-workbench-text">Project</span>
<div className="flex min-w-0 items-center gap-1">
<span className="truncate text-workbench-faint">{snapshot.project.version}</span>
<button
type="button"
aria-label="Create fragment"
title="Create fragment"
className="grid h-6 w-6 place-items-center border border-transparent text-workbench-muted hover:border-workbench-line hover:bg-workbench-panel2 hover:text-workbench-text"
onClick={createFragment}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div className="thin-scrollbar h-[calc(100%-2.25rem)] overflow-auto p-2">
<div className="mb-2 truncate px-1 text-[11px] uppercase text-workbench-faint">
{snapshot.project.id}
</div>
<ResourceNodes
nodes={snapshot.resourceTree.children}
selectedResourcePath={selectedResourcePath}
referenceCounts={referenceCounts}
onSelect={selectResource}
/>
</div>
</aside>
)
}
function ResourceNodes({
nodes,
selectedResourcePath,
referenceCounts,
onSelect,
depth = 0
}: {
nodes: ProjectResourceNode[]
selectedResourcePath: string
referenceCounts: Record<string, number>
onSelect: (resource: ProjectResourceFile) => void
depth?: number
}) {
return (
<div className="space-y-0.5">
{nodes.map((node) =>
node.type === "directory" ? (
<div key={node.relativePath}>
<div
className="flex h-7 items-center gap-1.5 text-workbench-muted"
style={{ paddingLeft: depth * 12 }}
>
<ChevronDown className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<Folder className="h-3.5 w-3.5 shrink-0 text-workbench-faint" aria-hidden="true" />
<span className="truncate">{node.name}</span>
</div>
<ResourceNodes
nodes={node.children}
selectedResourcePath={selectedResourcePath}
referenceCounts={referenceCounts}
onSelect={onSelect}
depth={depth + 1}
/>
</div>
) : (
<button
key={node.relativePath}
type="button"
className={`flex h-7 w-full items-center gap-1.5 border-l px-1 text-left ${
selectedResourcePath === node.relativePath
? "border-workbench-accent bg-workbench-panel2 text-workbench-text"
: "border-transparent text-workbench-muted hover:bg-workbench-panel2 hover:text-workbench-text"
}`}
style={{ paddingLeft: depth * 12 + 4 }}
onClick={() => onSelect(node)}
>
<ResourceIcon node={node} />
<span className="truncate">{node.name}</span>
{node.resourceKind === "fragment" ? (
<span className="ml-auto shrink-0 text-[11px] text-workbench-faint">
{referenceCounts[node.documentId ?? ""] ?? 0} ref
</span>
) : null}
</button>
)
)}
</div>
)
}
function ResourceIcon({ node }: { node: ProjectResourceFile }) {
const className = "h-3.5 w-3.5 shrink-0"
if (node.resourceKind === "template") {
return <FileText className={`${className} text-workbench-accent`} aria-hidden="true" />
}
if (node.resourceKind === "fragment") {
return <Layers className={`${className} text-workbench-good`} aria-hidden="true" />
}
if (node.resourceKind === "dataset") {
return <Database className={`${className} text-workbench-warn`} aria-hidden="true" />
}
if (node.resourceKind === "schema") {
return <Braces className={`${className} text-workbench-muted`} aria-hidden="true" />
}
if (node.resourceKind === "export") {
return <FileOutput className={`${className} text-workbench-muted`} aria-hidden="true" />
}
return <FileJson className={`${className} text-workbench-faint`} aria-hidden="true" />
}
function fragmentReferenceCounts(referenceIndex: ProjectSnapshot["referenceIndex"]): Record<string, number> {
return Object.fromEntries(
Object.entries(referenceIndex.incoming).map(([fragmentId, references]) => [fragmentId, references.length])
)
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react"
import { BlockFlowEditor } from "../../editor/BlockFlowEditor"
import { useWorkbenchStore } from "../store/workbenchStore"
import { LeftResourcePanel } from "./LeftResourcePanel"
import { RightInspectorPanel } from "./RightInspectorPanel"
import { StatusBar } from "./StatusBar"
import { TopBar } from "./TopBar"
export function MainLayout() {
const saveProjectChanges = useWorkbenchStore((state) => state.saveProjectChanges)
useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault()
const state = useWorkbenchStore.getState()
if (
state.isProjectPersistenceAvailable &&
state.dirtyDocumentIds.length > 0 &&
state.saveStatus !== "saving"
) {
void saveProjectChanges()
}
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [saveProjectChanges])
return (
<div className="flex h-full min-h-0 flex-col bg-workbench-bg text-workbench-text">
<TopBar />
<main className="grid min-h-0 flex-1 grid-cols-[minmax(210px,260px)_minmax(420px,1fr)_minmax(260px,340px)] overflow-hidden border-y border-workbench-line max-lg:grid-cols-[220px_minmax(360px,1fr)] max-lg:[&>aside:last-child]:hidden max-sm:grid-cols-1 max-sm:[&>aside]:hidden">
<LeftResourcePanel />
<BlockFlowEditor />
<RightInspectorPanel />
</main>
<StatusBar />
</div>
)
}

View File

@@ -0,0 +1,489 @@
import { AlertTriangle, Bug, Database, Eye, FileSliders, GitBranch } from "lucide-react"
import { useState, type ReactNode } from "react"
import type { BlockNode, ProjectSnapshot, RenderLog, RenderResult } from "../../core/types"
import type { RightPanelTab } from "../store/workbenchStore"
import {
getCurrentDataset,
getCurrentDocument,
getCurrentRenderResult,
getSelectedBlock,
useWorkbenchStore
} from "../store/workbenchStore"
const tabs: Array<{ id: RightPanelTab; label: string; icon: ReactNode }> = [
{ id: "properties", label: "属性", icon: <FileSliders className="h-3.5 w-3.5" aria-hidden="true" /> },
{ id: "data", label: "数据", icon: <Database className="h-3.5 w-3.5" aria-hidden="true" /> },
{ id: "preview", label: "预览", icon: <Eye className="h-3.5 w-3.5" aria-hidden="true" /> },
{ id: "debug", label: "调试", icon: <Bug className="h-3.5 w-3.5" aria-hidden="true" /> },
{ id: "references", label: "引用", icon: <GitBranch className="h-3.5 w-3.5" aria-hidden="true" /> }
]
export function RightInspectorPanel() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const activeTab = useWorkbenchStore((state) => state.rightPanelTab)
const setRightPanelTab = useWorkbenchStore((state) => state.setRightPanelTab)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const dataset = getCurrentDataset({ snapshot, currentDatasetId })
const renderResult = getCurrentRenderResult({ snapshot, selectedDocumentId, currentDatasetId })
return (
<aside className="min-w-0 border-l border-workbench-line bg-workbench-panel text-[12px]" data-testid="inspector-panel">
<div className="flex h-9 border-b border-workbench-line">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`flex min-w-0 flex-1 items-center justify-center gap-1 border-r border-workbench-line px-1 ${
activeTab === tab.id
? "bg-workbench-panel2 text-workbench-text"
: "text-workbench-muted hover:bg-workbench-panel2 hover:text-workbench-text"
}`}
onClick={() => setRightPanelTab(tab.id)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</button>
))}
</div>
<div className="thin-scrollbar h-[calc(100%-2.25rem)] overflow-auto p-3">
{activeTab === "properties" ? <PropertiesTab /> : null}
{activeTab === "data" ? <DataTab /> : null}
{activeTab === "preview" ? <PreviewTab /> : null}
{activeTab === "debug" ? <DebugTab /> : null}
{activeTab === "references" ? <ReferencesTab /> : null}
</div>
<div className="sr-only" aria-live="polite">
{document.name} {dataset.name} {renderResult.ok ? "render ok" : "render issue"}
</div>
</aside>
)
}
function PropertiesTab() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const selectedBlockId = useWorkbenchStore((state) => state.selectedBlockId)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const selectedBlock = getSelectedBlock({ snapshot, selectedDocumentId, selectedBlockId })
return (
<div className="space-y-4">
<Section title="Document">
<KeyValue label="Kind" value={document.kind} />
<KeyValue label="ID" value={document.id} />
<KeyValue label="Name" value={document.name} />
<KeyValue label="Path" value={document.filePath ?? "memory"} />
<KeyValue label="Blocks" value={String(document.children.length)} />
<KeyValue label="Tags" value={document.tags?.join(", ") ?? "-"} />
</Section>
<Section title="Selected Block">
<KeyValue label="ID" value={selectedBlock?.id ?? "-"} />
<KeyValue label="Type" value={selectedBlock?.type ?? "-"} />
</Section>
</div>
)
}
function DataTab() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const dataEditorText = useWorkbenchStore((state) => state.dataEditorText)
const dataEditorStatus = useWorkbenchStore((state) => state.dataEditorStatus)
const dataEditorError = useWorkbenchStore((state) => state.dataEditorError)
const setCurrentDataset = useWorkbenchStore((state) => state.setCurrentDataset)
const setDataEditorText = useWorkbenchStore((state) => state.setDataEditorText)
const dataset = getCurrentDataset({ snapshot, currentDatasetId })
return (
<div className="space-y-3" data-testid="data-tab">
<Section title="Dataset">
<select
aria-label="Current dataset"
className="h-7 w-full border border-workbench-line bg-workbench-bg px-2 text-workbench-text"
value={dataset.id}
onChange={(event) => setCurrentDataset(event.target.value)}
>
{Object.values(snapshot.datasets).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</Section>
<textarea
aria-label="Dataset JSON"
className="thin-scrollbar h-72 w-full resize-none border border-workbench-line bg-workbench-bg p-2 font-mono text-[12px] leading-5 text-workbench-text outline-none focus:border-workbench-accent"
spellCheck={false}
value={dataEditorText}
onChange={(event) => setDataEditorText(event.target.value)}
/>
{dataEditorStatus === "invalid" ? (
<IssueRow tone="bad" text={dataEditorError ?? "Invalid JSON"} />
) : (
<IssueRow tone="good" text="JSON valid, preview updated" />
)}
</div>
)
}
function PreviewTab() {
const [previewMode, setPreviewMode] = useState<"full" | "block">("full")
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const selectedBlockId = useWorkbenchStore((state) => state.selectedBlockId)
const selectedBlock = getSelectedBlock({ snapshot, selectedDocumentId, selectedBlockId })
const fullRenderResult = getCurrentRenderResult({ snapshot, selectedDocumentId, currentDatasetId })
const blockRenderResult =
selectedBlockId === null
? null
: getCurrentRenderResult({ snapshot, selectedDocumentId, currentDatasetId }, { targetBlockId: selectedBlockId })
const activeResult = previewMode === "block" ? blockRenderResult : fullRenderResult
return (
<div className="space-y-3" data-testid="preview-tab">
<div className="grid grid-cols-2 border border-workbench-line">
<PreviewModeButton active={previewMode === "full"} label="完整模板" onClick={() => setPreviewMode("full")} />
<PreviewModeButton active={previewMode === "block"} label="当前块" onClick={() => setPreviewMode("block")} />
</div>
{previewMode === "block" && selectedBlock === undefined ? (
<IssueRow tone="warn" text="请先在编辑器中选择一个块。" />
) : null}
{previewMode === "block" && selectedBlock !== undefined ? (
<Section title="Current Block">
<KeyValue label="ID" value={selectedBlock.id} />
<KeyValue label="Type" value={selectedBlock.type} />
</Section>
) : null}
{activeResult === null ? null : (
<>
<RenderSummary result={activeResult} />
<MissingVariableList result={activeResult} />
<pre
className="min-h-40 overflow-auto whitespace-pre-wrap border border-workbench-line bg-workbench-bg p-2 font-mono text-[12px] leading-5 text-workbench-text"
data-testid="preview-output"
>
{activeResult.output}
</pre>
</>
)}
</div>
)
}
function PreviewModeButton({
active,
label,
onClick
}: {
active: boolean
label: string
onClick: () => void
}) {
return (
<button
type="button"
className={`h-7 px-2 text-center ${
active ? "bg-workbench-panel2 text-workbench-text" : "text-workbench-muted hover:bg-workbench-panel2"
}`}
onClick={onClick}
>
{label}
</button>
)
}
function DebugTab() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const lastSaveError = useWorkbenchStore((state) => state.lastSaveError)
const renderResult = getCurrentRenderResult({ snapshot, selectedDocumentId, currentDatasetId })
return (
<div className="space-y-4" data-testid="debug-tab">
{lastSaveError !== null ? (
<Section title="Persistence">
<IssueRow tone="bad" text={lastSaveError} />
</Section>
) : null}
<Section title={`Errors (${renderResult.errors.length})`}>
{renderResult.errors.length === 0 ? (
<IssueRow tone="good" text="No errors" />
) : (
renderResult.errors.map((error, index) => (
<IssueRow key={`error-${index}`} tone="bad" text={`${error.code}: ${error.message}`} />
))
)}
</Section>
<Section title={`Warnings (${renderResult.warnings.length})`}>
{renderResult.warnings.length === 0 ? (
<IssueRow tone="good" text="No warnings" />
) : (
renderResult.warnings.map((warning, index) => (
<IssueRow key={`warning-${index}`} tone="warn" text={`${warning.code}: ${warning.message}`} />
))
)}
</Section>
<Section title={`Logs (${renderResult.logs.length})`}>
{renderResult.logs.length === 0 ? (
<div className="text-workbench-muted">No logs</div>
) : (
renderResult.logs.map((log, index) => (
<div key={`log-${index}`} className="border-b border-workbench-line/70 pb-1 text-workbench-muted">
{formatLog(log)}
</div>
))
)}
</Section>
</div>
)
}
function ReferencesTab() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const selectedBlockId = useWorkbenchStore((state) => state.selectedBlockId)
const lastSaveError = useWorkbenchStore((state) => state.lastSaveError)
const openDocumentById = useWorkbenchStore((state) => state.openDocumentById)
const expandSelectedFragmentReference = useWorkbenchStore((state) => state.expandSelectedFragmentReference)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const selectedBlock = getSelectedBlock({ snapshot, selectedDocumentId, selectedBlockId })
const outgoing = snapshot.referenceIndex.outgoing[document.id] ?? []
const incoming = snapshot.referenceIndex.incoming[document.id] ?? []
const missing = snapshot.referenceIndex.missingFragments.filter((item) => item.fromId === document.id)
const selectedFragment =
selectedBlock?.type === "fragmentRef" ? snapshot.fragments[selectedBlock.fragmentId] : undefined
return (
<div className="space-y-4" data-testid="references-tab">
{lastSaveError !== null ? <IssueRow tone="bad" text={lastSaveError} /> : null}
<Section title="Current">
<KeyValue label="File" value={document.filePath ?? document.id} />
<KeyValue label="Block" value={selectedBlock === undefined ? "-" : blockSummary(selectedBlock)} />
</Section>
{selectedBlock?.type === "fragmentRef" ? (
<Section title="Selected Fragment Ref">
<KeyValue label="Target" value={selectedBlock.fragmentId} />
{selectedFragment !== undefined ? (
<div className="grid grid-cols-2 gap-2 pt-1">
<PanelButton label="Open target" onClick={() => openDocumentById(selectedFragment.id)} />
<PanelButton label="Expand ref" onClick={expandSelectedFragmentReference} />
</div>
) : (
<IssueRow tone="bad" text={`missing ${selectedBlock.fragmentId}`} />
)}
</Section>
) : null}
<Section title="References">
<ReferenceList
title="References to"
items={outgoing.map((item) => ({
key: `${item.fromId}-${item.blockId ?? "inline"}-${item.toFragmentId}`,
label: referenceTargetLabel(snapshot, item.toFragmentId),
meta: item.toFragmentId,
ariaLabel: `Open ${item.toFragmentId}`,
onOpen: () => openDocumentById(item.toFragmentId)
}))}
/>
<ReferenceList
title="Referenced by"
items={incoming.map((item) => ({
key: `${item.fromId}-${item.blockId ?? "inline"}-${item.toFragmentId}`,
label: referenceSourceLabel(snapshot, item.fromId),
ariaLabel: `Open ${item.fromId}`,
onOpen: () => openDocumentById(item.fromId),
...(item.blockId === undefined ? {} : { meta: item.blockId })
}))}
/>
</Section>
{missing.length > 0 ? (
<div className="space-y-2">
{missing.map((item) => (
<IssueRow key={`${item.fromId}-${item.fragmentId}`} tone="bad" text={`missing ${item.fragmentId}`} />
))}
</div>
) : (
<IssueRow tone="good" text="refs ok" />
)}
{snapshot.referenceIndex.cycles.length > 0 ? (
snapshot.referenceIndex.cycles.map((cycle) => (
<IssueRow key={cycle.chain.join("->")} tone="bad" text={cycle.chain.join(" -> ")} />
))
) : (
<IssueRow tone="good" text="no cycles" />
)}
</div>
)
}
function RenderSummary({ result }: { result: RenderResult }) {
return (
<Section title="Render">
<KeyValue label="Status" value={result.ok ? "ok" : "issues"} />
<KeyValue label="Blocks" value={String(result.stats.renderedBlockCount)} />
<KeyValue label="Missing vars" value={String(result.stats.missingVariables.length)} />
</Section>
)
}
function MissingVariableList({ result }: { result: RenderResult }) {
if (result.stats.missingVariables.length === 0) {
return <IssueRow tone="good" text="No missing variables" />
}
return (
<Section title="Missing Variables">
{result.stats.missingVariables.map((path) => (
<IssueRow key={path} tone="warn" text={path} />
))}
</Section>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-2">
<h2 className="text-[11px] font-semibold uppercase text-workbench-faint">{title}</h2>
<div className="space-y-1">{children}</div>
</section>
)
}
function KeyValue({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-2 border-b border-workbench-line/70 pb-1">
<span className="text-workbench-faint">{label}</span>
<span className="min-w-0 truncate text-workbench-text" title={value}>
{value}
</span>
</div>
)
}
type ReferenceListItem = {
key: string
label: string
meta?: string
ariaLabel?: string
onOpen?: () => void
}
function ReferenceList({ title, items }: { title: string; items: ReferenceListItem[] }) {
return (
<div className="space-y-1">
<div className="text-workbench-faint">{title}</div>
{items.length === 0 ? (
<div className="text-workbench-muted">-</div>
) : (
items.map((item) =>
item.onOpen === undefined ? (
<div key={item.key} className="truncate text-workbench-text">
{item.label}
</div>
) : (
<button
key={item.key}
type="button"
aria-label={item.ariaLabel}
className="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-2 text-left text-workbench-text hover:text-workbench-accent"
onClick={item.onOpen}
>
<span className="truncate">{item.label}</span>
{item.meta !== undefined ? (
<span className="truncate text-workbench-faint">{item.meta}</span>
) : null}
</button>
)
)
)}
</div>
)
}
function PanelButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
className="h-7 border border-workbench-line px-2 text-workbench-muted hover:bg-workbench-panel2 hover:text-workbench-text"
onClick={onClick}
>
{label}
</button>
)
}
function IssueRow({ tone, text }: { tone: "good" | "warn" | "bad"; text: string }) {
const toneClass =
tone === "good" ? "text-workbench-good" : tone === "warn" ? "text-workbench-warn" : "text-workbench-bad"
return (
<div className={`flex items-start gap-2 ${toneClass}`}>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-words">{text}</span>
</div>
)
}
function formatLog(log: RenderLog): string {
if (log.type === "variable") {
return `variable ${log.path} ${log.resolved ? "=" : "missing"} ${formatUnknown(log.value)}`
}
if (log.type === "condition") {
return `condition ${log.expression} => ${String(log.result)}`
}
if (log.type === "loop") {
return `loop ${log.source} => ${String(log.count)} items`
}
if (log.type === "fragment") {
return `fragment ${log.fragmentId} ${log.resolved ? "loaded" : "missing"}`
}
if (log.type === "slot") {
return `slot ${log.slotName} ${log.filled ? "filled" : "empty"}`
}
return `output ${log.format}`
}
function formatUnknown(value: unknown): string {
if (value === undefined) return ""
if (typeof value === "string") return `"${value}"`
return JSON.stringify(value)
}
function blockSummary(block: BlockNode): string {
return `${block.type}:${block.id}`
}
function referenceTargetLabel(snapshot: ProjectSnapshot, fragmentId: string): string {
const fragment = snapshot.fragments[fragmentId]
if (fragment === undefined) {
return fragmentId
}
return fileName(fragment.filePath) ?? fragment.name
}
function referenceSourceLabel(snapshot: ProjectSnapshot, documentId: string): string {
const document = snapshot.templates[documentId] ?? snapshot.fragments[documentId]
if (document === undefined) {
return documentId
}
return fileName(document.filePath) ?? document.name
}
function fileName(filePath: string | undefined): string | undefined {
if (filePath === undefined) {
return undefined
}
return filePath.replace(/\\/g, "/").split("/").at(-1)
}

View File

@@ -0,0 +1,63 @@
import { GitBranch, HardDrive, Save } from "lucide-react"
import type { ReactNode } from "react"
import {
getCurrentDataset,
getCurrentDocument,
getCurrentRenderResult,
useWorkbenchStore
} from "../store/workbenchStore"
export function StatusBar() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const saveStatus = useWorkbenchStore((state) => state.saveStatus)
const dirtyDocumentIds = useWorkbenchStore((state) => state.dirtyDocumentIds)
const projectOpenStatus = useWorkbenchStore((state) => state.projectOpenStatus)
const lastSaveError = useWorkbenchStore((state) => state.lastSaveError)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const dataset = getCurrentDataset({ snapshot, currentDatasetId })
const renderResult = getCurrentRenderResult({ snapshot, selectedDocumentId, currentDatasetId })
const missingFragments = snapshot.referenceIndex.missingFragments.length
return (
<footer className="flex h-7 shrink-0 items-center gap-3 overflow-hidden bg-workbench-panel px-3 text-[12px] text-workbench-muted">
<StatusItem icon={<Save className="h-3.5 w-3.5" aria-hidden="true" />} text={saveStatus} />
{dirtyDocumentIds.length > 0 ? <span>dirty docs: {dirtyDocumentIds.length}</span> : null}
<span className="text-workbench-faint">{projectOpenStatus}</span>
<StatusItem icon={<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />} text={dataset.name} />
<span className={renderResult.stats.missingVariables.length > 0 ? "text-workbench-warn" : "text-workbench-good"}>
missing vars: {renderResult.stats.missingVariables.length}
</span>
<StatusItem
icon={<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />}
text={missingFragments === 0 ? "refs: ok" : `missing refs: ${missingFragments}`}
tone={missingFragments === 0 ? "good" : "bad"}
/>
{lastSaveError !== null ? (
<span className="truncate text-workbench-bad" title={lastSaveError}>
{lastSaveError}
</span>
) : null}
<span className="ml-auto truncate text-workbench-faint">{document.filePath ?? document.id}</span>
</footer>
)
}
function StatusItem({
icon,
text,
tone
}: {
icon: ReactNode
text: string
tone?: "good" | "bad"
}) {
const toneClass = tone === "good" ? "text-workbench-good" : tone === "bad" ? "text-workbench-bad" : ""
return (
<span className={`flex min-w-0 items-center gap-1 ${toneClass}`}>
{icon}
<span className="truncate">{text}</span>
</span>
)
}

92
src/app/layout/TopBar.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { Command, Copy, Download, FolderOpen, Save, Search } from "lucide-react"
import type { ReactNode } from "react"
import { getCurrentDataset, getCurrentDocument, useWorkbenchStore } from "../store/workbenchStore"
export function TopBar() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const saveStatus = useWorkbenchStore((state) => state.saveStatus)
const dirtyDocumentIds = useWorkbenchStore((state) => state.dirtyDocumentIds)
const projectOpenStatus = useWorkbenchStore((state) => state.projectOpenStatus)
const isProjectPersistenceAvailable = useWorkbenchStore((state) => state.isProjectPersistenceAvailable)
const openProjectFromDisk = useWorkbenchStore((state) => state.openProjectFromDisk)
const saveProjectChanges = useWorkbenchStore((state) => state.saveProjectChanges)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const currentDatasetId = useWorkbenchStore((state) => state.currentDatasetId)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const dataset = getCurrentDataset({ snapshot, currentDatasetId })
const canSave = isProjectPersistenceAvailable && dirtyDocumentIds.length > 0 && saveStatus !== "saving"
return (
<header className="flex h-11 shrink-0 items-center gap-2 border-b border-workbench-line bg-workbench-panel px-3 text-[13px]">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
title={isProjectPersistenceAvailable ? "Open project" : "Open project requires the Tauri app"}
aria-label="Open project"
className="grid h-7 w-7 place-items-center border border-transparent text-workbench-accent hover:border-workbench-line hover:bg-workbench-panel2 disabled:text-workbench-faint"
disabled={!isProjectPersistenceAvailable || projectOpenStatus === "opening"}
onClick={() => void openProjectFromDisk()}
>
<FolderOpen className="h-4 w-4" aria-hidden="true" />
</button>
<div className="truncate font-medium">{snapshot.project.name}</div>
<div className="h-4 w-px bg-workbench-line" />
<div className="truncate text-workbench-muted">{document.name}</div>
<span className="rounded-sm border border-workbench-line px-1.5 py-0.5 text-[11px] uppercase text-workbench-faint">
{document.kind}
</span>
</div>
<div className="hidden items-center gap-2 text-workbench-muted md:flex">
<span>{saveStatusLabel(saveStatus)}</span>
<span className="text-workbench-faint">dataset</span>
<span className="text-workbench-text">{dataset.name}</span>
</div>
<div className="flex items-center gap-1">
<IconButton
label="Save project"
disabled={!canSave}
onClick={() => void saveProjectChanges()}
icon={<Save className="h-4 w-4" aria-hidden="true" />}
/>
<IconButton label="Copy output" icon={<Copy className="h-4 w-4" aria-hidden="true" />} />
<IconButton label="Export output" icon={<Download className="h-4 w-4" aria-hidden="true" />} />
<IconButton label="Search resources" icon={<Search className="h-4 w-4" aria-hidden="true" />} />
<IconButton label="Command palette" icon={<Command className="h-4 w-4" aria-hidden="true" />} />
</div>
</header>
)
}
function IconButton({
label,
icon,
disabled = false,
onClick
}: {
label: string
icon: ReactNode
disabled?: boolean
onClick?: () => void
}) {
return (
<button
type="button"
title={label}
aria-label={label}
className="grid h-7 w-7 place-items-center border border-transparent text-workbench-muted hover:border-workbench-line hover:bg-workbench-panel2 hover:text-workbench-text disabled:text-workbench-faint disabled:hover:border-transparent disabled:hover:bg-transparent"
disabled={disabled}
onClick={onClick}
>
{icon}
</button>
)
}
function saveStatusLabel(status: string): string {
if (status === "saved") return "saved"
if (status === "dirty") return "dirty"
if (status === "saving") return "saving"
return "save error"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import { parseExpression, parsePathExpression, displayPath } from "./parser"
import type {
EvaluationResult,
ExpressionAst,
PathExpression,
ResolvedPath,
ScopeFrame
} from "./types"
export function evaluateExpression(
expression: string,
scopes: ScopeFrame[]
): EvaluationResult {
try {
const ast = parseExpression(expression)
return evaluateAst(ast, scopes)
} catch (cause) {
return {
ok: false,
message: cause instanceof Error ? cause.message : "Invalid expression",
cause
}
}
}
export function resolvePath(path: string, scopes: ScopeFrame[]): ResolvedPath {
try {
return resolvePathAst(parsePathExpression(path), scopes)
} catch {
return {
found: false,
value: undefined,
path
}
}
}
export function resolvePathAst(path: PathExpression, scopes: ScopeFrame[]): ResolvedPath {
const pathText = displayPath(path)
const [rootSegment, ...segments] = path.segments
if (rootSegment?.type !== "property") {
return { found: false, value: undefined, path: pathText }
}
const rootName = rootSegment.name
let current: unknown
let foundRoot = false
for (let index = scopes.length - 1; index >= 0; index -= 1) {
const values = scopes[index]?.values
if (values && hasOwn(values, rootName)) {
current = values[rootName]
foundRoot = true
break
}
}
if (!foundRoot) {
return { found: false, value: undefined, path: pathText }
}
for (const segment of segments) {
const next = readSegment(current, segment)
if (!next.found) {
return { found: false, value: undefined, path: pathText }
}
current = next.value
}
return { found: true, value: current, path: pathText }
}
function evaluateAst(ast: ExpressionAst, scopes: ScopeFrame[]): EvaluationResult {
try {
const evaluated = evaluateNode(ast, scopes)
return {
ok: true,
value: evaluated.value,
missingPaths: [...new Set(evaluated.missingPaths)]
}
} catch (cause) {
return {
ok: false,
message: cause instanceof Error ? cause.message : "Expression evaluation failed",
cause
}
}
}
function evaluateNode(
ast: ExpressionAst,
scopes: ScopeFrame[]
): { value: unknown; missingPaths: string[] } {
switch (ast.type) {
case "literal":
return { value: ast.value, missingPaths: [] }
case "path": {
const resolved = resolvePathAst(ast, scopes)
return {
value: resolved.value,
missingPaths: resolved.found ? [] : [resolved.path]
}
}
case "unary": {
const argument = evaluateNode(ast.argument, scopes)
return {
value: !isTruthy(argument.value),
missingPaths: argument.missingPaths
}
}
case "binary":
return evaluateBinary(ast.operator, ast.left, ast.right, scopes)
}
}
function evaluateBinary(
operator: string,
leftAst: ExpressionAst,
rightAst: ExpressionAst,
scopes: ScopeFrame[]
): { value: unknown; missingPaths: string[] } {
if (operator === "??") {
const left = evaluateNode(leftAst, scopes)
if (left.missingPaths.length > 0 || left.value === null || left.value === undefined) {
return evaluateNode(rightAst, scopes)
}
return left
}
if (operator === "&&") {
const left = evaluateNode(leftAst, scopes)
if (!isTruthy(left.value)) {
return { value: false, missingPaths: left.missingPaths }
}
const right = evaluateNode(rightAst, scopes)
return {
value: isTruthy(right.value),
missingPaths: mergeMissing(left, right)
}
}
if (operator === "||") {
const left = evaluateNode(leftAst, scopes)
if (isTruthy(left.value)) {
return { value: true, missingPaths: left.missingPaths }
}
const right = evaluateNode(rightAst, scopes)
return {
value: isTruthy(right.value),
missingPaths: mergeMissing(left, right)
}
}
const left = evaluateNode(leftAst, scopes)
const right = evaluateNode(rightAst, scopes)
const missingPaths = mergeMissing(left, right)
switch (operator) {
case "==":
return { value: left.value === right.value, missingPaths }
case "!=":
return { value: left.value !== right.value, missingPaths }
case ">":
return { value: compare(left.value, right.value, (a, b) => a > b), missingPaths }
case ">=":
return { value: compare(left.value, right.value, (a, b) => a >= b), missingPaths }
case "<":
return { value: compare(left.value, right.value, (a, b) => a < b), missingPaths }
case "<=":
return { value: compare(left.value, right.value, (a, b) => a <= b), missingPaths }
case "contains":
return { value: contains(left.value, right.value), missingPaths }
default:
throw new Error(`Unsupported operator ${operator}`)
}
}
function readSegment(
value: unknown,
segment: PathExpression["segments"][number]
): { found: boolean; value: unknown } {
if (segment.type === "index") {
if (Array.isArray(value) || typeof value === "string") {
return {
found: segment.index >= 0 && segment.index < value.length,
value: value[segment.index]
}
}
if (isRecord(value) && hasOwn(value, String(segment.index))) {
return { found: true, value: value[String(segment.index)] }
}
return { found: false, value: undefined }
}
if ((Array.isArray(value) || typeof value === "string") && segment.name === "length") {
return { found: true, value: value.length }
}
if (isRecord(value) && hasOwn(value, segment.name)) {
return { found: true, value: value[segment.name] }
}
return { found: false, value: undefined }
}
function mergeMissing(
left: { missingPaths: string[] },
right: { missingPaths: string[] }
): string[] {
return [...new Set([...left.missingPaths, ...right.missingPaths])]
}
function isTruthy(value: unknown): boolean {
return Boolean(value)
}
function compare(
left: unknown,
right: unknown,
predicate: (left: number | string, right: number | string) => boolean
): boolean {
if (
(typeof left === "number" || typeof left === "string") &&
(typeof right === "number" || typeof right === "string")
) {
return predicate(left, right)
}
return false
}
function contains(left: unknown, right: unknown): boolean {
if (Array.isArray(left)) {
return left.includes(right)
}
if (typeof left === "string") {
return left.includes(String(right))
}
return false
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key)
}

View File

@@ -0,0 +1,4 @@
export * from "./evaluator"
export * from "./parser"
export * from "./tokenizer"
export type * from "./types"

View File

@@ -0,0 +1,179 @@
import { ExpressionSyntaxError, tokenizeExpression } from "./tokenizer"
import type {
BinaryOperator,
ExpressionAst,
PathExpression,
PathSegment,
Token
} from "./types"
const binaryPrecedence: Record<BinaryOperator, number> = {
"??": 1,
"||": 2,
"&&": 3,
"==": 4,
"!=": 4,
">": 4,
">=": 4,
"<": 4,
"<=": 4,
contains: 4
}
export function parseExpression(input: string): ExpressionAst {
const parser = new Parser(tokenizeExpression(input))
const expression = parser.parseExpression()
parser.expect("eof")
return expression
}
export function parsePathExpression(input: string): PathExpression {
const expression = parseExpression(input)
if (expression.type !== "path") {
throw new ExpressionSyntaxError("Expected path expression", 0)
}
return expression
}
export function displayPath(path: PathExpression): string {
return path.segments
.map((segment, index) => {
if (segment.type === "index") {
return `[${segment.index}]`
}
return index === 0 ? segment.name : `.${segment.name}`
})
.join("")
}
class Parser {
private offset = 0
constructor(private readonly tokens: Token[]) {}
parseExpression(minPrecedence = 0): ExpressionAst {
let left = this.parseUnary()
while (true) {
const token = this.current()
if (token.type !== "operator" || !isBinaryOperator(token.value)) {
break
}
const precedence = binaryPrecedence[token.value]
if (precedence < minPrecedence) {
break
}
this.offset += 1
const right = this.parseExpression(precedence + 1)
left = {
type: "binary",
operator: token.value,
left,
right
}
}
return left
}
expect(type: Token["type"], value?: string): Token {
const token = this.current()
const valueMatches = value === undefined || token.value === value
if (token.type !== type || !valueMatches) {
const expected = value === undefined ? type : `${type} "${value}"`
throw new ExpressionSyntaxError(`Expected ${expected}`, token.position)
}
this.offset += 1
return token
}
private parseUnary(): ExpressionAst {
const token = this.current()
if (token.type === "operator" && token.value === "!") {
this.offset += 1
return {
type: "unary",
operator: "!",
argument: this.parseUnary()
}
}
return this.parsePrimary()
}
private parsePrimary(): ExpressionAst {
const token = this.current()
if (token.type === "string") {
this.offset += 1
return { type: "literal", value: token.value }
}
if (token.type === "number") {
this.offset += 1
return { type: "literal", value: Number(token.value) }
}
if (token.type === "identifier") {
if (token.value === "true" || token.value === "false") {
this.offset += 1
return { type: "literal", value: token.value === "true" }
}
if (token.value === "null") {
this.offset += 1
return { type: "literal", value: null }
}
return this.parsePath()
}
if (token.type === "punctuation" && token.value === "(") {
this.offset += 1
const expression = this.parseExpression()
this.expect("punctuation", ")")
return expression
}
throw new ExpressionSyntaxError("Expected expression", token.position)
}
private parsePath(): PathExpression {
const first = this.expect("identifier")
const segments: PathSegment[] = [{ type: "property", name: first.value }]
while (true) {
const token = this.current()
if (token.type === "punctuation" && token.value === ".") {
this.offset += 1
const property = this.expect("identifier")
segments.push({ type: "property", name: property.value })
continue
}
if (token.type === "punctuation" && token.value === "[") {
this.offset += 1
const index = this.expect("number")
if (!Number.isInteger(Number(index.value))) {
throw new ExpressionSyntaxError("Array index must be an integer", index.position)
}
segments.push({ type: "index", index: Number(index.value) })
this.expect("punctuation", "]")
continue
}
break
}
return { type: "path", segments }
}
private current(): Token {
return this.tokens[this.offset] ?? this.tokens[this.tokens.length - 1]!
}
}
function isBinaryOperator(value: string): value is BinaryOperator {
return Object.prototype.hasOwnProperty.call(binaryPrecedence, value)
}

View File

@@ -0,0 +1,140 @@
import type { Token } from "./types"
export class ExpressionSyntaxError extends Error {
constructor(
message: string,
readonly position: number
) {
super(`${message} at ${position}`)
this.name = "ExpressionSyntaxError"
}
}
const twoCharOperators = new Set(["==", "!=", ">=", "<=", "&&", "||", "??"])
const oneCharOperators = new Set([">", "<", "!"])
const punctuation = new Set([".", "(", ")", "[", "]"])
export function tokenizeExpression(input: string): Token[] {
const tokens: Token[] = []
let position = 0
while (position < input.length) {
const char = input[position]
if (char === undefined) {
break
}
if (/\s/.test(char)) {
position += 1
continue
}
if (char === "\"") {
const token = readString(input, position)
tokens.push(token)
position = token.position + token.value.length + 2
continue
}
if (/[0-9]/.test(char)) {
const start = position
position += 1
while (position < input.length && /[0-9]/.test(input[position] ?? "")) {
position += 1
}
if (input[position] === ".") {
position += 1
while (position < input.length && /[0-9]/.test(input[position] ?? "")) {
position += 1
}
}
tokens.push({ type: "number", value: input.slice(start, position), position: start })
continue
}
if (isIdentifierStart(char)) {
const start = position
position += 1
while (position < input.length && isIdentifierPart(input[position] ?? "")) {
position += 1
}
const value = input.slice(start, position)
tokens.push({
type: value === "contains" ? "operator" : "identifier",
value,
position: start
})
continue
}
const twoChars = input.slice(position, position + 2)
if (twoCharOperators.has(twoChars)) {
tokens.push({ type: "operator", value: twoChars, position })
position += 2
continue
}
if (oneCharOperators.has(char)) {
tokens.push({ type: "operator", value: char, position })
position += 1
continue
}
if (punctuation.has(char)) {
tokens.push({ type: "punctuation", value: char, position })
position += 1
continue
}
throw new ExpressionSyntaxError(`Unexpected character "${char}"`, position)
}
tokens.push({ type: "eof", value: "", position: input.length })
return tokens
}
function readString(input: string, start: number): Token {
let value = ""
let position = start + 1
while (position < input.length) {
const char = input[position]
if (char === "\"") {
return { type: "string", value, position: start }
}
if (char === "\\") {
const escaped = input[position + 1]
if (escaped === undefined) {
throw new ExpressionSyntaxError("Unterminated escape sequence", position)
}
value += decodeEscape(escaped)
position += 2
continue
}
value += char
position += 1
}
throw new ExpressionSyntaxError("Unterminated string literal", start)
}
function decodeEscape(char: string): string {
if (char === "n") return "\n"
if (char === "r") return "\r"
if (char === "t") return "\t"
if (char === "\"") return "\""
if (char === "\\") return "\\"
return char
}
function isIdentifierStart(char: string): boolean {
return /[A-Za-z_$]/.test(char)
}
function isIdentifierPart(char: string): boolean {
return /[A-Za-z0-9_$-]/.test(char)
}

View File

@@ -0,0 +1,81 @@
export type TokenType =
| "identifier"
| "number"
| "string"
| "operator"
| "punctuation"
| "eof"
export type Token = {
type: TokenType
value: string
position: number
}
export type PathSegment =
| { type: "property"; name: string }
| { type: "index"; index: number }
export type ExpressionAst =
| LiteralExpression
| PathExpression
| UnaryExpression
| BinaryExpression
export type LiteralExpression = {
type: "literal"
value: unknown
}
export type PathExpression = {
type: "path"
segments: PathSegment[]
}
export type UnaryExpression = {
type: "unary"
operator: "!"
argument: ExpressionAst
}
export type BinaryOperator =
| "??"
| "||"
| "&&"
| "=="
| "!="
| ">"
| ">="
| "<"
| "<="
| "contains"
export type BinaryExpression = {
type: "binary"
operator: BinaryOperator
left: ExpressionAst
right: ExpressionAst
}
export type ScopeFrame = {
type: "data" | "template" | "fragment" | "loop" | "slot"
values: Record<string, unknown>
}
export type ResolvedPath = {
found: boolean
value: unknown
path: string
}
export type EvaluationResult =
| {
ok: true
value: unknown
missingPaths: string[]
}
| {
ok: false
message: string
cause?: unknown
}

4
src/core/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from "./expression"
export * from "./reference"
export * from "./renderer"
export type * from "./types"

View File

@@ -0,0 +1,123 @@
import type { FragmentDocument, TemplateDocument } from "../types/document"
import type { BlockNode, InlineNode } from "../types/nodes"
import type {
DocumentRefId,
MissingFragmentRef,
ReferenceIndex,
ReferenceItem
} from "../types/reference"
import { detectReferenceCycles } from "./detectReferenceCycles"
export type ReferenceDocument = TemplateDocument | FragmentDocument
export function buildReferenceIndex(documents: ReferenceDocument[]): ReferenceIndex {
const fragmentIds = new Set(
documents.filter((document) => document.kind === "fragment").map((document) => document.id)
)
const outgoing: Record<DocumentRefId, ReferenceItem[]> = {}
const incoming: Record<DocumentRefId, ReferenceItem[]> = {}
const missingFragments: MissingFragmentRef[] = []
for (const document of documents) {
const references = collectReferences(document)
outgoing[document.id] = references
for (const reference of references) {
if (!fragmentIds.has(reference.toFragmentId)) {
const missing: MissingFragmentRef = {
fromId: document.id,
fragmentId: reference.toFragmentId
}
if (document.filePath !== undefined) missing.fromPath = document.filePath
if (reference.blockId !== undefined) missing.blockId = reference.blockId
missingFragments.push(missing)
continue
}
incoming[reference.toFragmentId] ??= []
incoming[reference.toFragmentId]!.push(reference)
}
}
return {
outgoing,
incoming,
missingFragments,
cycles: detectReferenceCycles(outgoing)
}
}
function collectReferences(document: ReferenceDocument): ReferenceItem[] {
const references: ReferenceItem[] = []
for (const block of document.children) {
collectBlockReferences(block, document, references)
}
return references
}
function collectBlockReferences(
block: BlockNode,
document: ReferenceDocument,
references: ReferenceItem[]
): void {
if (block.type === "fragmentRef") {
const reference: ReferenceItem = {
fromId: document.id,
toFragmentId: block.fragmentId,
blockId: block.id
}
if (document.filePath !== undefined) reference.fromPath = document.filePath
references.push(reference)
}
if (block.type === "paragraph") {
for (const inline of block.inlines) {
collectInlineReferences(inline, block.id, document, references)
}
}
if ("children" in block) {
for (const child of block.children) {
collectBlockReferences(child, document, references)
}
}
if (block.type === "condition" && block.elseChildren !== undefined) {
for (const child of block.elseChildren) {
collectBlockReferences(child, document, references)
}
}
if (block.type === "loop" && block.emptyChildren !== undefined) {
for (const child of block.emptyChildren) {
collectBlockReferences(child, document, references)
}
}
if (block.type === "slot" && block.fallbackChildren !== undefined) {
for (const child of block.fallbackChildren) {
collectBlockReferences(child, document, references)
}
}
}
function collectInlineReferences(
inline: InlineNode,
blockId: string,
document: ReferenceDocument,
references: ReferenceItem[]
): void {
if (inline.type !== "inlineFragment") {
return
}
const reference: ReferenceItem = {
fromId: document.id,
toFragmentId: inline.fragmentId,
blockId
}
if (document.filePath !== undefined) reference.fromPath = document.filePath
references.push(reference)
}

View File

@@ -0,0 +1,64 @@
import type { DocumentRefId, ReferenceCycle, ReferenceItem } from "../types/reference"
export function detectReferenceCycles(
outgoing: Record<DocumentRefId, ReferenceItem[]>
): ReferenceCycle[] {
const cycles: ReferenceCycle[] = []
const seenCycles = new Set<string>()
const visiting = new Set<DocumentRefId>()
const visited = new Set<DocumentRefId>()
for (const node of Object.keys(outgoing)) {
visit(node, [], outgoing, visiting, visited, cycles, seenCycles)
}
return cycles
}
function visit(
node: DocumentRefId,
stack: DocumentRefId[],
outgoing: Record<DocumentRefId, ReferenceItem[]>,
visiting: Set<DocumentRefId>,
visited: Set<DocumentRefId>,
cycles: ReferenceCycle[],
seenCycles: Set<string>
): void {
if (visiting.has(node)) {
const start = stack.indexOf(node)
const chain = start >= 0 ? [...stack.slice(start), node] : [...stack, node]
const key = canonicalCycleKey(chain)
if (!seenCycles.has(key)) {
seenCycles.add(key)
cycles.push({ chain })
}
return
}
if (visited.has(node)) {
return
}
visiting.add(node)
const nextStack = [...stack, node]
for (const edge of outgoing[node] ?? []) {
visit(edge.toFragmentId, nextStack, outgoing, visiting, visited, cycles, seenCycles)
}
visiting.delete(node)
visited.add(node)
}
function canonicalCycleKey(chain: DocumentRefId[]): string {
const uniqueChain = chain[0] === chain[chain.length - 1] ? chain.slice(0, -1) : chain
if (uniqueChain.length === 0) {
return ""
}
const rotations = uniqueChain.map((_, index) => [
...uniqueChain.slice(index),
...uniqueChain.slice(0, index)
])
return rotations.map((rotation) => rotation.join("->")).sort()[0] ?? uniqueChain.join("->")
}

View File

@@ -0,0 +1,2 @@
export * from "./buildReferenceIndex"
export * from "./detectReferenceCycles"

View File

@@ -0,0 +1 @@
export * from "./renderTemplate"

View File

@@ -0,0 +1,794 @@
import { evaluateExpression, resolvePath } from "../expression"
import type { ScopeFrame } from "../expression"
import type { FragmentDocument } from "../types/document"
import type {
BlockNode,
FragmentRefBlock,
InlineFragmentRef,
InlineNode,
VariableInline
} from "../types/nodes"
import type { OutputConfig } from "../types/output"
import type {
RenderError,
RenderInput,
VariableRenderLog,
RenderLog,
RenderMode,
RenderResult,
RenderStats,
RenderWarning
} from "../types/render"
type RenderContext = {
mode: RenderMode
outputConfig: OutputConfig
scopes: ScopeFrame[]
fragments: Record<string, FragmentDocument>
logs: RenderLog[]
warnings: RenderWarning[]
errors: RenderError[]
stats: RenderStats
fragmentStack: string[]
maxFragmentDepth: number
}
const defaultOutputConfig: OutputConfig = {
format: "text",
missingVariableStrategy: "error",
trimFinalOutput: true
}
export function renderTemplate(input: RenderInput): RenderResult {
const data = input.data ?? input.dataset?.data ?? {}
const targetBlockId = input.options?.targetBlockId
const outputConfig = {
...defaultOutputConfig,
...input.template.outputConfig,
...input.options?.outputConfig
}
const context: RenderContext = {
mode: input.options?.mode ?? "preview",
outputConfig,
scopes: [{ type: "data", values: data }],
fragments: input.fragments,
logs: [],
warnings: [],
errors: [],
stats: {
usedVariables: [],
missingVariables: [],
usedFragments: [],
missingFragments: [],
renderedBlockCount: 0
},
fragmentStack: [],
maxFragmentDepth: input.options?.maxFragmentDepth ?? 32
}
let renderedOutput = ""
if (targetBlockId === undefined) {
renderedOutput = renderBlocks(input.template.children, context)
} else {
const targetResult = renderTargetBlocks(input.template.children, targetBlockId, context)
renderedOutput = targetResult.output
if (!targetResult.found) {
addWarning(context, "TARGET_BLOCK_NOT_FOUND", `Target block not found: ${targetBlockId}`, {
blockId: targetBlockId
})
}
}
const output = finalizeOutput(renderedOutput, outputConfig)
return {
ok: context.errors.length === 0,
output,
logs: context.logs,
warnings: context.warnings,
errors: context.errors,
stats: context.stats
}
}
export function renderBlocks(blocks: BlockNode[], context: RenderContext): string {
return blocks.map((block) => renderBlock(block, context)).join("")
}
export function renderBlock(block: BlockNode, context: RenderContext): string {
if (block.enabled === false) {
return ""
}
context.stats.renderedBlockCount += 1
switch (block.type) {
case "paragraph":
return applyRenderingOptions(renderInlines(block.inlines, context, block.id) + "\n", block)
case "container":
return applyRenderingOptions(renderBlocks(block.children, context), block)
case "condition": {
const evaluated = evaluateExpression(block.expression, context.scopes)
if (!evaluated.ok) {
addError(context, "INVALID_EXPRESSION", `Invalid condition expression: ${block.expression}`, {
blockId: block.id,
cause: evaluated.cause
})
return ""
}
const result = Boolean(evaluated.value)
context.logs.push({
type: "condition",
blockId: block.id,
expression: block.expression,
result
})
const children = result ? block.children : block.elseChildren ?? []
return applyRenderingOptions(renderBlocks(children, context), block)
}
case "loop": {
const source = resolvePath(block.source, context.scopes)
if (!source.found || !Array.isArray(source.value)) {
addError(context, "LOOP_SOURCE_NOT_ARRAY", `Loop source is not an array: ${block.source}`, {
blockId: block.id,
path: block.source
})
context.logs.push({
type: "loop",
blockId: block.id,
source: block.source,
count: 0
})
return ""
}
context.logs.push({
type: "loop",
blockId: block.id,
source: block.source,
count: source.value.length
})
if (source.value.length === 0) {
addWarning(context, "EMPTY_LOOP", `Loop source is empty: ${block.source}`, {
blockId: block.id,
path: block.source
})
return applyRenderingOptions(renderBlocks(block.emptyChildren ?? [], context), block)
}
const renderedItems = source.value.map((item, index) => {
const values: Record<string, unknown> = { [block.itemName]: item }
if (block.indexName !== undefined) {
values[block.indexName] = index
}
context.scopes.push({ type: "loop", values })
const output = renderBlocks(block.children, context)
context.scopes.pop()
return output
})
return applyRenderingOptions(renderedItems.join(block.separator ?? ""), block)
}
case "fragmentRef":
return renderFragmentRef(block, context)
case "slot":
context.logs.push({
type: "slot",
blockId: block.id,
slotName: block.slotName,
filled: false,
fallbackUsed: block.fallbackChildren !== undefined
})
if (block.fallbackChildren !== undefined) {
return renderBlocks(block.fallbackChildren, context)
}
addWarning(context, "UNFILLED_SLOT", `Slot has no fill: ${block.slotName}`, {
blockId: block.id
})
return ""
case "comment":
return ""
case "output":
context.logs.push({
type: "output",
format: block.config.format
})
return ""
default:
{
const unknownBlock = block as unknown as { type: string; id?: string }
addWarning(
context,
"UNKNOWN_TYPE",
`Unknown block type: ${unknownBlock.type}`,
withBlockId(unknownBlock.id)
)
}
return ""
}
}
type TargetRenderResult = {
found: boolean
output: string
}
function renderTargetBlocks(
blocks: BlockNode[] | undefined,
targetBlockId: string,
context: RenderContext
): TargetRenderResult {
for (const block of blocks ?? []) {
const result = renderTargetBlock(block, targetBlockId, context)
if (result.found) {
return result
}
}
return { found: false, output: "" }
}
function renderTargetBlock(
block: BlockNode,
targetBlockId: string,
context: RenderContext
): TargetRenderResult {
if (block.id === targetBlockId) {
return {
found: true,
output: renderBlock(block, context)
}
}
if (block.enabled === false) {
return {
found: blockContainsTarget(block, targetBlockId),
output: ""
}
}
switch (block.type) {
case "container":
return renderTargetBlocks(block.children, targetBlockId, context)
case "condition":
return renderTargetCondition(block, targetBlockId, context)
case "loop":
return renderTargetLoop(block, targetBlockId, context)
case "slot": {
const fallbackResult = renderTargetBlocks(block.fallbackChildren, targetBlockId, context)
if (fallbackResult.found) {
context.logs.push({
type: "slot",
blockId: block.id,
slotName: block.slotName,
filled: false,
fallbackUsed: true
})
}
return fallbackResult
}
default:
return { found: false, output: "" }
}
}
function renderTargetCondition(
block: Extract<BlockNode, { type: "condition" }>,
targetBlockId: string,
context: RenderContext
): TargetRenderResult {
const evaluated = evaluateExpression(block.expression, context.scopes)
if (!evaluated.ok) {
if (blockContainsTarget(block, targetBlockId)) {
addError(context, "INVALID_EXPRESSION", `Invalid condition expression: ${block.expression}`, {
blockId: block.id,
cause: evaluated.cause
})
return { found: true, output: "" }
}
return { found: false, output: "" }
}
const result = Boolean(evaluated.value)
context.logs.push({
type: "condition",
blockId: block.id,
expression: block.expression,
result
})
const activeChildren = result ? block.children : block.elseChildren
const inactiveChildren = result ? block.elseChildren : block.children
const activeResult = renderTargetBlocks(activeChildren, targetBlockId, context)
if (activeResult.found) {
return activeResult
}
return {
found: blockListContainsTarget(inactiveChildren, targetBlockId),
output: ""
}
}
function renderTargetLoop(
block: Extract<BlockNode, { type: "loop" }>,
targetBlockId: string,
context: RenderContext
): TargetRenderResult {
const targetInChildren = blockListContainsTarget(block.children, targetBlockId)
const targetInEmptyChildren = blockListContainsTarget(block.emptyChildren, targetBlockId)
if (!targetInChildren && !targetInEmptyChildren) {
return { found: false, output: "" }
}
const source = resolvePath(block.source, context.scopes)
if (!source.found || !Array.isArray(source.value)) {
addError(context, "LOOP_SOURCE_NOT_ARRAY", `Loop source is not an array: ${block.source}`, {
blockId: block.id,
path: block.source
})
context.logs.push({
type: "loop",
blockId: block.id,
source: block.source,
count: 0
})
return { found: true, output: "" }
}
context.logs.push({
type: "loop",
blockId: block.id,
source: block.source,
count: source.value.length
})
if (source.value.length === 0) {
addWarning(context, "EMPTY_LOOP", `Loop source is empty: ${block.source}`, {
blockId: block.id,
path: block.source
})
if (targetInEmptyChildren) {
return renderTargetBlocks(block.emptyChildren, targetBlockId, context)
}
return { found: true, output: "" }
}
if (!targetInChildren) {
return { found: true, output: "" }
}
const renderedItems = source.value.map((item, index) => {
const values: Record<string, unknown> = { [block.itemName]: item }
if (block.indexName !== undefined) {
values[block.indexName] = index
}
context.scopes.push({ type: "loop", values })
const output = renderTargetBlocks(block.children, targetBlockId, context).output
context.scopes.pop()
return output
})
return {
found: true,
output: renderedItems.join(block.separator ?? "")
}
}
function blockListContainsTarget(blocks: BlockNode[] | undefined, targetBlockId: string): boolean {
return (blocks ?? []).some((block) => blockContainsTarget(block, targetBlockId))
}
function blockContainsTarget(block: BlockNode, targetBlockId: string): boolean {
if (block.id === targetBlockId) {
return true
}
switch (block.type) {
case "container":
return blockListContainsTarget(block.children, targetBlockId)
case "condition":
return (
blockListContainsTarget(block.children, targetBlockId) ||
blockListContainsTarget(block.elseChildren, targetBlockId)
)
case "loop":
return (
blockListContainsTarget(block.children, targetBlockId) ||
blockListContainsTarget(block.emptyChildren, targetBlockId)
)
case "slot":
return blockListContainsTarget(block.fallbackChildren, targetBlockId)
default:
return false
}
}
export function renderInlines(
inlines: InlineNode[],
context: RenderContext,
blockId?: string
): string {
return inlines.map((inline) => renderInline(inline, context, blockId)).join("")
}
export function renderInline(
inline: InlineNode,
context: RenderContext,
blockId?: string
): string {
switch (inline.type) {
case "text":
return inline.content
case "variable":
return renderVariable(inline, context, blockId)
case "expression":
return renderExpressionInline(inline.expression, inline.fallback, context, blockId)
case "inlineFragment":
return renderInlineFragmentRef(inline, context, blockId)
case "placeholder":
return `{${inline.name}}`
}
}
function renderVariable(
inline: VariableInline,
context: RenderContext,
blockId?: string
): string {
const resolved = resolvePath(inline.path, context.scopes)
if (resolved.found && resolved.value !== undefined) {
addUnique(context.stats.usedVariables, inline.path)
addVariableLog(context, blockId, {
path: inline.path,
resolved: true,
value: resolved.value
})
return formatValue(resolved.value)
}
addUnique(context.stats.missingVariables, inline.path)
if (inline.fallback !== undefined) {
addWarning(
context,
"FALLBACK_USED",
`Fallback used for missing variable: ${inline.path}`,
withBlockId(blockId, { path: inline.path })
)
addVariableLog(context, blockId, {
path: inline.path,
resolved: false,
value: inline.fallback,
fallbackUsed: true
})
return formatValue(inline.fallback)
}
return handleMissingInlineValue(
inline.path,
`{${inline.path}}`,
context,
withMissingOptions(blockId, inline.missingStrategy)
)
}
function renderExpressionInline(
expression: string,
fallback: unknown,
context: RenderContext,
blockId?: string
): string {
const evaluated = evaluateExpression(expression, context.scopes)
if (!evaluated.ok) {
if (fallback !== undefined) {
addWarning(
context,
"FALLBACK_USED",
`Fallback used for invalid expression: ${expression}`,
withBlockId(blockId, { path: expression })
)
return formatValue(fallback)
}
addError(
context,
"INVALID_EXPRESSION",
`Invalid inline expression: ${expression}`,
withBlockId(blockId, { path: expression, cause: evaluated.cause })
)
return ""
}
if (evaluated.missingPaths.length > 0 && evaluated.value === undefined) {
const missingPath = evaluated.missingPaths[0] ?? expression
addUnique(context.stats.missingVariables, missingPath)
if (fallback !== undefined) {
addWarning(
context,
"FALLBACK_USED",
`Fallback used for missing expression: ${expression}`,
withBlockId(blockId, { path: missingPath })
)
return formatValue(fallback)
}
return handleMissingInlineValue(missingPath, `{${expression}}`, context, withMissingOptions(blockId))
}
return formatValue(evaluated.value)
}
function renderFragmentRef(block: FragmentRefBlock, context: RenderContext): string {
return renderFragment(block.fragmentId, block.props, context, block.id)
}
function renderInlineFragmentRef(
inline: InlineFragmentRef,
context: RenderContext,
blockId?: string
): string {
return renderFragment(inline.fragmentId, inline.props, context, blockId)
}
function renderFragment(
fragmentId: string,
props: Record<string, unknown> | undefined,
context: RenderContext,
blockId?: string
): string {
if (context.fragmentStack.includes(fragmentId)) {
addError(
context,
"FRAGMENT_CYCLE",
`Fragment cycle detected: ${formatCycle(context.fragmentStack, fragmentId)}`,
withBlockId(blockId, { fragmentId })
)
return ""
}
if (context.fragmentStack.length >= context.maxFragmentDepth) {
addError(
context,
"RENDER_DEPTH_EXCEEDED",
`Fragment render depth exceeded at ${fragmentId}`,
withBlockId(blockId, { fragmentId })
)
return ""
}
const fragment = context.fragments[fragmentId]
if (fragment === undefined) {
addUnique(context.stats.missingFragments, fragmentId)
context.logs.push({
type: "fragment",
blockId: blockId ?? "",
fragmentId,
resolved: false
})
addError(
context,
"MISSING_FRAGMENT",
`Missing fragment: ${fragmentId}`,
withBlockId(blockId, { fragmentId })
)
return ""
}
addUnique(context.stats.usedFragments, fragmentId)
context.logs.push({
type: "fragment",
blockId: blockId ?? "",
fragmentId,
resolved: true
})
context.fragmentStack.push(fragmentId)
if (props !== undefined && Object.keys(props).length > 0) {
context.scopes.push({ type: "fragment", values: props })
}
const output = renderBlocks(fragment.children, context)
if (props !== undefined && Object.keys(props).length > 0) {
context.scopes.pop()
}
context.fragmentStack.pop()
return output
}
function handleMissingInlineValue(
path: string,
placeholder: string,
context: RenderContext,
options: { blockId?: string; strategy?: VariableInline["missingStrategy"] }
): string {
addUnique(context.stats.missingVariables, path)
addVariableLog(context, options.blockId, {
path,
resolved: false
})
const strategy = options.strategy === undefined || options.strategy === "global"
? context.mode === "export"
? context.outputConfig.missingVariableStrategy ?? "error"
: "keep-placeholder"
: options.strategy
if (strategy === "empty-string") {
addWarning(context, "MISSING_VARIABLE", `Missing variable: ${path}`, withBlockId(options.blockId, { path }))
return ""
}
if (strategy === "keep-placeholder") {
addWarning(context, "MISSING_VARIABLE", `Missing variable: ${path}`, withBlockId(options.blockId, { path }))
return placeholder
}
if (strategy === "fallback") {
addWarning(
context,
"FALLBACK_USED",
`Empty fallback used for missing variable: ${path}`,
withBlockId(options.blockId, { path })
)
return ""
}
addError(
context,
"MISSING_VARIABLE_EXPORT",
`Missing variable during export: ${path}`,
withBlockId(options.blockId, { path })
)
return placeholder
}
function applyRenderingOptions(text: string, block: BlockNode): string {
const rendering = block.rendering
if (rendering === undefined) {
return text
}
let output = text
if (rendering.trimStart === true) {
output = output.trimStart()
}
if (rendering.trimEnd === true) {
output = output.trimEnd()
}
return `${rendering.prefix ?? ""}${output}${rendering.suffix ?? ""}`
}
function finalizeOutput(output: string, outputConfig: OutputConfig): string {
let finalOutput = output
if (outputConfig.trimFinalOutput !== false) {
finalOutput = finalOutput.trimEnd()
}
if (outputConfig.ensureTrailingNewline === true && !finalOutput.endsWith("\n")) {
finalOutput += "\n"
}
return finalOutput
}
function formatValue(value: unknown): string {
if (value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value)
}
if (value === null) return "null"
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function addVariableLog(
context: RenderContext,
blockId: string | undefined,
log: Omit<VariableRenderLog, "type" | "blockId">
): void {
const entry: VariableRenderLog = {
type: "variable",
...log
}
if (blockId !== undefined) {
entry.blockId = blockId
}
context.logs.push(entry)
}
function withBlockId(
blockId: string | undefined,
details: { path?: string; fragmentId?: string; cause?: unknown } = {}
): { blockId?: string; path?: string; fragmentId?: string; cause?: unknown } {
const result: { blockId?: string; path?: string; fragmentId?: string; cause?: unknown } = {}
if (blockId !== undefined) result.blockId = blockId
if (details.path !== undefined) result.path = details.path
if (details.fragmentId !== undefined) result.fragmentId = details.fragmentId
if (details.cause !== undefined) result.cause = details.cause
return result
}
function withMissingOptions(
blockId: string | undefined,
strategy?: VariableInline["missingStrategy"]
): { blockId?: string; strategy?: VariableInline["missingStrategy"] } {
const result: { blockId?: string; strategy?: VariableInline["missingStrategy"] } = {}
if (blockId !== undefined) result.blockId = blockId
if (strategy !== undefined) result.strategy = strategy
return result
}
function addWarning(
context: RenderContext,
code: RenderWarning["code"],
message: string,
details: { blockId?: string; path?: string; fragmentId?: string } = {}
): void {
const warning: RenderWarning = { code, message }
if (details.blockId !== undefined) warning.blockId = details.blockId
if (details.path !== undefined) warning.path = details.path
if (details.fragmentId !== undefined) warning.fragmentId = details.fragmentId
context.warnings.push(warning)
}
function addError(
context: RenderContext,
code: RenderError["code"],
message: string,
details: { blockId?: string; path?: string; fragmentId?: string; cause?: unknown } = {}
): void {
const error: RenderError = { code, message }
if (details.blockId !== undefined) error.blockId = details.blockId
if (details.path !== undefined) error.path = details.path
if (details.fragmentId !== undefined) error.fragmentId = details.fragmentId
if (details.cause !== undefined) error.cause = details.cause
context.errors.push(error)
}
function addUnique(values: string[], value: string): void {
if (!values.includes(value)) {
values.push(value)
}
}
function formatCycle(stack: string[], next: string): string {
const start = stack.indexOf(next)
const cycle = start >= 0 ? stack.slice(start) : stack
return [...cycle, next].join(" -> ")
}

8
src/core/types/common.ts Normal file
View File

@@ -0,0 +1,8 @@
export type ID = string
export type FilePath = string
export type ISODateString = string
export type BlockId = ID
export type TemplateId = ID
export type FragmentId = ID
export type DataSetId = ID

13
src/core/types/dataset.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { DataSetId, FilePath, ISODateString } from "./common"
export type DataSetDocument = {
kind: "dataset"
id: DataSetId
name: string
description?: string
filePath?: FilePath
tags?: string[]
createdAt?: ISODateString
updatedAt?: ISODateString
data: Record<string, unknown>
}

View File

@@ -0,0 +1,31 @@
import type { FilePath, FragmentId, ISODateString, TemplateId } from "./common"
import type { BlockNode } from "./nodes"
import type { OutputConfig } from "./output"
import type { InputSchema } from "./schema"
export type TemplateDocument = {
kind: "template"
id: TemplateId
name: string
description?: string
filePath?: FilePath
tags?: string[]
createdAt?: ISODateString
updatedAt?: ISODateString
inputSchema?: InputSchema
outputConfig?: OutputConfig
children: BlockNode[]
}
export type FragmentDocument = {
kind: "fragment"
id: FragmentId
name: string
description?: string
filePath?: FilePath
tags?: string[]
createdAt?: ISODateString
updatedAt?: ISODateString
inputSchema?: InputSchema
children: BlockNode[]
}

9
src/core/types/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export type * from "./common"
export type * from "./dataset"
export type * from "./document"
export type * from "./nodes"
export type * from "./output"
export type * from "./project"
export type * from "./reference"
export type * from "./render"
export type * from "./schema"

137
src/core/types/nodes.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { BlockId, FragmentId } from "./common"
import type { OutputConfig } from "./output"
export type BlockNode =
| ParagraphBlock
| ContainerBlock
| ConditionBlock
| LoopBlock
| FragmentRefBlock
| SlotBlock
| CommentBlock
| OutputBlock
export type InlineNode =
| TextInline
| VariableInline
| ExpressionInline
| InlineFragmentRef
| PlaceholderInline
export type BaseBlock = {
id: BlockId
type: string
name?: string
enabled?: boolean
ui?: BlockUiState
rendering?: RenderingOptions
}
export type BlockUiState = {
collapsed?: boolean
selected?: boolean
color?: string
note?: string
}
export type RenderingOptions = {
prefix?: string
suffix?: string
separator?: string
trimStart?: boolean
trimEnd?: boolean
preserveEmptyLine?: boolean
}
export type ParagraphBlock = BaseBlock & {
type: "paragraph"
inlines: InlineNode[]
}
export type TextInline = {
type: "text"
content: string
}
export type VariableInline = {
type: "variable"
path: string
displayName?: string
fallback?: unknown
format?: string
required?: boolean
missingStrategy?: MissingVariableStrategy
}
export type ExpressionInline = {
type: "expression"
expression: string
fallback?: unknown
format?: string
}
export type InlineFragmentRef = {
type: "inlineFragment"
fragmentId: FragmentId
props?: Record<string, unknown>
}
export type PlaceholderInline = {
type: "placeholder"
name: string
description?: string
}
export type MissingVariableStrategy =
| "global"
| "error"
| "keep-placeholder"
| "empty-string"
| "fallback"
export type ContainerBlock = BaseBlock & {
type: "container"
name: string
children: BlockNode[]
}
export type ConditionBlock = BaseBlock & {
type: "condition"
expression: string
children: BlockNode[]
elseChildren?: BlockNode[]
}
export type LoopBlock = BaseBlock & {
type: "loop"
source: string
itemName: string
indexName?: string
children: BlockNode[]
emptyChildren?: BlockNode[]
separator?: string
}
export type FragmentRefBlock = BaseBlock & {
type: "fragmentRef"
fragmentId: FragmentId
props?: Record<string, unknown>
fills?: Record<string, BlockNode[]>
}
export type SlotBlock = BaseBlock & {
type: "slot"
slotName: string
fallbackChildren?: BlockNode[]
}
export type CommentBlock = BaseBlock & {
type: "comment"
content: string
multiline?: boolean
}
export type OutputBlock = BaseBlock & {
type: "output"
config: OutputConfig
}

21
src/core/types/output.ts Normal file
View File

@@ -0,0 +1,21 @@
export type OutputFormat =
| "text"
| "markdown"
| "json"
| "yaml"
| "html"
| "custom"
export type ExportMissingVariableStrategy =
| "error"
| "keep-placeholder"
| "empty-string"
export type OutputConfig = {
format: OutputFormat
customExtension?: string
fileNameTemplate?: string
missingVariableStrategy?: ExportMissingVariableStrategy
trimFinalOutput?: boolean
ensureTrailingNewline?: boolean
}

93
src/core/types/project.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { FilePath, ISODateString, TemplateId } from "./common"
import type { DataSetDocument } from "./dataset"
import type { FragmentDocument, TemplateDocument } from "./document"
import type { ReferenceIndex } from "./reference"
export type BlockFlowProject = {
id: string
name: string
version: string
createdAt: ISODateString
updatedAt: ISODateString
entryTemplateId?: TemplateId
paths: {
root: FilePath
templatesDir: FilePath
fragmentsDir: FilePath
datasetsDir: FilePath
exportsDir?: FilePath
}
}
export type ProjectSnapshot = {
project: BlockFlowProject
templates: Record<string, TemplateDocument>
fragments: Record<string, FragmentDocument>
datasets: Record<string, DataSetDocument>
resourceTree: ProjectResourceTree
referenceIndex: ReferenceIndex
diagnostics: ProjectDiagnostic[]
}
export type ProjectResourceTree = {
root: FilePath
children: ProjectResourceNode[]
}
export type ProjectResourceNode =
| ProjectResourceDirectory
| ProjectResourceFile
export type ProjectResourceDirectory = {
type: "directory"
name: string
path: FilePath
relativePath: FilePath
children: ProjectResourceNode[]
}
export type ProjectResourceFile = {
type: "file"
name: string
path: FilePath
relativePath: FilePath
resourceKind?: ProjectResourceKind
documentId?: string
}
export type ProjectResourceKind =
| "project"
| "template"
| "fragment"
| "dataset"
| "schema"
| "export"
| "unknown"
export type ProjectDiagnostic = {
code: ProjectDiagnosticCode
message: string
severity: "warning" | "error"
filePath?: FilePath
documentId?: string
fragmentId?: string
cause?: unknown
}
export type ProjectDiagnosticCode =
| "PROJECT_JSON_MISSING"
| "PROJECT_JSON_INVALID"
| "INVALID_JSON"
| "INVALID_DOCUMENT_SHAPE"
| "DUPLICATE_DOCUMENT_ID"
| "MISSING_ENTRY_TEMPLATE"
| "MISSING_FRAGMENT"
export type CreateProjectOptions = {
id?: string
name?: string
version?: string
entryTemplateId?: TemplateId
includeExportsDir?: boolean
now?: () => Date
}

View File

@@ -0,0 +1,28 @@
import type { FilePath, FragmentId, TemplateId } from "./common"
export type DocumentRefId = TemplateId | FragmentId
export type ReferenceIndex = {
outgoing: Record<DocumentRefId, ReferenceItem[]>
incoming: Record<DocumentRefId, ReferenceItem[]>
missingFragments: MissingFragmentRef[]
cycles: ReferenceCycle[]
}
export type ReferenceItem = {
fromId: DocumentRefId
fromPath?: FilePath
toFragmentId: FragmentId
blockId?: string
}
export type MissingFragmentRef = {
fromId: DocumentRefId
fromPath?: FilePath
fragmentId: FragmentId
blockId?: string
}
export type ReferenceCycle = {
chain: DocumentRefId[]
}

125
src/core/types/render.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { BlockId, DataSetId, FragmentId } from "./common"
import type { DataSetDocument } from "./dataset"
import type { FragmentDocument, TemplateDocument } from "./document"
import type { OutputConfig } from "./output"
export type RenderInput = {
template: TemplateDocument
fragments: Record<FragmentId, FragmentDocument>
dataset?: DataSetDocument
data?: Record<string, unknown>
options?: RenderOptions
}
export type RenderOptions = {
outputConfig?: OutputConfig
mode?: RenderMode
targetBlockId?: BlockId
maxFragmentDepth?: number
}
export type RenderMode = "preview" | "export"
export type RenderResult = {
ok: boolean
output: string
logs: RenderLog[]
warnings: RenderWarning[]
errors: RenderError[]
stats: RenderStats
}
export type RenderStats = {
usedVariables: string[]
missingVariables: string[]
usedFragments: FragmentId[]
missingFragments: FragmentId[]
renderedBlockCount: number
}
export type RenderLog =
| VariableRenderLog
| ConditionRenderLog
| LoopRenderLog
| FragmentRenderLog
| SlotRenderLog
| OutputRenderLog
export type VariableRenderLog = {
type: "variable"
blockId?: BlockId
path: string
resolved: boolean
value?: unknown
fallbackUsed?: boolean
}
export type ConditionRenderLog = {
type: "condition"
blockId: BlockId
expression: string
result: boolean
}
export type LoopRenderLog = {
type: "loop"
blockId: BlockId
source: string
count: number
}
export type FragmentRenderLog = {
type: "fragment"
blockId: BlockId
fragmentId: FragmentId
resolved: boolean
}
export type SlotRenderLog = {
type: "slot"
blockId: BlockId
slotName: string
filled: boolean
fallbackUsed?: boolean
}
export type OutputRenderLog = {
type: "output"
format: string
}
export type RenderWarning = {
code: RenderWarningCode
message: string
blockId?: BlockId
path?: string
fragmentId?: FragmentId
}
export type RenderError = {
code: RenderErrorCode
message: string
blockId?: BlockId
path?: string
fragmentId?: FragmentId
cause?: unknown
}
export type RenderWarningCode =
| "MISSING_VARIABLE"
| "FALLBACK_USED"
| "EMPTY_LOOP"
| "UNFILLED_SLOT"
| "TARGET_BLOCK_NOT_FOUND"
| "UNKNOWN_TYPE"
export type RenderErrorCode =
| "INVALID_EXPRESSION"
| "MISSING_VARIABLE_EXPORT"
| "MISSING_FRAGMENT"
| "FRAGMENT_CYCLE"
| "LOOP_SOURCE_NOT_ARRAY"
| "INVALID_BLOCK"
| "RENDER_DEPTH_EXCEEDED"
export type RenderedDataSetRef = DataSetId

22
src/core/types/schema.ts Normal file
View File

@@ -0,0 +1,22 @@
export type InputSchema = {
fields: InputField[]
}
export type InputField = {
path: string
type: InputFieldType
label?: string
description?: string
required?: boolean
defaultValue?: unknown
enumOptions?: string[]
}
export type InputFieldType =
| "string"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "unknown"

View File

@@ -0,0 +1,380 @@
import type { JSONContent } from "@tiptap/core"
import type { Editor } from "@tiptap/react"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { ChevronDown, Plus } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { astToTiptap, tiptapToAst } from "./adapter"
import {
CommentBlockExtension,
ConditionBlockExtension,
ContainerBlockExtension,
FragmentRefExtension,
LoopBlockExtension,
ParagraphBlockExtension,
SelectedBlockExtension,
VariableInlineExtension
} from "./extensions"
import { selectedBlockPluginKey } from "./extensions/SelectedBlockExtension"
import { getCurrentDocument, useWorkbenchStore } from "../app/store/workbenchStore"
import { createBlockId } from "../utils/id"
const editorExtensions = [
StarterKit.configure({
link: false,
paragraph: false
}),
ParagraphBlockExtension,
VariableInlineExtension,
ContainerBlockExtension,
ConditionBlockExtension,
LoopBlockExtension,
FragmentRefExtension,
CommentBlockExtension,
SelectedBlockExtension
]
export function BlockFlowEditor() {
const snapshot = useWorkbenchStore((state) => state.snapshot)
const selectedDocumentId = useWorkbenchStore((state) => state.selectedDocumentId)
const selectedBlockId = useWorkbenchStore((state) => state.selectedBlockId)
const documentRevision = useWorkbenchStore((state) => state.documentRevision)
const setSelectedBlockId = useWorkbenchStore((state) => state.setSelectedBlockId)
const updateOpenDocumentChildren = useWorkbenchStore((state) => state.updateOpenDocumentChildren)
const document = getCurrentDocument({ snapshot, selectedDocumentId })
const [commandMenuOpen, setCommandMenuOpen] = useState(false)
const content = useMemo(() => astToTiptap(document.children), [document.id, documentRevision])
const editor = useEditor({
extensions: editorExtensions,
content,
editorProps: {
attributes: {
class: "bf-editor-content thin-scrollbar"
},
handleDOMEvents: {
click(_view, event) {
const target = event.target
if (!(target instanceof HTMLElement)) {
return false
}
const blockElement = target.closest<HTMLElement>("[data-block-id]")
setSelectedBlockId(blockElement?.dataset.blockId ?? null)
return false
}
},
handleKeyDown(_view, event) {
if (event.key === "Enter" && maybeConvertBlockShortcut(editor)) {
event.preventDefault()
return true
}
return false
},
handleTextInput(view, from, to, text) {
return maybeConvertVariableInput(view, from, to, text)
}
},
onUpdate({ editor: activeEditor }) {
if (normalizeTypedVariable(activeEditor)) {
return
}
updateOpenDocumentChildren(tiptapToAst(activeEditor.getJSON()))
if (getCurrentParagraphText(activeEditor).endsWith("/")) {
setCommandMenuOpen(true)
}
}
})
useEffect(() => {
if (editor === null) {
return
}
editor.commands.setContent(astToTiptap(document.children), { emitUpdate: false })
setCommandMenuOpen(false)
}, [document.id, documentRevision, editor])
useEffect(() => {
if (editor === null) {
return
}
const storage = ((editor.storage as unknown) as Record<string, unknown>).blockFlowSelectedBlock as {
selectedBlockId: string | null
}
storage.selectedBlockId = selectedBlockId
editor.view.dispatch(editor.state.tr.setMeta(selectedBlockPluginKey, selectedBlockId))
}, [document.id, editor, selectedBlockId])
return (
<section className="min-w-0 bg-workbench-bg" data-testid="blockflow-editor">
<div className="flex h-9 items-center gap-2 border-b border-workbench-line bg-workbench-panel px-3 text-[12px]">
<ChevronDown className="h-3.5 w-3.5 text-workbench-muted" aria-hidden="true" />
<span className="truncate font-medium">{document.name}</span>
<span className="truncate text-workbench-faint">{document.filePath}</span>
<button
type="button"
aria-label="打开命令菜单"
title="打开命令菜单"
className="ml-auto grid h-7 w-7 place-items-center border border-workbench-line text-workbench-muted hover:bg-workbench-panel2 hover:text-workbench-text"
onClick={() => setCommandMenuOpen((open) => !open)}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="relative h-[calc(100%-2.25rem)] min-h-0">
<EditorContent editor={editor} />
{commandMenuOpen && editor !== null ? (
<CommandMenu
editor={editor}
fragmentId={Object.keys(snapshot.fragments)[0] ?? "fragment_common_footer"}
onClose={() => setCommandMenuOpen(false)}
/>
) : null}
</div>
</section>
)
}
function CommandMenu({
editor,
fragmentId,
onClose
}: {
editor: Editor
fragmentId: string
onClose: () => void
}) {
const commands: Array<{ label: string; hint: string; run: () => void }> = [
{
label: "变量",
hint: "user.email",
run: () => insertInlineNode(editor, { type: "variableInline", attrs: { path: "user.email" } })
},
{
label: "容器",
hint: "Container",
run: () => insertBlockNode(editor, containerNode())
},
{
label: "条件",
hint: "if user.isVip",
run: () => insertBlockNode(editor, conditionNode("user.isVip"))
},
{
label: "循环",
hint: "for item in items",
run: () => insertBlockNode(editor, loopNode("item", "items"))
},
{
label: "片段引用",
hint: fragmentId,
run: () => insertBlockNode(editor, fragmentRefNode(fragmentId))
},
{
label: "注释",
hint: "# 注释",
run: () => insertBlockNode(editor, commentNode("注释"))
}
]
return (
<div className="absolute left-6 top-4 z-20 w-56 border border-workbench-line bg-workbench-panel text-[12px] shadow-xl">
<div className="border-b border-workbench-line px-2 py-1 text-workbench-faint">/ command</div>
{commands.map((command) => (
<button
key={command.label}
type="button"
aria-label={`${command.label} ${command.hint}`}
className="flex h-8 w-full items-center justify-between gap-2 px-2 text-left text-workbench-muted hover:bg-workbench-panel2 hover:text-workbench-text"
onClick={() => {
command.run()
onClose()
}}
>
<span>{command.label}</span>
<span className="truncate text-workbench-faint">{command.hint}</span>
</button>
))}
</div>
)
}
function normalizeTypedVariable(editor: Editor): boolean {
const variableNode = editor.state.schema.nodes.variableInline
if (variableNode === undefined) {
return false
}
let replaced = false
editor.state.doc.descendants((node, position) => {
if (!node.isText || node.text === undefined) {
return true
}
const match = /\{([A-Za-z_$][A-Za-z0-9_$.-]*(?:\[\d+\])?)\}/.exec(node.text)
if (match?.[1] === undefined || match.index === undefined) {
return true
}
const from = position + match.index
const to = from + match[0].length
editor.view.dispatch(
editor.state.tr.replaceWith(
from,
to,
variableNode.create({
path: match[1],
required: false
})
)
)
replaced = true
return false
})
return replaced
}
function maybeConvertVariableInput(
view: Parameters<NonNullable<NonNullable<Editor["options"]["editorProps"]>["handleTextInput"]>>[0],
from: number,
to: number,
text: string
): boolean {
if (!text.endsWith("}")) {
return false
}
const variableNode = view.state.schema.nodes.variableInline
if (variableNode === undefined) {
return false
}
const $from = view.state.doc.resolve(from)
if ($from.parent.type.name !== "paragraph") {
return false
}
const textBefore = view.state.doc.textBetween($from.start(), from, "", "") + text
const match = /\{([A-Za-z_$][A-Za-z0-9_$.-]*(?:\[\d+\])?)\}$/.exec(textBefore)
if (match?.[1] === undefined) {
return false
}
const replaceFrom = from - (match[0].length - text.length)
const node = variableNode.create({
path: match[1],
required: false
})
view.dispatch(view.state.tr.replaceWith(replaceFrom, to, node))
return true
}
function maybeConvertBlockShortcut(editor: Editor | null): boolean {
if (editor === null) {
return false
}
const text = getCurrentParagraphText(editor).trim()
const ifMatch = /^@if\s+(.+)$/.exec(text)
if (ifMatch?.[1]) {
replaceCurrentParagraph(editor, conditionNode(ifMatch[1].trim()))
return true
}
const loopMatch = /^@for\s+([A-Za-z_$][A-Za-z0-9_$-]*)\s+in\s+(.+)$/.exec(text)
if (loopMatch?.[1] && loopMatch[2]) {
replaceCurrentParagraph(editor, loopNode(loopMatch[1], loopMatch[2].trim()))
return true
}
return false
}
function replaceCurrentParagraph(editor: Editor, node: JSONContent): void {
const { $from } = editor.state.selection
if ($from.parent.type.name !== "paragraph") {
return
}
editor
.chain()
.focus()
.insertContentAt(
{
from: $from.before($from.depth),
to: $from.after($from.depth)
},
node
)
.run()
}
function insertInlineNode(editor: Editor, node: JSONContent): void {
editor.chain().focus().insertContent(node).run()
}
function insertBlockNode(editor: Editor, node: JSONContent): void {
editor.chain().focus().insertContent(node).run()
}
function getCurrentParagraphText(editor: Editor): string {
const { $from } = editor.state.selection
return $from.parent.type.name === "paragraph" ? $from.parent.textContent : ""
}
function conditionNode(expression: string): JSONContent {
return {
type: "conditionBlock",
attrs: {
id: createBlockId("condition"),
expression
},
content: [{ type: "paragraph", attrs: { id: createBlockId("paragraph") } }]
}
}
function loopNode(itemName: string, source: string): JSONContent {
return {
type: "loopBlock",
attrs: {
id: createBlockId("loop"),
source,
itemName,
indexName: ""
},
content: [{ type: "paragraph", attrs: { id: createBlockId("paragraph") } }]
}
}
function containerNode(): JSONContent {
return {
type: "containerBlock",
attrs: {
id: createBlockId("container"),
name: "Container"
},
content: [{ type: "paragraph", attrs: { id: createBlockId("paragraph") } }]
}
}
function fragmentRefNode(fragmentId: string): JSONContent {
return {
type: "fragmentRefBlock",
attrs: {
id: createBlockId("fragment"),
fragmentId
}
}
}
function commentNode(content: string): JSONContent {
return {
type: "commentBlock",
attrs: {
id: createBlockId("comment"),
content
}
}
}

View File

@@ -0,0 +1,132 @@
import type { JSONContent } from "@tiptap/core"
import type { BlockNode, InlineNode } from "../../core/types"
export function astToTiptap(blocks: BlockNode[]): JSONContent {
return {
type: "doc",
content: blocks.length > 0 ? blocks.map(blockToTiptap) : [{ type: "paragraph" }]
}
}
export function blockToTiptap(block: BlockNode): JSONContent {
if (block.type === "paragraph") {
const paragraph: JSONContent = {
type: "paragraph",
attrs: { id: block.id }
}
const content = inlinesToTiptap(block.inlines)
if (content !== undefined) {
paragraph.content = content
}
return paragraph
}
if (block.type === "container") {
return {
type: "containerBlock",
attrs: {
id: block.id,
name: block.name
},
content: blocksOrEmpty(block.children)
}
}
if (block.type === "condition") {
return {
type: "conditionBlock",
attrs: {
id: block.id,
expression: block.expression
},
content: blocksOrEmpty(block.children)
}
}
if (block.type === "loop") {
return {
type: "loopBlock",
attrs: {
id: block.id,
source: block.source,
itemName: block.itemName,
indexName: block.indexName ?? ""
},
content: blocksOrEmpty(block.children)
}
}
if (block.type === "fragmentRef") {
return {
type: "fragmentRefBlock",
attrs: {
id: block.id,
fragmentId: block.fragmentId
}
}
}
if (block.type === "comment") {
return {
type: "commentBlock",
attrs: {
id: block.id,
content: block.content
}
}
}
if (block.type === "output") {
return {
type: "commentBlock",
attrs: {
id: block.id,
content: `output ${block.config.format}`
}
}
}
return {
type: "paragraph",
attrs: { id: block.id },
content: [{ type: "text", text: "" }]
}
}
function inlinesToTiptap(inlines: InlineNode[]): JSONContent[] | undefined {
const content = inlines.flatMap(inlineToTiptap)
return content.length > 0 ? content : undefined
}
function inlineToTiptap(inline: InlineNode): JSONContent[] {
if (inline.type === "text") {
return inline.content.length > 0 ? [{ type: "text", text: inline.content }] : []
}
if (inline.type === "variable") {
return [
{
type: "variableInline",
attrs: {
path: inline.path,
required: inline.required === true,
fallback: inline.fallback
}
}
]
}
if (inline.type === "expression") {
return [{ type: "text", text: `{${inline.expression}}` }]
}
if (inline.type === "inlineFragment") {
return [{ type: "text", text: `use ${inline.fragmentId}` }]
}
return [{ type: "text", text: `{${inline.name}}` }]
}
function blocksOrEmpty(blocks: BlockNode[]): JSONContent[] {
return blocks.length > 0 ? blocks.map(blockToTiptap) : [{ type: "paragraph" }]
}

View File

@@ -0,0 +1,2 @@
export * from "./astToTiptap"
export * from "./tiptapToAst"

View File

@@ -0,0 +1,144 @@
import type { JSONContent } from "@tiptap/core"
import type { BlockNode, InlineNode } from "../../core/types"
import { createBlockId } from "../../utils/id"
export function tiptapToAst(doc: JSONContent): BlockNode[] {
return (doc.content ?? [])
.map(nodeToBlock)
.filter((block): block is BlockNode => block !== undefined)
}
function nodeToBlock(node: JSONContent): BlockNode | undefined {
if (node.type === "paragraph") {
return {
id: getStringAttr(node, "id") ?? createBlockId("paragraph"),
type: "paragraph",
inlines: contentToInlines(node.content ?? [])
}
}
if (node.type === "containerBlock") {
return {
id: getStringAttr(node, "id") ?? createBlockId("container"),
type: "container",
name: getStringAttr(node, "name") ?? "Container",
children: contentToBlocks(node.content)
}
}
if (node.type === "conditionBlock") {
return {
id: getStringAttr(node, "id") ?? createBlockId("condition"),
type: "condition",
expression: getStringAttr(node, "expression") ?? "condition",
children: contentToBlocks(node.content)
}
}
if (node.type === "loopBlock") {
const indexName = getStringAttr(node, "indexName")
const block: BlockNode = {
id: getStringAttr(node, "id") ?? createBlockId("loop"),
type: "loop",
source: getStringAttr(node, "source") ?? "items",
itemName: getStringAttr(node, "itemName") ?? "item",
children: contentToBlocks(node.content)
}
if (indexName !== undefined && indexName.length > 0) {
block.indexName = indexName
}
return block
}
if (node.type === "fragmentRefBlock") {
return {
id: getStringAttr(node, "id") ?? createBlockId("fragment"),
type: "fragmentRef",
fragmentId: getStringAttr(node, "fragmentId") ?? "fragment_common_footer"
}
}
if (node.type === "commentBlock") {
return {
id: getStringAttr(node, "id") ?? createBlockId("comment"),
type: "comment",
content: getStringAttr(node, "content") ?? ""
}
}
return undefined
}
function contentToBlocks(content: JSONContent[] | undefined): BlockNode[] {
const blocks = (content ?? [])
.map(nodeToBlock)
.filter((block): block is BlockNode => block !== undefined)
return blocks.length > 0
? blocks
: [
{
id: createBlockId("paragraph"),
type: "paragraph",
inlines: []
}
]
}
function contentToInlines(content: JSONContent[]): InlineNode[] {
const inlines: InlineNode[] = []
for (const node of content) {
if (node.type === "text") {
const text = node.text ?? ""
if (text.length > 0) {
inlines.push({ type: "text", content: text })
}
continue
}
if (node.type === "variableInline") {
const inline: InlineNode = {
type: "variable",
path: getStringAttr(node, "path") ?? "variable"
}
if (getBooleanAttr(node, "required") === true) {
inline.required = true
}
const fallback = node.attrs?.fallback
if (fallback !== undefined) {
inline.fallback = fallback
}
inlines.push(inline)
}
}
return mergeTextInlines(inlines)
}
function mergeTextInlines(inlines: InlineNode[]): InlineNode[] {
const merged: InlineNode[] = []
for (const inline of inlines) {
const previous = merged[merged.length - 1]
if (inline.type === "text" && previous?.type === "text") {
previous.content += inline.content
continue
}
merged.push(inline)
}
return merged
}
function getStringAttr(node: JSONContent, key: string): string | undefined {
const value = node.attrs?.[key]
return typeof value === "string" ? value : undefined
}
function getBooleanAttr(node: JSONContent, key: string): boolean | undefined {
const value = node.attrs?.[key]
return typeof value === "boolean" ? value : undefined
}

View File

@@ -0,0 +1,31 @@
import { mergeAttributes, Node } from "@tiptap/core"
export const CommentBlockExtension = Node.create({
name: "commentBlock",
group: "block",
atom: true,
selectable: true,
addAttributes() {
return {
id: { default: null },
content: { default: "" }
}
},
parseHTML() {
return [{ tag: "div[data-blockflow-comment]" }]
},
renderHTML({ HTMLAttributes, node }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-blockflow-comment": "",
"data-block-id": node.attrs.id,
class: "bf-comment"
}),
`# ${node.attrs.content}`
]
}
})

View File

@@ -0,0 +1,34 @@
import { mergeAttributes, Node } from "@tiptap/core"
export const ConditionBlockExtension = Node.create({
name: "conditionBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
id: { default: null },
expression: { default: "condition" }
}
},
parseHTML() {
return [{ tag: "section[data-blockflow-condition]" }]
},
renderHTML({ HTMLAttributes, node }) {
return [
"section",
mergeAttributes(HTMLAttributes, {
"data-blockflow-condition": "",
"data-block-id": node.attrs.id,
"data-expression": node.attrs.expression,
class: "bf-structured-block"
}),
["div", { class: "bf-block-heading bf-condition-heading", contenteditable: "false" }, `▾ if ${node.attrs.expression}`],
["div", { class: "bf-block-content" }, 0]
]
}
})

View File

@@ -0,0 +1,34 @@
import { mergeAttributes, Node } from "@tiptap/core"
export const ContainerBlockExtension = Node.create({
name: "containerBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
id: { default: null },
name: { default: "Container" }
}
},
parseHTML() {
return [{ tag: "section[data-blockflow-container]" }]
},
renderHTML({ HTMLAttributes, node }) {
return [
"section",
mergeAttributes(HTMLAttributes, {
"data-blockflow-container": "",
"data-block-id": node.attrs.id,
"data-name": node.attrs.name,
class: "bf-structured-block"
}),
["div", { class: "bf-block-heading", contenteditable: "false" }, `${node.attrs.name}`],
["div", { class: "bf-block-content" }, 0]
]
}
})

View File

@@ -0,0 +1,33 @@
import { mergeAttributes, Node } from "@tiptap/core"
export const FragmentRefExtension = Node.create({
name: "fragmentRefBlock",
group: "block",
atom: true,
selectable: true,
addAttributes() {
return {
id: { default: null },
fragmentId: { default: "fragment_common_footer" }
}
},
parseHTML() {
return [{ tag: "div[data-blockflow-fragment-ref]" }]
},
renderHTML({ HTMLAttributes, node }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-blockflow-fragment-ref": "",
"data-block-id": node.attrs.id,
"data-fragment-id": node.attrs.fragmentId,
class: "bf-fragment-ref"
}),
["span", { class: "bf-muted" }, "use"],
["span", {}, node.attrs.fragmentId]
]
}
})

View File

@@ -0,0 +1,43 @@
import { mergeAttributes, Node } from "@tiptap/core"
export const LoopBlockExtension = Node.create({
name: "loopBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
id: { default: null },
source: { default: "items" },
itemName: { default: "item" },
indexName: { default: "" }
}
},
parseHTML() {
return [{ tag: "section[data-blockflow-loop]" }]
},
renderHTML({ HTMLAttributes, node }) {
const suffix = node.attrs.indexName ? ` index ${node.attrs.indexName}` : ""
return [
"section",
mergeAttributes(HTMLAttributes, {
"data-blockflow-loop": "",
"data-block-id": node.attrs.id,
"data-source": node.attrs.source,
"data-item-name": node.attrs.itemName,
"data-index-name": node.attrs.indexName,
class: "bf-structured-block"
}),
[
"div",
{ class: "bf-block-heading bf-loop-heading", contenteditable: "false" },
`▾ for ${node.attrs.itemName} in ${node.attrs.source}${suffix}`
],
["div", { class: "bf-block-content" }, 0]
]
}
})

View File

@@ -0,0 +1,16 @@
import Paragraph from "@tiptap/extension-paragraph"
export const ParagraphBlockExtension = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-block-id"),
renderHTML: (attributes) => ({
"data-block-id": attributes.id
})
}
}
}
})

View File

@@ -0,0 +1,61 @@
import { Extension } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import { Decoration, DecorationSet } from "@tiptap/pm/view"
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
export const selectedBlockPluginKey = new PluginKey<DecorationSet>("blockFlowSelectedBlock")
export const SelectedBlockExtension = Extension.create({
name: "blockFlowSelectedBlock",
addStorage() {
return {
selectedBlockId: null as string | null
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: selectedBlockPluginKey,
state: {
init: (_, state) => buildSelectedBlockDecorations(state.doc, this.storage.selectedBlockId as string | null),
apply: (transaction, previous, _oldState, newState) => {
const selectedBlockId = transaction.getMeta(selectedBlockPluginKey) as string | null | undefined
if (selectedBlockId === undefined && !transaction.docChanged) {
return previous
}
return buildSelectedBlockDecorations(
newState.doc,
selectedBlockId === undefined ? (this.storage.selectedBlockId as string | null) : selectedBlockId
)
}
},
props: {
decorations(state) {
return selectedBlockPluginKey.getState(state)
}
}
})
]
}
})
function buildSelectedBlockDecorations(doc: ProseMirrorNode, selectedBlockId: string | null): DecorationSet {
if (selectedBlockId === null) {
return DecorationSet.empty
}
const decorations: Decoration[] = []
doc.descendants((node, position) => {
if (node.attrs.id === selectedBlockId) {
decorations.push(Decoration.node(position, position + node.nodeSize, { class: "bf-selected-block" }))
return false
}
return true
})
return DecorationSet.create(doc, decorations)
}

View File

@@ -0,0 +1,61 @@
import { mergeAttributes, Node, nodeInputRule } from "@tiptap/core"
export const VariableInlineExtension = Node.create({
name: "variableInline",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
path: {
default: ""
},
required: {
default: false
},
fallback: {
default: null
}
}
},
parseHTML() {
return [
{
tag: "span[data-blockflow-variable]",
getAttrs: (element) => ({
path: (element as HTMLElement).getAttribute("data-path") ?? "",
required: (element as HTMLElement).getAttribute("data-required") === "true"
})
}
]
},
renderHTML({ HTMLAttributes, node }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-blockflow-variable": "",
"data-path": node.attrs.path,
"data-required": String(node.attrs.required),
class: "bf-variable-pill"
}),
node.attrs.required ? `${node.attrs.path} !` : node.attrs.path
]
},
addInputRules() {
return [
nodeInputRule({
find: /\{([A-Za-z_$][A-Za-z0-9_$.-]*(?:\[\d+\])?)\}$/,
type: this.type,
getAttributes: (match) => ({
path: match[1],
required: false
})
})
]
}
})

View File

@@ -0,0 +1,8 @@
export * from "./CommentBlockExtension"
export * from "./ConditionBlockExtension"
export * from "./ContainerBlockExtension"
export * from "./FragmentRefExtension"
export * from "./LoopBlockExtension"
export * from "./ParagraphBlockExtension"
export * from "./SelectedBlockExtension"
export * from "./VariableInlineExtension"

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./app/App"
import "./styles.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,62 @@
import type { BlockFlowProject, CreateProjectOptions } from "../core/types"
import type { ProjectFileSystem } from "./fileSystem"
import { requireProjectFileSystem } from "./fileSystem"
import { stringifyJson } from "./json"
import { getProjectPaths } from "./paths"
export type CreateProjectResult = {
project: BlockFlowProject
projectJsonPath: string
}
export async function createProject(
root: string,
options: CreateProjectOptions & { fs?: ProjectFileSystem } = {}
): Promise<CreateProjectResult> {
const fs = requireProjectFileSystem(options.fs)
const paths = getProjectPaths(root)
const now = (options.now ?? (() => new Date()))().toISOString()
await fs.mkdir(paths.root)
await fs.mkdir(paths.templatesDir)
await fs.mkdir(paths.fragmentsDir)
await fs.mkdir(paths.datasetsDir)
if (options.includeExportsDir === true) {
await fs.mkdir(paths.exportsDir)
}
const project: BlockFlowProject = {
id: options.id ?? "project_main",
name: options.name ?? "My BlockFlow Project",
version: options.version ?? "0.1.0",
createdAt: now,
updatedAt: now,
paths: {
root: paths.root,
templatesDir: paths.templatesDir,
fragmentsDir: paths.fragmentsDir,
datasetsDir: paths.datasetsDir
}
}
if (options.entryTemplateId !== undefined) {
project.entryTemplateId = options.entryTemplateId
}
if (options.includeExportsDir === true) {
project.paths.exportsDir = paths.exportsDir
}
await fs.writeText(paths.projectJson, stringifyJson(stripProjectPaths(project)))
return {
project,
projectJsonPath: paths.projectJson
}
}
function stripProjectPaths(project: BlockFlowProject): Omit<BlockFlowProject, "paths"> {
const { paths: _paths, ...projectJson } = project
return projectJson
}

22
src/project/fileSystem.ts Normal file
View File

@@ -0,0 +1,22 @@
export type DirectoryEntry = {
name: string
path: string
type: "file" | "directory"
}
export type ProjectFileSystem = {
readText(filePath: string): Promise<string>
writeText(filePath: string, content: string): Promise<void>
mkdir(dirPath: string): Promise<void>
readdir(dirPath: string): Promise<DirectoryEntry[]>
exists(filePath: string): Promise<boolean>
stat(filePath: string): Promise<{ type: "file" | "directory" }>
}
export function requireProjectFileSystem(fs: ProjectFileSystem | undefined): ProjectFileSystem {
if (fs === undefined) {
throw new Error("A ProjectFileSystem adapter is required.")
}
return fs
}

6
src/project/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from "./createProject"
export * from "./fileSystem"
export * from "./openProject"
export * from "./readDocument"
export * from "./scanProject"
export * from "./writeDocument"

7
src/project/json.ts Normal file
View File

@@ -0,0 +1,7 @@
export function parseJson(text: string): unknown {
return JSON.parse(text) as unknown
}
export function stringifyJson(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`
}

View File

@@ -0,0 +1,43 @@
import fs from "node:fs/promises"
import type { ProjectFileSystem } from "./fileSystem"
import { directoryName, joinPath } from "./paths"
export const nodeProjectFileSystem: ProjectFileSystem = {
async readText(filePath) {
return fs.readFile(filePath, "utf8")
},
async writeText(filePath, content) {
await fs.mkdir(directoryName(filePath), { recursive: true })
await fs.writeFile(filePath, content, "utf8")
},
async mkdir(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
},
async readdir(dirPath) {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() || entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: joinPath(dirPath, entry.name),
type: entry.isDirectory() ? "directory" : "file"
}))
},
async exists(filePath) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
},
async stat(filePath) {
const stats = await fs.stat(filePath)
return { type: stats.isDirectory() ? "directory" : "file" }
}
}

263
src/project/openProject.ts Normal file
View File

@@ -0,0 +1,263 @@
import { buildReferenceIndex } from "../core/reference"
import type {
BlockFlowProject,
DataSetDocument,
FragmentDocument,
ProjectDiagnostic,
ProjectResourceFile,
ProjectSnapshot,
TemplateDocument
} from "../core/types"
import type { ProjectFileSystem } from "./fileSystem"
import { requireProjectFileSystem } from "./fileSystem"
import { parseJson } from "./json"
import { getProjectPaths } from "./paths"
import { readDocument } from "./readDocument"
import { scanProject } from "./scanProject"
import { isProjectDocument, isRecord, type ProjectDocument } from "./validation"
export type OpenProjectOptions = {
fs?: ProjectFileSystem
}
export async function openProject(
root: string,
options: OpenProjectOptions = {}
): Promise<ProjectSnapshot> {
const fs = requireProjectFileSystem(options.fs)
const paths = getProjectPaths(root)
const diagnostics: ProjectDiagnostic[] = []
const project = await readProjectJson(paths.root, fs, diagnostics)
const scanned = await scanProject(paths.root, { fs })
const templates: Record<string, TemplateDocument> = {}
const fragments: Record<string, FragmentDocument> = {}
const datasets: Record<string, DataSetDocument> = {}
const seenIds = new Map<string, string>()
for (const file of scanned.resourceFiles) {
if (file.resourceKind === "project" || file.resourceKind === "schema" || file.resourceKind === "export") {
continue
}
if (
file.resourceKind !== "template" &&
file.resourceKind !== "fragment" &&
file.resourceKind !== "dataset"
) {
continue
}
const document = await readResourceDocument(file, fs, diagnostics)
if (document === undefined) {
continue
}
if (document.kind !== file.resourceKind) {
addDiagnostic(diagnostics, {
code: "INVALID_DOCUMENT_SHAPE",
severity: "error",
message: `Expected ${file.resourceKind} document but found ${document.kind}`,
filePath: file.path,
documentId: document.id
})
continue
}
const existingPath = seenIds.get(document.id)
if (existingPath !== undefined) {
addDiagnostic(diagnostics, {
code: "DUPLICATE_DOCUMENT_ID",
severity: "error",
message: `Duplicate document id "${document.id}"`,
filePath: file.path,
documentId: document.id,
cause: { firstFilePath: existingPath }
})
continue
}
seenIds.set(document.id, file.path)
file.documentId = document.id
if (document.kind === "template") {
templates[document.id] = document
} else if (document.kind === "fragment") {
fragments[document.id] = document
} else {
datasets[document.id] = document
}
}
const referenceIndex = buildReferenceIndex([
...Object.values(templates),
...Object.values(fragments)
])
if (project.entryTemplateId !== undefined && templates[project.entryTemplateId] === undefined) {
addDiagnostic(diagnostics, {
code: "MISSING_ENTRY_TEMPLATE",
severity: "error",
message: `Entry template is missing: ${project.entryTemplateId}`,
documentId: project.entryTemplateId
})
}
for (const missing of referenceIndex.missingFragments) {
const diagnostic: ProjectDiagnostic = {
code: "MISSING_FRAGMENT",
severity: "error",
message: `Missing fragment reference: ${missing.fragmentId}`,
documentId: missing.fromId,
fragmentId: missing.fragmentId
}
if (missing.fromPath !== undefined) {
diagnostic.filePath = missing.fromPath
}
addDiagnostic(diagnostics, diagnostic)
}
return {
project,
templates,
fragments,
datasets,
resourceTree: scanned.resourceTree,
referenceIndex,
diagnostics
}
}
async function readProjectJson(
root: string,
fs: ProjectFileSystem,
diagnostics: ProjectDiagnostic[]
): Promise<BlockFlowProject> {
const paths = getProjectPaths(root)
if (!(await fs.exists(paths.projectJson))) {
addDiagnostic(diagnostics, {
code: "PROJECT_JSON_MISSING",
severity: "error",
message: "project.json is missing",
filePath: paths.projectJson
})
return createFallbackProject(paths.root)
}
try {
const parsed = parseJson(await fs.readText(paths.projectJson))
if (!isProjectJson(parsed)) {
addDiagnostic(diagnostics, {
code: "PROJECT_JSON_INVALID",
severity: "error",
message: "project.json has an invalid shape",
filePath: paths.projectJson
})
return createFallbackProject(paths.root)
}
const project: BlockFlowProject = {
id: parsed.id,
name: parsed.name,
version: parsed.version,
createdAt: parsed.createdAt,
updatedAt: parsed.updatedAt,
paths: {
root: paths.root,
templatesDir: paths.templatesDir,
fragmentsDir: paths.fragmentsDir,
datasetsDir: paths.datasetsDir
}
}
if (parsed.entryTemplateId !== undefined) {
project.entryTemplateId = parsed.entryTemplateId
}
if (await fs.exists(paths.exportsDir)) {
project.paths.exportsDir = paths.exportsDir
}
return project
} catch (cause) {
addDiagnostic(diagnostics, {
code: "PROJECT_JSON_INVALID",
severity: "error",
message: "project.json could not be parsed",
filePath: paths.projectJson,
cause
})
return createFallbackProject(paths.root)
}
}
async function readResourceDocument(
file: ProjectResourceFile,
fs: ProjectFileSystem,
diagnostics: ProjectDiagnostic[]
): Promise<ProjectDocument | undefined> {
try {
const document = await readDocument(file.path, { fs })
return document
} catch (cause) {
const code = isSyntaxError(cause) ? "INVALID_JSON" : "INVALID_DOCUMENT_SHAPE"
addDiagnostic(diagnostics, {
code,
severity: "error",
message: code === "INVALID_JSON"
? `Invalid JSON: ${file.relativePath}`
: `Invalid BlockFlow document: ${file.relativePath}`,
filePath: file.path,
cause
})
return undefined
}
}
function createFallbackProject(root: string): BlockFlowProject {
const paths = getProjectPaths(root)
const now = new Date(0).toISOString()
return {
id: "project_invalid",
name: "Invalid BlockFlow Project",
version: "0.1.0",
createdAt: now,
updatedAt: now,
paths: {
root: paths.root,
templatesDir: paths.templatesDir,
fragmentsDir: paths.fragmentsDir,
datasetsDir: paths.datasetsDir
}
}
}
function isProjectJson(value: unknown): value is {
id: string
name: string
version: string
createdAt: string
updatedAt: string
entryTemplateId?: string
} {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.version === "string" &&
typeof value.createdAt === "string" &&
typeof value.updatedAt === "string" &&
(value.entryTemplateId === undefined || typeof value.entryTemplateId === "string")
)
}
function isSyntaxError(cause: unknown): boolean {
return cause instanceof SyntaxError
}
function addDiagnostic(
diagnostics: ProjectDiagnostic[],
diagnostic: ProjectDiagnostic
): void {
diagnostics.push(diagnostic)
}

128
src/project/paths.ts Normal file
View File

@@ -0,0 +1,128 @@
export type ProjectPaths = {
root: string
projectJson: string
templatesDir: string
fragmentsDir: string
datasetsDir: string
exportsDir: string
schemasDir: string
}
export function getProjectPaths(root: string): ProjectPaths {
const normalizedRoot = normalizePath(root)
return {
root: normalizedRoot,
projectJson: joinPath(normalizedRoot, "project.json"),
templatesDir: joinPath(normalizedRoot, "templates"),
fragmentsDir: joinPath(normalizedRoot, "fragments"),
datasetsDir: joinPath(normalizedRoot, "datasets"),
exportsDir: joinPath(normalizedRoot, "exports"),
schemasDir: joinPath(normalizedRoot, "schemas")
}
}
export function toRelativePath(root: string, filePath: string): string {
const normalizedRoot = normalizePath(root)
const normalizedPath = normalizePath(filePath)
const rootPrefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`
if (normalizedPath === normalizedRoot) {
return ""
}
if (normalizedPath.toLowerCase().startsWith(rootPrefix.toLowerCase())) {
return normalizedPath.slice(rootPrefix.length)
}
return normalizedPath
}
export function normalizeSlashes(filePath: string): string {
return filePath.replace(/\\/g, "/")
}
export function isJsonFile(filePath: string): boolean {
return extensionName(filePath).toLowerCase() === ".json"
}
export function joinPath(...parts: string[]): string {
const joined = parts
.filter((part) => part.length > 0)
.join("/")
return normalizePath(joined)
}
export function directoryName(filePath: string): string {
const normalized = normalizePath(filePath)
const root = pathRoot(normalized)
const trimmed = normalized.length > root.length ? normalized.replace(/\/+$/, "") : normalized
const slashIndex = trimmed.lastIndexOf("/")
if (slashIndex < 0) {
return "."
}
if (slashIndex < root.length) {
return root
}
return trimmed.slice(0, slashIndex)
}
export function baseName(filePath: string): string {
const normalized = normalizePath(filePath)
const root = pathRoot(normalized)
const trimmed = normalized.length > root.length ? normalized.replace(/\/+$/, "") : normalized
const slashIndex = trimmed.lastIndexOf("/")
return slashIndex < 0 ? trimmed : trimmed.slice(slashIndex + 1)
}
export function extensionName(filePath: string): string {
const name = baseName(filePath)
const dotIndex = name.lastIndexOf(".")
return dotIndex <= 0 ? "" : name.slice(dotIndex)
}
export function normalizePath(filePath: string): string {
const normalized = normalizeSlashes(filePath).replace(/\/+/g, "/")
const root = pathRoot(normalized)
const body = normalized.slice(root.length)
const segments = body.split("/").filter((segment) => segment.length > 0 && segment !== ".")
const resolvedSegments: string[] = []
for (const segment of segments) {
if (segment === "..") {
if (resolvedSegments.length > 0) {
resolvedSegments.pop()
}
continue
}
resolvedSegments.push(segment)
}
const resolved = `${root}${resolvedSegments.join("/")}`
if (resolved.length === 0) {
return "."
}
if (root.length > 0 && resolvedSegments.length === 0) {
return root
}
return resolved
}
function pathRoot(filePath: string): string {
const driveMatch = /^[A-Za-z]:\//.exec(filePath)
if (driveMatch !== null) {
return driveMatch[0]
}
if (filePath.startsWith("/")) {
return "/"
}
return ""
}

View File

@@ -0,0 +1,46 @@
import type { DataSetDocument, FragmentDocument, TemplateDocument } from "../core/types"
import type { ProjectFileSystem } from "./fileSystem"
import { requireProjectFileSystem } from "./fileSystem"
import { parseJson } from "./json"
import {
isDataSetDocument,
isFragmentDocument,
isTemplateDocument,
type ProjectDocument
} from "./validation"
export type ReadDocumentOptions = {
fs?: ProjectFileSystem
}
export async function readDocument(
filePath: string,
options: ReadDocumentOptions = {}
): Promise<ProjectDocument> {
const fs = requireProjectFileSystem(options.fs)
const parsed = parseJson(await fs.readText(filePath))
if (isTemplateDocument(parsed)) {
return attachFilePath(parsed, filePath)
}
if (isFragmentDocument(parsed)) {
return attachFilePath(parsed, filePath)
}
if (isDataSetDocument(parsed)) {
return attachFilePath(parsed, filePath)
}
throw new Error(`Invalid BlockFlow document shape: ${filePath}`)
}
function attachFilePath<T extends TemplateDocument | FragmentDocument | DataSetDocument>(
document: T,
filePath: string
): T {
return {
...document,
filePath
}
}

123
src/project/scanProject.ts Normal file
View File

@@ -0,0 +1,123 @@
import type {
ProjectResourceFile,
ProjectResourceKind,
ProjectResourceNode,
ProjectResourceTree
} from "../core/types"
import type { ProjectFileSystem } from "./fileSystem"
import { requireProjectFileSystem } from "./fileSystem"
import { baseName, getProjectPaths, isJsonFile, toRelativePath } from "./paths"
export type ScanProjectOptions = {
fs?: ProjectFileSystem
}
export type ScannedProject = {
resourceTree: ProjectResourceTree
resourceFiles: ProjectResourceFile[]
}
export async function scanProject(
root: string,
options: ScanProjectOptions = {}
): Promise<ScannedProject> {
const fs = requireProjectFileSystem(options.fs)
const paths = getProjectPaths(root)
const children: ProjectResourceNode[] = []
const resourceFiles: ProjectResourceFile[] = []
if (await fs.exists(paths.projectJson)) {
const projectFile = createResourceFile(paths.root, paths.projectJson, "project")
children.push(projectFile)
resourceFiles.push(projectFile)
}
for (const dir of [
paths.templatesDir,
paths.fragmentsDir,
paths.datasetsDir,
paths.schemasDir,
paths.exportsDir
]) {
if (await fs.exists(dir)) {
const node = await scanDirectory(paths.root, dir, fs, resourceFiles)
children.push(node)
}
}
return {
resourceTree: {
root: paths.root,
children: sortNodes(children)
},
resourceFiles
}
}
async function scanDirectory(
root: string,
dirPath: string,
fs: ProjectFileSystem,
resourceFiles: ProjectResourceFile[]
): Promise<ProjectResourceNode> {
const entries = await fs.readdir(dirPath)
const children: ProjectResourceNode[] = []
for (const entry of entries) {
if (entry.type === "directory") {
children.push(await scanDirectory(root, entry.path, fs, resourceFiles))
continue
}
const file = createResourceFile(root, entry.path, getResourceKind(root, entry.path))
children.push(file)
if (isJsonFile(entry.path)) {
resourceFiles.push(file)
}
}
return {
type: "directory",
name: baseName(dirPath),
path: dirPath,
relativePath: toRelativePath(root, dirPath),
children: sortNodes(children)
}
}
function createResourceFile(
root: string,
filePath: string,
resourceKind: ProjectResourceKind
): ProjectResourceFile {
return {
type: "file",
name: baseName(filePath),
path: filePath,
relativePath: toRelativePath(root, filePath),
resourceKind
}
}
function getResourceKind(root: string, filePath: string): ProjectResourceKind {
const relativePath = toRelativePath(root, filePath)
const [topLevel] = relativePath.split("/")
if (relativePath === "project.json") return "project"
if (!isJsonFile(filePath)) return "unknown"
if (topLevel === "templates") return "template"
if (topLevel === "fragments") return "fragment"
if (topLevel === "datasets") return "dataset"
if (topLevel === "schemas") return "schema"
if (topLevel === "exports") return "export"
return "unknown"
}
function sortNodes(nodes: ProjectResourceNode[]): ProjectResourceNode[] {
return [...nodes].sort((left, right) => {
if (left.type !== right.type) {
return left.type === "directory" ? -1 : 1
}
return left.name.localeCompare(right.name)
})
}

View File

@@ -0,0 +1,45 @@
import {
exists,
lstat,
mkdir,
readDir,
readTextFile,
writeTextFile
} from "@tauri-apps/plugin-fs"
import type { ProjectFileSystem } from "./fileSystem"
import { directoryName, joinPath } from "./paths"
export const tauriProjectFileSystem: ProjectFileSystem = {
async readText(filePath) {
return readTextFile(filePath)
},
async writeText(filePath, content) {
await mkdir(directoryName(filePath), { recursive: true })
await writeTextFile(filePath, content)
},
async mkdir(dirPath) {
await mkdir(dirPath, { recursive: true })
},
async readdir(dirPath) {
const entries = await readDir(dirPath)
return entries
.filter((entry) => entry.isFile || entry.isDirectory)
.map((entry) => ({
name: entry.name,
path: joinPath(dirPath, entry.name),
type: entry.isDirectory ? "directory" : "file"
}))
},
async exists(filePath) {
return exists(filePath)
},
async stat(filePath) {
const info = await lstat(filePath)
return { type: info.isDirectory ? "directory" : "file" }
}
}

49
src/project/validation.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { DataSetDocument, FragmentDocument, TemplateDocument } from "../core/types"
export type ProjectDocument =
| TemplateDocument
| FragmentDocument
| DataSetDocument
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function isTemplateDocument(value: unknown): value is TemplateDocument {
return (
isRecord(value) &&
value.kind === "template" &&
typeof value.id === "string" &&
typeof value.name === "string" &&
Array.isArray(value.children)
)
}
export function isFragmentDocument(value: unknown): value is FragmentDocument {
return (
isRecord(value) &&
value.kind === "fragment" &&
typeof value.id === "string" &&
typeof value.name === "string" &&
Array.isArray(value.children)
)
}
export function isDataSetDocument(value: unknown): value is DataSetDocument {
return (
isRecord(value) &&
value.kind === "dataset" &&
typeof value.id === "string" &&
typeof value.name === "string" &&
isRecord(value.data)
)
}
export function isProjectDocument(value: unknown): value is ProjectDocument {
return isTemplateDocument(value) || isFragmentDocument(value) || isDataSetDocument(value)
}
export function stripRuntimeFilePath<T extends ProjectDocument>(document: T): T {
const { filePath: _filePath, ...rest } = document
return rest as T
}

View File

@@ -0,0 +1,50 @@
import type { DataSetDocument, FragmentDocument, TemplateDocument } from "../core/types"
import type { ProjectFileSystem } from "./fileSystem"
import { requireProjectFileSystem } from "./fileSystem"
import { stringifyJson } from "./json"
import { getProjectPaths, joinPath } from "./paths"
import { stripRuntimeFilePath } from "./validation"
export type WritableProjectDocument =
| TemplateDocument
| FragmentDocument
| DataSetDocument
export type WriteDocumentOptions = {
fs?: ProjectFileSystem
fileName?: string
}
export async function writeDocument(
root: string,
document: WritableProjectDocument,
options: WriteDocumentOptions = {}
): Promise<string> {
const fs = requireProjectFileSystem(options.fs)
const filePath = resolveDocumentPath(root, document, options.fileName)
await fs.writeText(filePath, stringifyJson(stripRuntimeFilePath(document)))
return filePath
}
function resolveDocumentPath(
root: string,
document: WritableProjectDocument,
fileName?: string
): string {
if (document.filePath !== undefined) {
return document.filePath
}
const paths = getProjectPaths(root)
const baseName = fileName ?? `${document.id}.json`
if (document.kind === "template") {
return joinPath(paths.templatesDir, baseName)
}
if (document.kind === "fragment") {
return joinPath(paths.fragmentsDir, baseName)
}
return joinPath(paths.datasetsDir, baseName)
}

140
src/styles.css Normal file
View File

@@ -0,0 +1,140 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #111318;
color: #d7dce5;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
::selection {
background: rgba(118, 167, 255, 0.28);
}
.thin-scrollbar {
scrollbar-color: #3b4350 #171a21;
scrollbar-width: thin;
}
.thin-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: #171a21;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: #3b4350;
border-radius: 0;
}
.bf-editor-content {
height: 100%;
overflow: auto;
padding: 1rem;
font-family: "JetBrains Mono", "Cascadia Code", SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.55;
outline: none;
}
.bf-editor-content .ProseMirror {
min-height: 100%;
max-width: 56rem;
margin: 0 auto;
outline: none;
white-space: pre-wrap;
}
.bf-editor-content p {
min-height: 1.5rem;
margin: 0;
}
.bf-variable-pill {
display: inline-flex;
height: 1.25rem;
align-items: center;
margin: 0 0.125rem;
border: 1px solid #2b313c;
background: #1d2129;
padding: 0 0.375rem;
color: #76a7ff;
font-size: 12px;
vertical-align: baseline;
}
.bf-structured-block {
margin: 0.25rem 0;
border-left: 1px solid #2b313c;
}
.bf-selected-block {
outline: 1px solid rgba(118, 167, 255, 0.65);
outline-offset: 2px;
background: rgba(118, 167, 255, 0.08);
}
.bf-block-heading {
min-height: 1.5rem;
color: #76a7ff;
user-select: none;
}
.bf-condition-heading {
color: #76a7ff;
}
.bf-loop-heading {
color: #76a7ff;
}
.bf-block-content {
padding-left: 1.125rem;
}
.bf-fragment-ref {
display: flex;
min-height: 1.5rem;
align-items: center;
gap: 0.5rem;
color: #78c28f;
}
.bf-comment {
min-height: 1.5rem;
color: #5d6678;
}
.bf-muted {
color: #5d6678;
}

10
src/utils/id.ts Normal file
View File

@@ -0,0 +1,10 @@
let counter = 0
export function createBlockId(prefix = "block"): string {
counter += 1
return `${prefix}_${counter.toString(36)}`
}
export function resetBlockIdCounter(): void {
counter = 0
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

44
tailwind.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { Config } from "tailwindcss"
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
workbench: {
bg: "#111318",
panel: "#171a21",
panel2: "#1d2129",
line: "#2b313c",
text: "#d7dce5",
muted: "#8d96a8",
faint: "#5d6678",
accent: "#76a7ff",
good: "#78c28f",
warn: "#d8b35f",
bad: "#e06c75"
}
},
fontFamily: {
ui: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"sans-serif"
],
mono: [
"JetBrains Mono",
"Cascadia Code",
"SFMono-Regular",
"Consolas",
"Liberation Mono",
"monospace"
]
}
}
},
plugins: []
} satisfies Config

265
tests/app.test.tsx Normal file
View File

@@ -0,0 +1,265 @@
import "@testing-library/jest-dom/vitest"
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { App } from "../src/app/App"
import {
defaultDatasetId,
defaultDocumentId,
defaultResourcePath,
getCurrentDocument,
getCurrentRenderResult,
setProjectPersistenceForTests,
useWorkbenchStore
} from "../src/app/store/workbenchStore"
describe("workbench UI shell", () => {
beforeEach(() => {
setProjectPersistenceForTests(null)
useWorkbenchStore.getState().resetDemo()
})
afterEach(() => {
cleanup()
})
it("starts with the demo entry template and dataset selected", () => {
const state = useWorkbenchStore.getState()
expect(state.selectedDocumentId).toBe(defaultDocumentId)
expect(state.currentDatasetId).toBe(defaultDatasetId)
expect(state.selectedResourcePath).toBe(defaultResourcePath)
})
it("renders the resource tree from the project snapshot", () => {
render(<App />)
expect(screen.getByTestId("resource-panel")).toHaveTextContent("main.json")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("common-footer.json")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("default.json")
})
it("switches inspector tabs", () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "预览" }))
expect(useWorkbenchStore.getState().rightPanelTab).toBe("preview")
expect(screen.getByTestId("preview-tab")).toHaveTextContent("张三")
fireEvent.click(screen.getByRole("button", { name: "引用" }))
expect(useWorkbenchStore.getState().rightPanelTab).toBe("references")
expect(screen.getByTestId("references-tab")).toHaveTextContent("missing fragment_signature")
})
it("generates preview output from the existing renderer", () => {
const state = useWorkbenchStore.getState()
const result = getCurrentRenderResult(state)
expect(result.output).toContain("张三")
expect(result.output).toContain("- 产品 A: 99")
expect(result.errors).toContainEqual(
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "fragment_signature" })
)
})
it("shows missing reference status", () => {
render(<App />)
expect(screen.getByText("missing refs: 1")).toBeInTheDocument()
})
it("opens a project through the persistence adapter", async () => {
const diskSnapshot = {
...useWorkbenchStore.getState().snapshot,
project: {
...useWorkbenchStore.getState().snapshot.project,
name: "Disk Project",
paths: {
...useWorkbenchStore.getState().snapshot.project.paths,
root: "D:/DiskProject"
}
}
}
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => diskSnapshot,
saveProjectChanges: async () => diskSnapshot
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Open project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.projectOpenStatus).toBe("open")
expect(state.snapshot.project.name).toBe("Disk Project")
expect(state.dirtyDocumentIds).toEqual([])
})
expect(screen.getByText("Disk Project")).toBeInTheDocument()
})
it("creates a fragment from the resource panel and selects it", () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
const state = useWorkbenchStore.getState()
expect(state.snapshot.fragments.fragment_2).toMatchObject({
kind: "fragment",
name: "Fragment 2"
})
expect(state.selectedDocumentId).toBe("fragment_2")
expect(state.selectedResourcePath).toBe("fragments/fragment-2.json")
expect(state.saveStatus).toBe("dirty")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("fragment-2.json")
})
it("saves dirty fragment changes through the persistence adapter", async () => {
const savedDirtyIds: string[][] = []
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
saveProjectChanges: async (snapshot, dirtyDocumentIds) => {
savedDirtyIds.push(dirtyDocumentIds)
return snapshot
}
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(savedDirtyIds).toEqual([["fragment_2"]])
expect(state.saveStatus).toBe("saved")
expect(state.dirtyDocumentIds).toEqual([])
})
})
it("shows persistence save errors", async () => {
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
saveProjectChanges: async () => {
throw new Error("disk full")
}
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.saveStatus).toBe("error")
expect(state.lastSaveError).toBe("disk full")
})
expect(screen.getByText("disk full")).toBeInTheDocument()
})
it("opens a referenced fragment from the references panel", () => {
useWorkbenchStore.getState().setRightPanelTab("references")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Open fragment_common_footer" }))
const state = useWorkbenchStore.getState()
expect(state.selectedDocumentId).toBe("fragment_common_footer")
expect(state.selectedResourcePath).toBe("fragments/common-footer.json")
expect(getCurrentDocument(state).kind).toBe("fragment")
})
it("expands the selected fragment reference into editable blocks", async () => {
useWorkbenchStore.getState().setRightPanelTab("references")
useWorkbenchStore.getState().setSelectedBlockId("block_footer")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Expand ref" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
const outgoing = state.snapshot.referenceIndex.outgoing.template_main?.map((item) => item.toFragmentId) ?? []
expect(outgoing).not.toContain("fragment_common_footer")
expect(JSON.stringify(state.snapshot.templates.template_main?.children)).not.toContain("block_footer")
expect(state.saveStatus).toBe("dirty")
expect(screen.getByTestId("blockflow-editor")).not.toHaveTextContent("fragment_common_footer")
})
})
it("updates the current dataset from valid JSON and refreshes preview data", async () => {
useWorkbenchStore.getState().setRightPanelTab("data")
render(<App />)
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
target: {
value: JSON.stringify(
{
user: { name: "Ada", isVip: false },
items: []
},
null,
2
)
}
})
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.dataEditorStatus).toBe("valid")
expect(getCurrentRenderResult(state).output).toContain("Ada")
expect(state.saveStatus).toBe("dirty")
})
})
it("keeps the last valid dataset when JSON editing is invalid", async () => {
useWorkbenchStore.getState().setRightPanelTab("data")
render(<App />)
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
target: { value: "{" }
})
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.dataEditorStatus).toBe("invalid")
expect(state.snapshot.datasets[state.currentDatasetId]?.data.user).toEqual(
expect.objectContaining({ name: "张三" })
)
})
expect(screen.getByTestId("data-tab")).toHaveTextContent("Expected")
})
it("renders the selected block preview", () => {
useWorkbenchStore.getState().setRightPanelTab("preview")
useWorkbenchStore.getState().setSelectedBlockId("block_hello")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "当前块" }))
const expected = getCurrentRenderResult(useWorkbenchStore.getState(), { targetBlockId: "block_hello" }).output
expect(screen.getByTestId("preview-tab")).toHaveTextContent("block_hello")
expect(screen.getByTestId("preview-output").textContent).toBe(expected)
})
it("shows missing variable details in preview", () => {
useWorkbenchStore.getState().setCurrentDataset("dataset_empty")
useWorkbenchStore.getState().setRightPanelTab("preview")
render(<App />)
expect(screen.getByTestId("preview-tab")).toHaveTextContent("user.name")
})
it("groups render errors, warnings, and logs in the debug tab", () => {
useWorkbenchStore.getState().setRightPanelTab("debug")
render(<App />)
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Errors")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Warnings")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("condition user.isVip")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("loop items")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("fragment fragment_common_footer")
})
})

62
tests/editor.test.tsx Normal file
View File

@@ -0,0 +1,62 @@
import "@testing-library/jest-dom/vitest"
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { App } from "../src/app/App"
import { getCurrentRenderResult, useWorkbenchStore } from "../src/app/store/workbenchStore"
describe("BlockFlowEditor", () => {
beforeEach(() => {
useWorkbenchStore.getState().resetDemo()
})
afterEach(() => {
cleanup()
})
it("renders the demo template in the TipTap editor", () => {
render(<App />)
expect(screen.getByTestId("blockflow-editor")).toHaveTextContent("user.name")
expect(screen.getByTestId("blockflow-editor")).toHaveTextContent("fragment_common_footer")
})
it("inserts a variable through the command menu and updates AST", async () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
fireEvent.click(screen.getByRole("button", { name: "变量 user.email" }))
await waitFor(() => {
const blocks = useWorkbenchStore.getState().snapshot.templates.template_main?.children ?? []
expect(JSON.stringify(blocks)).toContain("user.email")
expect(useWorkbenchStore.getState().saveStatus).toBe("dirty")
})
})
it("inserts condition and loop blocks through the command menu", async () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
fireEvent.click(screen.getByRole("button", { name: "条件 if user.isVip" }))
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
fireEvent.click(screen.getByRole("button", { name: "循环 for item in items" }))
await waitFor(() => {
const blocks = useWorkbenchStore.getState().snapshot.templates.template_main?.children ?? []
expect(JSON.stringify(blocks)).toContain("\"type\":\"condition\"")
expect(JSON.stringify(blocks)).toContain("\"type\":\"loop\"")
})
})
it("keeps render preview connected to the AST after editing", async () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
fireEvent.click(screen.getByRole("button", { name: "变量 user.email" }))
await waitFor(() => {
const result = getCurrentRenderResult(useWorkbenchStore.getState())
expect(result.stats.missingVariables).toContain("user.email")
})
})
})

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest"
import { astToTiptap, tiptapToAst } from "../src/editor/adapter"
import type { BlockNode } from "../src/core/types"
describe("BlockFlow TipTap adapter", () => {
it("round-trips paragraphs, text, and variables", () => {
const blocks: BlockNode[] = [
{
id: "p1",
type: "paragraph",
inlines: [
{ type: "text", content: "你好," },
{ type: "variable", path: "user.name", required: true }
]
}
]
expect(tiptapToAst(astToTiptap(blocks))).toEqual(blocks)
})
it("round-trips structured blocks and atom blocks", () => {
const blocks: BlockNode[] = [
{
id: "container1",
type: "container",
name: "用户通知",
children: [
{
id: "if1",
type: "condition",
expression: "user.isVip",
children: [
{
id: "loop1",
type: "loop",
source: "items",
itemName: "item",
indexName: "index",
children: [
{
id: "p1",
type: "paragraph",
inlines: [{ type: "variable", path: "item.title" }]
}
]
}
]
},
{
id: "frag1",
type: "fragmentRef",
fragmentId: "fragment_common_footer"
},
{
id: "comment1",
type: "comment",
content: "备注"
}
]
}
]
expect(tiptapToAst(astToTiptap(blocks))).toEqual(blocks)
})
it("does not leak TipTap document wrapper into BlockFlow AST", () => {
const ast = tiptapToAst({
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "p1" },
content: [{ type: "text", text: "plain" }]
}
]
})
expect(ast).toEqual([
{
id: "p1",
type: "paragraph",
inlines: [{ type: "text", content: "plain" }]
}
])
expect(JSON.stringify(ast)).not.toContain("\"doc\"")
})
})

79
tests/expression.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest"
import { evaluateExpression, resolvePath } from "../src/core/expression"
import type { ScopeFrame } from "../src/core/expression"
const scopes: ScopeFrame[] = [
{
type: "data",
values: {
user: {
name: "张三",
level: 4,
type: "developer",
isVip: true
},
tags: ["important", "draft"],
items: [
{ title: "A", price: 1 },
{ title: "B", price: 2 }
],
disabled: false
}
}
]
describe("expression evaluator", () => {
it("resolves nested paths and array access", () => {
expect(resolvePath("user.name", scopes)).toMatchObject({
found: true,
value: "张三"
})
expect(resolvePath("items[0].title", scopes)).toMatchObject({
found: true,
value: "A"
})
})
it("evaluates comparisons", () => {
expect(evaluateExpression("user.level >= 3", scopes)).toMatchObject({
ok: true,
value: true
})
expect(evaluateExpression("user.type == \"developer\"", scopes)).toMatchObject({
ok: true,
value: true
})
})
it("evaluates logical operators", () => {
expect(evaluateExpression("user.isVip && user.level >= 3", scopes)).toMatchObject({
ok: true,
value: true
})
expect(evaluateExpression("!disabled", scopes)).toMatchObject({
ok: true,
value: true
})
})
it("evaluates contains", () => {
expect(evaluateExpression("tags contains \"important\"", scopes)).toMatchObject({
ok: true,
value: true
})
})
it("evaluates length access", () => {
expect(evaluateExpression("items.length > 0", scopes)).toMatchObject({
ok: true,
value: true
})
})
it("evaluates nullish fallback", () => {
expect(evaluateExpression("missing.name ?? \"未命名\"", scopes)).toMatchObject({
ok: true,
value: "未命名"
})
})
})

256
tests/project.test.ts Normal file
View File

@@ -0,0 +1,256 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import type { BlockNode, DataSetDocument, FragmentDocument, InlineNode, TemplateDocument } from "../src/core/types"
import { nodeProjectFileSystem } from "../src/project/nodeFileSystem"
import { getProjectPaths, joinPath, normalizePath, toRelativePath } from "../src/project/paths"
import {
createProject,
openProject,
readDocument,
scanProject,
writeDocument
} from "../src/project"
const tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })))
tempRoots.length = 0
})
describe("project folder system", () => {
it("normalizes project paths without Node path helpers", () => {
expect(normalizePath("D:\\Projects\\BlockFlow\\templates\\..\\fragments")).toBe("D:/Projects/BlockFlow/fragments")
expect(joinPath("D:\\Projects\\BlockFlow", "templates", "main.json")).toBe("D:/Projects/BlockFlow/templates/main.json")
expect(toRelativePath("D:\\Projects\\BlockFlow", "D:\\Projects\\BlockFlow\\templates\\main.json")).toBe("templates/main.json")
expect(getProjectPaths("D:\\Projects\\BlockFlow").fragmentsDir).toBe("D:/Projects/BlockFlow/fragments")
})
it("creates a minimal project folder", async () => {
const root = await makeTempRoot()
const result = await createProject(root, {
id: "project_test",
name: "Test Project",
entryTemplateId: "template_main",
includeExportsDir: true,
fs: nodeProjectFileSystem,
now: () => new Date("2026-05-24T00:00:00.000Z")
})
await expectPathExists(path.join(root, "project.json"))
await expectPathExists(path.join(root, "templates"))
await expectPathExists(path.join(root, "fragments"))
await expectPathExists(path.join(root, "datasets"))
await expectPathExists(path.join(root, "exports"))
expect(result.project.id).toBe("project_test")
const projectJson = JSON.parse(await fs.readFile(path.join(root, "project.json"), "utf8")) as {
paths?: unknown
entryTemplateId?: string
}
expect(projectJson.paths).toBeUndefined()
expect(projectJson.entryTemplateId).toBe("template_main")
})
it("opens templates, fragments, datasets, and resource tree entries", async () => {
const root = await makeProjectWithResources()
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.project.entryTemplateId).toBe("template_main")
expect(Object.keys(snapshot.templates)).toEqual(["template_main"])
expect(Object.keys(snapshot.fragments)).toEqual(["fragment_footer"])
expect(Object.keys(snapshot.datasets)).toEqual(["dataset_default"])
expect(snapshot.templates.template_main?.filePath).toContain("templates/main.json")
expect(snapshot.datasets.dataset_default?.data).toEqual({ user: { name: "张三" } })
const templateFile = findResource(snapshot.resourceTree.children, "templates/main.json")
expect(templateFile).toMatchObject({
type: "file",
resourceKind: "template",
documentId: "template_main"
})
})
it("detects missing fragment references", async () => {
const root = await makeProjectWithResources({
templateChildren: [{ id: "missing_ref", type: "fragmentRef", fragmentId: "missing" }]
})
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.referenceIndex.missingFragments).toContainEqual(
expect.objectContaining({ fromId: "template_main", fragmentId: "missing" })
)
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "missing" })
)
})
it("skips invalid resource JSON and returns diagnostics", async () => {
const root = await makeProjectWithResources()
await fs.writeFile(path.join(root, "templates", "broken.json"), "{ nope", "utf8")
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.templates.template_main).toBeDefined()
expect(snapshot.templates.broken).toBeUndefined()
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({ code: "INVALID_JSON" })
)
})
it("detects duplicate document ids across scanned files", async () => {
const root = await makeProjectWithResources()
await writeJson(path.join(root, "fragments", "duplicate.json"), {
kind: "fragment",
id: "template_main",
name: "Duplicate",
children: []
})
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({
code: "DUPLICATE_DOCUMENT_ID",
documentId: "template_main"
})
)
})
it("writes documents without persisting runtime filePath", async () => {
const root = await makeProjectWithResources()
const filePath = path.join(root, "templates", "main.json")
const document = await readDocument(filePath, { fs: nodeProjectFileSystem }) as TemplateDocument
document.children = [paragraph([{ type: "text", content: "更新" }])]
const writtenPath = await writeDocument(root, document, { fs: nodeProjectFileSystem })
expect(writtenPath).toBe(filePath)
const saved = JSON.parse(await fs.readFile(filePath, "utf8")) as { filePath?: unknown }
expect(saved.filePath).toBeUndefined()
const reopened = await openProject(root, { fs: nodeProjectFileSystem })
expect(reopened.templates.template_main?.children).toEqual(document.children)
})
it("writes new fragments to the fragments directory", async () => {
const root = await makeProjectWithResources()
const fragment: FragmentDocument = {
kind: "fragment",
id: "fragment_new",
name: "New Fragment",
children: [paragraph([{ type: "text", content: "New body" }])]
}
const writtenPath = await writeDocument(root, fragment, { fs: nodeProjectFileSystem })
expect(writtenPath).toContain("fragments/fragment_new.json")
const saved = JSON.parse(await fs.readFile(writtenPath, "utf8")) as { filePath?: unknown }
expect(saved.filePath).toBeUndefined()
const reopened = await openProject(root, { fs: nodeProjectFileSystem })
expect(reopened.fragments.fragment_new?.children).toEqual(fragment.children)
})
it("scans nested json resource files", async () => {
const root = await makeProjectWithResources()
await fs.mkdir(path.join(root, "templates", "nested"), { recursive: true })
await writeJson(path.join(root, "templates", "nested", "extra.json"), {
kind: "template",
id: "template_extra",
name: "Extra",
children: []
})
const scanned = await scanProject(root, { fs: nodeProjectFileSystem })
expect(scanned.resourceFiles).toContainEqual(
expect.objectContaining({
relativePath: "templates/nested/extra.json",
resourceKind: "template"
})
)
})
})
async function makeProjectWithResources(options: { templateChildren?: BlockNode[] } = {}): Promise<string> {
const root = await makeTempRoot()
await createProject(root, {
entryTemplateId: "template_main",
fs: nodeProjectFileSystem,
now: () => new Date("2026-05-24T00:00:00.000Z")
})
await writeJson(path.join(root, "templates", "main.json"), {
kind: "template",
id: "template_main",
name: "Main",
children: options.templateChildren ?? [
paragraph([{ type: "text", content: "正文" }]),
{ id: "ref_footer", type: "fragmentRef", fragmentId: "fragment_footer" }
]
})
await writeJson(path.join(root, "fragments", "footer.json"), {
kind: "fragment",
id: "fragment_footer",
name: "Footer",
children: [paragraph([{ type: "text", content: "以上。" }])]
})
await writeJson(path.join(root, "datasets", "default.json"), {
kind: "dataset",
id: "dataset_default",
name: "Default",
data: { user: { name: "张三" } }
} satisfies DataSetDocument)
return root
}
function paragraph(inlines: InlineNode[]): BlockNode {
return {
id: `p_${Math.random().toString(36).slice(2)}`,
type: "paragraph",
inlines
}
}
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "blockflow-project-"))
tempRoots.push(root)
return root
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8")
}
async function expectPathExists(filePath: string): Promise<void> {
await expect(fs.stat(filePath)).resolves.toBeDefined()
}
function findResource(
nodes: Array<{ type: string; relativePath: string; children?: unknown[] }>,
relativePath: string
): unknown {
for (const node of nodes) {
if (node.relativePath === relativePath) {
return node
}
if (Array.isArray(node.children)) {
const found = findResource(
node.children as Array<{ type: string; relativePath: string; children?: unknown[] }>,
relativePath
)
if (found !== undefined) {
return found
}
}
}
return undefined
}

View File

@@ -0,0 +1,374 @@
import { describe, expect, it } from "vitest"
import { buildReferenceIndex, renderTemplate } from "../src/core"
import type {
BlockNode,
FragmentDocument,
InlineNode,
TemplateDocument
} from "../src/core/types"
function template(children: BlockNode[]): TemplateDocument {
return {
kind: "template",
id: "template_main",
name: "Main",
children
}
}
function fragment(id: string, children: BlockNode[]): FragmentDocument {
return {
kind: "fragment",
id,
name: id,
children
}
}
function paragraph(inlines: InlineNode[], id = "p1"): BlockNode {
return {
id,
type: "paragraph",
inlines
}
}
describe("renderTemplate", () => {
it("replaces variables", () => {
const result = renderTemplate({
template: template([
paragraph([
{ type: "text", content: "你好," },
{ type: "variable", path: "user.name" }
])
]),
fragments: {},
data: { user: { name: "张三" } }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("你好,张三")
expect(result.stats.usedVariables).toEqual(["user.name"])
})
it("keeps missing variables as placeholders in preview mode", () => {
const result = renderTemplate({
template: template([
paragraph([
{ type: "text", content: "你好," },
{ type: "variable", path: "user.name" }
])
]),
fragments: {},
data: { user: {} }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("你好,{user.name}")
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: "MISSING_VARIABLE", path: "user.name" })
)
})
it("fails missing variables in export mode by default", () => {
const result = renderTemplate({
template: template([
paragraph([
{ type: "text", content: "你好," },
{ type: "variable", path: "user.name" }
])
]),
fragments: {},
data: { user: {} },
options: { mode: "export" }
})
expect(result.ok).toBe(false)
expect(result.errors).toContainEqual(
expect.objectContaining({ code: "MISSING_VARIABLE_EXPORT", path: "user.name" })
)
})
it("uses expression fallback with nullish coalescing", () => {
const result = renderTemplate({
template: template([
paragraph([
{ type: "text", content: "你好," },
{ type: "expression", expression: "user.name ?? \"未命名\"" }
])
]),
fragments: {},
data: { user: {} }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("你好,未命名")
})
it("renders matching conditions", () => {
const result = renderTemplate({
template: template([
{
id: "if1",
type: "condition",
expression: "user.isVip",
children: [paragraph([{ type: "text", content: "VIP 用户" }], "p_vip")]
}
]),
fragments: {},
data: { user: { isVip: true } }
})
expect(result.output).toBe("VIP 用户")
expect(result.logs).toContainEqual(
expect.objectContaining({ type: "condition", blockId: "if1", result: true })
)
})
it("omits non-matching conditions", () => {
const result = renderTemplate({
template: template([
{
id: "if1",
type: "condition",
expression: "user.isVip",
children: [paragraph([{ type: "text", content: "VIP 用户" }], "p_vip")]
}
]),
fragments: {},
data: { user: { isVip: false } }
})
expect(result.output).toBe("")
})
it("expands loops with item and index scopes", () => {
const result = renderTemplate({
template: template([
{
id: "loop1",
type: "loop",
source: "items",
itemName: "item",
indexName: "index",
children: [
paragraph([
{ type: "text", content: "- " },
{ type: "variable", path: "item.title" },
{ type: "text", content: ": " },
{ type: "variable", path: "item.price" }
])
]
}
]),
fragments: {},
data: {
items: [
{ title: "A", price: 1 },
{ title: "B", price: 2 }
]
}
})
expect(result.ok).toBe(true)
expect(result.output).toBe("- A: 1\n- B: 2")
})
it("errors when loop source is not an array", () => {
const result = renderTemplate({
template: template([
{
id: "loop1",
type: "loop",
source: "items",
itemName: "item",
children: [paragraph([{ type: "variable", path: "item.title" }])]
}
]),
fragments: {},
data: { items: "not-array" },
options: { mode: "export" }
})
expect(result.ok).toBe(false)
expect(result.errors).toContainEqual(
expect.objectContaining({ code: "LOOP_SOURCE_NOT_ARRAY", path: "items" })
)
})
it("renders fragment references", () => {
const result = renderTemplate({
template: template([
paragraph([{ type: "text", content: "正文" }], "body"),
{ id: "ref1", type: "fragmentRef", fragmentId: "fragment_footer" }
]),
fragments: {
fragment_footer: fragment("fragment_footer", [
paragraph([{ type: "text", content: "以上。" }], "footer")
])
},
data: {}
})
expect(result.ok).toBe(true)
expect(result.output).toBe("正文\n以上。")
expect(result.stats.usedFragments).toEqual(["fragment_footer"])
})
it("renders a target paragraph block only", () => {
const result = renderTemplate({
template: template([
paragraph([{ type: "text", content: "First" }], "p1"),
paragraph([{ type: "text", content: "Second" }], "p2")
]),
fragments: {},
data: {},
options: { targetBlockId: "p2" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("Second")
expect(result.stats.renderedBlockCount).toBe(1)
})
it("renders a target condition block", () => {
const result = renderTemplate({
template: template([
{
id: "if1",
type: "condition",
expression: "user.isVip",
children: [paragraph([{ type: "text", content: "VIP" }], "vip_text")]
},
paragraph([{ type: "text", content: "Tail" }], "tail")
]),
fragments: {},
data: { user: { isVip: true } },
options: { targetBlockId: "if1" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("VIP")
expect(result.logs).toContainEqual(expect.objectContaining({ type: "condition", blockId: "if1" }))
})
it("renders a target loop block", () => {
const result = renderTemplate({
template: template([
{
id: "loop1",
type: "loop",
source: "items",
itemName: "item",
children: [paragraph([{ type: "variable", path: "item.title" }], "row")]
}
]),
fragments: {},
data: { items: [{ title: "A" }, { title: "B" }] },
options: { targetBlockId: "loop1" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("A\nB")
})
it("keeps loop scope when rendering a nested target block", () => {
const result = renderTemplate({
template: template([
{
id: "loop1",
type: "loop",
source: "items",
itemName: "item",
children: [
paragraph([
{ type: "text", content: "- " },
{ type: "variable", path: "item.title" }
], "row")
]
}
]),
fragments: {},
data: { items: [{ title: "A" }, { title: "B" }] },
options: { targetBlockId: "row" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("- A\n- B")
})
it("renders a target fragment reference block", () => {
const result = renderTemplate({
template: template([{ id: "ref1", type: "fragmentRef", fragmentId: "fragment_footer" }]),
fragments: {
fragment_footer: fragment("fragment_footer", [paragraph([{ type: "text", content: "Footer" }], "footer")])
},
data: {},
options: { targetBlockId: "ref1" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("Footer")
expect(result.stats.usedFragments).toEqual(["fragment_footer"])
})
it("warns when a target block is missing", () => {
const result = renderTemplate({
template: template([paragraph([{ type: "text", content: "Only" }], "p1")]),
fragments: {},
data: {},
options: { targetBlockId: "missing_block" }
})
expect(result.ok).toBe(true)
expect(result.output).toBe("")
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: "TARGET_BLOCK_NOT_FOUND", blockId: "missing_block" })
)
})
it("errors on missing fragment references", () => {
const result = renderTemplate({
template: template([{ id: "ref1", type: "fragmentRef", fragmentId: "missing" }]),
fragments: {},
data: {}
})
expect(result.ok).toBe(false)
expect(result.errors).toContainEqual(
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "missing" })
)
})
it("detects fragment cycles during render", () => {
const result = renderTemplate({
template: template([{ id: "ref_a", type: "fragmentRef", fragmentId: "A" }]),
fragments: {
A: fragment("A", [{ id: "ref_b", type: "fragmentRef", fragmentId: "B" }]),
B: fragment("B", [{ id: "ref_a2", type: "fragmentRef", fragmentId: "A" }])
},
data: {}
})
expect(result.ok).toBe(false)
expect(result.errors).toContainEqual(expect.objectContaining({ code: "FRAGMENT_CYCLE" }))
})
})
describe("buildReferenceIndex", () => {
it("collects missing fragments and cycles", () => {
const main = template([
{ id: "ref_missing", type: "fragmentRef", fragmentId: "missing" },
{ id: "ref_a", type: "fragmentRef", fragmentId: "A" }
])
const fragmentA = fragment("A", [{ id: "ref_b", type: "fragmentRef", fragmentId: "B" }])
const fragmentB = fragment("B", [{ id: "ref_a2", type: "fragmentRef", fragmentId: "A" }])
const index = buildReferenceIndex([main, fragmentA, fragmentB])
expect(index.missingFragments).toContainEqual(
expect.objectContaining({ fromId: "template_main", fragmentId: "missing" })
)
expect(index.cycles).toContainEqual(expect.objectContaining({ chain: ["A", "B", "A"] }))
})
})

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"types": ["vitest/globals", "node"]
},
"include": ["src", "tests", "vitest.config.ts"]
}

29
vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
const host = process.env.TAURI_DEV_HOST
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
host: host || "127.0.0.1",
port: 5173,
strictPort: true,
hmr: host
? {
protocol: "ws",
host,
port: 1421
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"]
}
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
sourcemap: Boolean(process.env.TAURI_ENV_DEBUG)
}
})

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
globals: true
}
})