diff --git a/README.md b/README.md index 788dca0d..06cc269c 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Chartifact is a low-code document format for creating interactive, data-driven p * A **library of components** — charts, inputs, tables, images, text, and layout elements — defined declaratively and wired together with reactive variables: + * **Markdown** – With dynamic {{variable}} placeholders * **Inputs** – Textboxes, checkboxes, sliders, dropdowns * **Charts** – Vega and Vega-Lite visualizations * **Tables** – Sortable, filterable, and selectable data grids * **Images** – Dynamic image URLs based on variables - * **Text** – Markdown with dynamic placeholders * **Presets** – Named sets of variable values for quick scenario switching * A **VS Code extension** for editing, previewing, and exporting documents, with optional AI assistance. @@ -40,8 +40,6 @@ The format is designed with AI assistance in mind: * In-editor tools like Ctrl+I and agent mode available in VS Code * HTML exports retain semantic structure for downstream AI tools -This enables both authoring and remixing workflows with language models and agent-based tooling. - ## Data Flow The document runtime is reactive. Components stay in sync through a shared set of variables: @@ -52,17 +50,10 @@ The document runtime is reactive. Components stay in sync through a shared set o * **Vega transforms** provide built-in tools for reshaping data * **Signal bus** coordinates state across all components -Chartifact documents behave like small reactive systems — without custom JavaScript. - ## Styling -Styling is done using scoped CSS blocks embedded in the document. This allows flexible layout and visual design without global side effects: - -* Style documents as articles, dashboards, or slides -* Use CSS to control layout and theming -* No raw HTML injection or global styles - -The predictable, declarative nature of the styling model makes it easy for both humans and LLMs to work with. +* Use CSS to style documents as articles, dashboards, or slides +* Load and apply Google Fonts with semantic font mapping ## Security @@ -71,6 +62,5 @@ Chartifact is designed to be safe by default: * No custom JavaScript execution * CSP-compliant via Vega expression language * No raw HTML in Markdown +* Sanitized CSS * Rendered in sandboxed iframes to isolate execution - -These constraints help ensure portability and safe embedding in various environments. diff --git a/docs/assets/examples/google-fonts-demo.idoc.md b/docs/assets/examples/google-fonts-demo.idoc.md new file mode 100644 index 00000000..9d833349 --- /dev/null +++ b/docs/assets/examples/google-fonts-demo.idoc.md @@ -0,0 +1,54 @@ +# Google Fonts Demo + +This document demonstrates the Google Fonts plugin with cascading font behavior and semantic mapping. + +```json google-fonts +{ + "googleFontsUrl": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Source+Code+Pro:wght@400;600&family=Inter:wght@300;400;500&family=Roboto+Slab:wght@400;700&display=swap", + "mapping": { + "body": "Inter", + "hero": "Playfair Display", + "headings": "Roboto Slab", + "code": "Source Code Pro" + } +} +``` + +# Hero Headline Level 1 +## Hero Headline Level 2 +### Hero Headline Level 3 + +#### Regular Heading Level 4 +##### Regular Heading Level 5 +###### Regular Heading Level 6 + +This is regular paragraph text that demonstrates the body font. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +- This is a bulleted list item +- Another list item +- Third item to show consistency + +1. Numbered list item one +2. Second numbered item +3. Third numbered item + +**Bold text** and *italic text* also inherit the body font. + +Here's some `inline code` and a code block: + +```javascript +// Code blocks use the code font mapping +function example() { + console.log("Hello, World!"); +} +``` + +## Tables + +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Cell 1 | Cell 2 | Cell 3 | +| Data A | Data B | Data C | +| Numbers | 123.45 | 678.90 | + +This content lets you see how different fonts apply to different semantic elements without any distracting references to the font names. \ No newline at end of file diff --git a/packages/markdown/src/plugins/google-fonts.ts b/packages/markdown/src/plugins/google-fonts.ts new file mode 100644 index 00000000..3115eeb7 --- /dev/null +++ b/packages/markdown/src/plugins/google-fonts.ts @@ -0,0 +1,276 @@ +/*! +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +import { definePlugin, IInstance, Plugin } from '../factory.js'; +import { sanitizedHTML } from '../sanitize.js'; + +export interface GoogleFontsSpec { + googleFontsUrl: string; + mapping?: { + body?: string; // Base font - applies to everything if others not specified + hero?: string; // H1 only - the main hero title + headings?: string; // all headings + code?: string; // Code blocks and inline code + table?: string; // Tables and tabulator + }; + sizing?: { + body?: number; // 1.0 = normal, 1.1 = 10% larger, 0.9 = 10% smaller + hero?: number; // Relative size multiplier for H1 + headings?: number; // Relative size multiplier for all headings + code?: number; // Relative size multiplier for code elements + table?: number; // Relative size multiplier for tables + }; +} + +interface GoogleFontsInstance { + id: string; + spec: GoogleFontsSpec; + styleElement: HTMLStyleElement; + linkElement?: HTMLLinkElement; +} + +interface FontFamily { + name: string; + fallback: string; +} + +// Helper function to validate Google Fonts URL +function isValidGoogleFontsUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' && + parsed.hostname === 'fonts.googleapis.com' && + parsed.pathname === '/css2'; + } catch { + return false; + } +} + +// Helper function to sanitize font family names for CSS safety +function sanitizeFontFamily(fontName: string): string { + // Remove any characters that could be used for CSS injection + // Allow only letters, numbers, spaces, hyphens, and basic punctuation + return fontName.replace(/[^a-zA-Z0-9\s\-_]/g, '').trim(); +} + +// Helper function to sanitize sizing values for CSS safety +function sanitizeSizing(sizeValue: number): number | null { + // Ensure it's a valid number + if (typeof sizeValue !== 'number' || isNaN(sizeValue) || !isFinite(sizeValue)) { + console.warn('Invalid sizing value - must be a finite number:', sizeValue); + return null; + } + + // Reasonable bounds: 0.1 (10%) to 9.999... (just under 1000%) + if (sizeValue < 0.1 || sizeValue >= 10) { + console.warn('Sizing value out of safe range (0.1 to 9.999):', sizeValue); + return null; + } + + // Round to 3 decimal places to prevent excessive precision + return Math.round(sizeValue * 1000) / 1000; +} + +// Helper function to extract font families from Google Fonts URL +function extractFontFamilies(googleFontsUrl: string): FontFamily[] { + const families: FontFamily[] = []; + + try { + // Security: Validate this is a Google Fonts URL + if (!isValidGoogleFontsUrl(googleFontsUrl)) { + console.error('Invalid Google Fonts URL - only https://fonts.googleapis.com/css2 URLs are allowed'); + return families; + } + + const url = new URL(googleFontsUrl); + const params = url.searchParams; + + // Extract family parameters - Google Fonts uses 'family' parameter multiple times + const familyParams = params.getAll('family'); + for (const value of familyParams) { + // Parse: family=Cascadia+Code:ital,wght@0,200..700;1,200..700 + const rawFamilyName = value.split(':')[0].replace(/\+/g, ' '); + + // Security: Sanitize font family name to prevent CSS injection + const familyName = sanitizeFontFamily(rawFamilyName); + + if (!familyName) { + console.warn('Skipped invalid font family name:', rawFamilyName); + continue; + } + + // Determine fallback based on font name patterns + let fallback = 'sans-serif'; + const lowerName = familyName.toLowerCase(); + if (lowerName.includes('mono') || lowerName.includes('code') || lowerName.includes('consola')) { + fallback = 'monospace'; + } else if (lowerName.includes('serif') && !lowerName.includes('sans')) { + fallback = 'serif'; + } + + families.push({ name: familyName, fallback }); + } + } catch (error) { + console.error('Failed to parse Google Fonts URL:', error); + } + + return families; +} + +// Helper function to generate scoped CSS for semantic elements +function generateSemanticCSS(spec: GoogleFontsSpec, families: FontFamily[], scopeId: string): string { + const cssRules: string[] = []; + + // Helper to generate CSS rule for a specific element type + const generateRule = (elementType: keyof NonNullable, selectors: string) => { + const fontFamily = spec.mapping?.[elementType]; + const sizeValue = spec.sizing?.[elementType]; + + // Skip if neither font nor sizing is specified + if (!fontFamily && !sizeValue) return; + + let css = `${selectors} {`; + + // Add font-family if mapping exists + if (fontFamily) { + const sanitizedName = sanitizeFontFamily(fontFamily); + const family = families.find(f => f.name === sanitizedName); + if (family) { + css += `\n font-family: '${family.name}';`; + } + } + + // Add font-size if sizing exists + if (sizeValue) { + const sanitizedSize = sanitizeSizing(sizeValue); + if (sanitizedSize !== null) { + css += `\n font-size: ${sanitizedSize}em;`; + } + } + + css += '\n}'; + cssRules.push(css); + }; + + // Generate rules in cascading order (most general to most specific) + generateRule('body', 'body'); + generateRule('headings', 'h1, h2, h3, h4, h5, h6'); + generateRule('code', 'code, pre, kbd, samp, tt, .hljs'); + generateRule('table', 'table, .tabulator'); + generateRule('hero', 'h1'); // Most specific, goes last + + return cssRules.join('\n\n'); +} + +// Helper function to generate unique document ID +function generateDocumentId(): string { + return Math.random().toString(36).substr(2, 8); +} + +export const googleFontsPlugin: Plugin = { + name: 'google-fonts', + initializePlugin: (md) => definePlugin(md, 'google-fonts'), + fence: (token, idx) => { + const googleFontsId = `google-fonts-${idx}`; + return sanitizedHTML('div', { id: googleFontsId, class: 'google-fonts' }, token.content.trim()); + }, + hydrateComponent: async (renderer, errorHandler) => { + const googleFontsInstances: GoogleFontsInstance[] = []; + const containers = renderer.element.querySelectorAll('.google-fonts'); + + // Enforce one Google Fonts block per page + if (containers.length > 1) { + for (let i = 1; i < containers.length; i++) { + containers[i].innerHTML = ''; + } + } + + // Only process the first container + const container = containers[0]; + if (!container || !container.textContent) { + return []; + } + + try { + const spec: GoogleFontsSpec = JSON.parse(container.textContent); + + if (!spec.googleFontsUrl) { + container.innerHTML = ''; + return []; + } + + // Security: Validate Google Fonts URL before processing + if (!isValidGoogleFontsUrl(spec.googleFontsUrl)) { + container.innerHTML = ''; + return []; + } + + // Extract font families from the URL + const families = extractFontFamilies(spec.googleFontsUrl); + + if (families.length === 0) { + container.innerHTML = ''; + return []; + } + + // Generate unique instance ID for scoping + const instanceId = `gf-${Date.now()}-0`; + + // Create font import (using @import for now, could switch to tags) + const importCSS = `@import url('${spec.googleFontsUrl}');`; + + // Generate semantic CSS with instance-specific scoping + const semanticCSS = generateSemanticCSS(spec, families, instanceId); + + // Combine import and semantic CSS + const fullCSS = importCSS + '\n\n' + semanticCSS; + + // Create style element + const styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + styleElement.id = `idocs-google-fonts-${container.id}`; + styleElement.textContent = fullCSS; + + // Apply to shadow DOM if available, otherwise document + const target = renderer.shadowRoot || document.head; + target.appendChild(styleElement); + + const googleFontsInstance: GoogleFontsInstance = { + id: container.id, + spec, + styleElement + }; + + googleFontsInstances.push(googleFontsInstance); + + // Replace container content with summary + const fontsList = families.map(f => f.name).join(', '); + container.innerHTML = ``; + + } catch (e) { + container.innerHTML = ``; + errorHandler(e, 'Google Fonts', 0, 'parse', container); + } + + const instances: IInstance[] = googleFontsInstances.map((googleFontsInstance) => { + return { + id: googleFontsInstance.id, + initialSignals: [], // Google Fonts doesn't need signals + destroy: () => { + // Remove the style element when the instance is destroyed + if (googleFontsInstance.styleElement && googleFontsInstance.styleElement.parentNode) { + googleFontsInstance.styleElement.parentNode.removeChild(googleFontsInstance.styleElement); + } + // Remove link element if it exists + if (googleFontsInstance.linkElement && googleFontsInstance.linkElement.parentNode) { + googleFontsInstance.linkElement.parentNode.removeChild(googleFontsInstance.linkElement); + } + }, + }; + }); + + return instances; + }, +}; diff --git a/packages/markdown/src/plugins/index.ts b/packages/markdown/src/plugins/index.ts index c947d2c4..5ec406dc 100644 --- a/packages/markdown/src/plugins/index.ts +++ b/packages/markdown/src/plugins/index.ts @@ -8,6 +8,7 @@ import { registerMarkdownPlugin } from '../factory.js'; import { checkboxPlugin } from './checkbox.js'; import { cssPlugin } from './css.js'; import { dropdownPlugin } from './dropdown.js'; +import { googleFontsPlugin } from './google-fonts.js'; import { imagePlugin } from './image.js'; import { placeholdersPlugin } from './placeholders.js'; import { presetsPlugin } from './presets.js'; @@ -19,6 +20,7 @@ export function registerNativePlugins() { registerMarkdownPlugin(checkboxPlugin); registerMarkdownPlugin(cssPlugin); registerMarkdownPlugin(dropdownPlugin); + registerMarkdownPlugin(googleFontsPlugin); registerMarkdownPlugin(imagePlugin); registerMarkdownPlugin(placeholdersPlugin); registerMarkdownPlugin(presetsPlugin);