From 10f96e6a26ecc15e610676098da354de804ba7d5 Mon Sep 17 00:00:00 2001 From: Dmitry Remezov Date: Mon, 24 Jul 2023 22:40:48 +0300 Subject: [PATCH] feat(svg): rework sprites `metadata` generation --- .../colors-advanced/generated/sprite-info.ts | 7 +- .../examples/colors/generated/sprite-info.ts | 5 +- .../groups-with-root/generated/sprite-info.ts | 6 +- .../generated/sprite-info.ts | 6 +- .../assets/common/close.svg | 0 .../assets/common/favourite.svg | 0 .../assets/format/align-left.svg | 0 .../assets/format/tag.svg | 0 .../generated/common.2eb4b56f.svg | 0 .../generated/format.c890959d.svg | 0 .../generated/meta.ts} | 37 +-- .../examples/react/generated/sprite-info.ts | 6 +- .../examples/simple/generated/sprite-info.ts | 5 +- .../__snapshots__/examples.test.ts.snap | 54 ++--- .../__snapshots__/plugins.test.ts.snap | 219 ++++++++++++++++++ libs/svg/src/__tests__/examples.test.ts | 11 +- libs/svg/src/__tests__/plugins.test.ts | 161 ++++++++++++- libs/svg/src/__tests__/testing-utils.ts | 2 +- libs/svg/src/core/create-sprite-builder.ts | 39 +++- libs/svg/src/core/types.ts | 7 + libs/svg/src/plugins/fix-view-box.ts | 29 ++- libs/svg/src/plugins/index.ts | 2 +- .../{typescript.ts => legacy-typescript.ts} | 12 +- libs/svg/src/plugins/metadata.ts | 143 ++++++++++++ 24 files changed, 672 insertions(+), 79 deletions(-) rename libs/svg/examples/{experimental-runtime => metadata}/assets/common/close.svg (100%) rename libs/svg/examples/{experimental-runtime => metadata}/assets/common/favourite.svg (100%) rename libs/svg/examples/{experimental-runtime => metadata}/assets/format/align-left.svg (100%) rename libs/svg/examples/{experimental-runtime => metadata}/assets/format/tag.svg (100%) rename libs/svg/examples/{experimental-runtime => metadata}/generated/common.2eb4b56f.svg (100%) rename libs/svg/examples/{experimental-runtime => metadata}/generated/format.c890959d.svg (100%) rename libs/svg/examples/{experimental-runtime/generated/sprite-info.ts => metadata/generated/meta.ts} (50%) create mode 100644 libs/svg/src/__tests__/__snapshots__/plugins.test.ts.snap rename libs/svg/src/plugins/{typescript.ts => legacy-typescript.ts} (91%) create mode 100644 libs/svg/src/plugins/metadata.ts diff --git a/libs/svg/examples/colors-advanced/generated/sprite-info.ts b/libs/svg/examples/colors-advanced/generated/sprite-info.ts index 6a02592..725b78e 100644 --- a/libs/svg/examples/colors-advanced/generated/sprite-info.ts +++ b/libs/svg/examples/colors-advanced/generated/sprite-info.ts @@ -3,9 +3,12 @@ export interface SpritesMap { flags: 'ac' | 'ad' | 'ae' | 'af'; logos: 'linkedin' | 'twitter'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { sprite: ['close', 'exit', 'favourite', 'folder-colored', 'sort-by-visibility'], flags: ['ac', 'ad', 'ae', 'af'], logos: ['linkedin', 'twitter'] +} satisfies { + sprite: Array<'close' | 'exit' | 'favourite' | 'folder-colored' | 'sort-by-visibility'>; + flags: Array<'ac' | 'ad' | 'ae' | 'af'>; + logos: Array<'linkedin' | 'twitter'>; }; diff --git a/libs/svg/examples/colors/generated/sprite-info.ts b/libs/svg/examples/colors/generated/sprite-info.ts index 92ddc92..ac8d7d1 100644 --- a/libs/svg/examples/colors/generated/sprite-info.ts +++ b/libs/svg/examples/colors/generated/sprite-info.ts @@ -1,7 +1,8 @@ export interface SpritesMap { sprite: 'custom' | 'fill' | 'mixed' | 'stroke'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { sprite: ['custom', 'fill', 'mixed', 'stroke'] +} satisfies { + sprite: Array<'custom' | 'fill' | 'mixed' | 'stroke'>; }; diff --git a/libs/svg/examples/groups-with-root/generated/sprite-info.ts b/libs/svg/examples/groups-with-root/generated/sprite-info.ts index 1d07f76..cc530dd 100644 --- a/libs/svg/examples/groups-with-root/generated/sprite-info.ts +++ b/libs/svg/examples/groups-with-root/generated/sprite-info.ts @@ -2,8 +2,10 @@ export interface SpritesMap { common: 'close' | 'favourite'; format: 'align-left' | 'tag'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { common: ['close', 'favourite'], format: ['align-left', 'tag'] +} satisfies { + common: Array<'close' | 'favourite'>; + format: Array<'align-left' | 'tag'>; }; diff --git a/libs/svg/examples/groups-without-root/generated/sprite-info.ts b/libs/svg/examples/groups-without-root/generated/sprite-info.ts index 645c88a..dab91a7 100644 --- a/libs/svg/examples/groups-without-root/generated/sprite-info.ts +++ b/libs/svg/examples/groups-without-root/generated/sprite-info.ts @@ -2,8 +2,10 @@ export interface SpritesMap { 'assets/common': 'close' | 'favourite'; 'assets/format': 'align-left' | 'tag'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { 'assets/common': ['close', 'favourite'], 'assets/format': ['align-left', 'tag'] +} satisfies { + 'assets/common': Array<'close' | 'favourite'>; + 'assets/format': Array<'align-left' | 'tag'>; }; diff --git a/libs/svg/examples/experimental-runtime/assets/common/close.svg b/libs/svg/examples/metadata/assets/common/close.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/assets/common/close.svg rename to libs/svg/examples/metadata/assets/common/close.svg diff --git a/libs/svg/examples/experimental-runtime/assets/common/favourite.svg b/libs/svg/examples/metadata/assets/common/favourite.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/assets/common/favourite.svg rename to libs/svg/examples/metadata/assets/common/favourite.svg diff --git a/libs/svg/examples/experimental-runtime/assets/format/align-left.svg b/libs/svg/examples/metadata/assets/format/align-left.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/assets/format/align-left.svg rename to libs/svg/examples/metadata/assets/format/align-left.svg diff --git a/libs/svg/examples/experimental-runtime/assets/format/tag.svg b/libs/svg/examples/metadata/assets/format/tag.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/assets/format/tag.svg rename to libs/svg/examples/metadata/assets/format/tag.svg diff --git a/libs/svg/examples/experimental-runtime/generated/common.2eb4b56f.svg b/libs/svg/examples/metadata/generated/common.2eb4b56f.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/generated/common.2eb4b56f.svg rename to libs/svg/examples/metadata/generated/common.2eb4b56f.svg diff --git a/libs/svg/examples/experimental-runtime/generated/format.c890959d.svg b/libs/svg/examples/metadata/generated/format.c890959d.svg similarity index 100% rename from libs/svg/examples/experimental-runtime/generated/format.c890959d.svg rename to libs/svg/examples/metadata/generated/format.c890959d.svg diff --git a/libs/svg/examples/experimental-runtime/generated/sprite-info.ts b/libs/svg/examples/metadata/generated/meta.ts similarity index 50% rename from libs/svg/examples/experimental-runtime/generated/sprite-info.ts rename to libs/svg/examples/metadata/generated/meta.ts index 46cbc3d..841f35d 100644 --- a/libs/svg/examples/experimental-runtime/generated/sprite-info.ts +++ b/libs/svg/examples/metadata/generated/meta.ts @@ -2,16 +2,19 @@ export interface SpritesMap { common: 'close' | 'favourite'; format: 'align-left' | 'tag'; } - export const SPRITES_META = { common: { filePath: 'common.2eb4b56f.svg', items: { close: { - viewBox: '0 0 48 48' + viewBox: '0 0 48 48', + width: 48, + height: 48 }, favourite: { - viewBox: '0 0 48 48' + viewBox: '0 0 48 48', + width: 48, + height: 48 } } }, @@ -19,20 +22,28 @@ export const SPRITES_META = { filePath: 'format.c890959d.svg', items: { 'align-left': { - viewBox: '0 0 48 48' + viewBox: '0 0 48 48', + width: 48, + height: 48 }, tag: { - viewBox: '0 0 48 48' + viewBox: '0 0 48 48', + width: 48, + height: 48 } } } -} satisfies { - [SpriteName in keyof SpritesMap]: { +} satisfies Record< + string, + { filePath: string; - items: { - [ItemName in SpritesMap[SpriteName]]: { + items: Record< + string, + { viewBox: string; - }; - }; - }; -}; + width: number; + height: number; + } + >; + } +>; diff --git a/libs/svg/examples/react/generated/sprite-info.ts b/libs/svg/examples/react/generated/sprite-info.ts index 1d07f76..cc530dd 100644 --- a/libs/svg/examples/react/generated/sprite-info.ts +++ b/libs/svg/examples/react/generated/sprite-info.ts @@ -2,8 +2,10 @@ export interface SpritesMap { common: 'close' | 'favourite'; format: 'align-left' | 'tag'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { common: ['close', 'favourite'], format: ['align-left', 'tag'] +} satisfies { + common: Array<'close' | 'favourite'>; + format: Array<'align-left' | 'tag'>; }; diff --git a/libs/svg/examples/simple/generated/sprite-info.ts b/libs/svg/examples/simple/generated/sprite-info.ts index 8d52b80..2a05ea8 100644 --- a/libs/svg/examples/simple/generated/sprite-info.ts +++ b/libs/svg/examples/simple/generated/sprite-info.ts @@ -1,7 +1,8 @@ export interface SpritesMap { sprite: 'arrow-drop-down' | 'arrow-drop-up'; } - -export const SPRITES_META: { [K in keyof SpritesMap]: SpritesMap[K][] } = { +export const SPRITES_META = { sprite: ['arrow-drop-down', 'arrow-drop-up'] +} satisfies { + sprite: Array<'arrow-drop-down' | 'arrow-drop-up'>; }; diff --git a/libs/svg/src/__tests__/__snapshots__/examples.test.ts.snap b/libs/svg/src/__tests__/__snapshots__/examples.test.ts.snap index efc7ae2..71d4b5f 100644 --- a/libs/svg/src/__tests__/__snapshots__/examples.test.ts.snap +++ b/libs/svg/src/__tests__/__snapshots__/examples.test.ts.snap @@ -19,7 +19,7 @@ exports[`examples > "colors" example should replay same output 1`] = ` ], [ "generated/sprite-info.ts", - "4793076202450a65b7b04efe27b0b74ddda29399e95da47f55cfe47450854d12", + "68ee54e6a56a9c10472f3613d7b688b49087d69ca2ad2591318a01138ff5f669", ], ] `; @@ -60,94 +60,94 @@ exports[`examples > "colors-advanced" example should replay same output 1`] = ` ], [ "generated/sprite-info.ts", - "ccea76ebfa7898989ef6da5c21ff18e8225a629e84098600b52debde57b37d44", + "666e8fa9a10d2e5127d31cb8f8604ab36c16aaba9485ceb31aeb18e87e5cddd0", ], ] `; -exports[`examples > "experimental-runtime" example should generate files 1`] = ` +exports[`examples > "groups-with-root" example should generate files 1`] = ` [ "assets/common/close.svg", "assets/common/favourite.svg", "assets/format/align-left.svg", "assets/format/tag.svg", - "generated/common.2eb4b56f.svg", - "generated/format.c890959d.svg", + "generated/common.svg", + "generated/format.svg", "generated/sprite-info.ts", ] `; -exports[`examples > "experimental-runtime" example should replay same output 1`] = ` +exports[`examples > "groups-with-root" example should replay same output 1`] = ` [ [ - "generated/common.2eb4b56f.svg", + "generated/common.svg", "2eb4b56ff266279ca9c0af3b16f7f3114ae35c4555657bb106acbd15ff9d4f52", ], [ - "generated/format.c890959d.svg", + "generated/format.svg", "c890959d268bf53d6006012438788b4104189d3cfaf997b0b43b2fd4f1a41159", ], [ "generated/sprite-info.ts", - "046df9f47812fc3681fd787ae23e64db1f089a509fd18607a57d2613bf4cdff7", + "0c82bd5eb1a18ea3337261408254d09db5cee2c8eeb4561ed6bca996b0e0d9c9", ], ] `; -exports[`examples > "groups-with-root" example should generate files 1`] = ` +exports[`examples > "groups-without-root" example should generate files 1`] = ` [ "assets/common/close.svg", "assets/common/favourite.svg", "assets/format/align-left.svg", "assets/format/tag.svg", - "generated/common.svg", - "generated/format.svg", + "generated/assets/common.svg", + "generated/assets/format.svg", "generated/sprite-info.ts", ] `; -exports[`examples > "groups-with-root" example should replay same output 1`] = ` +exports[`examples > "groups-without-root" example should replay same output 1`] = ` [ [ - "generated/common.svg", + "generated/assets/common.svg", "2eb4b56ff266279ca9c0af3b16f7f3114ae35c4555657bb106acbd15ff9d4f52", ], [ - "generated/format.svg", + "generated/assets/format.svg", "c890959d268bf53d6006012438788b4104189d3cfaf997b0b43b2fd4f1a41159", ], [ "generated/sprite-info.ts", - "fac807c42c630ce77d6e1fea77b808338cf68776235c48736f3b82aa09e761df", + "6280072f9bbc11d77c987a1ce220c9a70197c7612119b86606025455d3feae97", ], ] `; -exports[`examples > "groups-without-root" example should generate files 1`] = ` +exports[`examples > "metadata" example should generate files 1`] = ` [ "assets/common/close.svg", "assets/common/favourite.svg", "assets/format/align-left.svg", "assets/format/tag.svg", - "generated/assets/common.svg", - "generated/assets/format.svg", - "generated/sprite-info.ts", + "generated/common.2eb4b56f.svg", + "generated/format.c890959d.svg", + "generated/meta.ts", ] `; -exports[`examples > "groups-without-root" example should replay same output 1`] = ` +exports[`examples > "metadata" example should replay same output 1`] = ` [ [ - "generated/assets/common.svg", + "generated/common.2eb4b56f.svg", "2eb4b56ff266279ca9c0af3b16f7f3114ae35c4555657bb106acbd15ff9d4f52", ], [ - "generated/assets/format.svg", + "generated/format.c890959d.svg", "c890959d268bf53d6006012438788b4104189d3cfaf997b0b43b2fd4f1a41159", ], [ - "generated/sprite-info.ts", - "1153d305b1db1f1605d0d5e9b30a19ec4fea65b221553ef862158f31c2270de8", + "generated/meta.ts", + "789b7ca4856d0b2d1b105e8267a5950f77d4928ef1710b4db05438cefd96c27f", ], ] `; @@ -178,7 +178,7 @@ exports[`examples > "react" example should replay same output 1`] = ` ], [ "generated/sprite-info.ts", - "fac807c42c630ce77d6e1fea77b808338cf68776235c48736f3b82aa09e761df", + "0c82bd5eb1a18ea3337261408254d09db5cee2c8eeb4561ed6bca996b0e0d9c9", ], ] `; @@ -200,7 +200,7 @@ exports[`examples > "simple" example should replay same output 1`] = ` ], [ "generated/sprite-info.ts", - "89e0e5e36909c3dce4af5977af027d8779b20860232d34612d27a7778bdcc1e3", + "528cef17918096f4bec20bf6e16ff1d970c3999eb5b405469661a12b24c7cac4", ], ] `; diff --git a/libs/svg/src/__tests__/__snapshots__/plugins.test.ts.snap b/libs/svg/src/__tests__/__snapshots__/plugins.test.ts.snap new file mode 100644 index 0000000..2fcf5fd --- /dev/null +++ b/libs/svg/src/__tests__/__snapshots__/plugins.test.ts.snap @@ -0,0 +1,219 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`plugins system > "metadata" plugin > should generate basic runtime info with metadata.runtime as constant name 1`] = ` +"export interface SpritesMap { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const RuntimeExample = { + 'a': ['a', +'b'], +'b': ['a', +'b'] + } satisfies { + 'a': Array<'a' | 'b'>, +'b': Array<'a' | 'b'> + };" +`; + +exports[`plugins system > "metadata" plugin > should generate runtime with size 1`] = ` +"export interface SpritesMap { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const SPRITES_META = { + 'a': { + filePath: 'a.svg', + items: { + 'a': { + + width: 16, height: 16, + }, +'b': { + + width: 16, height: 16, + } + } +}, +'b': { + filePath: 'b.svg', + items: { + 'a': { + + width: 16, height: 16, + }, +'b': { + + width: 16, height: 16, + } + } +} + } satisfies Record + }>;" +`; + +exports[`plugins system > "metadata" plugin > should generate runtime with size, viewBox and custom name 1`] = ` +"export interface SpritesMap { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const RuntimeExample = { + 'a': { + filePath: 'a.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + width: 16, height: 16, + }, +'b': { + viewBox: '0 0 16 16', + width: 16, height: 16, + } + } +}, +'b': { + filePath: 'b.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + width: 16, height: 16, + }, +'b': { + viewBox: '0 0 16 16', + width: 16, height: 16, + } + } +} + } satisfies Record + }>;" +`; + +exports[`plugins system > "metadata" plugin > should generate runtime with viewBox 1`] = ` +"export interface SpritesMap { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const SPRITES_META = { + 'a': { + filePath: 'a.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + + }, +'b': { + viewBox: '0 0 16 16', + + } + } +}, +'b': { + filePath: 'b.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + + }, +'b': { + viewBox: '0 0 16 16', + + } + } +} + } satisfies Record + }>;" +`; + +exports[`plugins system > "metadata" plugin > should generate runtime without types 1`] = ` +"export const RuntimeExample = { + 'a': { + filePath: 'a.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + width: 16, height: 16, + }, +'b': { + viewBox: '0 0 16 16', + width: 16, height: 16, + } + } +}, +'b': { + filePath: 'b.svg', + items: { + 'a': { + viewBox: '0 0 16 16', + width: 16, height: 16, + }, +'b': { + viewBox: '0 0 16 16', + width: 16, height: 16, + } + } +} + };" +`; + +exports[`plugins system > "metadata" plugin > should generate same default output with metadata as string and as { path } 1`] = ` +"export interface SpritesMap { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const SPRITES_META = { + 'a': ['a', +'b'], +'b': ['a', +'b'] + } satisfies { + 'a': Array<'a' | 'b'>, +'b': Array<'a' | 'b'> + };" +`; + +exports[`plugins system > "metadata" plugin > should generate types and runtime at the same time 1`] = ` +"export interface TypesExample { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const RuntimeExample = { + 'a': ['a', +'b'], +'b': ['a', +'b'] + } satisfies { + 'a': Array<'a' | 'b'>, +'b': Array<'a' | 'b'> + };" +`; + +exports[`plugins system > "metadata" plugin > should generate types with metadata.types as interface name 1`] = ` +"export interface TypesExample { + 'a': 'a' | 'b', +'b': 'a' | 'b' + } +export const SPRITES_META = { + 'a': ['a', +'b'], +'b': ['a', +'b'] + } satisfies { + 'a': Array<'a' | 'b'>, +'b': Array<'a' | 'b'> + };" +`; diff --git a/libs/svg/src/__tests__/examples.test.ts b/libs/svg/src/__tests__/examples.test.ts index 520b0e2..625a269 100644 --- a/libs/svg/src/__tests__/examples.test.ts +++ b/libs/svg/src/__tests__/examples.test.ts @@ -52,11 +52,18 @@ describe('examples', () => { } ] }, - 'experimental-runtime': { + metadata: { root: 'assets', group: true, fileName: '{name}.{hash:8}.svg', - experimentalRuntime: true + metadata: { + path: 'generated/meta.ts', + types: true, + runtime: { + viewBox: true, + size: true + } + } } }; diff --git a/libs/svg/src/__tests__/plugins.test.ts b/libs/svg/src/__tests__/plugins.test.ts index a2acd8a..d1df637 100644 --- a/libs/svg/src/__tests__/plugins.test.ts +++ b/libs/svg/src/__tests__/plugins.test.ts @@ -2,18 +2,46 @@ import { toArray } from '@neodx/std'; import { createTmpVfs } from '@neodx/vfs/testing-utils'; import { describe, expect, test } from 'vitest'; import type { SvgFile, SvgNode } from '..'; +import { buildSprites } from '..'; import { groupSprites } from '../plugins'; +import type { MetadataPluginParams } from '../plugins/metadata'; import { combinePlugins } from '../plugins/plugin-utils'; describe('plugins system', async () => { + const defaultSpritesConfig = { + a: ['a/a', 'a/b'], + b: ['b/a', 'b/b'] + }; const emptyContext = { vfs: await createTmpVfs() }; const file = (path: string): SvgFile => ({ name: path, path: `${path}.svg`, + meta: {}, node: {} as SvgNode, - content: '' + content: + '\n' + + ' \n' + + '' }); const files = (...paths: Array) => paths.flatMap(toArray).map(file); + const sprites = (map: Record) => + new Map( + Object.entries(map).map(([name, contents]) => [name, { name, files: files(...contents) }]) + ); + const createTestContext = async ( + spritesConfig: Record = defaultSpritesConfig + ) => { + const map = sprites(spritesConfig); + const vfs = await createTmpVfs({ + initialFiles: { + ...Array.from(map.values()) + .flatMap(({ files }) => files) + .reduce((acc, { path, content }) => ({ ...acc, [path]: content }), {}) + } + }); + + return { vfs, map }; + }; test('resolveEntriesMap should return new map', () => { const hooks = combinePlugins([ @@ -28,7 +56,7 @@ describe('plugins system', async () => { ]); }); - test('groupSprites should reorganize sprites', () => { + test('"groupSprites" plugin should reorganize sprites', () => { const original = new Map([['a', { name: 'a', files: files('a/a', 'a/b', 'b/a') }]]); const hooks = combinePlugins([{ name: 'a' }, groupSprites(), { name: 'b' }]); @@ -37,4 +65,133 @@ describe('plugins system', async () => { ['b', { name: 'b', files: files('b/a') }] ]); }); + + describe('"metadata" plugin', () => { + async function buildWithMetadata(metadata: MetadataPluginParams) { + const { vfs } = await createTestContext(); + + await buildSprites({ + vfs, + input: ['**/*.svg'], + group: true, + output: 'public', + keepTreeChanges: true, + metadata + }); + await vfs.formatChangedFiles(); + + return { vfs }; + } + + test('should disable with metadata: false', async () => { + const { vfs } = await buildWithMetadata(false); + + expect(await vfs.exists('metadata.json')).toBe(false); + }); + + test('should generate same default output with metadata as string and as { path }', async () => { + const asString = await buildWithMetadata('runtime.ts'); + const asPath = await buildWithMetadata({ + path: 'runtime.ts' + }); + + expect(await asString.vfs.read('runtime.ts', 'utf-8')).toEqual( + await asPath.vfs.read('runtime.ts', 'utf-8') + ); + expect(await asString.vfs.read('runtime.ts', 'utf-8')).toMatchSnapshot(); + }); + + test('should skip output when everything is disabled', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + types: false, + runtime: false + }); + + expect(await vfs.exists('runtime.ts')).toBe(false); + }); + + test('should generate types with metadata.types as interface name', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + types: 'TypesExample' + }); + const content = await vfs.read('runtime.ts', 'utf-8'); + + expect(content).toContain('export interface TypesExample {'); + expect(content).toMatchSnapshot(); + }); + + test('should generate basic runtime info with metadata.runtime as constant name', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + runtime: 'RuntimeExample' + }); + const content = await vfs.read('runtime.ts', 'utf-8'); + + expect(content).toContain('export const RuntimeExample = {'); + expect(content).toMatchSnapshot(); + }); + + test('should generate types and runtime at the same time', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + types: 'TypesExample', + runtime: 'RuntimeExample' + }); + const content = await vfs.read('runtime.ts', 'utf-8'); + + expect(content).toContain('export interface TypesExample {'); + expect(content).toContain('export const RuntimeExample = {'); + expect(content).toMatchSnapshot(); + }); + + test('should generate runtime with size', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + runtime: { + size: true + } + }); + + expect(await vfs.read('runtime.ts', 'utf-8')).toMatchSnapshot(); + }); + + test('should generate runtime with viewBox', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + runtime: { + viewBox: true + } + }); + + expect(await vfs.read('runtime.ts', 'utf-8')).toMatchSnapshot(); + }); + + test('should generate runtime with size, viewBox and custom name', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.ts', + runtime: { + name: 'RuntimeExample', + size: true, + viewBox: true + } + }); + + expect(await vfs.read('runtime.ts', 'utf-8')).toMatchSnapshot(); + }); + + test('should generate runtime without types', async () => { + const { vfs } = await buildWithMetadata({ + path: 'runtime.mjs', + runtime: { + name: 'RuntimeExample', + size: true, + viewBox: true + } + }); + + expect(await vfs.read('runtime.mjs', 'utf-8')).toMatchSnapshot(); + }); + }); }); diff --git a/libs/svg/src/__tests__/testing-utils.ts b/libs/svg/src/__tests__/testing-utils.ts index 1d9c679..60f308e 100644 --- a/libs/svg/src/__tests__/testing-utils.ts +++ b/libs/svg/src/__tests__/testing-utils.ts @@ -32,7 +32,7 @@ export async function generateExample( input: ['**/*.svg'], output: 'generated', optimize: true, - definitions: 'generated/sprite-info.ts', + metadata: 'generated/sprite-info.ts', keepTreeChanges: !write, ...options }); diff --git a/libs/svg/src/core/create-sprite-builder.ts b/libs/svg/src/core/create-sprite-builder.ts index c627e0d..1243780 100644 --- a/libs/svg/src/core/create-sprite-builder.ts +++ b/libs/svg/src/core/create-sprite-builder.ts @@ -4,11 +4,13 @@ import { compact, isTruthy, quickPluralize } from '@neodx/std'; import type { VFS } from '@neodx/vfs'; import { basename, join } from 'pathe'; import { parse } from 'svgson'; -import type { ResetColorsPluginParams } from '../plugins'; -import { fixViewBox, groupSprites, resetColors, setId, svgo, typescript } from '../plugins'; +import { fixViewBox, groupSprites, legacyTypescript, resetColors, setId, svgo } from '../plugins'; +import { extractSvgMeta } from '../plugins/fix-view-box'; +import { metadata as metadataPlugin, type MetadataPluginParams } from '../plugins/metadata'; import { combinePlugins } from '../plugins/plugin-utils'; +import type { ResetColorsPluginParams } from '../plugins/reset-colors'; import { renderSvgNodesToString } from './render'; -import type { GeneratedSprites, SvgFile, SvgNode } from './types'; +import type { GeneratedSprites, SvgFile, SvgFileMeta, SvgNode } from './types'; export interface CreateSpriteBuilderParams { vfs: VFS; @@ -44,8 +46,17 @@ export interface CreateSpriteBuilderParams { * Should we optimize icons? */ optimize?: boolean; + /** + * Configures metadata generation + * @example "src/sprites/meta.ts" + * @example { path: "meta.ts", runtime: false } // will generate only types + * @example { path: "meta.ts", types: 'TypeName', runtime: 'InfoName' } // will generate "interface TypeName" types and "const InfoName" runtime metadata + * @example { path: "meta.ts", runtime: { size: true, viewBox: true } } // will generate runtime metadata with size and viewBox + */ + metadata?: MetadataPluginParams; /** * Path to generated definitions file + * @deprecated use `metadata` instead */ definitions?: string; /** @@ -56,6 +67,7 @@ export interface CreateSpriteBuilderParams { * WILL BE CHANGED IN FUTURE * Replaces current approach (just array of IDs per sprite) with extended runtime metadata * + * @deprecated use `metadata` instead * @unstable * @example * export const SPRITES_META = { @@ -85,12 +97,19 @@ export function createSpriteBuilder({ output, logger, group: enableGroup, + metadata, fileName: fileNameTemplate = '{name}.svg', optimize, definitions, resetColors: resetColorsParams, experimentalRuntime }: CreateSpriteBuilderParams) { + if (definitions || experimentalRuntime) { + logger?.error( + 'DEPRECATED: `definitions` and `experimentalRuntime` options will be removed in future versions, use `metadata` instead' + ); + } + const hooks = combinePlugins( compact([ enableGroup && groupSprites(), @@ -98,8 +117,10 @@ export function createSpriteBuilder({ fixViewBox(), resetColors(resetColorsParams), optimize && svgo(), - definitions && - typescript({ + !definitions && metadataPlugin(metadata), + !metadata && + definitions && + legacyTypescript({ output: definitions, experimentalRuntime }) @@ -119,11 +140,15 @@ export function createSpriteBuilder({ return null; } try { - const nodeToFile = (node: SvgNode): SvgFile => ({ name, node, path, content }); + let meta: SvgFileMeta; + const nodeToFile = (node: SvgNode): SvgFile => ({ name, meta, node, path, content }); const node = await parse(await hooks.transformSourceContent(path, content), { camelcase: true, - transformNode: node => hooks.transformNode(nodeToFile(node)) + transformNode: node => { + meta = extractSvgMeta(node); + return hooks.transformNode(nodeToFile(node)); + } }); return nodeToFile(node); diff --git a/libs/svg/src/core/types.ts b/libs/svg/src/core/types.ts index ba61846..cfe46e7 100644 --- a/libs/svg/src/core/types.ts +++ b/libs/svg/src/core/types.ts @@ -39,12 +39,19 @@ export interface GeneratedSprite extends SpriteGroup { } export interface SvgFile { + meta: SvgFileMeta; node: SvgNode; path: string; name: string; content: string; } +export interface SvgFileMeta { + width?: number; + height?: number; + viewBox?: string; +} + export interface SvgNode { name: string; type: string; diff --git a/libs/svg/src/plugins/fix-view-box.ts b/libs/svg/src/plugins/fix-view-box.ts index 0d09524..3f90fec 100644 --- a/libs/svg/src/plugins/fix-view-box.ts +++ b/libs/svg/src/plugins/fix-view-box.ts @@ -1,4 +1,5 @@ import { omit } from '@neodx/std'; +import type { SvgFileMeta, SvgNode } from '../core'; import { createPlugin } from './plugin-utils'; /** @@ -6,17 +7,33 @@ import { createPlugin } from './plugin-utils'; */ export const fixViewBox = () => createPlugin('fix-view-box', { - transformNode({ node }) { - const { - attributes: { viewBox, width, height } - } = node; - + transformNode({ node, meta: { viewBox } }) { return { ...node, attributes: { ...omit(node.attributes, ['width', 'height']), - viewBox: viewBox || !width || !height ? viewBox : `0 0 ${width} ${height}` + viewBox: viewBox as string } }; } }); + +export function extractSvgMeta({ + attributes: { width: originalWidth, height: originalHeight, viewBox } +}: SvgNode): SvgFileMeta { + const [parsedWidth, parsedHeight] = viewBox ? parseViewBox(viewBox) : []; + const width = originalWidth ? Number.parseFloat(originalWidth) : parsedWidth; + const height = originalHeight ? Number.parseFloat(originalHeight) : parsedHeight; + + return { + width, + height, + viewBox: viewBox || (width && height ? `0 0 ${width} ${height}` : undefined) + }; +} + +const parseViewBox = (viewBox: string): [number, number] => { + const [, , width, height] = viewBox.split(' ').map(Number.parseFloat); + + return [width, height]; +}; diff --git a/libs/svg/src/plugins/index.ts b/libs/svg/src/plugins/index.ts index c6c2b96..228ddca 100644 --- a/libs/svg/src/plugins/index.ts +++ b/libs/svg/src/plugins/index.ts @@ -1,5 +1,6 @@ export { fixViewBox } from './fix-view-box'; export { type GroupPluginOptions, groupSprites } from './group'; +export { legacyTypescript, type LegacyTypescriptPluginOptions } from './legacy-typescript'; export type { AnyColorInput, ColorPropertyReplacementInput, @@ -9,4 +10,3 @@ export type { export { resetColors } from './reset-colors'; export { setId } from './set-id'; export { svgo, type SvgoPluginOptions } from './svgo'; -export { typescript, type TypescriptPluginOptions } from './typescript'; diff --git a/libs/svg/src/plugins/typescript.ts b/libs/svg/src/plugins/legacy-typescript.ts similarity index 91% rename from libs/svg/src/plugins/typescript.ts rename to libs/svg/src/plugins/legacy-typescript.ts index 2832141..6da1241 100644 --- a/libs/svg/src/plugins/typescript.ts +++ b/libs/svg/src/plugins/legacy-typescript.ts @@ -1,19 +1,19 @@ import type { GeneratedSprite, GeneratedSprites, SvgFile } from '../core'; import { createPlugin } from './plugin-utils'; -export interface TypescriptPluginOptions { +export interface LegacyTypescriptPluginOptions { output?: string; metaName?: string; typeName?: string; experimentalRuntime?: boolean; } -export function typescript({ +export function legacyTypescript({ output = 'sprite.types.ts', typeName = 'SpritesMap', metaName = 'SPRITES_META', experimentalRuntime -}: TypescriptPluginOptions = {}) { +}: LegacyTypescriptPluginOptions = {}) { return createPlugin('typescript', { async afterWriteAll(entries, context) { await context.vfs.write( @@ -80,11 +80,7 @@ const renderSpriteAsExperimentalRuntimeMeta = (sprite: GeneratedSprite) => } }`; -const renderSvgFileAsExperimentalRuntimeMeta = ({ - node: { - attributes: { viewBox } - } -}: SvgFile) => +const renderSvgFileAsExperimentalRuntimeMeta = ({ meta: { viewBox } }: SvgFile) => `{ viewBox: '${viewBox}', }`; diff --git a/libs/svg/src/plugins/metadata.ts b/libs/svg/src/plugins/metadata.ts new file mode 100644 index 0000000..157557a --- /dev/null +++ b/libs/svg/src/plugins/metadata.ts @@ -0,0 +1,143 @@ +import type { GeneratedSprite, GeneratedSprites, SvgFile } from '../core'; +import { createPlugin } from './plugin-utils'; + +export type MetadataPluginParams = false | string | MetadataPluginParamsConfig; + +export interface MetadataPluginParamsConfig { + path: string; + types?: Partial | boolean | string; + runtime?: Partial | boolean | string; +} + +export interface MetadataTypesParams { + name: string; +} + +export interface MetadataRuntimeParams { + name: string; + size?: boolean; + viewBox?: boolean; +} + +export function metadata(params: MetadataPluginParams = false) { + if (!params) return null; + const config = Object.assign( + { + runtime: true, + types: true + }, + typeof params === 'string' ? { path: params } : params + ); + const types = toNamedParams({ name: 'SpritesMap' }, config.types); + const runtime = toNamedParams({ name: 'SPRITES_META' }, config.runtime); + const isTypeScript = config.path.endsWith('.ts'); + + if (!types && !runtime) return null; + return createPlugin('metadata', { + async afterWriteAll(sprites, context) { + await context.vfs.write( + config.path, + [ + isTypeScript && types && renderTypes(types, sprites), + runtime && renderRuntime(runtime, sprites, isTypeScript) + ] + .filter(Boolean) + .join('\n') + ); + } + }); +} + +const renderTypes = ({ name }: MetadataTypesParams, sprites: GeneratedSprites) => + `export interface ${name} { + ${renderIterableAsRecord( + sprites.values(), + sprite => sprite.name, + ({ files }) => files.map(file => stringLiteral(file.name)).join(' | ') + )} + }`; + +const renderRuntime = ( + params: MetadataRuntimeParams, + sprites: GeneratedSprites, + isTypeScript: boolean +) => { + const { name, size, viewBox } = params; + const detailedRuntime = Boolean(size || viewBox); + const satisfies = detailedRuntime + ? `Record + }>` + : `{ + ${renderIterableAsRecord( + sprites.values(), + sprite => sprite.name, + ({ files }) => `Array<${files.map(file => stringLiteral(file.name)).join(' | ')}>` + )} + }`; + + return `export const ${name} = { + ${renderIterableAsRecord( + sprites.values(), + sprite => sprite.name, + sprite => + detailedRuntime ? renderRuntimeAsDetails(sprite, params) : renderRuntimeAsArray(sprite) + )} + }${isTypeScript ? ` satisfies ${satisfies}` : ''};`; +}; + +const renderRuntimeAsArray = ({ files }: GeneratedSprite) => + `[${files.map(file => stringLiteral(file.name)).join(',\n')}]`; + +const renderRuntimeAsDetails = ( + { files, filePath }: GeneratedSprite, + params: MetadataRuntimeParams +) => `{ + filePath: '${filePath}', + items: { + ${renderIterableAsRecord( + files, + file => file.name, + file => renderMetadata(params, file) + )} + } +}`; + +const renderMetadata = ( + { size, viewBox: displayViewBox }: MetadataRuntimeParams, + { meta: { width, height, viewBox } }: SvgFile +) => + `{ + ${displayViewBox ? `viewBox: '${viewBox}',` : ''} + ${size ? `width: ${width}, height: ${height},` : ''} + }`; + +const toNamedParams = ( + defaultValue: T, + params: Partial | boolean | string +): T | null => { + if (!params) return null; + if (typeof params === 'string' || typeof params === 'boolean') { + return { ...defaultValue, name: params === true ? defaultValue.name : params } as T; + } + return { + ...defaultValue, + ...params + }; +}; + +const renderIterableAsRecord = ( + items: Iterable, + key: (item: T) => string, + value: (item: T) => string, + separator = ',\n' +) => + Array.from(items) + .map(item => `${stringLiteral(key(item))}: ${value(item)}`) + .join(separator); + +const stringLiteral = (value: string) => `'${value}'`;