eleventy-plugin-uncharted 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/css/uncharted.css CHANGED
@@ -1674,8 +1674,11 @@
1674
1674
  Download Link
1675
1675
  ========================================================================== */
1676
1676
 
1677
- .chart-download {
1678
- display: block;
1677
+ .chart-downloads {
1679
1678
  font-size: 0.75em;
1680
1679
  margin-block-start: 0.5em;
1681
1680
  }
1681
+
1682
+ .chart-download-sep {
1683
+ opacity: 0.5;
1684
+ }
@@ -28,9 +28,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
28
  * @param {boolean} [options.dataPassthrough] - Copy CSV files to public dataPath (default: false)
29
29
  * @param {string} [options.dataPath] - Public URL path for CSV files (default: '/data/')
30
30
  * @param {boolean|string} [options.downloadData] - Enable download links globally (individual charts can override)
31
+ * @param {boolean|string} [options.downloadImage] - Enable image download links globally (requires image generation)
31
32
  * @param {Object} [options.image] - Image generation options
32
33
  * @param {boolean} [options.image.enabled=false] - Enable PNG image generation
33
- * @param {string} [options.image.outputDir='/images/charts/'] - Output directory for images
34
+ * @param {string} [options.image.outputDir='/images/charts/'] - Output directory for images (URL path)
35
+ * @param {string} [options.image.cacheDir] - Source directory for cached images (enables caching when set)
34
36
  * @param {number} [options.image.width=800] - Default image width in pixels
35
37
  * @param {number} [options.image.height=400] - Default image height in pixels
36
38
  * @param {number} [options.image.scale=2] - Device scale factor (2 for retina)
@@ -66,6 +68,7 @@ export default function(eleventyConfig, options = {}) {
66
68
  const dataPassthrough = options.dataPassthrough ?? false;
67
69
  const dataPath = options.dataPath || '/data/';
68
70
  const globalDownloadData = options.downloadData ?? false;
71
+ const globalDownloadImage = options.downloadImage ?? false;
69
72
 
70
73
  // Image generation options
71
74
  const imageOptions = normalizeImageOptions(options.image);
@@ -123,6 +126,15 @@ export default function(eleventyConfig, options = {}) {
123
126
  });
124
127
  }
125
128
 
