Skip to content

Commit 0fac94e

Browse files
committed
feat: parse basic shape
1 parent 9e1ca0b commit 0fac94e

File tree

2 files changed

+317
-0
lines changed

2 files changed

+317
-0
lines changed

lib/parsers.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,224 @@ exports.parsePosition = function parsePosition(val, validPositions = positions)
11861186
return null;
11871187
};
11881188

1189+
/**
1190+
* https://drafts.csswg.org/css-shapes-1/#supported-basic-shapes
1191+
* https://drafts.csswg.org/cssom/#ref-for-value-def-shape
1192+
*
1193+
* Used for `clip-path`, `offset-path`, and `shape-outside`.
1194+
*
1195+
* <resource> | [<basic-shape> || <geometry-box>] | none
1196+
*/
1197+
exports.parseBasicShape = function parseBasicShape(val) {
1198+
const variable = exports.parseCustomVariable(val);
1199+
if (variable) {
1200+
return variable;
1201+
}
1202+
1203+
const resource = exports.parseResource(val);
1204+
if (resource) {
1205+
return resource;
1206+
}
1207+
1208+
const shapeRegEx = new RegExp(`^(circle|ellipse|inset|path|polygon)\\(${ws}(.*)${ws}\\)$`, 'i');
1209+
let res = shapeRegEx.exec(val);
1210+
if (res) {
1211+
const [, fn, stringArgs] = res;
1212+
const parsedArgs = [];
1213+
1214+
/**
1215+
* circle(<shape-radius>? [at <position>]?)
1216+
*
1217+
* <shape-radius> should be positive <length-percentage>, closest-side, or
1218+
* farthest-side.
1219+
* <shape-radius> is in browsers resolved value only if user defined.
1220+
* <position> default to 50% 50% in Chrome.
1221+
* <position> default to center center in Firefox.
1222+
*/
1223+
if (fn === 'circle') {
1224+
const circleRegEx1 = new RegExp(`^(.+)${whitespace}+(at${whitespace}+(.+))?$`, 'i');
1225+
const circleRegEx2 = new RegExp(`^at${whitespace}+(.+)$`, 'i');
1226+
let radius;
1227+
let position;
1228+
1229+
if ((res = circleRegEx1.exec(stringArgs))) {
1230+
[, radius, , position = ''] = res;
1231+
} else if ((res = circleRegEx2.exec(stringArgs))) {
1232+
[, position] = res;
1233+
} else {
1234+
radius = stringArgs;
1235+
}
1236+
if (radius) {
1237+
radius =
1238+
exports.parseLengthOrPercentage(radius, false, true) ||
1239+
exports.parseKeyword(radius, ['closest-side', 'farthest-side']);
1240+
if (radius === null) {
1241+
return null;
1242+
}
1243+
parsedArgs.push(radius);
1244+
}
1245+
if (position) {
1246+
position = exports.parsePosition(position);
1247+
if (position === null) {
1248+
return null;
1249+
}
1250+
parsedArgs.push('at', position);
1251+
} else {
1252+
parsedArgs.push('at center center');
1253+
}
1254+
return `circle(${parsedArgs.join(' ')})`;
1255+
}
1256+
1257+
/**
1258+
* ellipse(<shape-radius>{2}? [at <position>]?)
1259+
*
1260+
* <shape-radius> should be positive <length-percentage>, closest-side, or
1261+
* farthest-side.
1262+
* <shape-radius> is in browsers resolved value only if user defined.
1263+
* <position> default to 50% 50% in Chrome.
1264+
* <position> default to center center in Firefox.
1265+
*/
1266+
if (fn === 'ellipse') {
1267+
const circleRegEx1 = new RegExp(`^(.+)${whitespace}+(at${whitespace}+(.+))?$`, 'i');
1268+
const circleRegEx2 = new RegExp(`^at${whitespace}+(.+)$`, 'i');
1269+
let radii;
1270+
let position;
1271+
1272+
if ((res = circleRegEx1.exec(stringArgs))) {
1273+
[, radii, , position = ''] = res;
1274+
} else if ((res = circleRegEx2.exec(stringArgs))) {
1275+
[, position] = res;
1276+
} else {
1277+
radii = stringArgs;
1278+
}
1279+
if (radii) {
1280+
[radii] = exports.splitTokens(radii);
1281+
if (radii.length !== 2) {
1282+
return null;
1283+
}
1284+
let [rx, ry] = radii;
1285+
rx =
1286+
exports.parseLengthOrPercentage(rx, false, true) ||
1287+
exports.parseKeyword(rx, ['closest-side', 'farthest-side']);
1288+
ry =
1289+
exports.parseLengthOrPercentage(ry, false, true) ||
1290+
exports.parseKeyword(ry, ['closest-side', 'farthest-side']);
1291+
if (!(rx && ry)) {
1292+
return null;
1293+
}
1294+
parsedArgs.push(rx, ry);
1295+
}
1296+
if (position) {
1297+
position = exports.parsePosition(position);
1298+
if (position === null) {
1299+
return null;
1300+
}
1301+
parsedArgs.push('at', exports.parsePosition(position));
1302+
} else {
1303+
parsedArgs.push('at center center');
1304+
}
1305+
return `ellipse(${parsedArgs.join(' ')})`;
1306+
}
1307+
1308+
/**
1309+
* inset(<length-percentage>{1,4} [round <border-radius>]?)
1310+
*/
1311+
if (fn === 'inset') {
1312+
const insetRegEx = new RegExp(`^(.+)${whitespace}+round${whitespace}+(.+)$`, 'i');
1313+
let corners;
1314+
let radii;
1315+
1316+
if ((res = insetRegEx.exec(stringArgs))) {
1317+
[, corners, radii] = res;
1318+
} else {
1319+
corners = stringArgs;
1320+
}
1321+
1322+
[corners] = exports.splitTokens(corners);
1323+
const { length: cornerLength } = corners;
1324+
1325+
if (
1326+
cornerLength > 4 ||
1327+
!corners.every((corner, i) => (corners[i] = exports.parseLengthOrPercentage(corner)))
1328+
) {
1329+
return null;
1330+
}
1331+
corners = corners.filter((corner, i) => {
1332+
if (i > 1) {
1333+
return corner !== corners[i - 2];
1334+
}
1335+
if (i === 1) {
1336+
return corner !== corners[i - 1];
1337+
}
1338+
return true;
1339+
});
1340+
parsedArgs.push(corners.join(' '));
1341+
if (radii) {
1342+
radii = exports.parseBorderRadius(radii);
1343+
if (radii === null) {
1344+
return null;
1345+
}
1346+
if (radii !== '0%' && radii !== '0px') {
1347+
parsedArgs.push('round', radii);
1348+
}
1349+
}
1350+
return `inset(${parsedArgs.join(' ')})`;
1351+
}
1352+
1353+
/**
1354+
* path([<fill-rule>,]? <string>)
1355+
*
1356+
* TODO: validate <string> as a valid path definition.
1357+
*/
1358+
if (fn === 'path') {
1359+
const [args] = exports.splitTokens(stringArgs, /,/);
1360+
if (args.length === 2) {
1361+
const fill = exports.parseKeyword(args[0], ['evenodd', 'nonzero']);
1362+
const string = exports.parseString(args[1]);
1363+
if (!(fill && string)) {
1364+
return null;
1365+
}
1366+
parsedArgs.push(fill, string);
1367+
} else if (args.length === 1) {
1368+
const string = exports.parseString(args[0]);
1369+
if (!string) {
1370+
return null;
1371+
}
1372+
parsedArgs.push(string);
1373+
}
1374+
return `path(${parsedArgs.join(', ')})`;
1375+
}
1376+
1377+
/**
1378+
* polygon(<fill-rule>?, <length-percentage>{2}+)
1379+
*/
1380+
if (fn === 'polygon') {
1381+
const [args] = exports.splitTokens(stringArgs, /,/);
1382+
if (args.length > 2) {
1383+
return null;
1384+
}
1385+
if (args.length === 2) {
1386+
const fill = exports.parseKeyword(args.shift(), ['evenodd', 'nonzero']);
1387+
if (!fill) {
1388+
return null;
1389+
}
1390+
parsedArgs.push(fill);
1391+
}
1392+
const [vertices] = exports.splitTokens(args.shift());
1393+
if (
1394+
vertices.length % 2 ||
1395+
!vertices.every((vertex, i) => (vertices[i] = exports.parseLengthOrPercentage(vertex)))
1396+
) {
1397+
return null;
1398+
}
1399+
parsedArgs.push(vertices.join(' '));
1400+
return `polygon(${parsedArgs.join(', ')})`;
1401+
}
1402+
}
1403+
1404+
return null;
1405+
};
1406+
11891407
/**
11901408
* @param {array} parts [horizontal radii, vertical radii]
11911409
* @returns {string}

lib/parsers.test.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,105 @@ describe('parseGeometryBox', () => {
352352
expect(parsers.parseGeometryBox('var(--geometry-box)')).toBe('var(--geometry-box)');
353353
});
354354
});
355+
describe('parseBasicShape', () => {
356+
it('returns null for invalid values', () => {
357+
const invalid = [
358+
'ccircle()',
359+
'circle())',
360+
'circle(at 50% 50% 50%)',
361+
'circle(0deg)',
362+
'circle() invalid-box',
363+
'circle() ellipse()',
364+
'eellipse()',
365+
'ellipse())',
366+
'ellipse(50%)',
367+
'ellipse(0deg 0deg)',
368+
'ellipse(at 50% 50% 50%)',
369+
'ellipse() invalid-box',
370+
'ellipse() circle()',
371+
'inset()',
372+
'iinset(1px)',
373+
'inset(1px))',
374+
'inset(1deg)',
375+
'inset(1px) invalid-box',
376+
'inset(1px) circle()',
377+
'path()',
378+
'ppath("M0 0")',
379+
'path("M0 0h1"))',
380+
'path(nonone, "M0 0h1")',
381+
'path(nonzero "M0 0h1")',
382+
'path("M0 0") invalid-box',
383+
'path("M0 0") circle()',
384+
'polygon()',
385+
'ppolygon(10px 10px)',
386+
'polygon(10px 10px))',
387+
'polygon(nonone, 10px 10px)',
388+
'polygon(nozero 10px 10px)',
389+
'polygon(10px 10px 10px)',
390+
'polygon(10px 10px) invalid-box',
391+
'polygon(10px 10px) circle()',
392+
];
393+
invalid.forEach(value => expect(parsers.parseBasicShape(value)).toBeNull());
394+
});
395+
it('parses valid values', () => {
396+
const valid = [
397+
// [input, expected = input]
398+
['circle()', 'circle(at center center)'],
399+
['circle(50%)', 'circle(50% at center center)'],
400+
['circle(50% at center)', 'circle(50% at center center)'],
401+
['circle(50% at center center)'],
402+
['circle(50% at left top)'],
403+
['circle(50% at 50%)', 'circle(50% at 50% center)'],
404+
['circle(50% at 50% 50%)'],
405+
['circle(at 50% 50%)'],
406+
['ellipse()', 'ellipse(at center center)'],
407+
['ellipse(50% 50%)', 'ellipse(50% 50% at center center)'],
408+
['ellipse(50% 50% at center)', 'ellipse(50% 50% at center center)'],
409+
['ellipse(50% 50% at center center)'],
410+
['ellipse(50% 50% at left top)'],
411+
['ellipse(50% 50% at 50%)', 'ellipse(50% 50% at 50% center)'],
412+
['ellipse(50% 50% at 50% 50%)'],
413+
['ellipse(at 50% 50%)'],
414+
['inset(10%)'],
415+
['inset(10% round 0%)', 'inset(10%)'],
416+
['inset(10% 10% 10% 10% round 10% 10% 10% 10% / 10% 10% 10% 10%)', 'inset(10% round 10%)'],
417+
['path("M0 0")', 'path("M0 0")'],
418+
['path(nonzero, "M0 0")', 'path(nonzero, "M0 0")'],
419+
['polygon(10px 10%)'],
420+
['polygon(nonzero, 10px 10px)'],
421+
];
422+
valid.forEach(([input, expected = input]) =>
423+
expect(parsers.parseBasicShape(input)).toBe(expected)
424+
);
425+
});
426+
it('works with calc', () => {
427+
const input = [
428+
// [input, expected = input]
429+
['circle(calc(25% * 2) at calc(50% * 2))', 'circle(calc(50%) at calc(100%) center)'],
430+
[
431+
'ellipse(calc(25px * 2) calc(25px * 2) at calc(25px * 2))',
432+
'ellipse(calc(50px) calc(50px) at calc(50px) center)',
433+
],
434+
['inset(calc(5% * 2) round calc(1% * 2))', 'inset(calc(10%) round calc(2%))'],
435+
['polygon(calc(1% + 1%) calc(1px + 1px))', 'polygon(calc(2%) calc(2px))'],
436+
];
437+
input.forEach(([value, expected = value]) =>
438+
expect(parsers.parseBasicShape(value)).toBe(expected)
439+
);
440+
});
441+
it('works with custom variable', () => {
442+
const input = [
443+
'var(--shape)',
444+
'circle(var(--radius))',
445+
'circle(var(--radius)) var(--geometry-box)',
446+
'ellipse(var(--radii))',
447+
'inset(var(--radii))',
448+
'path(var(--definition))',
449+
'polygon(var(--vertices))',
450+
];
451+
input.forEach(value => expect(parsers.parseBasicShape(value)).toBe(value));
452+
});
453+
});
355454
describe('parseImage', () => {
356455
it.todo('tests');
357456
});

0 commit comments

Comments
 (0)