Fix app startup issues and add TASK-009 template system refactoring

This commit is contained in:
Tara West
2026-01-08 13:36:03 +01:00
parent 4a1080d547
commit 199b4f9cb2
9 changed files with 482 additions and 63 deletions

View File

@@ -1520,3 +1520,37 @@ Starting with Subtask 1 now..."
6. **Learn from errors** - If you hit limits, that task was too large 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. **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).

View File

@@ -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<string, unknown>;
}
interface TemplateProvider {
name: string;
list(): Promise<ProjectTemplate[]>;
get(id: string): Promise<ProjectTemplate>;
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<ProjectTemplate[]> {
// Aggregates from all providers
}
async getTemplate(id: string): Promise<ProjectTemplate> {
// Finds the right provider and fetches template
}
async createProjectFromTemplate(template: ProjectTemplate, projectPath: string, projectName: string): Promise<void> {
// 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

View File

@@ -86,6 +86,9 @@ export function NodeGraphContextProvider({ children }: NodeGraphContextProviderP
if (!nodeGraph) return; if (!nodeGraph) return;
function _update(model: ComponentModel) { function _update(model: ComponentModel) {
// Guard against undefined model (happens on empty projects)
if (!model) return;
if (isComponentModel_CloudRuntime(model)) { if (isComponentModel_CloudRuntime(model)) {
setActive('backend'); setActive('backend');
if (SidebarModel.instance.ActiveId === 'components') { if (SidebarModel.instance.ActiveId === 'components') {

View File

@@ -1,4 +1,5 @@
import { filesystem } from '@noodl/platform'; import { filesystem } from '@noodl/platform';
import { bugtracker } from '@noodl-utils/bugtracker'; import { bugtracker } from '@noodl-utils/bugtracker';
// TODO: Can we merge this with ProjectModules ? // 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 modulesPath = project._retainedProjectDirectory + '/noodl_modules';
const files = await filesystem.listDirectory(modulesPath);
await Promise.all( try {
files.map(async (file) => { const files = await filesystem.listDirectory(modulesPath);
if (file.isDirectory) {
const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json');
const manifest = await filesystem.readJson(manifestPath);
modules.push({ await Promise.all(
name: file.name, files.map(async (file) => {
manifest 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; return modules;
} }
@@ -50,40 +62,51 @@ export async function readProjectModules(project: TSFixme /* ProjectModel */): P
bugtracker.debug('ProjectModel.readModules'); bugtracker.debug('ProjectModel.readModules');
const modulesPath = project._retainedProjectDirectory + '/noodl_modules'; const modulesPath = project._retainedProjectDirectory + '/noodl_modules';
const files = await filesystem.listDirectory(modulesPath);
project.modules = []; project.modules = [];
project.previews = []; project.previews = [];
project.componentAnnotations = {}; project.componentAnnotations = {};
await Promise.all( try {
files.map(async (file) => { const files = await filesystem.listDirectory(modulesPath);
if (file.isDirectory) {
const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json');
const manifest = await filesystem.readJson(manifestPath);
if (manifest) { await Promise.all(
manifest.name = file.name; files.map(async (file) => {
project.modules.push(manifest); if (file.isDirectory) {
const manifestPath = filesystem.join(modulesPath, file.name, 'manifest.json');
const manifest = await filesystem.readJson(manifestPath);
if (manifest.componentAnnotations) { if (manifest) {
for (var comp in manifest.componentAnnotations) { manifest.name = file.name;
var ca = manifest.componentAnnotations[comp]; project.modules.push(manifest);
if (!project.componentAnnotations[comp]) project.componentAnnotations[comp] = {}; if (manifest.componentAnnotations) {
for (var key in ca) project.componentAnnotations[comp][key] = ca[key]; 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; return project.modules;
} }

View File

@@ -260,36 +260,67 @@ export class LocalProjectsModel extends Model {
}); });
}); });
} else { } else {
// Default template path // Create a minimal Hello World project programmatically
const defaultTemplatePath = './external/projecttemplates/helloworld.zip'; // This is a temporary solution until TASK-009-template-system-refactoring is implemented
const minimalProject = {
// Check if template exists, otherwise create an empty project name: name,
if (filesystem.exists(defaultTemplatePath)) { components: [
this._unzipAndLaunchProject(defaultTemplatePath, dirEntry, fn, options); {
} else { name: 'App',
console.warn('Default project template not found, creating empty project'); ports: [],
visual: true,
// Create minimal project.json for empty project visualStateTransitions: [],
const minimalProject = { nodes: [
name: name, {
components: [], id: guid(),
settings: {} type: 'Group',
}; x: 0,
y: 0,
await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2)); parameters: {},
ports: [],
// Load the newly created empty project children: [
projectFromDirectory(dirEntry, (project) => { {
if (!project) { id: guid(),
fn(); type: 'Text',
return; x: 50,
y: 50,
parameters: {
text: 'Hello World!'
},
ports: [],
children: []
}
]
}
]
} }
],
settings: {},
metadata: {
title: name,
description: 'A new Noodl project'
}
};
project.name = name; await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2));
this._addProject(project);
fn(project); // 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();
}
}); });
} });
} }
} }

View File

@@ -3,6 +3,9 @@ import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import { RuntimeType } from '@noodl-models/nodelibrary/NodeLibraryData'; import { RuntimeType } from '@noodl-models/nodelibrary/NodeLibraryData';
export function getComponentModelRuntimeType(node: ComponentModel) { export function getComponentModelRuntimeType(node: ComponentModel) {
// Guard against undefined node (happens on empty projects)
if (!node) return RuntimeType.Browser;
const name = node.name; const name = node.name;
if (name.startsWith('/#__cloud__/')) { if (name.startsWith('/#__cloud__/')) {

View File

@@ -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. // Fetch all the Page nodes.
const pages: TraverseNode[] = traverser.filter((node) => node.node.typename === 'Page'); const pages: TraverseNode[] = traverser.filter((node) => node.node.typename === 'Page');

View File

@@ -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<readonly TemplateItem[]> {
// 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<boolean> {
// Handle local:// protocol
return Promise.resolve(url.startsWith('local://'));
}
async download(url: string, destination: string): Promise<void> {
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}`);
}
}
}

View File

@@ -59,14 +59,24 @@ export class NodeGraphTraverser {
this.traverseComponent = typeof options.traverseComponent === 'boolean' ? options.traverseComponent : true; this.traverseComponent = typeof options.traverseComponent === 'boolean' ? options.traverseComponent : true;
this.tagSelector = typeof options.tagSelector === 'function' ? options.tagSelector : null; 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) { public forEach(callback: (node: TraverseNode) => void) {
if (!this.root) return;
this.root.forEach(callback); this.root.forEach(callback);
} }
public map<T = any>(callback: (node: TraverseNode) => T) { public map<T = any>(callback: (node: TraverseNode) => T) {
if (!this.root) return [];
const items: T[] = []; const items: T[] = [];
this.forEach((node) => { this.forEach((node) => {
const result = callback(node); const result = callback(node);
@@ -76,6 +86,7 @@ export class NodeGraphTraverser {
} }
public filter(callback: (node: TraverseNode) => boolean) { public filter(callback: (node: TraverseNode) => boolean) {
if (!this.root) return [];
const items: TraverseNode[] = []; const items: TraverseNode[] = [];
this.forEach((node) => { this.forEach((node) => {
if (callback(node)) items.push(node); if (callback(node)) items.push(node);