diff --git a/README.md b/README.md index 788dca0d..94954451 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ Chartifact is a low-code document format for creating interactive, data-driven pages such as reports, dashboards, and presentations. It travels like a document and works like an app. Easily editable and remixable, it’s a file type for an AI-native world. -## Features +## Ecosystem -* A **library of components** — charts, inputs, tables, images, text, and layout elements — defined declaratively and wired together with reactive variables: +The Chartifact GitHub repo has source code for these interoperating modules: +* A **document schema** that defines plugins and components that communicate together with reactive variables: + + * **Text** – Markdown with dynamic placeholders * **Inputs** – Textboxes, checkboxes, sliders, dropdowns + * **Tables** – Sortable, selectable, and editable data grids * **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. @@ -21,8 +23,6 @@ Chartifact is a low-code document format for creating interactive, data-driven p * Tools to **export standalone HTML** documents you can share or embed anywhere. ---- - ## Authoring Formats Chartifact documents can be written in two formats: @@ -56,21 +56,13 @@ Chartifact documents behave like small reactive systems — without custom JavaS ## 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. +Styling is done using standard CSS. Examples provided to style documents as articles, dashboards, or slides. ## Security Chartifact is designed to be safe by default: +* Rendered in sandboxed iframes to isolate execution * No custom JavaScript execution -* CSP-compliant via Vega expression language * No raw HTML in Markdown -* Rendered in sandboxed iframes to isolate execution - -These constraints help ensure portability and safe embedding in various environments. +* XSS-Defensive CSS parsing diff --git a/docs/assets/data/abc.json b/docs/assets/data/abc.json new file mode 100644 index 00000000..77c111eb --- /dev/null +++ b/docs/assets/data/abc.json @@ -0,0 +1,11 @@ +[ + {"category":"A", "group": "x", "value":0.1}, + {"category":"A", "group": "y", "value":0.6}, + {"category":"A", "group": "z", "value":0.9}, + {"category":"B", "group": "x", "value":0.7}, + {"category":"B", "group": "y", "value":0.2}, + {"category":"B", "group": "z", "value":1.1}, + {"category":"C", "group": "x", "value":0.6}, + {"category":"C", "group": "y", "value":0.1}, + {"category":"C", "group": "z", "value":0.2} +] \ No newline at end of file diff --git a/docs/assets/data/def.json b/docs/assets/data/def.json new file mode 100644 index 00000000..ba91faf5 --- /dev/null +++ b/docs/assets/data/def.json @@ -0,0 +1,11 @@ +[ + {"category":"D", "group": "x", "value":1.2}, + {"category":"D", "group": "y", "value":2.3}, + {"category":"D", "group": "z", "value":3.4}, + {"category":"E", "group": "x", "value":4.5}, + {"category":"E", "group": "y", "value":5.6}, + {"category":"E", "group": "z", "value":6.7}, + {"category":"F", "group": "x", "value":7.8}, + {"category":"F", "group": "y", "value":8.9}, + {"category":"F", "group": "z", "value":9.0} +] \ No newline at end of file diff --git a/docs/assets/examples/features/chart.idoc.json b/docs/assets/examples/features/chart.idoc.json new file mode 100644 index 00000000..cfdc2c16 --- /dev/null +++ b/docs/assets/examples/features/chart.idoc.json @@ -0,0 +1,58 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Chart", + "dataLoaders": [ + { + "dataSourceName": "chartData", + "type": "json", + "content": [ + {"category": "A", "value": 20}, + {"category": "B", "value": 34}, + {"category": "C", "value": 55}, + {"category": "D", "value": 40}, + {"category": "E", "value": 67} + ] + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Chart\nUse charts for data visualizations with Vega-Lite specifications.", + { + "type": "chart", + "chart": { + "chartIntent": "Simple bar chart showing values by category", + "chartTemplateKey": "bar", + "dataSourceBase": { + "dataSourceName": "chartData" + }, + "spec": { + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "data": { + "name": "chartData" + }, + "mark": "bar", + "encoding": { + "x": { + "field": "category", + "type": "nominal", + "title": "Category" + }, + "y": { + "field": "value", + "type": "quantitative", + "title": "Value" + }, + "color": { + "field": "category", + "type": "nominal" + } + } + } + } + } + ] + } + ] +} diff --git a/docs/assets/examples/features/css.idoc.json b/docs/assets/examples/features/css.idoc.json new file mode 100644 index 00000000..74a611dd --- /dev/null +++ b/docs/assets/examples/features/css.idoc.json @@ -0,0 +1,29 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: CSS", + "layout": { + "css": ".group { padding: 20px; background-color: #f8f9fa; border: 2px solid #3498db; border-radius: 8px; margin-bottom: 20px; } .group h2 { color: #3498db; } .group h3 { color: #2c3e50; } #demo { background-color: #e8f5e8; border-color: #28a745; } #demo h2 { color: #28a745; }" + }, + "groups": [ + { + "groupId": "main", + "elements": [ + "## CSS Styling\nUse CSS to style documents with custom layouts and appearance.", + "### How CSS Works", + "- Each group gets a `.group` className automatically", + "- Each group gets an `#id` based on the groupId", + "- Add custom CSS in the `layout.css` property", + "- Style headers, backgrounds, borders, and spacing", + "- CSS applies to all groups in the document" + ] + }, + { + "groupId": "demo", + "elements": [ + "## Demo Section", + "This section has `groupId=\"demo\"` so it gets `id=\"demo\"` and can be styled with `#demo`.", + "Notice this section has a green theme instead of blue, applied via the `#demo` CSS selector." + ] + } + ] +} diff --git a/docs/assets/examples/features/data-sources.idoc.json b/docs/assets/examples/features/data-sources.idoc.json new file mode 100644 index 00000000..117225d8 --- /dev/null +++ b/docs/assets/examples/features/data-sources.idoc.json @@ -0,0 +1,60 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Data Sources", + "dataLoaders": [ + { + "dataSourceName": "jsonData", + "type": "json", + "content": [ + {"item": "Stapler", "category": "Office Tools", "price": 12.99, "inStock": true, "quantity": 25}, + {"item": "Printer Paper", "category": "Paper", "price": 8.50, "inStock": true, "quantity": 150}, + {"item": "Blue Pens", "category": "Writing", "price": 3.25, "inStock": false, "quantity": 0}, + {"item": "Notebooks", "category": "Paper", "price": 5.75, "inStock": true, "quantity": 40}, + {"item": "Desk Lamp", "category": "Furniture", "price": 29.99, "inStock": true, "quantity": 8}, + {"item": "Paper Clips", "category": "Office Tools", "price": 2.10, "inStock": true, "quantity": 200}, + {"item": "Whiteboard Markers", "category": "Writing", "price": 7.80, "inStock": false, "quantity": 0}, + {"item": "File Folders", "category": "Storage", "price": 4.45, "inStock": true, "quantity": 75}, + {"item": "Ergonomic Chair", "category": "Furniture", "price": 189.99, "inStock": true, "quantity": 3}, + {"item": "Post-it Notes", "category": "Paper", "price": 6.25, "inStock": true, "quantity": 120}, + {"item": "Hole Punch", "category": "Office Tools", "price": 15.50, "inStock": false, "quantity": 0}, + {"item": "Scissors", "category": "Office Tools", "price": 9.75, "inStock": true, "quantity": 18} + ] + }, + { + "dataSourceName": "csvData", + "type": "url", + "format": "csv", + "url": "https://vega.github.io/editor/data/stocks.csv" + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Data Sources\nLoad data from JSON, CSV, TSV, or URLs with optional transformations.", + "### JSON Data", + { + "type": "table", + "dataSourceName": "jsonData", + "variableId": "jsonTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "100px" + } + }, + "### CSV from URL", + { + "type": "table", + "dataSourceName": "csvData", + "variableId": "csvTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "200px" + } + } + ] + } + ] +} diff --git a/docs/assets/examples/features/data-transformations.idoc.json b/docs/assets/examples/features/data-transformations.idoc.json new file mode 100644 index 00000000..2241ebc6 --- /dev/null +++ b/docs/assets/examples/features/data-transformations.idoc.json @@ -0,0 +1,86 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Data Transformations", + "dataLoaders": [ + { + "dataSourceName": "rawData", + "type": "json", + "content": [ + {"name": "Product A", "category": "Electronics", "price": 299, "inStock": true}, + {"name": "Product B", "category": "Electronics", "price": 199, "inStock": false}, + {"name": "Product C", "category": "Clothing", "price": 49, "inStock": true}, + {"name": "Product D", "category": "Clothing", "price": 79, "inStock": true}, + {"name": "Product E", "category": "Books", "price": 15, "inStock": true} + ], + "dataFrameTransformations": [ + { + "type": "filter", + "expr": "datum.inStock && datum.price <= maxPrice" + } + ] + } + ], + "variables": [ + { + "variableId": "maxPrice", + "type": "number", + "initialValue": 100 + }, + { + "variableId": "categoryStats", + "type": "object", + "isArray": true, + "initialValue": [], + "calculation": { + "dependsOn": ["rawData"], + "dataFrameTransformations": [ + { + "type": "aggregate", + "groupby": ["category"], + "ops": ["count", "mean"], + "fields": ["name", "price"], + "as": ["count", "avgPrice"] + } + ] + } + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Data Transformations\nUse data transformations to filter, aggregate, and manipulate data.", + { + "type": "slider", + "variableId": "maxPrice", + "label": "Maximum price filter:", + "min": 0, + "max": 500, + "step": 10 + }, + "### Filtered Products (in stock, price ≤ ${{maxPrice}})", + { + "type": "table", + "dataSourceName": "rawData", + "variableId": "filteredTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "200px" + } + }, + "### Category Statistics", + { + "type": "table", + "dataSourceName": "categoryStats", + "variableId": "categoryStatsTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "150px" + } + } + ] + } + ] +} diff --git a/docs/assets/examples/features/data-url.idoc.json b/docs/assets/examples/features/data-url.idoc.json new file mode 100644 index 00000000..ead2503a --- /dev/null +++ b/docs/assets/examples/features/data-url.idoc.json @@ -0,0 +1,77 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Data Url", + "variables": [ + { + "variableId": "anything", + "type": "string", + "initialValue": "abc" + } + ], + "dataLoaders": [ + { + "dataSourceName": "jsonData", + "type": "url", + "url": "http://127.0.0.1:8000/{{anything}}.json" + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Data Sources\nLoad data from JSON, CSV, TSV, or URLs with optional transformations.", + "### JSON Data", + { + "type": "dropdown", + "variableId": "anything", + "options": [ + "abc", + "def" + ] + }, + { + "type": "chart", + "chart": { + "chartIntent": "show a grouped bar chart", + "chartTemplateKey": "grouped-bar", + "dataSourceBase": { + "dataSourceName": "jsonData" + }, + "spec": { + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "data": { + "name": "jsonData" + }, + "mark": "bar", + "encoding": { + "x": { + "field": "category" + }, + "y": { + "field": "value", + "type": "quantitative" + }, + "xOffset": { + "field": "group" + }, + "color": { + "field": "group" + } + } + } + } + }, + { + "type": "table", + "dataSourceName": "jsonData", + "variableId": "jsonTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "100px" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/assets/examples/features/dropdown.idoc.json b/docs/assets/examples/features/dropdown.idoc.json new file mode 100644 index 00000000..0e157e08 --- /dev/null +++ b/docs/assets/examples/features/dropdown.idoc.json @@ -0,0 +1,56 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Dropdown", + "dataLoaders": [ + { + "dataSourceName": "productData", + "type": "json", + "content": [ + {"name": "Laptop Pro", "category": "Electronics", "price": 1299}, + {"name": "Wireless Mouse", "category": "Electronics", "price": 49}, + {"name": "Office Chair", "category": "Furniture", "price": 299}, + {"name": "Standing Desk", "category": "Furniture", "price": 599}, + {"name": "Coffee Mug", "category": "Kitchen", "price": 15}, + {"name": "Water Bottle", "category": "Kitchen", "price": 25} + ] + } + ], + "variables": [ + { + "variableId": "selectedProduct", + "type": "string", + "initialValue": "Laptop Pro" + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Dropdown\nUse dropdowns with data-driven options from data sources.", + "### Data-Driven Options", + "Dropdown options populated from data using `dynamicOptions`:", + { + "type": "dropdown", + "variableId": "selectedProduct", + "label": "Choose product:", + "dynamicOptions": { + "dataSourceName": "productData", + "fieldName": "name" + } + }, + "Selected: **{{selectedProduct}}**", + "### Product Data", + { + "type": "table", + "dataSourceName": "productData", + "variableId": "productTable", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "200px" + } + } + ] + } + ] +} diff --git a/docs/assets/examples/features/image.idoc.json b/docs/assets/examples/features/image.idoc.json new file mode 100644 index 00000000..38551171 --- /dev/null +++ b/docs/assets/examples/features/image.idoc.json @@ -0,0 +1,59 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Image", + "variables": [ + { + "variableId": "img_height", + "type": "number", + "initialValue": "300" + }, + { + "variableId": "img_width", + "type": "number", + "initialValue": "400" + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Image\nUse images for displaying graphics from URLs or server-generated visualizations.", + { + "type": "dropdown", + "variableId": "img_height", + "options": [ + "100", + "200", + "300", + "400" + ] + }, + { + "type": "dropdown", + "variableId": "img_width", + "options": [ + "100", + "200", + "300", + "400" + ] + }, + { + "type": "image", + "alt": "Sample visualization", + "url": "https://picsum.photos/{{img_width}}/{{img_height}}" + }, + "### Server-Generated Images", + "Images are particularly powerful for server-side generated visualizations:", + "- **Python plots** - matplotlib, seaborn, plotly exports", + "- **R visualizations** - ggplot2, base R graphics", + "- **Dynamic charts** - generated based on current data", + "- **Custom graphics** - any server-side image generation", + "", + "Example server endpoint: `/api/regressionplot?target={{targetVariable}}&model={{modelType}}&theme={{colorTheme}}`", + "", + "Images support dynamic URLs with query parameters and can be regenerated in real-time." + ] + } + ] +} \ No newline at end of file diff --git a/docs/assets/examples/features/input-controls.idoc.json b/docs/assets/examples/features/input-controls.idoc.json new file mode 100644 index 00000000..72871251 --- /dev/null +++ b/docs/assets/examples/features/input-controls.idoc.json @@ -0,0 +1,82 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Input Controls", + "variables": [ + { + "variableId": "isEnabled", + "type": "boolean", + "initialValue": true + }, + { + "variableId": "userName", + "type": "string", + "initialValue": "John Doe" + }, + { + "variableId": "temperature", + "type": "number", + "initialValue": 20 + }, + { + "variableId": "opacity", + "type": "number", + "initialValue": 0.5 + }, + { + "variableId": "selectedColor", + "type": "string", + "initialValue": "blue" + }, + { + "variableId": "selectedItems", + "type": "string", + "isArray": true, + "initialValue": ["apple", "banana"] + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Input Controls", + "Hello **{{userName}}**! Feature enabled: **{{isEnabled}}**. Temperature: **{{temperature}}°C**. Color: **{{selectedColor}}**. Items: **{{selectedItems}}**.", + "### Checkbox", + { + "type": "checkbox", + "variableId": "isEnabled", + "label": "Enable feature" + }, + "### Textbox", + { + "type": "textbox", + "variableId": "userName", + "label": "Name:" + }, + "### Slider", + { + "type": "slider", + "variableId": "temperature", + "label": "Temperature:", + "min": -10, + "max": 40, + "step": 1 + }, + "### Dropdown", + { + "type": "dropdown", + "variableId": "selectedColor", + "label": "Color:", + "options": ["red", "green", "blue", "yellow"] + }, + { + "type": "dropdown", + "variableId": "selectedItems", + "label": "Items:", + "options": ["apple", "banana", "orange", "grape"], + "multiple": true, + "size": 3 + } + ] + } + ] +} diff --git a/docs/assets/examples/features/markdown.idoc.json b/docs/assets/examples/features/markdown.idoc.json new file mode 100644 index 00000000..b701a363 --- /dev/null +++ b/docs/assets/examples/features/markdown.idoc.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Markdown", + "groups": [ + { + "groupId": "main", + "elements": [ + "## Markdown\nUse markdown to format text, create headings, lists, and more. HTML tags are not allowed.", + "### Text Formatting", + "**Bold**, *italic*, `code` text, and [links](https://microsoft.com).", + "### Lists", + "1. Numbered lists\n2. Work great", + "- Bullet points\n- Also work" + ] + } + ] +} \ No newline at end of file diff --git a/docs/assets/examples/features/presets.idoc.json b/docs/assets/examples/features/presets.idoc.json new file mode 100644 index 00000000..ecbe4ba0 --- /dev/null +++ b/docs/assets/examples/features/presets.idoc.json @@ -0,0 +1,65 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Presets", + "variables": [ + { + "variableId": "size", + "type": "number", + "initialValue": 5 + }, + { + "variableId": "color", + "type": "string", + "initialValue": "blue" + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Presets\nUse presets to provide predefined state configurations.", + { + "type": "presets", + "presets": [ + { + "name": "Small & Red", + "state": { + "size": 2, + "color": "red" + } + }, + { + "name": "Large & Green", + "state": { + "size": 10, + "color": "green" + } + }, + { + "name": "Medium & Gray", + "state": { + "size": 7, + "color": "gray" + } + } + ] + }, + { + "type": "slider", + "variableId": "size", + "label": "Size:", + "min": 1, + "max": 15, + "step": 1 + }, + { + "type": "dropdown", + "variableId": "color", + "label": "Color:", + "options": ["red", "green", "blue", "gray"] + }, + "Current state: Size **{{size}}**, Color **{{color}}**" + ] + } + ] +} diff --git a/docs/assets/examples/features/table.idoc.json b/docs/assets/examples/features/table.idoc.json new file mode 100644 index 00000000..08442eb4 --- /dev/null +++ b/docs/assets/examples/features/table.idoc.json @@ -0,0 +1,55 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Table", + "dataLoaders": [ + { + "dataSourceName": "sampleData", + "type": "json", + "content": [ + {"name": "Alice", "age": 25, "city": "New York"}, + {"name": "Bob", "age": 30, "city": "Chicago"}, + {"name": "Carol", "age": 35, "city": "Los Angeles"}, + {"name": "David", "age": 28, "city": "Seattle"} + ] + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Table\nUse tables for displaying and interacting with tabular data.", + "### Basic Table", + { + "type": "table", + "dataSourceName": "sampleData", + "variableId": "table1", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "200px" + } + }, + "### Selectable Table", + { + "type": "table", + "dataSourceName": "sampleData", + "variableId": "table2", + "tabulatorOptions": { + "autoColumns": true, + "layout": "fitColumns", + "maxHeight": "200px", + "selectableRows": true, + "rowHeader": { + "formatter": "rowSelection", + "titleFormatter": "rowSelection", + "headerSort": false, + "headerHozAlign": "center", + "hozAlign": "center", + "width": 40 + } + } + } + ] + } + ] +} diff --git a/docs/assets/examples/features/urls.idoc.json b/docs/assets/examples/features/urls.idoc.json new file mode 100644 index 00000000..3e948c47 --- /dev/null +++ b/docs/assets/examples/features/urls.idoc.json @@ -0,0 +1,91 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: URLs", + "variables": [ + { + "variableId": "imageSize", + "type": "number", + "initialValue": 300 + }, + { + "variableId": "imageCategory", + "type": "string", + "initialValue": "nature" + }, + { + "variableId": "seed", + "type": "number", + "initialValue": 123 + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## URLs\nConstruct dynamic URLs using variables in paths and query parameters.", + { + "type": "slider", + "variableId": "imageSize", + "label": "Image size:", + "min": 100, + "max": 600, + "step": 50 + }, + { + "type": "dropdown", + "variableId": "imageCategory", + "label": "Category:", + "options": ["nature", "city", "technology", "abstract"] + }, + { + "type": "slider", + "variableId": "seed", + "label": "Random seed:", + "min": 1, + "max": 1000, + "step": 1 + }, + "### Basic URL Construction", + "URLs are built with `origin` + `urlPath`:", + { + "type": "image", + "alt": "Dynamic image example", + "width": 400, + "height": 300, + "urlRef": { + "origin": "https://picsum.photos", + "urlPath": "/{{imageSize}}" + } + }, + "Current URL: `https://picsum.photos/{{imageSize}}`", + "### URL with Query Parameters", + "Use `mappedParams` to add variables as query parameters:", + { + "type": "image", + "alt": "Image with query parameters", + "width": 400, + "height": 300, + "urlRef": { + "origin": "https://picsum.photos", + "urlPath": "/{{imageSize}}", + "mappedParams": [ + { + "name": "category", + "value": "{{imageCategory}}", + "variableId": "imageCategory" + }, + { + "name": "seed", + "value": "{{seed}}", + "variableId": "seed" + } + ] + } + }, + "Current URL: `https://picsum.photos/{{imageSize}}?category={{imageCategory}}&seed={{seed}}`", + "### Usage in Data Sources", + "URLs work the same way for loading data - origin, path, and optional query parameters with variables." + ] + } + ] +} diff --git a/docs/assets/examples/features/variables.idoc.json b/docs/assets/examples/features/variables.idoc.json new file mode 100644 index 00000000..e620ce4f --- /dev/null +++ b/docs/assets/examples/features/variables.idoc.json @@ -0,0 +1,58 @@ +{ + "$schema": "../../../schema/idoc_v1.json", + "title": "Feature: Variables", + "variables": [ + { + "variableId": "userName", + "type": "string", + "initialValue": "Alice" + }, + { + "variableId": "temperature", + "type": "number", + "initialValue": 20 + }, + { + "variableId": "isEnabled", + "type": "boolean", + "initialValue": true + }, + { + "variableId": "obj", + "type": "object", + "initialValue": {"foo": "bar", "value": 42} + }, + { + "variableId": "doubled_temperature", + "type": "number", + "initialValue": 40, + "calculation": { + "dependsOn": ["temperature"], + "vegaExpression": "temperature * 2" + } + }, + { + "variableId": "value", + "type": "number", + "initialValue": 42, + "calculation": { + "dependsOn": ["obj"], + "vegaExpression": "obj.value" + } + } + ], + "groups": [ + { + "groupId": "main", + "elements": [ + "## Variables\nVariables store values that can be referenced in markdown using `{{variableName}}` syntax.", + "**String:** Hello, **{{userName}}**!", + "**Number:** The count is **{{temperature}}**", + "**Boolean:** Feature status: **{{isEnabled}}**", + "**Object:** `obj` = JSON object with foo and value properties", + "**Calculated:** Count doubled is **{{doubled_temperature}}**", + "**Calculated:** Value from object: **{{value}}**" + ] + } + ] +} diff --git a/docs/assets/examples/seattle-weather/3.idoc.json b/docs/assets/examples/seattle-weather/3.idoc.json index 510376e2..6c8fea4b 100644 --- a/docs/assets/examples/seattle-weather/3.idoc.json +++ b/docs/assets/examples/seattle-weather/3.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [] diff --git a/docs/assets/examples/seattle-weather/4.idoc.json b/docs/assets/examples/seattle-weather/4.idoc.json index 653bc93b..01ab59d3 100644 --- a/docs/assets/examples/seattle-weather/4.idoc.json +++ b/docs/assets/examples/seattle-weather/4.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [] diff --git a/docs/assets/examples/seattle-weather/5.idoc.json b/docs/assets/examples/seattle-weather/5.idoc.json index 88350e26..d932b029 100644 --- a/docs/assets/examples/seattle-weather/5.idoc.json +++ b/docs/assets/examples/seattle-weather/5.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [ diff --git a/docs/assets/examples/seattle-weather/6.idoc.json b/docs/assets/examples/seattle-weather/6.idoc.json index a3ac113a..886c21af 100644 --- a/docs/assets/examples/seattle-weather/6.idoc.json +++ b/docs/assets/examples/seattle-weather/6.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [ diff --git a/docs/assets/examples/seattle-weather/7.idoc.json b/docs/assets/examples/seattle-weather/7.idoc.json index 47afc0eb..8c51a3a5 100644 --- a/docs/assets/examples/seattle-weather/7.idoc.json +++ b/docs/assets/examples/seattle-weather/7.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [ diff --git a/docs/assets/examples/seattle-weather/8.idoc.json b/docs/assets/examples/seattle-weather/8.idoc.json index 73cd015e..3e897dae 100644 --- a/docs/assets/examples/seattle-weather/8.idoc.json +++ b/docs/assets/examples/seattle-weather/8.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [ diff --git a/docs/assets/examples/seattle-weather/9.idoc.json b/docs/assets/examples/seattle-weather/9.idoc.json index ec1b2255..8e1caea3 100644 --- a/docs/assets/examples/seattle-weather/9.idoc.json +++ b/docs/assets/examples/seattle-weather/9.idoc.json @@ -4,10 +4,7 @@ "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [ diff --git a/docs/index.md b/docs/index.md index 9b24b279..df9b3a69 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,72 @@ layout: default title: "Chartifact Home" --- - # Chartifact +**Declarative, interactive data documents** + +Chartifact is a low-code document format for creating interactive, data-driven pages such as reports, dashboards, and presentations. It travels like a document and works like an app. Easily editable and remixable, it’s a file type for an AI-native world. + +## Ecosystem + +The Chartifact GitHub repo has source code for these interoperating modules: + +* A **document schema** that defines plugins and components that communicate together with reactive variables: + + * **Text** – Markdown with dynamic placeholders + * **Inputs** – Textboxes, checkboxes, sliders, dropdowns + * **Tables** – Sortable, selectable, and editable data grids + * **Charts** – Vega and Vega-Lite visualizations + * **Images** – Dynamic image URLs based on variables + * **Presets** – Named sets of variable values for quick scenario switching + +* A **VS Code extension** for editing, previewing, and exporting documents, with optional AI assistance. + +* A **web-based viewer and editor** for quick edits and sharing. + +* Tools to **export standalone HTML** documents you can share or embed anywhere. + +## Authoring Formats + +Chartifact documents can be written in two formats: + +* **Markdown** – Human-readable, easy to write and review. Interactive elements are embedded as fenced JSON blocks. +* **JSON** – Structured and precise. Ideal for programmatic generation or when working directly with the document schema. + +Both formats are functionally equivalent and supported across all tooling. + +## AI Support + +The format is designed with AI assistance in mind: + +* Structured syntax makes documents easy to edit and generate with LLMs +* 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: + +* **Reactive variables** update elements and data sources automatically +* **Dynamic bindings** let variables appear in chart specs, text, URLs, and API calls +* **REST integration** supports fetching data from external sources +* **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 standard CSS. Examples provided to style documents as articles, dashboards, or slides. + +## Security + +Chartifact is designed to be safe by default: -Visit our [GitHub repository](https://github.com/microsoft/chartifact) to learn more, report issues, or contribute to the project. +* No custom JavaScript execution +* CSP-compliant via Vega expression language +* No raw HTML in Markdown +* XSS-Defensive CSS parsing +* Rendered in sandboxed iframes to isolate execution diff --git a/package-lock.json b/package-lock.json index 7cb920c4..e069567d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12754,8 +12754,12 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "dev": true }, - "node_modules/web": { - "resolved": "packages/web", + "node_modules/web-deploy": { + "resolved": "packages/web-deploy", + "link": true + }, + "node_modules/web-frontend": { + "resolved": "packages/web-frontend", "link": true }, "node_modules/web-streams-polyfill": { @@ -13241,6 +13245,16 @@ "license": "MIT" }, "packages/web": { + "name": "web-frontend", + "version": "1.0.0", + "extraneous": true, + "license": "MIT" + }, + "packages/web-deploy": { + "version": "1.0.0", + "license": "MIT" + }, + "packages/web-frontend": { "version": "1.0.0", "license": "MIT" } diff --git a/packages/common/package.json b/packages/common/package.json index 5a733ad9..5bed0948 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -6,7 +6,7 @@ "main": "dist/esnext/index.js", "scripts": { "clean": "rimraf dist", - "dev": "tsc -p . -w", + "watch": "tsc -p . -w", "tsc": "tsc -p .", "build": "npm run tsc", "build:01": "npm run build" diff --git a/packages/common/src/expression.ts b/packages/common/src/expression.ts new file mode 100644 index 00000000..e0fdc1f4 --- /dev/null +++ b/packages/common/src/expression.ts @@ -0,0 +1,138 @@ +/** +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +//import { parseExpression } from 'vega'; + +type VegaExpressionNode = { + type: string; + [key: string]: any; +}; + +/** + * Collects all identifiers from a Vega expression AST. + * use with the result of vega.parseExpression + * example: + * const ast = vega.parseExpression('datum.value + item.datum.value + 'f' + foo + 'b' + bar'); + * const identifiers = collectIdentifiers(ast); + * console.log(identifiers); // Set { 'datum', 'item', 'foo', 'bar' } + * @param ast - The Vega expression AST to analyze. + * @returns A set of unique identifier names found in the AST. + */ +export function collectIdentifiers(ast: VegaExpressionNode): Set { + const identifiers = new Set(); + + function walk(node: VegaExpressionNode) { + if (!node || typeof node !== 'object') return; + + switch (node.type) { + case 'Identifier': + if (!VEGA_BUILTIN_FUNCTIONS.includes(node.name)) { + identifiers.add(node.name); + } + break; + + case 'CallExpression': + walk(node.callee); // still walk to get built-in func but filter later + (node.arguments || []).forEach(walk); + break; + + case 'MemberExpression': + walk(node.object); // only the object may be a variable + break; + + case 'BinaryExpression': + case 'LogicalExpression': + walk(node.left); + walk(node.right); + break; + + case 'ConditionalExpression': + walk(node.test); + walk(node.consequent); + walk(node.alternate); + break; + + case 'ArrayExpression': + (node.elements || []).forEach(walk); + break; + + default: + for (const key in node) { + if (node.hasOwnProperty(key)) { + const value = node[key]; + if (Array.isArray(value)) value.forEach(walk); + else if (typeof value === 'object') walk(value); + } + } + } + } + + walk(ast); + return identifiers; +} + +export const VEGA_BUILTIN_FUNCTIONS = Object.freeze([ + // Built-ins from Vega Expression docs + "abs", "acos", "asin", "atan", "atan2", "ceil", "clamp", "cos", "exp", "expm1", + "floor", "hypot", "log", "log1p", "max", "min", "pow", "random", "round", "sign", + "sin", "sqrt", "tan", "trunc", "length", "isNaN", "isFinite", "parseFloat", + "parseInt", "Date", "now", "time", "utc", "timezoneOffset", "quarter", "month", + "day", "hours", "minutes", "seconds", "milliseconds", "year" +]); + +/* + +const tests: [string, string[]][] = [ + // ✅ No variables + ["'foo' + 42", []], + + // ✅ Single identifier + ["foo", ["foo"]], + + // ✅ Simple binary expression + ["foo + bar", ["foo", "bar"]], + + // ✅ Function call with variable + ["length(foo)", ["foo"]], + + // ✅ Function call with array of identifiers + ["length([a, b, 3])", ["a", "b"]], + + // ✅ Nested binary expression + ["foo + (bar + (baz * 2))", ["foo", "bar", "baz"]], + + // ✅ Ternary conditional expression + ["foo ? bar : baz", ["foo", "bar", "baz"]], + + // ✅ Member expressions + ["config.x + data['y']", ["config", "data"]], + + // ✅ Deduplicates + ["foo + foo + bar", ["foo", "bar"]], + + // ✅ Function call with no identifiers + ["length([1, 2, 3])", []], + + // ✅ Array literal + ["[foo, 'bar', baz]", ["foo", "baz"]], + + // ✅ Nested function calls + ["log(sqrt(a) + cos(b))", ["a", "b"]], + + // ✅ Complex expression + ["foo + '|' + (bar + length([1,2,3]) + 'z')", ["foo", "bar"]], +]; + +tests.forEach(([expr, expected], i) => { + const ast = parseExpression(expr); + const result = [...collectIdentifiers(ast)].sort(); + const pass = JSON.stringify(result) === JSON.stringify(expected.sort()); + + console.log( + `${pass ? '✅' : '❌'} Test ${i + 1}: ${pass ? 'PASS' : `FAIL\n Expression: ${expr}\n Got: ${JSON.stringify(result)}\n Expected: ${JSON.stringify(expected)}`}` + ); +}); + +*/ diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 609dfa9b..862f7f49 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,4 +3,6 @@ * Licensed under the MIT License. */ export * from './common.js'; +export * from './expression.js'; export * from './messages.js'; +export * from './util.js'; diff --git a/packages/common/src/util.ts b/packages/common/src/util.ts new file mode 100644 index 00000000..aac58f41 --- /dev/null +++ b/packages/common/src/util.ts @@ -0,0 +1,133 @@ +/** +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +export type TemplateToken = + | { type: 'literal'; value: string } + | { type: 'variable'; name: string }; + +/** + * Tokenizes a template string into an array of tokens. + * @param input - The input string containing template variables in the format {{variableName}}. + * @returns An array of template tokens. + */ +export function tokenizeTemplate(input: string) { + const allVars = /{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}/g; + + const tokens: TemplateToken[] = []; + let lastIndex = 0; + + input.replace(allVars, (match, varName, offset) => { + // Static part before the match + const staticPart = input.slice(lastIndex, offset); + if (staticPart) { + tokens.push({ type: 'literal', value: staticPart }); + } + + tokens.push({ type: 'variable', name: varName }); + lastIndex = offset + match.length; + + return match; + }); + + // Remainder after last match + const tail = input.slice(lastIndex); + if (tail) { + tokens.push({ type: 'literal', value: tail }); + } + + return tokens; +} + +/** + * Renders a Vega expression from an array of template tokens. + * example input: [{ type: 'literal', value: 'foo' }, { type: 'variable', name: 'bar' }] + * This will produce a string like: "'foo' + encodeURIComponent(bar)" + * @param tokens - An array of template tokens. + * @returns A string representing the rendered Vega expression. + */ +export function renderVegaExpression(tokens: TemplateToken[], funcName = 'encodeURIComponent'): string { + const escape = (str: string) => `'${str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; + + return tokens + .map(token => + token.type === 'literal' + ? escape(token.value) + : `${funcName}(${token.name})` + ) + .join(' + '); +} + +export function encodeTemplateVariables(input: string): string { + const tokens = tokenizeTemplate(input); + return renderVegaExpression(tokens); +} + +/* +const tests = [ + // ✅ No variables + ["foobar", "'foobar'"], + + // ✅ Single valid variable in middle + ["foo{{bar}}baz", "'foo' + encodeURIComponent(bar) + 'baz'"], + + // ✅ Multiple valid variables + ["{{a}}-{{b}}/{{c}}", "encodeURIComponent(a) + '-' + encodeURIComponent(b) + '/' + encodeURIComponent(c)"], + + // ✅ Valid variable at start + ["{{bar}}baz", "encodeURIComponent(bar) + 'baz'"], + + // ✅ Valid variable at end + ["foo{{bar}}", "'foo' + encodeURIComponent(bar)"], + + // ✅ Only a variable + ["{{bar}}", "encodeURIComponent(bar)"], + + // ✅ Starts with single quote in static text + ["'quote{{bar}}", "'\\'quote' + encodeURIComponent(bar)"], + + // ✅ Ends with single quote in static text + ["foo{{bar}}'", "'foo' + encodeURIComponent(bar) + '\\''"], + + // ✅ Single quotes around and between variables + ["'a'{{b}}'c'", "'\\'a\\'' + encodeURIComponent(b) + '\\'c\\''"], + + // ✅ Underscore and digits in variable name + ["pre_{{var_123}}_post", "'pre_' + encodeURIComponent(var_123) + '_post'"], + + // ❌ Invalid variable (starts with number) should be left untouched + ["foo{{123abc}}baz", "'foo{{123abc}}baz'"], + + // ❌ Invalid variable (contains dash) should be left untouched + ["foo{{bad-name}}baz", "'foo{{bad-name}}baz'"], + + // ❌ Invalid variable (has space inside) should be left untouched + ["foo{{ some thing }}baz", "'foo{{ some thing }}baz'"], + + // 🔄 Mix of valid and invalid: only valid one gets replaced + ["a{{1bad}}b{{good}}c", "'a{{1bad}}b' + encodeURIComponent(good) + 'c'"], + + // 🔄 Complex mixed: valid and invalid placeholders + ["{{ok}} {{1bad}} x{{also_ok}}y", "encodeURIComponent(ok) + ' {{1bad}} x' + encodeURIComponent(also_ok) + 'y'"], + + // 🧪 Static string with single quotes + ["a'b'c", "'a\\'b\\'c'"], + + // 🧪 Single quotes in middle with variables + ["{{x}}'middle'{{y}}", "encodeURIComponent(x) + '\\'middle\\'' + encodeURIComponent(y)"], + + // 🧪 Backslashes in static part (not escaped here, just preserved) + ["path\\to\\file{{var}}", "'path\\to\\file' + encodeURIComponent(var)"], +]; + +tests.forEach(([input, expected], i) => { + const output = encodeTemplateVariables(input); + const pass = output === expected; + + console.log( + `${pass ? '✅' : '❌'} Test ${i + 1}: ${pass ? 'PASS' : `FAIL\n Input: ${input}\n Got: ${output}\n Expected: ${expected}`}` + ); +}); + +*/ \ No newline at end of file diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 712e5630..c5ef1e83 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -3,4 +3,3 @@ * Licensed under the MIT License. */ export { targetMarkdown } from './md.js'; -export { changePageOrigin } from './util.js'; diff --git a/packages/compiler/src/loader.ts b/packages/compiler/src/loader.ts index 7714b3e0..fa580d3b 100644 --- a/packages/compiler/src/loader.ts +++ b/packages/compiler/src/loader.ts @@ -38,7 +38,7 @@ export function addDynamicDataLoaderToSpec(vegaScope: VegaScope, dataSource: Dat const { spec } = vegaScope; const { dataSourceName } = dataSource; - const urlSignal = vegaScope.createUrlSignal(dataSource.urlRef); + const urlSignal = vegaScope.createUrlSignal(dataSource.url); const url: SignalRef = { signal: urlSignal.name }; ensureDataAndSignalsArray(spec); diff --git a/packages/compiler/src/md.ts b/packages/compiler/src/md.ts index f7537c44..44688dac 100644 --- a/packages/compiler/src/md.ts +++ b/packages/compiler/src/md.ts @@ -147,10 +147,9 @@ function groupMarkdown(group: ElementGroup, variables: Variable[], vegaScope: Ve break; } case 'image': { - const { urlRef, alt, width, height } = element; - const urlSignal = vegaScope.createUrlSignal(urlRef); + const { url, alt, width, height } = element; const imageSpec: Plugins.ImageSpec = { - srcSignalName: urlSignal.name, + url, alt, width, height, diff --git a/packages/compiler/src/scope.ts b/packages/compiler/src/scope.ts index efccb03a..e1ce48e9 100644 --- a/packages/compiler/src/scope.ts +++ b/packages/compiler/src/scope.ts @@ -3,44 +3,22 @@ * Licensed under the MIT License. */ import { Spec as VegaSpec } from 'vega-typings'; -import { MappedNameValuePairs, UrlRef } from '@microsoft/chartifact-schema'; +import { TemplatedUrl } from '@microsoft/chartifact-schema'; import { NewSignal } from "vega"; import { safeVariableName } from "./util.js"; +import { encodeTemplateVariables } from 'common'; export class VegaScope { private urlCount = 0; constructor(public spec: VegaSpec) { } - private addOrigin(origin: string) { - if (!this.spec.signals) { - this.spec.signals = []; - } - let origins = this.spec.signals.find(d => d.name === 'origins') as NewSignal; - if (!origins) { - origins = { - name: 'origins', - value: {}, - }; - this.spec.signals.unshift(origins); //add to the beginning of the signals array - } - origins.value[origin] = origin; - } - - createUrlSignal(urlRef: UrlRef) { - const { origin, urlPath, mappedParams } = urlRef; - const name = `url:${this.urlCount++}:${safeVariableName(origin + urlPath)}`; + createUrlSignal(url: TemplatedUrl) { + const name = `url:${this.urlCount++}:${safeVariableName(url)}`; const signal: NewSignal = { name }; - //TODO parse via URL object to get the actual base url - - this.addOrigin(origin); - - signal.update = `origins[${JSON.stringify(origin)}]+'${urlPath}'`; - - if (mappedParams && mappedParams.length > 0) { - signal.update += ` + '?' + ${mappedParams.map(p => `urlParam('${p.name}', ${variableValueExpression(p)})`).join(` + '&' + `)}`; - } + // Build a string expression for urlPath, replacing variables with encodeURIComponent + signal.update = encodeTemplateVariables(url); if (!this.spec.signals) { this.spec.signals = []; @@ -49,13 +27,3 @@ export class VegaScope { return signal; } } - -function variableValueExpression(param: MappedNameValuePairs) { - if (param.variableId) { - return param.variableId; - } else if (param.calculation) { - return '(' + param.calculation.vegaExpression + ')'; - } else { - return JSON.stringify(param.value); - } -} diff --git a/packages/compiler/src/util.ts b/packages/compiler/src/util.ts index fbb529c6..ffd1ac31 100644 --- a/packages/compiler/src/util.ts +++ b/packages/compiler/src/util.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. */ -import { DataSourceBaseFormat, DataSourceByDynamicURL, ImageElement, InteractiveElement, InteractiveDocument, PageElement, UrlRef } from '@microsoft/chartifact-schema'; +import { DataSourceBaseFormat, InteractiveElement, InteractiveDocument, PageElement } from '@microsoft/chartifact-schema'; import { Spec as VegaSpec } from 'vega-typings'; import { TopLevelSpec as VegaLiteSpec } from "vega-lite"; @@ -25,29 +25,6 @@ export function getFormatByURLOrFileName(urlOrFileName: string): DataSourceBaseF return undefined; } -export function exemplarUrl(dataSource: DataSourceByDynamicURL) { - //if there are no mappedParams, return the baseURL - if (!dataSource.urlRef.mappedParams || dataSource.urlRef.mappedParams.length === 0) { - return urlRefToBaseUrl(dataSource.urlRef); - } - //TODO urlPath may have a query string - - //make a copy of dataSource.mappedParams - const params = dataSource.urlRef.mappedParams.map(param => ({ ...param })); - //if a param is named **kwargs, then it is a querystring-style string of paramName=paramValue&etc - const kwargs = params.find(param => param.name === '**kwargs'); - //build url with all non-**kwargs params - let query = params.filter(param => param.name !== '**kwargs').map(param => `${param.name}=${param.value}`).join('&'); - //if there are **kwargs, append to query - if (kwargs) { - if (query.length > 0) { - query += '&'; - } - query += kwargs.value; - } - return `${urlRefToBaseUrl(dataSource.urlRef)}?${query}`; -} - export function getChartType(spec?: VegaSpec | VegaLiteSpec) { const $schema = spec?.$schema; if (!$schema) { @@ -73,85 +50,3 @@ export function getInteractiveElements(page: InteractiveDocument) { return interactiveElements; } -export function compareUrls(a: string, b: string) { - if (a === b) { - return true; - } - if (!a && !b) { - return true; - } - if (!a || !b) { - return false; - } - const urlA = new URL(a); - const urlB = new URL(b); - return urlA.protocol === urlB.protocol - && urlA.origin === urlB.origin - && urlA.pathname === urlB.pathname - && urlA.search === urlB.search - && urlA.hash === urlB.hash - ; -} - -export function gatherPageOrigins(page: InteractiveDocument) { - const origins = new Set(); - - // Collect origins from dataLoaders - page.dataLoaders.forEach(loader => { - if (loader.type === 'url') { - origins.add(loader.urlRef.origin); - } - }); - - // Collect origins from elements in groups - page.groups.forEach(group => { - group.elements.forEach(element => { - if (typeof element === 'object' && element.type === 'image') { - origins.add(element.urlRef.origin); - } - }); - }); - - return Array.from(origins); -} - -export function changePageOrigin(page: InteractiveDocument, oldOrigin: string, newOriginUrl: URL) { - const newPage: InteractiveDocument = { - ...page, - dataLoaders: page.dataLoaders.map(loader => { - if (loader.type === 'url' && loader.urlRef.origin === oldOrigin) { - return { - ...loader, - urlRef: { - ...loader.urlRef, - origin: newOriginUrl.origin, - }, - }; - } - return loader; - }), - groups: page.groups.map(group => ({ - ...group, - elements: group.elements.map(element => { - if (typeof element === 'object' && element.type === 'image' && element.urlRef.origin === oldOrigin) { - const newImageElement: ImageElement = { - ...element, - urlRef: { - ...element.urlRef, - origin: newOriginUrl.origin, - }, - }; - return newImageElement; - } - return element; - }), - })), - }; - return newPage; -} - -export function urlRefToBaseUrl(urlRef: UrlRef) { - //make sure there is a slash between origin and urlPath - const baseUrl = urlRef.origin + (urlRef.urlPath.startsWith('/') ? '' : '/') + urlRef.urlPath; - return baseUrl; -} diff --git a/packages/editor/src/app.tsx b/packages/editor/src/app.tsx index 6d7b7e50..3c9c0c1f 100644 --- a/packages/editor/src/app.tsx +++ b/packages/editor/src/app.tsx @@ -12,7 +12,6 @@ export interface AppProps { onApprove: (message: SandboxedPreHydrateMessage) => SpecReview<{}>[]; } -// Alternative implementation using same-origin communication export function App(props: AppProps) { const { previewer } = props; @@ -165,10 +164,7 @@ const initialPage: InteractiveDocument = { "dataLoaders": [ { "type": "url", - "urlRef": { - "origin": "https://vega.github.io", - "urlPath": "/editor/data/seattle-weather.csv" - }, + "url": "https://vega.github.io/editor/data/seattle-weather.csv", "dataSourceName": "seattle_weather", "format": "csv", "dataFrameTransformations": [] @@ -182,8 +178,7 @@ const initialPage: InteractiveDocument = { { "type": "table", "dataSourceName": "seattle_weather", - "variableId": "seattle_weather_selected", - "tabulatorOptions": {} + "variableId": "seattle_weather_selected" }, "Here is a stacked bar chart of Seattle weather:\nEach bar represents the count of weather types for each month.\nThe colors distinguish between different weather conditions such as sun, fog, drizzle, rain, and snow.", { diff --git a/packages/host/src/url.ts b/packages/host/src/url.ts index bf1e91f6..77ab5329 100644 --- a/packages/host/src/url.ts +++ b/packages/host/src/url.ts @@ -13,21 +13,11 @@ export function checkUrlForFile(host: Listener) { return false; // No load parameter found } - // First, validate the URL format and security - if (!isValidLoadUrl(loadUrl)) { + // Allow same-origin (including relative) URLs, or validate external URLs + if (!isSameOrigin(loadUrl) && !isValidLoadUrl(loadUrl)) { host.errorHandler( new Error('Invalid URL format'), - 'The URL provided has an invalid format or contains suspicious characters.' - ); - return false; - } - - // Then check origin/protocol requirements - const isValidUrl = (url: string) => isSameOrigin(url) || isHttps(url); - if (!isValidUrl(loadUrl)) { - host.errorHandler( - new Error(`Invalid URL provided`), - 'The URL provided is not valid. Please ensure it is on the same origin or uses HTTPS.' + 'The URL provided is not same-origin or has an invalid format, protocol, or contains suspicious characters.' ); return false; } @@ -89,13 +79,16 @@ function isValidLoadUrl(url: string): boolean { // Resolve relative URLs against current location const parsedUrl = new URL(url, window.location.href); - // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + // Only allow http and https protocols, but allow http for localhost + if ( + parsedUrl.protocol !== 'https:' && + !(parsedUrl.protocol === 'http:' && (parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1')) + ) { return false; } // Prevent javascript:, vbscript:, and data: URLs (redundant check but good for clarity) - if (parsedUrl.protocol === 'javascript:' || parsedUrl.protocol === 'vbscript:' || parsedUrl.protocol === 'data:') { + if (['javascript:', 'vbscript:', 'data:'].includes(parsedUrl.protocol)) { return false; } diff --git a/packages/markdown/index.html b/packages/markdown/index.html new file mode 100644 index 00000000..c19723f0 --- /dev/null +++ b/packages/markdown/index.html @@ -0,0 +1,294 @@ + + + + + + + Mdv test + + + + + + + + + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/packages/markdown/package.json b/packages/markdown/package.json index a0b351c5..962bbb55 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -14,7 +14,8 @@ "scripts": { "clean": "rimraf dist", "deploy": "node ./scripts/deploy.js", - "dev": "tsc -p . -w", + "dev": "vite", + "watch": "tsc -p . -w", "tsc": "tsc -p .", "bundle": "vite build --config vite.bundle.config.js", "build": "npm run tsc && npm run bundle", diff --git a/packages/markdown/src/factory.ts b/packages/markdown/src/factory.ts index d8bc3ff2..033de191 100644 --- a/packages/markdown/src/factory.ts +++ b/packages/markdown/src/factory.ts @@ -30,7 +30,7 @@ export interface Batch { export interface IInstance { id: string; initialSignals: PrioritizedSignal[]; - recieveBatch?: (batch: Batch, from: string) => Promise; + receiveBatch?: (batch: Batch, from: string) => Promise; beginListening?: (sharedSignals: { signalName: string, isData: boolean }[]) => void; broadcastComplete?: () => Promise; destroy?: () => void; diff --git a/packages/markdown/src/plugins/checkbox.ts b/packages/markdown/src/plugins/checkbox.ts index c392daf1..df1e2ec1 100644 --- a/packages/markdown/src/plugins/checkbox.ts +++ b/packages/markdown/src/plugins/checkbox.ts @@ -50,7 +50,7 @@ export const checkboxPlugin: Plugin = { checkboxInstances.push(checkboxInstance); } - const instances: IInstance[] = checkboxInstances.map((checkboxInstance) => { + const instances = checkboxInstances.map((checkboxInstance): IInstance => { const { element, spec } = checkboxInstance; const initialSignals = [{ name: spec.variableId, @@ -62,7 +62,7 @@ export const checkboxPlugin: Plugin = { return { ...checkboxInstance, initialSignals, - recieveBatch: async (batch) => { + receiveBatch: async (batch) => { if (batch[spec.variableId]) { const value = batch[spec.variableId].value as boolean; element.checked = value; diff --git a/packages/markdown/src/plugins/dropdown.ts b/packages/markdown/src/plugins/dropdown.ts index a01832a9..f32c76d2 100644 --- a/packages/markdown/src/plugins/dropdown.ts +++ b/packages/markdown/src/plugins/dropdown.ts @@ -53,7 +53,7 @@ export const dropdownPlugin: Plugin = { const dropdownInstance: DropdownInstance = { id: `${pluginName}-${index}`, spec, element }; dropdownInstances.push(dropdownInstance); } - const instances: IInstance[] = dropdownInstances.map((dropdownInstance, index) => { + const instances = dropdownInstances.map((dropdownInstance, index) : IInstance => { const { element, spec } = dropdownInstance; const initialSignals = [{ name: spec.variableId, @@ -72,7 +72,7 @@ export const dropdownPlugin: Plugin = { return { ...dropdownInstance, initialSignals, - recieveBatch: async (batch) => { + receiveBatch: async (batch) => { const { dynamicOptions } = spec; if (dynamicOptions?.dataSourceName) { const newData = batch[dynamicOptions.dataSourceName]?.value as object[]; diff --git a/packages/markdown/src/plugins/image.ts b/packages/markdown/src/plugins/image.ts index 36aef248..802938b2 100644 --- a/packages/markdown/src/plugins/image.ts +++ b/packages/markdown/src/plugins/image.ts @@ -8,15 +8,15 @@ import { IInstance, Plugin } from '../factory.js'; import { pluginClassName } from './util.js'; import { flaggableJsonPlugin } from './config.js'; import { PluginNames } from './interfaces.js'; +import { DynamicUrl } from './url.js'; export interface ImageSpec extends ImageElementProps { - srcSignalName: string; } interface ImageInstance { id: string; spec: ImageSpec; - element: HTMLImageElement; + img: HTMLImageElement; spinner: HTMLDivElement; } @@ -41,7 +41,7 @@ export const imagePlugin: Plugin = { const container = renderer.element.querySelector(`#${specReview.containerId}`); const spec: ImageSpec = specReview.approvedSpec; - const element = document.createElement('img'); + const img = document.createElement('img'); const spinner = document.createElement('div'); spinner.innerHTML = ` @@ -50,61 +50,63 @@ export const imagePlugin: Plugin = { `; - if (spec.alt) element.alt = spec.alt; - if (spec.width) element.width = spec.width; - if (spec.height) element.height = spec.height; - element.onload = () => { + if (spec.alt) img.alt = spec.alt; + if (spec.width) img.width = spec.width; + if (spec.height) img.height = spec.height; + img.onload = () => { spinner.style.display = 'none'; - element.style.opacity = ImageOpacity.full; + img.style.opacity = ImageOpacity.full; }; - element.onerror = () => { + img.onerror = () => { spinner.style.display = 'none'; - element.style.opacity = ImageOpacity.error; - errorHandler(new Error('Image failed to load'), pluginName, index, 'load', container, element.src); + img.style.opacity = ImageOpacity.error; + errorHandler(new Error('Image failed to load'), pluginName, index, 'load', container, img.src); }; (container as HTMLElement).style.position = 'relative'; spinner.style.position = 'absolute'; container.innerHTML = ''; container.appendChild(spinner); - container.appendChild(element); + container.appendChild(img); - const imageInstance: ImageInstance = { id: `${pluginName}-${index}`, spec, element, spinner }; + const imageInstance: ImageInstance = { id: `${pluginName}-${index}`, spec, img, spinner }; imageInstances.push(imageInstance); } - const instances: IInstance[] = imageInstances.map((imageInstance, index) => { - const { element, spinner, id, spec } = imageInstance; + const instances = imageInstances.map((imageInstance, index): IInstance => { + const { img, spinner, id, spec } = imageInstance; + + const dynamicUrl = new DynamicUrl(spec.url, (src) => { + if (src) { + spinner.style.display = ''; + img.src = src.toString(); + img.style.opacity = ImageOpacity.loading; + } else { + img.src = ''; //TODO placeholder image + spinner.style.display = 'none'; + img.style.opacity = ImageOpacity.full; + } + }); + + const signalNames = Object.keys(dynamicUrl.signals); + return { id, - initialSignals: [ - { - name: spec.srcSignalName, - value: null, - priority: -1, - isData: false, - }, - ], + initialSignals: Array.from(signalNames).map(name => ({ + name, + value: null, + priority: -1, + isData: false, + })), destroy: () => { - if (element) { - element.remove(); + if (img) { + img.remove(); } if (spinner) { spinner.remove(); } }, - recieveBatch: async (batch, from) => { - if (spec.srcSignalName in batch) { - const src = batch[spec.srcSignalName].value; - if (src) { - spinner.style.display = ''; - element.src = src.toString(); - element.style.opacity = ImageOpacity.loading; - } else { - element.src = ''; //TODO placeholder image - spinner.style.display = 'none'; - element.style.opacity = ImageOpacity.full; - } - } + receiveBatch: async (batch, from) => { + dynamicUrl.receiveBatch(batch); }, }; }); diff --git a/packages/markdown/src/plugins/placeholders.ts b/packages/markdown/src/plugins/placeholders.ts index ec9038e4..b4e9f376 100644 --- a/packages/markdown/src/plugins/placeholders.ts +++ b/packages/markdown/src/plugins/placeholders.ts @@ -152,7 +152,7 @@ export const placeholdersPlugin: Plugin = { { id: pluginName, initialSignals, - recieveBatch: async (batch) => { + receiveBatch: async (batch) => { for (const key of Object.keys(batch)) { const elements = elementsByKeys.get(key) || []; for (const element of elements) { diff --git a/packages/markdown/src/plugins/slider.ts b/packages/markdown/src/plugins/slider.ts index 59c8679b..9c040e0c 100644 --- a/packages/markdown/src/plugins/slider.ts +++ b/packages/markdown/src/plugins/slider.ts @@ -52,7 +52,7 @@ export const sliderPlugin: Plugin = { sliderInstances.push(sliderInstance); } - const instances: IInstance[] = sliderInstances.map((sliderInstance) => { + const instances = sliderInstances.map((sliderInstance): IInstance => { const { element, spec } = sliderInstance; const valueSpan = element.parentElement?.querySelector('.vega-bind-value') as HTMLSpanElement; @@ -66,7 +66,7 @@ export const sliderPlugin: Plugin = { return { ...sliderInstance, initialSignals, - recieveBatch: async (batch) => { + receiveBatch: async (batch) => { if (batch[spec.variableId]) { const value = batch[spec.variableId].value as number; element.value = value.toString(); diff --git a/packages/markdown/src/plugins/tabulator.ts b/packages/markdown/src/plugins/tabulator.ts index 8098ede1..77276e55 100644 --- a/packages/markdown/src/plugins/tabulator.ts +++ b/packages/markdown/src/plugins/tabulator.ts @@ -113,7 +113,7 @@ export const tabulatorPlugin: Plugin = { }); tabulatorInstances.push(tabulatorInstance); } - const instances: IInstance[] = tabulatorInstances.map((tabulatorInstance, index) => { + const instances = tabulatorInstances.map((tabulatorInstance, index): IInstance => { const { container, spec, table, selectableRows } = tabulatorInstance; const initialSignals = [{ name: spec.dataSourceName, @@ -236,7 +236,7 @@ export const tabulatorPlugin: Plugin = { return { ...tabulatorInstance, initialSignals, - recieveBatch: async (batch, from) => { + receiveBatch: async (batch, from) => { const newData = batch[spec.dataSourceName]?.value as object[]; if (newData) { //make sure tabulator is ready before setting data diff --git a/packages/markdown/src/plugins/textbox.ts b/packages/markdown/src/plugins/textbox.ts index f76b8786..6af50e89 100644 --- a/packages/markdown/src/plugins/textbox.ts +++ b/packages/markdown/src/plugins/textbox.ts @@ -55,7 +55,7 @@ export const textboxPlugin: Plugin = { textboxInstances.push(textboxInstance); } - const instances: IInstance[] = textboxInstances.map((textboxInstance) => { + const instances = textboxInstances.map((textboxInstance): IInstance => { const { element, spec } = textboxInstance; const initialSignals = [{ name: spec.variableId, @@ -67,7 +67,7 @@ export const textboxPlugin: Plugin = { return { ...textboxInstance, initialSignals, - recieveBatch: async (batch) => { + receiveBatch: async (batch) => { if (batch[spec.variableId]) { const value = batch[spec.variableId].value as string; element.value = value; diff --git a/packages/markdown/src/plugins/url.ts b/packages/markdown/src/plugins/url.ts new file mode 100644 index 00000000..035260f3 --- /dev/null +++ b/packages/markdown/src/plugins/url.ts @@ -0,0 +1,78 @@ +/*! +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +import { TemplatedUrl } from "@microsoft/chartifact-schema"; +import { TemplateToken, tokenizeTemplate } from "common"; +import { Batch } from "../factory.js"; + +// export function getUrlSignals(url: TemplatedUrl) { +// const signalNames = new Set(); + +// //get signal names from tokens in the url +// const tokens = tokenizeTemplate(url); +// const signalsFromToken = tokens.filter(token => token.type === 'variable').map(token => token.name); +// signalsFromToken.forEach(token => signalNames.add(token)); + +// return Array.from(signalNames); +// } + +export class DynamicUrl { + public signals: Record; + public tokens: TemplateToken[]; + public lastUrl: string; + + constructor(public templateUrl: TemplatedUrl, public onChange: (url: string) => void) { + this.signals = {}; + this.tokens = tokenizeTemplate(templateUrl); + const signalNames = this.tokens.filter(token => token.type === 'variable').map(token => token.name); + if (signalNames.length === 0) { + // If there are no signals, we can set the url directly + onChange(templateUrl); + this.lastUrl = templateUrl; + return; + } + signalNames.forEach(signalName => { + this.signals[signalName] = undefined; + }); + } + + public makeUrl() { + const signalNames = Object.keys(this.signals); + if (signalNames.length === 0) { + return this.templateUrl; + } + const urlParts: string[] = []; + this.tokens.forEach(token => { + if (token.type === 'literal') { + urlParts.push(token.value); + } else if (token.type === 'variable') { + const signalValue = this.signals[token.name]; + if (signalValue !== undefined) { + urlParts.push(encodeURIComponent(signalValue)); + } else { + //leave variable slot empty + } + } + }); + return urlParts.join(''); + } + + public receiveBatch(batch: Batch) { + for (const [signalName, batchItem] of Object.entries(batch)) { + if (signalName in this.signals) { + if (batchItem.isData || batchItem.value === undefined) { + continue; + } + this.signals[signalName] = batchItem.value.toString(); + } + } + const newUrl = this.makeUrl(); + if (newUrl !== this.lastUrl) { + this.onChange(newUrl); + this.lastUrl = newUrl; + } + } + +} diff --git a/packages/markdown/src/plugins/util.ts b/packages/markdown/src/plugins/util.ts index a6627152..56544119 100644 --- a/packages/markdown/src/plugins/util.ts +++ b/packages/markdown/src/plugins/util.ts @@ -2,14 +2,6 @@ * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. */ -export function urlParam(urlParamName: string, value: any) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) { - return value.map(vn => `${urlParamName}[]=${encodeURIComponent(vn)}`).join('&'); - } else { - return `${urlParamName}=${encodeURIComponent(value)}`; - } -} export function getJsonScriptTag(container: Element, errorHandler: (error: Error) => void) { const scriptTag = container.previousElementSibling; diff --git a/packages/markdown/src/plugins/vega.ts b/packages/markdown/src/plugins/vega.ts index 1892f795..654df4c5 100644 --- a/packages/markdown/src/plugins/vega.ts +++ b/packages/markdown/src/plugins/vega.ts @@ -8,7 +8,7 @@ import { Batch, IInstance, Plugin, PrioritizedSignal, RawFlaggableSpec } from '. import { BaseSignal, InitSignal, NewSignal, Runtime, Spec, ValuesData } from 'vega-typings'; import { ErrorHandler, Renderer } from '../renderer.js'; import { LogLevel } from '../signalbus.js'; -import { pluginClassName, urlParam } from './util.js'; +import { pluginClassName } from './util.js'; import { defaultCommonOptions } from 'common'; import { flaggableJsonPlugin, } from './config.js'; import { PluginNames } from './interfaces.js'; @@ -46,7 +46,7 @@ export const vegaPlugin: Plugin = { hydrateComponent: async (renderer, errorHandler, specs) => { //initialize the expressionFunction only once if (!expressionsInitialized) { - expressionFunction('urlParam', urlParam); + expressionFunction('encodeURIComponent', encodeURIComponent); expressionsInitialized = true; } @@ -92,7 +92,7 @@ export const vegaPlugin: Plugin = { } } - const instances: IInstance[] = vegaInstances.map((vegaInstance) => { + const instances = vegaInstances.map((vegaInstance): IInstance => { const { spec, view, initialSignals } = vegaInstance; const startBatch = (from: string) => { if (!vegaInstance.batch) { @@ -109,11 +109,11 @@ export const vegaPlugin: Plugin = { return { ...vegaInstance, initialSignals, - recieveBatch: async (batch, from) => { - renderer.signalBus.log(vegaInstance.id, 'recieved batch', batch, from); + receiveBatch: async (batch, from) => { + renderer.signalBus.log(vegaInstance.id, 'received batch', batch, from); return new Promise(resolve => { view.runAfter(async () => { - if (recieveBatch(batch, renderer, vegaInstance)) { + if (receiveBatch(batch, renderer, vegaInstance)) { renderer.signalBus.log(vegaInstance.id, 'running after _pulse, changes from', from); vegaInstance.needToRun = true; } else { @@ -196,15 +196,15 @@ export const vegaPlugin: Plugin = { }, }; -function recieveBatch(batch: Batch, renderer: Renderer, vegaInstance: VegaInstance) { +function receiveBatch(batch: Batch, renderer: Renderer, vegaInstance: VegaInstance) { const { spec, view } = vegaInstance; const doLog = renderer.signalBus.logLevel === LogLevel.all; - doLog && renderer.signalBus.log(vegaInstance.id, 'recieveBatch', batch); + doLog && renderer.signalBus.log(vegaInstance.id, 'receiveBatch', batch); let hasAnyChange = false; for (const signalName in batch) { const batchItem = batch[signalName]; if (ignoredSignals.includes(signalName)) { - doLog && renderer.signalBus.log(vegaInstance.id, 'ignoring reverved signal name', signalName, batchItem.value); + doLog && renderer.signalBus.log(vegaInstance.id, 'ignoring reserved signal name', signalName, batchItem.value); continue; } if (batchItem.isData) { diff --git a/packages/markdown/src/renderer.ts b/packages/markdown/src/renderer.ts index b8de3ddc..9098445f 100644 --- a/packages/markdown/src/renderer.ts +++ b/packages/markdown/src/renderer.ts @@ -138,7 +138,7 @@ export class Renderer { } } } - this.signalBus.beginListening(); + await this.signalBus.beginListening(); } catch (error) { console.error('Error in rendering plugins', error); } diff --git a/packages/markdown/src/signalbus.ts b/packages/markdown/src/signalbus.ts index 8579c8eb..af328f62 100644 --- a/packages/markdown/src/signalbus.ts +++ b/packages/markdown/src/signalbus.ts @@ -63,7 +63,7 @@ export class SignalBus { } if (!hasBatch) continue; - peer.recieveBatch && await peer.recieveBatch(peerBatch, originId); + peer.receiveBatch && await peer.receiveBatch(peerBatch, originId); } this.broadcastingStack.pop(); @@ -116,7 +116,7 @@ export class SignalBus { } } - beginListening() { + async beginListening() { //set the initial batch on each peer this.log('beginListening', 'begin initial batch', this.signalDeps); @@ -127,7 +127,12 @@ export class SignalBus { const { value, isData } = signalDep; batch[signalName] = { value, isData }; } - peer.recieveBatch && peer.recieveBatch(batch, 'initial'); + peer.receiveBatch && peer.receiveBatch(batch, 'initial'); + } + + //need to call broadcast complete to ensure that all peers have the initial values + for (const peer of this.peers) { + peer.broadcastComplete && await peer.broadcastComplete(); } this.log('beginListening', 'end initial batch'); diff --git a/packages/schema/src/common.ts b/packages/schema/src/common.ts index d0b997a9..57636efc 100644 --- a/packages/schema/src/common.ts +++ b/packages/schema/src/common.ts @@ -12,7 +12,7 @@ import { Transforms } from 'vega'; * - Do NOT use space characters in the VariableID, but you may use underscores. * - Do NOT prefix the VariableID with a digit. * - Do NOT prefix/suffix the VariableID with the type, e.g. "value_number" is bad. - * - The following names are not allowed as VariableIDs: "width", "height", "padding", "autosize", "background", "style", "parent", "datum", "item", "event", "cursor", "origins" + * - The following names are not allowed as VariableIDs: "width", "height", "padding", "autosize", "background", "style", "parent", "datum", "item", "event", "cursor" */ export type VariableID = string; @@ -39,27 +39,8 @@ export interface Calculation { dataFrameTransformations?: Transforms[]; } -export interface NameValuePairs { - /** case-sensitive, do not rename */ - name: string; - value: VariableValue; -} - -export interface MappedNameValuePairs extends NameValuePairs { - /** IMPORTANT! map to a variable whenever possible */ - variableId?: VariableID; - - /** a calculated value */ - calculation?: Calculation; -} - -export interface UrlRef { - origin: string; - urlPath: string; - - /** these become query parameters in the URL */ - mappedParams?: MappedNameValuePairs[]; -} + /** A url, it may contain template variables, e.g. https://example.com/{{category}}/{{item}} */ +export type TemplatedUrl = string; export interface DataSourceBase { /** name of the data source, used to reference it in the UI, has same constraints as VariableID */ diff --git a/packages/schema/src/datasource.ts b/packages/schema/src/datasource.ts index a1af7e77..df66a6ad 100644 --- a/packages/schema/src/datasource.ts +++ b/packages/schema/src/datasource.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. */ -import { DataSourceBase, VariableType, UrlRef } from './common.js'; +import { DataSourceBase, TemplatedUrl, VariableType } from './common.js'; export interface ReturnType { type: VariableType; @@ -29,7 +29,7 @@ export interface DataSourceByFile extends DataSourceBase { /** User references a data source by URL, may be either static or dynamic */ export interface DataSourceByDynamicURL extends DataSourceBase { type: 'url'; - urlRef: UrlRef; + url: TemplatedUrl; returnType?: ReturnType; /** Assistant should not populate this. */ diff --git a/packages/schema/src/interactive.ts b/packages/schema/src/interactive.ts index 65d8e88a..fb340923 100644 --- a/packages/schema/src/interactive.ts +++ b/packages/schema/src/interactive.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. */ -import { DataSourceBase, VariableID, VariableControl, ElementBase, UrlRef } from './common.js'; +import { DataSourceBase, VariableID, VariableControl, ElementBase, TemplatedUrl } from './common.js'; /** * Interactive Elements @@ -103,9 +103,9 @@ export interface ChartElement extends ElementBase { */ export interface ImageElement extends ElementBase, ImageElementProps { type: 'image'; - urlRef: UrlRef; } export interface ImageElementProps { + url: TemplatedUrl; alt?: string; height?: number; width?: number; diff --git a/packages/web-deploy/package.json b/packages/web-deploy/package.json new file mode 100644 index 00000000..13b1e10a --- /dev/null +++ b/packages/web-deploy/package.json @@ -0,0 +1,14 @@ +{ + "name": "web-deploy", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "readme": "node ./dist/index.mjs", + "clean": "rimraf dist", + "build": "tsc -p .", + "deploy": "npm run build && npm run readme" + }, + "author": "Dan Marshall", + "license": "MIT", + "description": "" +} diff --git a/packages/web-deploy/src/index.mts b/packages/web-deploy/src/index.mts new file mode 100644 index 00000000..46e9520d --- /dev/null +++ b/packages/web-deploy/src/index.mts @@ -0,0 +1,21 @@ +import { readFile, writeFile } from 'fs/promises'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const frontmatter = `--- +layout: default +title: "Chartifact Home" +--- +`; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const readmePath = resolve(__dirname, '../../../README.md'); +const outputPath = resolve(__dirname, '../../../docs/index.md'); + +async function generateIndex() { + const readme = await readFile(readmePath, 'utf-8'); + await writeFile(outputPath, frontmatter + readme, 'utf-8'); +} + +generateIndex().catch(console.error); \ No newline at end of file diff --git a/packages/web-deploy/tsconfig.json b/packages/web-deploy/tsconfig.json new file mode 100644 index 00000000..9204ae8a --- /dev/null +++ b/packages/web-deploy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "node16", + "moduleResolution": "node16", + "skipLibCheck": true, + "target": "esnext", + "allowSyntheticDefaultImports": true, + "lib": [ + "esnext" + ], + "outDir": "dist", + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/web/package.json b/packages/web-frontend/package.json similarity index 91% rename from packages/web/package.json rename to packages/web-frontend/package.json index fb102fdb..437cb881 100644 --- a/packages/web/package.json +++ b/packages/web-frontend/package.json @@ -1,5 +1,5 @@ { - "name": "web", + "name": "web-frontend", "private": true, "version": "1.0.0", "main": "index.js", diff --git a/packages/web/src/chartifact.d.ts b/packages/web-frontend/src/chartifact.d.ts similarity index 100% rename from packages/web/src/chartifact.d.ts rename to packages/web-frontend/src/chartifact.d.ts diff --git a/packages/web/src/edit.ts b/packages/web-frontend/src/edit.ts similarity index 100% rename from packages/web/src/edit.ts rename to packages/web-frontend/src/edit.ts diff --git a/packages/web/src/react-dom.d.ts b/packages/web-frontend/src/react-dom.d.ts similarity index 100% rename from packages/web/src/react-dom.d.ts rename to packages/web-frontend/src/react-dom.d.ts diff --git a/packages/web/src/view.ts b/packages/web-frontend/src/view.ts similarity index 100% rename from packages/web/src/view.ts rename to packages/web-frontend/src/view.ts diff --git a/packages/web/tsconfig.json b/packages/web-frontend/tsconfig.json similarity index 100% rename from packages/web/tsconfig.json rename to packages/web-frontend/tsconfig.json