129
+ // Image cache passthrough - copies cached images to output
130
+ if (imageOptions.cacheDir) {
131
+ const cacheDirClean = imageOptions.cacheDir.replace(/^\/|\/$/g, '');
132
+ const outputDirClean = imageOptions.outputDir.replace(/^\/|\/$/g, '');
133
+ eleventyConfig.addPassthroughCopy({
134
+ [cacheDirClean]: outputDirClean
135
+ });
136
+ }
137
+
126
138
  eleventyConfig.addShortcode('chart', function(chartId) {
127
139
  // Get resolved data directory (from Eleventy config or plugin options)
128
140
  const resolvedDataDir = getDataDir();
@@ -174,6 +186,7 @@ export default function(eleventyConfig, options = {}) {
174
186
  // Render the chart (chart-specific settings override global)
175
187
  const animate = chartConfig.animate ?? globalAnimate;
176
188
  const downloadData = chartConfig.downloadData ?? globalDownloadData;
189
+ const downloadImage = chartConfig.downloadImage ?? globalDownloadImage;
177
190
 
178
191
  // Calculate download URL if download is enabled and file is specified
179
192
  let downloadDataUrl = null;
@@ -182,6 +195,13 @@ export default function(eleventyConfig, options = {}) {
182
195
  downloadDataUrl = normalizedDataPath + chartConfig.file;
183
196
  }
184
197
 
198
+ // Calculate image download URL if enabled and image generation is active
199
+ const chartImageEnabled = chartConfig.image?.enabled ?? imageOptions.enabled;
200
+ let downloadImageUrl = null;
201
+ if (downloadImage && chartImageEnabled) {
202
+ downloadImageUrl = getImageUrl(chartId, chartConfig, imageOptions);
203
+ }
204
+
185
205
  // Normalize configuration (handles deprecated keys, axis config)
186
206
  const normalizedConfig = normalizeConfig(chartConfig, chartId);
187
207
 
@@ -196,6 +216,8 @@ export default function(eleventyConfig, options = {}) {
196
216
  animate,
197
217
  downloadData,
198
218
  downloadDataUrl,
219
+ downloadImage,
220
+ downloadImageUrl,
199
221
  _columns: columns
200
222
  });
201
223
 
@@ -207,7 +229,6 @@ export default function(eleventyConfig, options = {}) {
207
229
  );
208
230
 
209
231
  // Handle image generation
210
- const chartImageEnabled = chartConfig.image?.enabled ?? imageOptions.enabled;
211
232
  if (chartImageEnabled && !skipImageGeneration && eleventyDirs?.output) {
212
233
  // Queue chart for image generation
213
234
  queueChartForImage(chartId, chartHtml, chartConfig, imageOptions, eleventyDirs.output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
@@ -15,6 +15,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  * @typedef {Object} ImageOptions
16
16
  * @property {boolean} [enabled=false] - Enable image generation
17
17
  * @property {string} [outputDir='/images/charts/'] - Output directory for images (URL path)
18
+ * @property {string} [cacheDir=null] - Source directory for cached images (enables caching when set)
18
19
  * @property {number} [width=800] - Default image width
19
20
  * @property {number} [height=400] - Default image height
20
21
  * @property {number} [scale=2] - Device scale factor (2 for retina)
@@ -33,6 +34,7 @@ const imageUrls = new Map();
33
34
  const defaultOptions = {
34
35
  enabled: false,
35
36
  outputDir: '/images/charts/',
37
+ cacheDir: null,
36
38
  width: 800,
37
39
  height: 400,
38
40
  scale: 2,
@@ -83,6 +85,23 @@ export function getImageUrl(chartId, chartConfig, globalOptions) {
83
85
  return `${dir}${filename}.png`;
84
86
  }
85
87
 
88
+ /**
89
+ * Get the cache file path for a chart image.
90
+ * @param {string} chartId - Chart identifier
91
+ * @param {Object} chartConfig - Chart configuration
92
+ * @param {ImageOptions} globalOptions - Global image options
93
+ * @returns {string|null} Absolute cache path, or null if caching disabled
94
+ */
95
+ export function getCachePath(chartId, chartConfig, globalOptions) {
96
+ if (!globalOptions.cacheDir) return null;
97
+
98
+ const imageConfig = chartConfig.image || {};
99
+ const filename = imageConfig.filename || chartId;
100
+ const cacheDir = globalOptions.cacheDir.replace(/\/$/, '');
101
+
102
+ return path.resolve(process.cwd(), cacheDir, `${filename}.png`);
103
+ }
104
+
86
105
  /**
87
106
  * Store the image URL for a chart (for shortcode lookup).
88
107
  * @param {string} chartId
@@ -143,6 +162,7 @@ export function queueChartForImage(chartId, chartHtml, chartConfig, globalOption
143
162
 
144
163
  const outputPath = getImageOutputPath(chartId, chartConfig, globalOptions, outputDir);
145
164
  const url = getImageUrl(chartId, chartConfig, globalOptions);
165
+ const cachePath = getCachePath(chartId, chartConfig, globalOptions);
146
166
 
147
167
  // Store URL for shortcode lookup
148
168
  storeImageUrl(chartId, url);
@@ -152,7 +172,8 @@ export function queueChartForImage(chartId, chartHtml, chartConfig, globalOption
152
172
  id: chartId,
153
173
  html: chartHtml,
154
174
  config,
155
- outputPath
175
+ outputPath,
176
+ cachePath
156
177
  });
157
178
  }
158
179
 
@@ -171,7 +192,10 @@ export async function processQueue(options) {
171
192
  if (!available) {
172
193
  const count = getQueueSize();
173
194
  clearQueue();
174
- console.warn(`[uncharted] Puppeteer not installed. Skipped ${count} chart image(s).`);
195
+ // Suppress warning if cacheDir is set (cached images will be used via passthrough)
196
+ if (!options.cacheDir) {
197
+ console.warn(`[uncharted] Puppeteer not installed. Skipped ${count} chart image(s).`);
198
+ }
175
199
  return { success: [], failed: [], skipped: true };
176
200
  }
177
201
 
@@ -170,7 +170,8 @@ ${stylesheetLinks}
170
170
  * @param {Object} chart - Chart data
171
171
  * @param {string} chart.html - Chart HTML
172
172
  * @param {Object} chart.config - Image configuration
173
- * @param {string} chart.outputPath - Output file path
173
+ * @param {string} chart.outputPath - Output file path (_site)
174
+ * @param {string} [chart.cachePath] - Cache file path (source directory, if caching enabled)
174
175
  * @param {string} css - Chart CSS content
175
176
  * @param {Object} defaults - Default image options
176
177
  * @returns {Promise<void>}
@@ -183,6 +184,9 @@ async function renderChart(page, chart, css, defaults) {
183
184
  const background = config.background || '#ffffff';
184
185
  const stylesheets = config.stylesheets || defaults.stylesheets || [];
185
186
 
187
+ // Use cache path if provided, otherwise write directly to output
188
+ const writePath = chart.cachePath || chart.outputPath;
189
+
186
190
  // Set viewport
187
191
  await page.setViewport({
188
192
  width,
@@ -206,14 +210,14 @@ async function renderChart(page, chart, css, defaults) {
206
210
  }
207
211
 
208
212
  // Ensure output directory exists
209
- const outputDir = path.dirname(chart.outputPath);
213
+ const outputDir = path.dirname(writePath);
210
214
  if (!fs.existsSync(outputDir)) {
211
215
  fs.mkdirSync(outputDir, { recursive: true });
212
216
  }
213
217
 
214
218
  // Take screenshot
215
219
  await page.screenshot({
216
- path: chart.outputPath,
220
+ path: writePath,
217
221
  type: 'png',
218
222
  omitBackground: background === 'transparent'
219
223
  });
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat, getRotateLabels } from '../config.js';
4
4
 
@@ -17,7 +17,7 @@ import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat, getRotateLabels }
17
17
  * @returns {string} - HTML string
18
18
  */
19
19
  export function renderBubble(config) {
20
- const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, icons, _columns } = config;
20
+ const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, icons, _columns } = config;
21
21
 
22
22
  // Get axis-specific format configs
23
23
  const fmtY = getAxisFormat(config, 'y');
@@ -262,7 +262,7 @@ export function renderBubble(config) {
262
262
  html += `</div>`;
263
263
  }
264
264
 
265
- html += renderDownloadLink(downloadDataUrl, downloadData);
265
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
266
266
  html += `</figure>`;
267
267
 
268
268
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
 
4
4
  /**
@@ -17,7 +17,7 @@ import { formatNumber } from '../formatters.js';
17
17
  * @returns {string} - HTML string
18
18
  */
19
19
  export function renderDonut(config) {
20
- const { title, subtitle, data, legend, center, animate, format, id, showPercentages, downloadData, downloadDataUrl, _columns } = config;
20
+ const { title, subtitle, data, legend, center, animate, format, id, showPercentages, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, _columns } = config;
21
21
 
22
22
  if (!data || data.length === 0) {
23
23
  return `<!-- Donut chart: no data provided -->`;
@@ -136,7 +136,7 @@ export function renderDonut(config) {
136
136
  html += `</div>`;
137
137
  }
138
138
 
139
- html += renderDownloadLink(downloadDataUrl, downloadData);
139
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
140
140
  html += `</figure>`;
141
141
 
142
142
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisMin, getAxisFormat, getAxisTitle, getRotateLabels } from '../config.js';
4
4
 
@@ -17,7 +17,7 @@ import { getAxisMax, getAxisMin, getAxisFormat, getAxisTitle, getRotateLabels }
17
17
  * @returns {string} - HTML string
18
18
  */
19
19
  export function renderLine(config) {
20
- const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, icons, _columns } = config;
20
+ const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, icons, _columns } = config;
21
21
 
22
22
  // Line-specific options
23
23
  const showLines = config.lines !== false; // default true
@@ -211,7 +211,7 @@ export function renderLine(config) {
211
211
  html += `</div>`;
212
212
  }
213
213
 
214
- html += renderDownloadLink(downloadDataUrl, downloadData);
214
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
215
215
  html += `</figure>`;
216
216
 
217
217
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
 
4
4
  /**
@@ -17,7 +17,7 @@ import { formatNumber } from '../formatters.js';
17
17
  * @returns {string} - HTML string
18
18
  */
19
19
  export function renderSankey(config) {
20
- const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, nodeWidth = 20, nodePadding = 10, endLabelsOutside = false, proportional = false, _columns } = config;
20
+ const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, nodeWidth = 20, nodePadding = 10, endLabelsOutside = false, proportional = false, _columns } = config;
21
21
 
22
22
  if (!data || data.length === 0) {
23
23
  return `<!-- Sankey chart: no data provided -->`;
@@ -526,7 +526,7 @@ export function renderSankey(config) {
526
526
  html += `</div>`;
527
527
  }
528
528
 
529
- html += renderDownloadLink(downloadDataUrl, downloadData);
529
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
530
530
  html += `</figure>`;
531
531
 
532
532
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat } from '../config.js';
4
4
 
@@ -18,7 +18,7 @@ import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat } from '../config.j
18
18
  * @returns {string} - HTML string
19
19
  */
20
20
  export function renderScatter(config) {
21
- const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, proportional, icons, _columns } = config;
21
+ const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, proportional, icons, _columns } = config;
22
22
 
23
23
  // Get axis-specific format configs (normalized config provides x.format/y.format)
24
24
  const fmtX = getAxisFormat(config, 'x');
@@ -259,7 +259,7 @@ export function renderScatter(config) {
259
259
  html += `</div>`;
260
260
  }
261
261
 
262
- html += renderDownloadLink(downloadDataUrl, downloadData);
262
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
263
263
  html += `</figure>`;
264
264
 
265
265
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLink } from '../utils.js';
1
+ import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisFormat } from '../config.js';
4
4
 
