# racletteJS Documentation Project This is the official documentation site for racletteJS, a fullstack web framework for rapid development of platforms and portals with built-in multi-tenancy, authentication, and modular plugin architecture. The documentation project is built with VitePress and features a custom markdown processing plugin called "recipe-docs" that extends VitePress with frontmatter variable inheritance, recursive includes with variable overrides, YAML-style inline variable definitions, and build-time validation capabilities. This documentation provides comprehensive guides for developers building SaaS products, customer portals, and business tools with racletteJS. The project includes extensive documentation covering the complete development lifecycle from initial setup through plugin development and deployment. The custom VitePress plugin enables powerful documentation patterns like reusable markdown snippets with configurable variables, allowing documentation authors to create maintainable and DRY (Don't Repeat Yourself) content. The documentation is automatically deployed from the GitLab repository and includes sections on framework introduction, workbench usage, plugin development (frontend and backend), official plugins, directory structure reference, and configuration options. ## Project Structure ### Documentation Organization ``` docs/ ├── introduction/ # Framework overview and getting started │ ├── what-is-raclette.md │ ├── architecture.md │ ├── getting-started.md │ └── cli-commands.md ├── workbench/ # Visual configuration interface │ ├── introduction.md │ ├── compositions.md │ ├── interactionLinks.md │ ├── users.md │ ├── tags.md │ └── plugins.md ├── plugin-development/ # Creating plugins for racletteJS │ ├── metadata.md │ ├── api.md │ ├── widgets.md │ └── backend.md ├── examples/ # Code examples and recipes │ ├── cookbook.md │ ├── recipes/ │ └── cooking-steps/ ├── official-plugins/ # Documentation for official plugins │ ├── boilerplate.md │ └── cli-connector.md ├── coding-with-ai/ # AI-assisted development guides │ └── introduction.md └── directory-structure/ # File and folder reference ├── raclette.md ├── raclette-config.md ├── plugins.md └── ... reference/ └── raclette-config.md # Configuration reference ``` ### VitePress Configuration ```typescript // .vitepress/config.ts import { defineConfig } from "vitepress" import recipeDocsPlugin from "./plugins/recipe-plugin" export default defineConfig({ title: "raclette-docs", description: "Documentation of the awesome raclette framework.", lang: "en-EU", cleanUrls: true, srcExclude: ["**/compiled-docs/**/*.md"], vite: { plugins: [ recipeDocsPlugin({ exportCompiled: false, exportPath: "./compiled-docs", exportExclude: ["index.md", "README.md", "LICENSE.md"] }) ] }, sitemap: { hostname: "https://docs.raclettejs.com" } }) ``` ### Recipe Plugin Architecture ``` .vitepress/plugins/recipe-plugin/ ├── index.ts # Main plugin entry point ├── types.ts # TypeScript type definitions ├── frontmatter.ts # YAML frontmatter parsing ├── includes.ts # Recursive include processing ├── interpolation.ts # Variable interpolation ├── export.ts # Compiled markdown export └── utils.ts # Utility functions ``` ## Recipe-Docs Plugin API ### Plugin Configuration Options ```typescript interface RecipeDocsPluginOptions { exportCompiled?: boolean // Enable exporting compiled markdown exportPath?: string // Path for compiled files exportExclude?: string[] | ((filePath: string) => boolean) // Files to exclude } // Basic usage recipeDocsPlugin({ exportCompiled: true, exportPath: "./compiled-docs", exportExclude: ["index.md", "README.md", "*.draft.md"] }) // Custom filter function recipeDocsPlugin({ exportCompiled: true, exportExclude: (filePath) => { return filePath.includes('drafts') || filePath.endsWith('index.md') } }) ``` ### Frontmatter Variables Define variables in YAML frontmatter that can be referenced throughout the document. Supports both simple values and multiline content: ```markdown --- PLUGIN_NAME: todo_plugin API_VERSION: v1 BASE_URL: /api/{{$frontmatter.API_VERSION}}/{{$frontmatter.PLUGIN_NAME}} DESCRIPTION: | This is a multiline description that preserves line breaks and indentation FOLDED_TEXT: > This text will be folded into a single line with spaces between words --- # API Documentation Base URL: {{$frontmatter.BASE_URL}} Create endpoint: {{$frontmatter.BASE_URL}}/create {{$frontmatter.DESCRIPTION}} ``` **Supported YAML Features:** - Simple key-value pairs - Multiline literal blocks using `|` (preserves line breaks) - Multiline folded blocks using `>` (folds into single line) - Quoted strings (both single and double quotes) - Comments starting with `#` ### Recursive Includes with Variable Overrides Include external markdown files and override variables: ```markdown --- DEFAULT_METHOD: GET DEFAULT_AUTH: required --- # API Endpoints ``` The include directive supports: - Recursive includes (included files can include other files) - Variable inheritance from parent document - Variable overrides using YAML syntax in include directive - Circular dependency detection ### Variable Interpolation Variables are interpolated using the `{{$frontmatter.VARIABLE}}` syntax with support for fallback values: ```markdown --- API_VERSION: v1 PLUGIN_NAME: weather_plugin BASE_URL: /api/{{$frontmatter.API_VERSION}}/{{$frontmatter.PLUGIN_NAME}} OPTIONAL_NOTE: "" --- ## Weather Plugin API The Weather Plugin exposes its API at: {{$frontmatter.BASE_URL}} {{$frontmatter.OPTIONAL_NOTE:No additional notes}} Example request: ```typescript const response = await fetch('{{$frontmatter.BASE_URL}}/current', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_TOKEN' } }) ``` ``` **Advanced Features:** - **Fallback Values**: Use `{{$frontmatter.VAR:fallback text}}` to provide default values - **Empty Variable Handling**: Lines containing only empty variables are automatically removed - **Multiline Values**: Multiline variable values preserve indentation automatically - **Escaped Variables**: Use `\{{$frontmatter.VAR}}` to prevent interpolation ### Processing Context ```typescript interface ProcessingContext { filePath: string // Current file being processed includeChain: string[] // Stack of included files (for circular detection) variables: VariableContext // Merged variables from frontmatter and overrides } interface VariableContext { [key: string]: string // Variable name to value mapping } ``` ## Development Workflow ### Local Development Setup ```bash # Clone the repository git clone https://gitlab.com/raclettejs/docs cd docs # Install dependencies yarn install # Start development server yarn dev # Opens at http://localhost:5173 # Build for production yarn build # Preview production build yarn preview ``` ### Scripts ```json { "scripts": { "dev": "vitepress dev", "build": "vitepress build", "preview": "vitepress preview", "export:compiled": "tsx ./.vitepress/export-compiled.ts", "prepare": "husky" } } ``` ### Writing Documentation Documentation files use markdown with additional recipe-docs features: ```markdown --- title: My Documentation Page COMMON_VAR: shared value --- # {{$frontmatter.title}} Content here can use {{$frontmatter.COMMON_VAR}} ``` ### Including Reusable Snippets Create shared snippets in a common location: ```markdown --- REQUIRED_ROLE: user --- ::: warning Authentication Required This endpoint requires authentication with role: **{{$frontmatter.REQUIRED_ROLE}}** ::: ``` Then include with overrides: ```markdown ``` ## Recipe Plugin Implementation ### Plugin Lifecycle ```typescript export function recipeDocsPlugin(options: RecipeDocsPluginOptions = {}): Plugin { const { exportCompiled = false, exportPath = "./compiled-docs", exportExclude } = options let exporter: CompiledDocsExporter | null = null return { name: "recipe-docs", enforce: "pre", buildStart() { if (exportCompiled) { exporter = new CompiledDocsExporter(exportPath, exportExclude) this.info(`📝 Recipe Docs: Exporting compiled markdown to ${exportPath}`) } }, transform(code: string, id: string) { if (!id.endsWith(".md")) return null try { const processedContent = processMarkdownFile(id, code) // Export compiled version if enabled if (exportCompiled && exporter) { exporter.exportFile(id, processedContent) } return { code: processedContent, map: null } } catch (error) { if (error instanceof RecipeDocsError) { this.error(error) } throw error } }, buildEnd() { if (exportCompiled && exporter) { const stats = exporter.getStats() this.info( `✅ Recipe Docs: Exported ${stats.totalFiles} compiled markdown files to ${stats.exportPath}` ) } } } } ``` ### Markdown Processing Pipeline ```typescript function processMarkdownFile(filePath: string, content: string): string { // 1. Extract frontmatter variables const { frontmatter, body } = extractFrontmatter(content) // 2. Create processing context const context: ProcessingContext = { filePath, includeChain: [filePath], variables: frontmatter } // 3. Process includes recursively const processedBody = processIncludes(body, context) // 4. Interpolate variables const finalContent = interpolateVariables(processedBody, context) return finalContent } ``` ### Frontmatter Extraction ```typescript // frontmatter.ts export function extractFrontmatter(content: string): FrontmatterResult { const frontmatterMatch = content.match( /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/ ) if (!frontmatterMatch) { return { frontmatter: {}, body: content } } const yamlContent = frontmatterMatch[1] const body = frontmatterMatch[2] try { const frontmatter = parseYaml(yamlContent) return { frontmatter, body } } catch (error) { throw new RecipeDocsError( `Failed to parse frontmatter: ${ error instanceof Error ? error.message : String(error) }` ) } } export function parseYaml(yamlContent: string): VariableContext { const result: VariableContext = {} const lines = yamlContent.split(/\r?\n/) let i = 0 while (i < lines.length) { const line = lines[i] const trimmedLine = line.trim() // Skip empty lines and comments if (!trimmedLine || trimmedLine.startsWith("#")) { i++ continue } const colonIndex = trimmedLine.indexOf(":") if (colonIndex === -1) { i++ continue } const key = trimmedLine.slice(0, colonIndex).trim() const valueStart = trimmedLine.slice(colonIndex + 1).trim() if (valueStart === "|" || valueStart === ">") { // Block scalar (multiline value) const { value, nextIndex } = parseBlockScalar( lines, i, line.length - line.trimStart().length, valueStart === "|" // true = literal, false = folded ) result[key] = value i = nextIndex } else { // Simple value result[key] = parseSimpleValue(valueStart) i++ } } return result } function parseBlockScalar( lines: string[], startIndex: number, baseIndent: number, isLiteral: boolean ): { value: string; nextIndex: number } { const blockLines: string[] = [] let i = startIndex + 1 while (i < lines.length) { const line = lines[i] const lineIndent = line.length - line.trimStart().length if (line.trim() === "") { blockLines.push("") i++ continue } if (lineIndent > baseIndent) { blockLines.push(line) i++ } else { break } } if (blockLines.length === 0) { return { value: "", nextIndex: i } } // Remove common indentation const nonEmptyLines = blockLines.filter((l) => l.trim()) const minIndent = Math.min( ...nonEmptyLines.map((l) => l.length - l.trimStart().length) ) const dedentedLines = blockLines.map((l) => { if (!l.trim()) return "" return l.slice(minIndent) }) if (isLiteral) { // Literal block: preserve line breaks return { value: dedentedLines.join("\n"), nextIndex: i } } else { // Folded block: fold lines into single line const value = dedentedLines .join("\n") .replace(/\n+/g, "\n") .replace(/\n/g, " ") .trim() return { value, nextIndex: i } } } function parseSimpleValue(value: string): string { if (!value) return "" // Strip outermost quotes (both single and double) if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { return value.slice(1, -1) } return value } ``` ### Include Processing ```typescript // includes.ts export function processIncludes(content: string, context: ProcessingContext): string { // Match: const includeRegex = //g const matches: Array<{ fullMatch: string path: string inlineVars: VariableContext start: number end: number }> = [] let match: RegExpExecArray | null while ((match = includeRegex.exec(content)) !== null) { const [fullMatch, pathPart, inlineVarsPart] = match matches.push({ fullMatch, path: pathPart.trim(), inlineVars: parseYaml(inlineVarsPart?.trim() || ""), start: match.index, end: match.index + fullMatch.length, }) } // Process matches in reverse to maintain string positions let result = content for (let i = matches.length - 1; i >= 0; i--) { const { path, inlineVars, start, end } = matches[i] const includedContent = loadInclude(path, inlineVars, context) result = result.slice(0, start) + includedContent + result.slice(end) } return result } function loadInclude( includePath: string, inlineVars: VariableContext, parentContext: ProcessingContext ): string { const currentDir = dirname(parentContext.filePath) const resolvedPath = resolve(currentDir, includePath) if (!existsSync(resolvedPath)) { const relPath = relative(process.cwd(), parentContext.filePath) throw new RecipeDocsError( `Include file not found: "${includePath}"\n Referenced in: ${relPath}`, parentContext.filePath ) } // Detect circular dependencies if (parentContext.includeChain.includes(resolvedPath)) { const chain = [...parentContext.includeChain, resolvedPath] .map((p) => relative(process.cwd(), p)) .join("\n → ") throw new RecipeDocsError( `Circular include detected:\n ${chain}`, parentContext.filePath ) } const includeContent = readFileSync(resolvedPath, "utf-8") const { frontmatter: includeFrontmatter, body: includeBody } = extractFrontmatter(includeContent) // Variable precedence: inline > parent > included file const mergedVariables: VariableContext = { ...includeFrontmatter, ...parentContext.variables, ...inlineVars, } const includeContext: ProcessingContext = { filePath: resolvedPath, includeChain: [...parentContext.includeChain, resolvedPath], variables: mergedVariables, } // Recursively process includes and interpolate variables const processedBody = processIncludes(includeBody, includeContext) return interpolateVariables(processedBody, includeContext) } ``` ### Variable Interpolation ```typescript // interpolation.ts export function interpolateVariables(content: string, context: ProcessingContext): string { // Handle escaped variables first content = content.replace(/\\(\{\{\s*\$frontmatter\.[^}]+\}\})/g, "$1") // Identify lines with only empty variables for removal const linesToRemove = identifyEmptyVariableLines(content, context) // Replace all variables with support for fallback syntax (VAR:fallback text) const variableRegex = /\{\{\s*\$frontmatter\.([^}]+?)\}\}/g const result = content.replace(variableRegex, (match, varExpr, offset) => { if (offset > 0 && content[offset - 1] === "\\") { return match } // Parse variable name and optional fallback const [varName, ...fallbackParts] = varExpr.split(":") const cleanVarName = varName.trim() const fallback = fallbackParts.join(":").trim() const hasVariable = cleanVarName in context.variables const value = context.variables[cleanVarName] let replacementValue = "" if (hasVariable) { if (value) { replacementValue = value } else { return "" // Empty variable } } else if (fallback) { replacementValue = fallback } else { throw new RecipeDocsError( `Undefined variable: "{{$frontmatter.${cleanVarName}}}"\n` + ` File: ${context.filePath}\n` + ` Available variables: ${Object.keys(context.variables).join(", ") || "(none)"}\n` + ` Tip: Add the variable to frontmatter (even if empty) to suppress this error` ) } // Apply indentation for multiline values if (replacementValue.includes("\n")) { return applyIndentation(replacementValue, content, offset) } return replacementValue }) // Remove lines containing only empty variables and clean up excessive whitespace return cleanupResult(result, linesToRemove) } function applyIndentation(value: string, content: string, offset: number): string { const lineStart = content.lastIndexOf("\n", offset - 1) + 1 const lineBeforeVar = content.substring(lineStart, offset) const indentation = lineBeforeVar.match(/^(\s*)/)?.[1] || "" const valueLines = value.split("\n") return valueLines .map((line, idx) => (idx === 0 ? line : indentation + line)) .join("\n") } ``` ### Compiled Docs Export ```typescript // export.ts export class CompiledDocsExporter { private exportPath: string private sourceRoot: string private processedFiles = new Map() private excludeFilter?: string[] | ((filePath: string) => boolean) constructor(exportPath: string, excludeFilter?: string[] | ((path: string) => boolean)) { this.exportPath = resolve(process.cwd(), exportPath) this.sourceRoot = resolve(process.cwd()) this.excludeFilter = excludeFilter } exportFile(sourcePath: string, compiledContent: string): void { // Check if file should be excluded if (shouldExcludeFile(sourcePath, this.excludeFilter)) { return } // Strip VitePress-specific components and clean up whitespace const cleanedContent = this.prepareForExport(compiledContent) const outputPath = this.getOutputPath(sourcePath) this.writeFile(outputPath, cleanedContent) } private prepareForExport(content: string): string { let cleaned = content // Strip VitePress components and directives cleaned = stripVitePressComponents(cleaned) // Clean up extra whitespace cleaned = cleanupExtraWhitespace(cleaned) return cleaned } private writeFile(outputPath: string, content: string): void { const dir = dirname(outputPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } writeFileSync(outputPath, content, "utf-8") } getStats() { return { totalFiles: this.processedFiles.size, exportPath: this.exportPath } } } // Strip VitePress-specific components for clean exports export function stripVitePressComponents(content: string): string { let result = content // Remove