Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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.
54 changes: 54 additions & 0 deletions docs/assets/examples/google-fonts-demo.idoc.md
Original file line number Diff line number Diff line change
@@ -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.
276 changes: 276 additions & 0 deletions packages/markdown/src/plugins/google-fonts.ts
Original file line number Diff line number Diff line change
@@ -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<GoogleFontsSpec['mapping']>, 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 = '<!-- Additional Google Fonts blocks ignored - only one per page allowed -->';
}
}

// 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 = '<!-- Google Fonts Error: googleFontsUrl is required -->';
return [];
}

// Security: Validate Google Fonts URL before processing
if (!isValidGoogleFontsUrl(spec.googleFontsUrl)) {
container.innerHTML = '<!-- Google Fonts Error: Only HTTPS Google Fonts URLs (https://fonts.googleapis.com/css2) are allowed -->';
return [];
}

// Extract font families from the URL
const families = extractFontFamilies(spec.googleFontsUrl);

if (families.length === 0) {
container.innerHTML = '<!-- Google Fonts Error: No font families found in URL -->';
return [];
}

// Generate unique instance ID for scoping
const instanceId = `gf-${Date.now()}-0`;

// Create font import (using @import for now, could switch to <link> 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 = `<!-- Google Fonts loaded: ${fontsList} -->`;

} catch (e) {
container.innerHTML = `<!-- Google Fonts Error: ${e.toString()} -->`;
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;
},
};
2 changes: 2 additions & 0 deletions packages/markdown/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +20,7 @@ export function registerNativePlugins() {
registerMarkdownPlugin(checkboxPlugin);
registerMarkdownPlugin(cssPlugin);
registerMarkdownPlugin(dropdownPlugin);
registerMarkdownPlugin(googleFontsPlugin);
registerMarkdownPlugin(imagePlugin);
registerMarkdownPlugin(placeholdersPlugin);
registerMarkdownPlugin(presetsPlugin);
Expand Down