chore: 初始化 BlockFlow Workbench 仓库
建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。 补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal 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
38
.gitignore
vendored
Normal 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
2944
Design_Spec.md
Normal file
File diff suppressed because it is too large
Load Diff
12
index.html
Normal file
12
index.html
Normal 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
4246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
4726
src-tauri/Cargo.lock
generated
Normal file
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
14
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
src-tauri/capabilities/default.json
Normal file
18
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
5934
src-tauri/gen/schemas/desktop-schema.json
Normal file
5934
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
5934
src-tauri/gen/schemas/windows-schema.json
Normal file
5934
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
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
19
src-tauri/src/main.rs
Normal 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
31
src-tauri/tauri.conf.json
Normal 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
5
src/app/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MainLayout } from "./layout/MainLayout"
|
||||
|
||||
export function App() {
|
||||
return <MainLayout />
|
||||
}
|
||||
256
src/app/demo/demoProject.ts
Normal file
256
src/app/demo/demoProject.ts
Normal 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
|
||||
}
|
||||
}
|
||||
71
src/app/desktop/projectPersistence.ts
Normal file
71
src/app/desktop/projectPersistence.ts
Normal 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
|
||||
}
|
||||
181
src/app/layout/CenterEditorPlaceholder.tsx
Normal file
181
src/app/layout/CenterEditorPlaceholder.tsx
Normal 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>
|
||||
}
|
||||
138
src/app/layout/LeftResourcePanel.tsx
Normal file
138
src/app/layout/LeftResourcePanel.tsx
Normal 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])
|
||||
)
|
||||
}
|
||||
42
src/app/layout/MainLayout.tsx
Normal file
42
src/app/layout/MainLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
489
src/app/layout/RightInspectorPanel.tsx
Normal file
489
src/app/layout/RightInspectorPanel.tsx
Normal 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)
|
||||
}
|
||||
63
src/app/layout/StatusBar.tsx
Normal file
63
src/app/layout/StatusBar.tsx
Normal 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
92
src/app/layout/TopBar.tsx
Normal 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"
|
||||
}
|
||||
1003
src/app/store/workbenchStore.ts
Normal file
1003
src/app/store/workbenchStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
253
src/core/expression/evaluator.ts
Normal file
253
src/core/expression/evaluator.ts
Normal 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)
|
||||
}
|
||||
4
src/core/expression/index.ts
Normal file
4
src/core/expression/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./evaluator"
|
||||
export * from "./parser"
|
||||
export * from "./tokenizer"
|
||||
export type * from "./types"
|
||||
179
src/core/expression/parser.ts
Normal file
179
src/core/expression/parser.ts
Normal 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)
|
||||
}
|
||||
140
src/core/expression/tokenizer.ts
Normal file
140
src/core/expression/tokenizer.ts
Normal 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)
|
||||
}
|
||||
81
src/core/expression/types.ts
Normal file
81
src/core/expression/types.ts
Normal 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
4
src/core/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./expression"
|
||||
export * from "./reference"
|
||||
export * from "./renderer"
|
||||
export type * from "./types"
|
||||
123
src/core/reference/buildReferenceIndex.ts
Normal file
123
src/core/reference/buildReferenceIndex.ts
Normal 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)
|
||||
}
|
||||
64
src/core/reference/detectReferenceCycles.ts
Normal file
64
src/core/reference/detectReferenceCycles.ts
Normal 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("->")
|
||||
}
|
||||
2
src/core/reference/index.ts
Normal file
2
src/core/reference/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./buildReferenceIndex"
|
||||
export * from "./detectReferenceCycles"
|
||||
1
src/core/renderer/index.ts
Normal file
1
src/core/renderer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./renderTemplate"
|
||||
794
src/core/renderer/renderTemplate.ts
Normal file
794
src/core/renderer/renderTemplate.ts
Normal 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
8
src/core/types/common.ts
Normal 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
13
src/core/types/dataset.ts
Normal 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>
|
||||
}
|
||||
31
src/core/types/document.ts
Normal file
31
src/core/types/document.ts
Normal 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
9
src/core/types/index.ts
Normal 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
137
src/core/types/nodes.ts
Normal 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
21
src/core/types/output.ts
Normal 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
93
src/core/types/project.ts
Normal 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
|
||||
}
|
||||
28
src/core/types/reference.ts
Normal file
28
src/core/types/reference.ts
Normal 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
125
src/core/types/render.ts
Normal 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
22
src/core/types/schema.ts
Normal 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"
|
||||
380
src/editor/BlockFlowEditor.tsx
Normal file
380
src/editor/BlockFlowEditor.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
132
src/editor/adapter/astToTiptap.ts
Normal file
132
src/editor/adapter/astToTiptap.ts
Normal 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" }]
|
||||
}
|
||||
2
src/editor/adapter/index.ts
Normal file
2
src/editor/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./astToTiptap"
|
||||
export * from "./tiptapToAst"
|
||||
144
src/editor/adapter/tiptapToAst.ts
Normal file
144
src/editor/adapter/tiptapToAst.ts
Normal 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
|
||||
}
|
||||
31
src/editor/extensions/CommentBlockExtension.ts
Normal file
31
src/editor/extensions/CommentBlockExtension.ts
Normal 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}`
|
||||
]
|
||||
}
|
||||
})
|
||||
34
src/editor/extensions/ConditionBlockExtension.ts
Normal file
34
src/editor/extensions/ConditionBlockExtension.ts
Normal 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]
|
||||
]
|
||||
}
|
||||
})
|
||||
34
src/editor/extensions/ContainerBlockExtension.ts
Normal file
34
src/editor/extensions/ContainerBlockExtension.ts
Normal 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]
|
||||
]
|
||||
}
|
||||
})
|
||||
33
src/editor/extensions/FragmentRefExtension.ts
Normal file
33
src/editor/extensions/FragmentRefExtension.ts
Normal 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]
|
||||
]
|
||||
}
|
||||
})
|
||||
43
src/editor/extensions/LoopBlockExtension.ts
Normal file
43
src/editor/extensions/LoopBlockExtension.ts
Normal 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]
|
||||
]
|
||||
}
|
||||
})
|
||||
16
src/editor/extensions/ParagraphBlockExtension.ts
Normal file
16
src/editor/extensions/ParagraphBlockExtension.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
61
src/editor/extensions/SelectedBlockExtension.ts
Normal file
61
src/editor/extensions/SelectedBlockExtension.ts
Normal 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)
|
||||
}
|
||||
61
src/editor/extensions/VariableInlineExtension.ts
Normal file
61
src/editor/extensions/VariableInlineExtension.ts
Normal 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
|
||||
})
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
8
src/editor/extensions/index.ts
Normal file
8
src/editor/extensions/index.ts
Normal 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
10
src/main.tsx
Normal 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>
|
||||
)
|
||||
62
src/project/createProject.ts
Normal file
62
src/project/createProject.ts
Normal 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
22
src/project/fileSystem.ts
Normal 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
6
src/project/index.ts
Normal 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
7
src/project/json.ts
Normal 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`
|
||||
}
|
||||
43
src/project/nodeFileSystem.ts
Normal file
43
src/project/nodeFileSystem.ts
Normal 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
263
src/project/openProject.ts
Normal 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
128
src/project/paths.ts
Normal 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 ""
|
||||
}
|
||||
46
src/project/readDocument.ts
Normal file
46
src/project/readDocument.ts
Normal 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
123
src/project/scanProject.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
45
src/project/tauriFileSystem.ts
Normal file
45
src/project/tauriFileSystem.ts
Normal 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
49
src/project/validation.ts
Normal 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
|
||||
}
|
||||
50
src/project/writeDocument.ts
Normal file
50
src/project/writeDocument.ts
Normal 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
140
src/styles.css
Normal 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
10
src/utils/id.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
44
tailwind.config.ts
Normal file
44
tailwind.config.ts
Normal 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
265
tests/app.test.tsx
Normal 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
62
tests/editor.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
87
tests/editorAdapter.test.ts
Normal file
87
tests/editorAdapter.test.ts
Normal 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
79
tests/expression.test.ts
Normal 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
256
tests/project.test.ts
Normal 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
|
||||
}
|
||||
374
tests/renderTemplate.test.ts
Normal file
374
tests/renderTemplate.test.ts
Normal 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
17
tsconfig.json
Normal 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
29
vite.config.ts
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user