@@ -15,7 +15,7 @@ import { getAxisMax, getAxisFormat } from '../config.js';
15
15
  * @returns {string} - HTML string
16
16
  */
17
17
  export function renderStackedBar(config) {
18
- const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, _columns } = config;
18
+ const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, _columns } = config;
19
19
 
20
20
  if (!data || data.length === 0) {
21
21
  return `<!-- Stacked bar chart: no data provided -->`;
@@ -114,7 +114,7 @@ export function renderStackedBar(config) {
114
114
  });
115
115
 
116
116
  html += `</div>`;
117
- html += renderDownloadLink(downloadDataUrl, downloadData);
117
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
118
118
  html += `</figure>`;
119
119
 
120
120
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLink } from '../utils.js';
1
+ import { slugify, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../config.js';
4
4
 
@@ -15,7 +15,7 @@ import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../confi
15
15
  * @returns {string} - HTML string
16
16
  */
17
17
  export function renderStackedColumn(config) {
18
- const { title, subtitle, data, max, min, legend, animate, format, id, downloadData, downloadDataUrl, _columns } = config;
18
+ const { title, subtitle, data, max, min, legend, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, _columns } = config;
19
19
 
20
20
  if (!data || data.length === 0) {
21
21
  return `<!-- Stacked column chart: no data provided -->`;
@@ -208,7 +208,7 @@ export function renderStackedColumn(config) {
208
208
  html += `</div>`;
209
209
  }
210
210
 
211
- html += renderDownloadLink(downloadDataUrl, downloadData);
211
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
212
212
  html += `</figure>`;
213
213
 
214
214
  return html;
@@ -1,4 +1,4 @@
1
- import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
1
+ import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLinks } from '../utils.js';
2
2
  import { formatNumber } from '../formatters.js';
3
3
  import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat, getRotateLabels } from '../config.js';
4
4
 
@@ -237,7 +237,7 @@ function getAxisTicks(min, max, isDate) {
237
237
  * @returns {string} - HTML string
238
238
  */
239
239
  export function renderTimeseries(config) {
240
- const { title, subtitle, data, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, dots: showDots = false, icons, _columns } = config;
240
+ const { title, subtitle, data, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, downloadImage, downloadImageUrl, dots: showDots = false, icons, _columns } = config;
241
241
 
242
242
  if (!data || data.length === 0) {
243
243
  return `<!-- Timeseries chart: no data provided -->`;
@@ -498,7 +498,7 @@ export function renderTimeseries(config) {
498
498
  html += `</div>`;
499
499
  }
500
500
 
501
- html += renderDownloadLink(downloadDataUrl, downloadData);
501
+ html += renderDownloadLinks(downloadDataUrl, downloadData, downloadImageUrl, downloadImage);
502
502
  html += `</figure>`;
503
503
 
504
504
  return html;
package/src/utils.js CHANGED
@@ -82,3 +82,31 @@ export function renderDownloadLink(url, label) {
82
82
  return `<a href="${escapeHtml(url)}" class="chart-download" download>${escapeHtml(text)}</a>`;
83
83
  }
84
84
 
85
+ /**
86
+ * Render a download link for chart image
87
+ * @param {string} url - URL to the image file
88
+ * @param {boolean|string} label - true for default label, or custom string
89
+ * @returns {string} - HTML string for the download link, or empty string if no URL
90
+ */
91
+ export function renderDownloadImageLink(url, label) {
92
+ if (!url) return '';
93
+ const text = typeof label === 'string' ? label : '↓ Download image';
94
+ return `<a href="${escapeHtml(url)}" class="chart-download" download>${escapeHtml(text)}</a>`;
95
+ }
96
+
97
+ /**
98
+ * Render download links container (wraps data and image download links)
99
+ * @param {string} dataUrl - URL to the CSV file
100
+ * @param {boolean|string} dataLabel - true for default label, or custom string
101
+ * @param {string} imageUrl - URL to the image file
102
+ * @param {boolean|string} imageLabel - true for default label, or custom string
103
+ * @returns {string} - HTML string for the download links container, or empty string if no URLs
104
+ */
105
+ export function renderDownloadLinks(dataUrl, dataLabel, imageUrl, imageLabel) {
106
+ const dataLink = renderDownloadLink(dataUrl, dataLabel);
107
+ const imageLink = renderDownloadImageLink(imageUrl, imageLabel);
108
+ if (!dataLink && !imageLink) return '';
109
+ const separator = dataLink && imageLink ? ' <span class="chart-download-sep">•</span> ' : '';
110
+ return `<div class="chart-downloads">${dataLink}${separator}${imageLink}</div>`;
111
+ }
112
+