diff --git a/.clinerules b/.clinerules index 173d58a..5adae5b 100644 --- a/.clinerules +++ b/.clinerules @@ -1520,3 +1520,37 @@ Starting with Subtask 1 now..." 6. **Learn from errors** - If you hit limits, that task was too large **Remember**: It's better to complete 3 small subtasks successfully than fail on 1 large task repeatedly. + +--- + +## 16. Code Comments Language + +**All code comments must be in English**, regardless of the user's language. This ensures: + +- Consistent codebase for international collaboration +- Better compatibility with AI tools +- Easier code review and maintenance + +```typescript +// ✅ GOOD: English comments +function calculateTotal(items: Item[]): number { + // Sum up all item prices + return items.reduce((sum, item) => sum + item.price, 0); +} + +// ❌ BAD: Non-English comments +function calculateTotal(items: Item[]): number { + // Additionner tous les prix des articles + return items.reduce((sum, item) => sum + item.price, 0); +} +``` + +This rule applies to: + +- Inline comments +- Function/class documentation (JSDoc) +- Block comments explaining logic +- TODO/FIXME notes +- Commit messages (covered in Git Workflow section) + +**Exception**: User-facing strings in UI components may be in any language (they will be localized later). diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-009-template-system-refactoring/README.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-009-template-system-refactoring/README.md new file mode 100644 index 0000000..70706c7 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-009-template-system-refactoring/README.md @@ -0,0 +1,255 @@ +# TASK-009: Template System Refactoring + +**Status**: 📋 Planned +**Priority**: Medium +**Complexity**: Medium +**Estimated Effort**: 2-3 days + +## Context + +The current project template system has several issues: + +- Path resolution fails in webpack bundles (`__dirname` doesn't work correctly) +- No proper template provider for local/bundled templates +- Template loading depends on external URLs or fragile file paths +- New projects currently use a programmatic workaround (minimal project.json generation) + +## Current Temporary Solution + +As of January 2026, new projects are created programmatically in `LocalProjectsModel.ts`: + +```typescript +// Create a minimal Hello World project programmatically +const minimalProject = { + name: name, + components: [ + /* basic App component with Text node */ + ], + settings: {}, + metadata: { + /* ... */ + } +}; +``` + +This works but is not ideal for: + +- Creating rich starter templates +- Allowing custom/community templates +- Supporting multiple bundled templates (e.g., "Hello World", "Dashboard", "E-commerce") + +## Goals + +### Primary Goals + +1. **Robust Template Loading**: Support templates in both development and production +2. **Local Templates**: Bundle templates with the editor that work reliably +3. **Template Gallery**: Support multiple built-in templates +4. **Custom Templates**: Allow users to create and share templates + +### Secondary Goals + +1. Template versioning and migration +2. Template metadata (screenshots, descriptions, categories) +3. Template validation before project creation +4. Template marketplace integration (future) + +## Proposed Architecture + +### 1. Template Storage Options + +**Option A: Embedded Templates (Recommended)** + +- Store templates as JSON structures in TypeScript files +- Import and use directly (no file I/O) +- Bundle with webpack automatically +- Example: + +```typescript +export const helloWorldTemplate: ProjectTemplate = { + name: 'Hello World', + components: [ + /* ... */ + ], + settings: { + /* ... */ + } +}; +``` + +**Option B: Asset-Based Templates** + +- Store templates in `packages/noodl-editor/assets/templates/` +- Copy to build output during webpack build +- Use proper asset loading (webpack copy plugin) +- Access via runtime asset path resolution + +**Option C: Hybrid Approach** + +- Small templates: embedded in code +- Large templates: assets with proper bundling +- Choose based on template size/complexity + +### 2. Template Provider Architecture + +```typescript +interface ProjectTemplate { + id: string; + name: string; + description: string; + category: string; + version: string; + thumbnail?: string; + + // Template content + components: ComponentDefinition[]; + settings: ProjectSettings; + metadata?: Record; +} + +interface TemplateProvider { + name: string; + list(): Promise; + get(id: string): Promise; + canHandle(id: string): boolean; +} + +class EmbeddedTemplateProvider implements TemplateProvider { + // Returns templates bundled with the editor +} + +class RemoteTemplateProvider implements TemplateProvider { + // Fetches templates from Noodl docs/CDN +} + +class LocalFileTemplateProvider implements TemplateProvider { + // Loads templates from user's filesystem (for custom templates) +} +``` + +### 3. Template Manager + +```typescript +class TemplateManager { + private providers: TemplateProvider[]; + + async listTemplates(filter?: TemplateFilter): Promise { + // Aggregates from all providers + } + + async getTemplate(id: string): Promise { + // Finds the right provider and fetches template + } + + async createProjectFromTemplate(template: ProjectTemplate, projectPath: string, projectName: string): Promise { + // Creates project structure from template + } +} +``` + +## Implementation Plan + +### Phase 1: Foundation (1 day) + +- [ ] Define `ProjectTemplate` interface +- [ ] Create `TemplateProvider` interface +- [ ] Implement `EmbeddedTemplateProvider` +- [ ] Create `TemplateManager` class + +### Phase 2: Built-in Templates (1 day) + +- [ ] Convert current Hello World to embedded template +- [ ] Add "Blank" template (truly empty) +- [ ] Add "Dashboard" template (with nav + pages) +- [ ] Add template metadata and thumbnails + +### Phase 3: Integration (0.5 days) + +- [ ] Update `LocalProjectsModel` to use `TemplateManager` +- [ ] Remove programmatic project creation workaround +- [ ] Update project creation UI to show template gallery +- [ ] Add template preview/selection dialog + +### Phase 4: Advanced Features (0.5 days) + +- [ ] Implement template validation +- [ ] Add template export functionality (for users to create templates) +- [ ] Support template variables/parameters +- [ ] Add template upgrade/migration system + +## Files to Modify + +### New Files + +- `packages/noodl-editor/src/editor/src/models/template/ProjectTemplate.ts` +- `packages/noodl-editor/src/editor/src/models/template/TemplateProvider.ts` +- `packages/noodl-editor/src/editor/src/models/template/TemplateManager.ts` +- `packages/noodl-editor/src/editor/src/models/template/providers/EmbeddedTemplateProvider.ts` +- `packages/noodl-editor/src/editor/src/models/template/templates/` (folder for template definitions) + - `hello-world.ts` + - `blank.ts` + - `dashboard.ts` + +### Existing Files to Update + +- `packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts` + - Replace programmatic project creation with template system +- `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` + - Add template selection UI +- `packages/noodl-editor/src/editor/src/utils/forge/` (might be refactored or replaced) + +## Testing Strategy + +### Unit Tests + +- Template provider loading +- Template validation +- Project creation from template +- Template merging/variables + +### Integration Tests + +- Create project from each bundled template +- Verify all templates load correctly +- Test template provider fallback + +### Manual Tests + +- Create projects from templates in dev mode +- Create projects from templates in production build +- Verify all components and nodes are created correctly +- Test custom template import/export + +## Success Criteria + +- [ ] New projects can be created from bundled templates reliably +- [ ] Templates work identically in dev and production +- [ ] At least 3 high-quality bundled templates available +- [ ] Template system is extensible for future templates +- [ ] No file path resolution issues +- [ ] User can export their project as a template +- [ ] Documentation for creating custom templates + +## Future Enhancements + +- **Template Marketplace**: Browse and download community templates +- **Template Packages**: Include external dependencies/modules +- **Template Generator**: AI-powered template creation +- **Template Forking**: Modify and save as new template +- **Template Versioning**: Update templates without breaking existing projects + +## References + +- Current implementation: `packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts` (lines 295-360) +- Failed attempt: `packages/noodl-editor/src/editor/src/utils/forge/template/providers/local-template-provider.ts` +- Template registry: `packages/noodl-editor/src/editor/src/utils/forge/index.ts` + +## Related Tasks + +- None yet (this is the first comprehensive template system task) + +--- + +**Created**: January 8, 2026 +**Last Updated**: January 8, 2026 +**Assignee**: TBD diff --git a/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx b/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx index add9451..6de82a1 100644 --- a/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx +++ b/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx @@ -86,6 +86,9 @@ export function NodeGraphContextProvider({ children }: NodeGraphContextProviderP if (!nodeGraph) return; function _update(model: ComponentModel) { + // Guard against undefined model (happens on empty projects) + if (!model) return; + if (isComponentModel_CloudRuntime(model)) { setActive('backend'); if (SidebarModel.instance.ActiveId === 'components') { diff --git a/packages/noodl-editor/src/editor/src/models/projectmodel.modules.ts b/packages/noodl-editor/src/editor/src/models/projectmodel.modules.ts index e6c36dd..c0998c7 100644 --- a/packages/noodl-editor/src/editor/src/models/projectmodel.modules.ts +++ b/packages/noodl-editor/src/editor/src/models/projectmodel.modules.ts @@ -1,4 +1,5 @@ import { filesystem } from '@noodl/platform'; + import { bugtracker } from '@noodl-utils/bugtracker'; // TODO: Can we merge this with ProjectModules ? @@ -27,21 +28,32 @@ export async function listProjectModules(project: TSFixme /* ProjectModel */): P }[] = []; const modulesPath = project._retainedProjectDirectory + '/noodl_modules'; - const files = await filesystem.listDirectory(modulesPath); - await Promise.all( - files.map(async (file) => { - if (file.isDirectory) { - const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json'); - const manifest = await filesystem.readJson(manifestPath); + try { + const files = await filesystem.listDirectory(modulesPath); - modules.push({ - name: file.name, - manifest - }); - } - }) - ); + await Promise.all( + files.map(async (file) => { + if (file.isDirectory) { + const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json'); + const manifest = await filesystem.readJson(manifestPath); + + modules.push({ + name: file.name, + manifest + }); + } + }) + ); + } catch (error) { + // noodl_modules folder doesn't exist (fresh/empty project) + if (error.code === 'ENOENT') { + console.log('noodl_modules folder not found (fresh project), skipping module loading'); + return []; + } + // Re-throw other errors + throw error; + } return modules; } @@ -50,40 +62,51 @@ export async function readProjectModules(project: TSFixme /* ProjectModel */): P bugtracker.debug('ProjectModel.readModules'); const modulesPath = project._retainedProjectDirectory + '/noodl_modules'; - const files = await filesystem.listDirectory(modulesPath); project.modules = []; project.previews = []; project.componentAnnotations = {}; - await Promise.all( - files.map(async (file) => { - if (file.isDirectory) { - const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json'); - const manifest = await filesystem.readJson(manifestPath); + try { + const files = await filesystem.listDirectory(modulesPath); - if (manifest) { - manifest.name = file.name; - project.modules.push(manifest); + await Promise.all( + files.map(async (file) => { + if (file.isDirectory) { + const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json'); + const manifest = await filesystem.readJson(manifestPath); - if (manifest.componentAnnotations) { - for (var comp in manifest.componentAnnotations) { - var ca = manifest.componentAnnotations[comp]; + if (manifest) { + manifest.name = file.name; + project.modules.push(manifest); - if (!project.componentAnnotations[comp]) project.componentAnnotations[comp] = {}; - for (var key in ca) project.componentAnnotations[comp][key] = ca[key]; + if (manifest.componentAnnotations) { + for (var comp in manifest.componentAnnotations) { + var ca = manifest.componentAnnotations[comp]; + + if (!project.componentAnnotations[comp]) project.componentAnnotations[comp] = {}; + for (var key in ca) project.componentAnnotations[comp][key] = ca[key]; + } + } + + if (manifest.previews) { + project.previews = manifest.previews.concat(project.previews); } } - - if (manifest.previews) { - project.previews = manifest.previews.concat(project.previews); - } } - } - }) - ); + }) + ); - console.log(`Loaded ${project.modules.length} modules`); + console.log(`Loaded ${project.modules.length} modules`); + } catch (error) { + // noodl_modules folder doesn't exist (fresh/empty project) + if (error.code === 'ENOENT') { + console.log('noodl_modules folder not found (fresh project), skipping module loading'); + return []; + } + // Re-throw other errors + throw error; + } return project.modules; } diff --git a/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts b/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts index c6cdcc8..d602d70 100644 --- a/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts +++ b/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts @@ -260,36 +260,67 @@ export class LocalProjectsModel extends Model { }); }); } else { - // Default template path - const defaultTemplatePath = './external/projecttemplates/helloworld.zip'; - - // Check if template exists, otherwise create an empty project - if (filesystem.exists(defaultTemplatePath)) { - this._unzipAndLaunchProject(defaultTemplatePath, dirEntry, fn, options); - } else { - console.warn('Default project template not found, creating empty project'); - - // Create minimal project.json for empty project - const minimalProject = { - name: name, - components: [], - settings: {} - }; - - await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2)); - - // Load the newly created empty project - projectFromDirectory(dirEntry, (project) => { - if (!project) { - fn(); - return; + // Create a minimal Hello World project programmatically + // This is a temporary solution until TASK-009-template-system-refactoring is implemented + const minimalProject = { + name: name, + components: [ + { + name: 'App', + ports: [], + visual: true, + visualStateTransitions: [], + nodes: [ + { + id: guid(), + type: 'Group', + x: 0, + y: 0, + parameters: {}, + ports: [], + children: [ + { + id: guid(), + type: 'Text', + x: 50, + y: 50, + parameters: { + text: 'Hello World!' + }, + ports: [], + children: [] + } + ] + } + ] } + ], + settings: {}, + metadata: { + title: name, + description: 'A new Noodl project' + } + }; - project.name = name; - this._addProject(project); - fn(project); + await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2)); + + // Load the newly created project + projectFromDirectory(dirEntry, (project) => { + if (!project) { + fn(); + return; + } + + project.name = name; + this._addProject(project); + project.toDirectory(project._retainedProjectDirectory, (res) => { + if (res.result === 'success') { + fn(project); + } else { + fn(); + } }); - } + }); } } diff --git a/packages/noodl-editor/src/editor/src/utils/NodeGraph/index.ts b/packages/noodl-editor/src/editor/src/utils/NodeGraph/index.ts index b5d185d..7dd1354 100644 --- a/packages/noodl-editor/src/editor/src/utils/NodeGraph/index.ts +++ b/packages/noodl-editor/src/editor/src/utils/NodeGraph/index.ts @@ -3,6 +3,9 @@ import { NodeGraphNode } from '@noodl-models/nodegraphmodel'; import { RuntimeType } from '@noodl-models/nodelibrary/NodeLibraryData'; export function getComponentModelRuntimeType(node: ComponentModel) { + // Guard against undefined node (happens on empty projects) + if (!node) return RuntimeType.Browser; + const name = node.name; if (name.startsWith('/#__cloud__/')) { diff --git a/packages/noodl-editor/src/editor/src/utils/compilation/context/pages.ts b/packages/noodl-editor/src/editor/src/utils/compilation/context/pages.ts index 019a97b..666a63c 100644 --- a/packages/noodl-editor/src/editor/src/utils/compilation/context/pages.ts +++ b/packages/noodl-editor/src/editor/src/utils/compilation/context/pages.ts @@ -154,6 +154,11 @@ export async function getPageRoutes(project: ProjectModel, options: IndexedPages } }); + // Check if traverser has valid root (empty project case) + if (!traverser.root) { + return { routes: [], pages: [], dynamicHash: {} }; + } + // Fetch all the Page nodes. const pages: TraverseNode[] = traverser.filter((node) => node.node.typename === 'Page'); diff --git a/packages/noodl-editor/src/editor/src/utils/forge/template/providers/local-template-provider.ts b/packages/noodl-editor/src/editor/src/utils/forge/template/providers/local-template-provider.ts new file mode 100644 index 0000000..e460c75 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/utils/forge/template/providers/local-template-provider.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { filesystem } from '@noodl/platform'; + +import FileSystem from '../../../filesystem'; +import { ITemplateProvider, TemplateItem, TemplateListFilter } from '../template'; + +/** + * Provides access to locally bundled project templates. + * This provider is used for templates that ship with the editor. + */ +export class LocalTemplateProvider implements ITemplateProvider { + get name(): string { + return 'local-templates'; + } + + async list(_options: TemplateListFilter): Promise { + // Return only the Hello World template + return [ + { + title: 'Hello World', + category: 'Getting Started', + desc: 'A simple starter project to begin your Noodl journey', + iconURL: './assets/template-hello-world-icon.png', + projectURL: 'local://hello-world', + cloudServicesTemplateURL: undefined + } + ]; + } + + canDownload(url: string): Promise { + // Handle local:// protocol + return Promise.resolve(url.startsWith('local://')); + } + + async download(url: string, destination: string): Promise { + if (url === 'local://hello-world') { + // The template is in project-examples folder at the repository root + // Use process.cwd() which points to repository root during development + const repoRoot = process.cwd(); + const sourcePath = path.join(repoRoot, 'project-examples', 'version 1.1.0', 'template-project'); + + if (!filesystem.exists(sourcePath)) { + throw new Error('Hello World template not found at: ' + sourcePath); + } + + // Copy the template folder to destination + // The destination is expected to be where unzipped content goes + // So we copy the folder contents directly + FileSystem.instance.copyRecursiveSync(sourcePath, destination); + } else { + throw new Error(`Unknown local template: ${url}`); + } + } +} diff --git a/packages/noodl-editor/src/editor/src/utils/node-graph-traverser.ts b/packages/noodl-editor/src/editor/src/utils/node-graph-traverser.ts index 8c5581d..22f7641 100644 --- a/packages/noodl-editor/src/editor/src/utils/node-graph-traverser.ts +++ b/packages/noodl-editor/src/editor/src/utils/node-graph-traverser.ts @@ -59,14 +59,24 @@ export class NodeGraphTraverser { this.traverseComponent = typeof options.traverseComponent === 'boolean' ? options.traverseComponent : true; this.tagSelector = typeof options.tagSelector === 'function' ? options.tagSelector : null; - this.root = new TraverseNode(this, null, targetNode || project.getRootNode(), null); + const rootNode = targetNode || project.getRootNode(); + + // Handle empty projects with no root node + if (!rootNode) { + this.root = null; + return; + } + + this.root = new TraverseNode(this, null, rootNode, null); } public forEach(callback: (node: TraverseNode) => void) { + if (!this.root) return; this.root.forEach(callback); } public map(callback: (node: TraverseNode) => T) { + if (!this.root) return []; const items: T[] = []; this.forEach((node) => { const result = callback(node); @@ -76,6 +86,7 @@ export class NodeGraphTraverser { } public filter(callback: (node: TraverseNode) => boolean) { + if (!this.root) return []; const items: TraverseNode[] = []; this.forEach((node) => { if (callback(node)) items.push(node);