# CONFIG-004: SEO Build Integration ## Overview Integrate app configuration values into the HTML build process to generate proper SEO meta tags, Open Graph tags, and favicon links. **Estimated effort:** 8-10 hours **Dependencies:** CONFIG-001, CONFIG-002 **Blocks:** None --- ## Objectives 1. Inject `` tag from appName 2. Inject meta description tag 3. Inject Open Graph tags (og:title, og:description, og:image) 4. Inject Twitter Card tags 5. Inject favicon link 6. Inject theme-color meta tag 7. Support canonical URL (optional) --- ## Generated HTML Structure ```html <!DOCTYPE html> <html> <head> <!-- Basic Meta --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>My Amazing App {{#customHeadCode#}} ``` --- ## Files to Modify ### 1. HTML Processor **File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/html-processor.ts` Extend the existing processor: ```typescript import { ProjectModel } from '@noodl-models/projectmodel'; import { AppConfig } from '@noodl/runtime/src/config/types'; export interface HtmlProcessorParameters { title?: string; headCode?: string; indexJsPath?: string; baseUrl?: string; envVariables?: Record; } export class HtmlProcessor { constructor(public readonly project: ProjectModel) {} public async process(content: string, parameters: HtmlProcessorParameters): Promise { const settings = this.project.getSettings(); const appConfig = this.project.getAppConfig(); let baseUrl = parameters.baseUrl || settings.baseUrl || '/'; if (!baseUrl.endsWith('/')) { baseUrl = baseUrl + '/'; } // Title from app config, falling back to settings, then default const title = parameters.title || appConfig.identity.appName || settings.htmlTitle || 'Noodl App'; // Build head code with SEO tags let headCode = this.generateSEOTags(appConfig, baseUrl); headCode += settings.headCode || ''; if (parameters.headCode) { headCode += parameters.headCode; } if (baseUrl !== '/') { headCode = `\n` + headCode; } // Inject into template let injected = await this.injectIntoHtml(content, baseUrl); injected = injected.replace('{{#title#}}', this.escapeHtml(title)); injected = injected.replace('{{#customHeadCode#}}', headCode); injected = injected.replace(/%baseUrl%/g, baseUrl); // ... rest of existing processing return injected; } private generateSEOTags(config: AppConfig, baseUrl: string): string { const tags: string[] = []; // Description const description = config.seo.ogDescription || config.identity.description; if (description) { tags.push(``); } // Theme color if (config.seo.themeColor) { tags.push(``); } // Favicon if (config.seo.favicon) { const faviconPath = this.resolveAssetPath(config.seo.favicon, baseUrl); const faviconType = this.getFaviconType(config.seo.favicon); tags.push(``); // Apple touch icon (use og:image or favicon) const touchIcon = config.seo.ogImage || config.identity.coverImage || config.seo.favicon; if (touchIcon) { tags.push(``); } } // Open Graph tags.push(...this.generateOpenGraphTags(config, baseUrl)); // Twitter Card tags.push(...this.generateTwitterTags(config, baseUrl)); // PWA Manifest if (config.pwa?.enabled) { tags.push(``); } return tags.join('\n ') + '\n'; } private generateOpenGraphTags(config: AppConfig, baseUrl: string): string[] { const tags: string[] = []; tags.push(``); const ogTitle = config.seo.ogTitle || config.identity.appName; if (ogTitle) { tags.push(``); } const ogDescription = config.seo.ogDescription || config.identity.description; if (ogDescription) { tags.push(``); } const ogImage = config.seo.ogImage || config.identity.coverImage; if (ogImage) { // OG image should be absolute URL const imagePath = this.resolveAssetPath(ogImage, baseUrl); tags.push(``); } // og:url would need the deployment URL - could be added via env variable // tags.push(``); return tags; } private generateTwitterTags(config: AppConfig, baseUrl: string): string[] { const tags: string[] = []; // Use large image card if we have an image const hasImage = config.seo.ogImage || config.identity.coverImage; tags.push(``); const title = config.seo.ogTitle || config.identity.appName; if (title) { tags.push(``); } const description = config.seo.ogDescription || config.identity.description; if (description) { tags.push(``); } if (hasImage) { const imagePath = this.resolveAssetPath( config.seo.ogImage || config.identity.coverImage!, baseUrl ); tags.push(``); } return tags; } private resolveAssetPath(path: string, baseUrl: string): string { if (!path) return ''; // Already absolute URL if (path.startsWith('http://') || path.startsWith('https://')) { return path; } // Relative path - prepend base URL const cleanPath = path.startsWith('/') ? path.substring(1) : path; return baseUrl + cleanPath; } private getFaviconType(path: string): string { if (path.endsWith('.ico')) return 'image/x-icon'; if (path.endsWith('.png')) return 'image/png'; if (path.endsWith('.svg')) return 'image/svg+xml'; return 'image/png'; } private escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>'); } private escapeAttr(text: string): string { return text .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } private injectIntoHtml(template: string, pathPrefix: string) { return new Promise((resolve) => { ProjectModules.instance.injectIntoHtml( this.project._retainedProjectDirectory, template, pathPrefix, resolve ); }); } } ``` ### 2. Update HTML Template **File:** `packages/noodl-viewer-react/static/index.html` (or equivalent) Ensure template has the right placeholders: ```html {{#title#}} {{#customHeadCode#}}
<%index_js%> ``` --- ## Asset Handling ### Image Resolution Images referenced in config (coverImage, ogImage, favicon) can be: 1. **Project file paths**: `/assets/images/cover.png` - copied to build output 2. **External URLs**: `https://example.com/image.jpg` - used as-is 3. **Uploaded files**: Handled through the existing project file system **File:** Add to build process in `packages/noodl-editor/src/editor/src/utils/compilation/build/` ```typescript async function copyConfigAssets(project: ProjectModel, outputDir: string): Promise { const config = project.getAppConfig(); const assets: string[] = []; // Collect asset paths if (config.identity.coverImage && !isExternalUrl(config.identity.coverImage)) { assets.push(config.identity.coverImage); } if (config.seo.ogImage && !isExternalUrl(config.seo.ogImage)) { assets.push(config.seo.ogImage); } if (config.seo.favicon && !isExternalUrl(config.seo.favicon)) { assets.push(config.seo.favicon); } // Copy each asset to output for (const asset of assets) { const sourcePath = path.join(project._retainedProjectDirectory, asset); const destPath = path.join(outputDir, asset); if (await fileExists(sourcePath)) { await ensureDir(path.dirname(destPath)); await copyFile(sourcePath, destPath); } } } function isExternalUrl(path: string): boolean { return path.startsWith('http://') || path.startsWith('https://'); } ``` --- ## Testing Checklist ### Build Output Tests - [ ] Title tag contains app name - [ ] Meta description present - [ ] Theme color meta tag present - [ ] Favicon link correct type and path - [ ] Apple touch icon link present - [ ] og:type is "website" - [ ] og:title matches config - [ ] og:description matches config - [ ] og:image URL correct - [ ] Twitter card type correct - [ ] Twitter tags mirror OG tags - [ ] Manifest link present when PWA enabled - [ ] Manifest link absent when PWA disabled ### Asset Handling Tests - [ ] Local image paths copied to build - [ ] External URLs used as-is - [ ] Missing assets don't break build - [ ] Paths correct with custom baseUrl ### Edge Cases - [ ] Empty description doesn't create empty tag - [ ] Missing favicon doesn't break build - [ ] Special characters escaped in meta content - [ ] Very long descriptions truncated appropriately --- ## Notes for Implementer ### OG Image Requirements Open Graph images have specific requirements: - Minimum 200x200 pixels - Recommended 1200x630 pixels - Maximum 8MB file size Consider adding validation in the App Setup panel. ### Canonical URL The canonical URL (`og:url`) requires knowing the deployment URL, which isn't always known at build time. Options: 1. Add a `canonicalUrl` field to app config 2. Inject via environment variable at deploy time 3. Use JavaScript to set dynamically (loses SEO benefit) ### Testing SEO Output Use tools like: - https://developers.facebook.com/tools/debug/ (OG tags) - https://cards-dev.twitter.com/validator (Twitter cards) - Browser DevTools to inspect head tags