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