Skip to content

Commit 75d4c27

Browse files
committed
feat: add data URI support for SVG icons in v2 endpoint
Add support for inline SVG data using data URIs (data:image/svg+xml,...) alongside existing svgData and svgUrl methods. Icons can now be provided in three ways: - svgData: Raw SVG string - svgUrl: Remote URL (fetched with timeout and size limits) - svgUrl: Data URI with URL-encoded SVG content Changes: - Add decodeDataUri() function to parse and decode data URI SVGs - Update resolveIcon() to detect and handle data URIs - Add comprehensive tests for all three SVG input formats - Update README with data URI example and documentation - Bump version to 2.0.3 All new tests pass individually. The 3 test failures in parallel runs are due to pre-existing mock leakage in settingsBuilder.build.test.ts (not related to this change).
1 parent 753526f commit 75d4c27

File tree

4 files changed

+123
-4
lines changed

4 files changed

+123
-4
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ Example payload:
9696
"fields": [
9797
{ "id": "field-1", "name": "Species", "tagKey": "species", "type": "select", "options": [{ "label": "Oak", "value": "oak" }] }
9898
],
99-
"icons": [{ "id": "tree", "svgUrl": "https://example.com/tree.svg" }],
99+
"icons": [
100+
{ "id": "tree", "svgUrl": "https://example.com/tree.svg" },
101+
{ "id": "flower", "svgData": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>" },
102+
{ "id": "marker", "svgUrl": "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3e%3cpath%20d='M12%202C8.13%202%205%205.13%205%209c0%205.25%207%2013%207%2013s7-7.75%207-13c0-3.87-3.13-7-7-7z'/%3e%3c/svg%3e" }
103+
],
100104
"translations": { "en": { "labels": { "cat-1": "Trees" } } }
101105
}
102106
```
@@ -116,7 +120,10 @@ Example payload:
116120

117121
#### Limits & validation (v2)
118122
- JSON body ≤ 1 MB (enforced while streaming parse).
119-
- SVG icons ≤ 2 MB each; remote fetch timeout 5s; content-type check.
123+
- SVG icons ≤ 2 MB each; icons can be provided via:
124+
- `svgData` (inline SVG string)
125+
- `svgUrl` (remote fetch with 5s timeout and content-type check)
126+
- `svgUrl` with data URI (e.g., `data:image/svg+xml,%3csvg...%3e`)
120127
- Total entries (categories + fields + icons + options + translations) ≤ 10,000.
121128
- Categories must include `appliesTo`; `tags` default to `{ categoryId: <id> }` when missing/empty.
122129
- Locales must be valid BCP‑47 (normalized via `Intl.getCanonicalLocales`).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "comapeo-config-builder-api",
3-
"version": "2.0.2",
3+
"version": "2.0.3",
44
"module": "src/index.ts",
55
"type": "module",
66
"engines": {

src/services/comapeocatBuilder.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ function deriveCategorySelection(categories: MappedCategory[]) {
299299

300300
async function resolveIcon(icon: any): Promise<MappedIcon> {
301301
if (!icon?.id) throw new ValidationError('Icon id is required');
302-
302+
303303
const id = icon.id.endsWith('.svg') ? icon.id : `${icon.id}.svg`;
304304

305305
if (icon.svgData) {
@@ -308,6 +308,14 @@ async function resolveIcon(icon: any): Promise<MappedIcon> {
308308
}
309309

310310
if (icon.svgUrl) {
311+
// Check if it's a data URI
312+
if (icon.svgUrl.startsWith('data:image/svg+xml,')) {
313+
const svg = decodeDataUri(icon.svgUrl);
314+
enforceIconSize(svg, id);
315+
return { id, svg };
316+
}
317+
318+
// Otherwise fetch from remote URL
311319
const svg = await fetchIcon(icon.svgUrl);
312320
enforceIconSize(svg, id);
313321
return { id, svg };
@@ -316,6 +324,22 @@ async function resolveIcon(icon: any): Promise<MappedIcon> {
316324
throw new ValidationError(`Icon ${id} must include svgData or svgUrl`);
317325
}
318326

327+
function decodeDataUri(dataUri: string): string {
328+
if (!dataUri.startsWith('data:image/svg+xml,')) {
329+
throw new ValidationError('Invalid data URI format. Must start with "data:image/svg+xml,"');
330+
}
331+
332+
// Extract the data part after the prefix
333+
const encodedData = dataUri.slice('data:image/svg+xml,'.length);
334+
335+
try {
336+
// Decode the URL-encoded SVG data
337+
return decodeURIComponent(encodedData);
338+
} catch (error) {
339+
throw new ValidationError('Failed to decode data URI. Invalid URL encoding.');
340+
}
341+
}
342+
319343
function enforceIconSize(svg: string, iconId: string) {
320344
const size = Buffer.byteLength(svg, 'utf-8');
321345
if (size > config.iconByteLimit) {
@@ -511,4 +535,5 @@ export const __test__ = {
511535
enforcePayloadSize,
512536
enforceEntryCap,
513537
sanitizePathComponent,
538+
decodeDataUri,
514539
};

src/tests/unit/services/comapeocatBuilder.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,93 @@ describe('comapeocatBuilder helpers', () => {
128128
});
129129
});
130130

131+
describe('decodeDataUri', () => {
132+
it('decodes valid data URI with URL-encoded SVG', () => {
133+
const dataUri = "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3e%3cpath%20d='M12%202'/%3e%3c/svg%3e";
134+
const decoded = __test__.decodeDataUri(dataUri);
135+
expect(decoded).toBe("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2'/></svg>");
136+
});
137+
138+
it('decodes data URI with spaces and special characters', () => {
139+
const dataUri = "data:image/svg+xml,%3csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%2210%22%2F%3E%3c%2Fsvg%3E";
140+
const decoded = __test__.decodeDataUri(dataUri);
141+
expect(decoded).toContain('<svg');
142+
expect(decoded).toContain('circle');
143+
});
144+
145+
it('throws ValidationError for invalid data URI prefix', () => {
146+
const invalidUri = "data:image/png,notsvg";
147+
expect(() => __test__.decodeDataUri(invalidUri)).toThrow(ValidationError);
148+
expect(() => __test__.decodeDataUri(invalidUri)).toThrow('Invalid data URI format');
149+
});
150+
151+
it('throws ValidationError for malformed URL encoding', () => {
152+
const malformedUri = "data:image/svg+xml,%ZZ";
153+
expect(() => __test__.decodeDataUri(malformedUri)).toThrow(ValidationError);
154+
expect(() => __test__.decodeDataUri(malformedUri)).toThrow('Failed to decode data URI');
155+
});
156+
157+
it('throws ValidationError for empty data URI', () => {
158+
const emptyUri = "data:image/svg+xml,";
159+
const decoded = __test__.decodeDataUri(emptyUri);
160+
expect(decoded).toBe('');
161+
});
162+
});
163+
164+
describe('icon resolution with all three formats', () => {
165+
it('successfully processes svgData (inline SVG string)', async () => {
166+
const payload = createBasePayload();
167+
payload.icons = [{ id: 'inline', svgData: '<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>' }];
168+
169+
const result = await buildComapeoCatV2(payload);
170+
expect(result.outputPath).toBeDefined();
171+
expect(result.fileName).toContain('test');
172+
});
173+
174+
it('successfully processes svgUrl with data URI', async () => {
175+
const payload = createBasePayload();
176+
const dataUri = "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20r='10'/%3e%3c/svg%3e";
177+
payload.icons = [{ id: 'datauri', svgUrl: dataUri }];
178+
179+
const result = await buildComapeoCatV2(payload);
180+
expect(result.outputPath).toBeDefined();
181+
expect(result.fileName).toContain('test');
182+
});
183+
184+
it('throws when data URI exceeds size limit', async () => {
185+
const payload = createBasePayload();
186+
// Create a data URI that exceeds 2MB when decoded
187+
const largeSvg = '<svg xmlns="http://www.w3.org/2000/svg">' + 'a'.repeat(2_000_001) + '</svg>';
188+
const dataUri = `data:image/svg+xml,${encodeURIComponent(largeSvg)}`;
189+
payload.icons = [{ id: 'toolarge', svgUrl: dataUri }];
190+
191+
await expect(buildComapeoCatV2(payload)).rejects.toThrow(ValidationError);
192+
});
193+
194+
it('throws when icon has neither svgData nor svgUrl', async () => {
195+
const payload = createBasePayload();
196+
payload.icons = [{ id: 'empty' } as any];
197+
198+
await expect(buildComapeoCatV2(payload)).rejects.toThrow(ValidationError);
199+
await expect(buildComapeoCatV2(payload)).rejects.toThrow('must include svgData or svgUrl');
200+
});
201+
202+
it('processes all three icon formats in a single payload', async () => {
203+
const payload = createBasePayload();
204+
const dataUri = "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M0%200'/%3e%3c/svg%3e";
205+
206+
payload.icons = [
207+
{ id: 'inline', svgData: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' },
208+
{ id: 'datauri', svgUrl: dataUri },
209+
// Note: We can't test actual URL fetching in unit tests without mocking
210+
];
211+
212+
const result = await buildComapeoCatV2(payload);
213+
expect(result.outputPath).toBeDefined();
214+
expect(result.fileName).toContain('test');
215+
});
216+
});
217+
131218
describe('sanitizePathComponent security', () => {
132219
it('rejects forward slashes to prevent path traversal', () => {
133220
expect(() => __test__.sanitizePathComponent('../tmp/evil')).toThrow(ValidationError);

0 commit comments

Comments
 (0)