openvibers/openvibe.design Commits

Every glyph as a CSS class: /icons.css (#124)

OP OpenVibers committed 11 days ago · 2ad4141b66 · 3 files, +71 −1

Changes

ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex d2afd2f..2cd4798 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -10,6 +10,7 @@
* GET /icons/<name>.xml an Android VectorDrawable, with the maker's colour and style
* GET /icons/<name>/favicon.zip a favicon set from the maker's choices: SVG, PNGs, manifest, the <head> lines
* GET /ov-icon.js (also /icons.js) the <ov-icon name="…"> web component: any page, no sign-in (L50 step 2)
++ * GET /icons.css[?names=a,b] every glyph as a CSS class (icon-<name>, is-solid for the solid), through a mask
* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
* ov-glyphs-svg.zip with every glyph
* GET /icons/font-awesome-alternative how to switch, and what keeps working
@@@ -155,6 +156,38 @@ function createIconRoutes(ctx) {
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*').attachment(req.params.file).type('application/gzip').send(body); // attachment() would type it by extension
});
++ // Every glyph as a CSS class (L50: "a CSS file of every mask class"): <i class="icon-search"></i> paints the glyph in
++ // the text's colour through a mask, with no markup and no script; add "is-solid" for the solid drawing where there is
++ // one. ?names=search,heart keeps the file to those. One rule carries the shape; each glyph only sets its picture.
++ let iconsCss = null;
++ function cssFor(names) {
++ const enc = (svg) => `data:image/svg+xml,${svg.replace(/"/g, "'").replace(/[<>#%{}\n]/g, (c) => encodeURIComponent(c))}`;
++ const pic = (name, style) => {
++ const svg = formats.inlineSvg(name, ex.options({ size: 24, style }));
++ return svg ? enc(svg.replace(/currentColor/g, '#000').replace(' aria-hidden="true"', '')) : '';
++ };
++ const out = [
++ '/* OpenVibe glyphs as CSS classes (openvibe.design/icons.css). <i class="icon-search"></i>; add is-solid for the solid',
++ ' drawing. Painted in currentColor through a mask, so the text colour is the icon colour. Free to use (see /icons). */',
++ // A name that is not a glyph draws nothing (a transparent picture), never a filled square.
++ '[class^="icon-"],[class*=" icon-"]{--ovi:linear-gradient(transparent,transparent);display:inline-block;width:1em;height:1em;vertical-align:-.125em;background-color:currentColor;'
++ + '-webkit-mask:var(--ovi) center/contain no-repeat;mask:var(--ovi) center/contain no-repeat}',
++ ];
++ for (const name of names) {
++ const line = pic(name, 'line');
++ if (!line) continue;
++ out.push(`.icon-${name}{--ovi:url("${line}")}`);
++ const solid = pic(name, 'solid');
++ if (solid && solid !== line) out.push(`.icon-${name}.is-solid{--ovi:url("${solid}")}`);
++ }
++ return `${out.join('\n')}\n`;
++ }
++ r.get('/icons.css', (req, res) => {
++ const want = String(req.query.names || '').split(',').map((x) => x.trim()).filter((x) => /^[a-z][a-z0-9-]{0,40}$/.test(x) && glyphs.path(x));
++ const body = want.length ? cssFor([...new Set(want)]) : (iconsCss = iconsCss || cssFor(glyphs.NAMES));
++ res.set(OPEN).set('Cache-Control', 'public, max-age=86400').type('text/css').send(body);
++ });
++
r.get(['/ov-icon.js', '/icons.js'], (_req, res) => {
res.set(OPEN).set('Cache-Control', 'public, max-age=86400').type('application/javascript').send(ovIcon());
});
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex 4f1ef6c..0ca9d53 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -116,7 +116,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
fa.length ? way('Font Awesome markup', `<script src="${base}/shared/ov-glyphs.js" defer></script>\n${faTag}`, 'gp-fa', 'Keep your markup; the drop-in draws ours.') : '',
cp ? way('The font', `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, 'gp-font') : '',
way('The sprite', `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, 'gp-sprite'),
-- way('CSS, no markup', formats.cssMask(name, at24), 'gp-mask', 'A mask painted in the text&rsquo;s colour: <code>&lt;span class="icon-${esc(name)}"&gt;&lt;/span&gt;</code>.'),
++ way('CSS, no markup', formats.cssMask(name, at24), 'gp-mask', `A mask painted in the text&rsquo;s colour: <code>&lt;span class="icon-${esc(name)}"&gt;&lt;/span&gt;</code>. Or link it, with any others: <code>&lt;link rel="stylesheet" href="${esc(base)}/icons.css?names=${esc(name)}"&gt;</code>; add <code>is-solid</code> for the solid drawing.`),
way('Data URI', formats.dataUri(name, at24), 'gp-data', 'For an <code>&lt;img&gt;</code> or a CSS <code>url()</code>, in a fixed colour.'),
way('ES module', `import ${name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())} from '${base}/icons/${name}.mjs';\n// { name, line, solid }: the drawings, for a component of your own`, 'gp-mjs'),
way('Node', `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, 'gp-node'),
ddiff --git a/test/icons-css.test.js b/test/icons-css.test.js
nnew file mode 100644
iindex 0000000..18ffbfb
---- /dev/null
++++ b/test/icons-css.test.js
@@@ -0,0 +1,37 @@
++'use strict';
++// Every glyph as a CSS class (plan L50: "a CSS file of every mask class"): /icons.css gives icon-<name> for every glyph,
++// is-solid where a solid drawing exists, one shared rule for the shape, and ?names= for a smaller file. Open to any site.
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const glyphs = require('openvibe-shared/glyphs');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the whole set: one rule for the shape, a class per glyph, solids where they differ', async () => {
++ const res = await t.get('/icons.css');
++ assert.equal(res.status, 200);
++ assert.match(String(res.headers.get('content-type')), /text\/css/);
++ assert.equal(res.headers.get('access-control-allow-origin'), '*', 'any site may link it');
++ const css = res.text;
++ assert.ok(css.includes('[class^="icon-"],[class*=" icon-"]{--ovi:linear-gradient(transparent,transparent);display:inline-block;width:1em;height:1em;'), 'one rule for the shape');
++ assert.ok(css.includes('mask:var(--ovi) center/contain no-repeat'));
++ assert.ok(css.includes('{--ovi:linear-gradient(transparent,transparent);'), 'a name that is not a glyph draws nothing, not a square');
++ const classes = css.match(/^\.icon-[a-z0-9-]+\{--ovi:url\("data:image\/svg\+xml,/gm) || [];
++ assert.equal(classes.length, glyphs.NAMES.length, 'a class for every glyph');
++ assert.ok(/^\.icon-search\.is-solid\{--ovi:url\(/m.test(css), 'search has a solid');
++ assert.ok(!/^\.icon-check\.is-solid\{/m.test(css), 'a glyph of lines only has no solid rule');
++ assert.ok(!/currentColor/.test(css.split('\n').slice(3).join('\n')), 'the pictures are masks: plain black shapes');
++ });
++
++ await check('?names= keeps the file to those glyphs, and ignores what is not one', async () => {
++ const res = await t.get('/icons.css?names=search,heart,search,not-a-glyph,%3Cx%3E');
++ assert.equal(res.status, 200);
++ const rules = res.text.match(/^\.icon-[a-z0-9-]+\{/gm) || [];
++ assert.deepEqual(rules, ['.icon-search{', '.icon-heart{'], 'each once, in order, nothing else');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });