Skip to content

Commit ef921f1

Browse files
chore: Merge pull request #73 from linkedin/ident-splitting.
feat: Ability to specify identifier ranges.
2 parents 14268aa + 27dac99 commit ef921f1

4 files changed

Lines changed: 215 additions & 10 deletions

File tree

packages/opticss/src/OpticssOptions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ export interface OptiCSSOptions extends Optimizations {
4646
*/
4747
css?: Partial<CSSFeatureFlags>;
4848

49+
identifiers?: {
50+
/**
51+
* Sets the starting value for identifiers. This is a standard base-10 number
52+
* that is converted to a corresponding identifier.
53+
*
54+
* An integer greater than or equal to 1.
55+
* Defaults to 1.
56+
*/
57+
startValue?: number;
58+
59+
/**
60+
* How many identifiers the ident generator for each namespace should be
61+
* allowed to produce. Note that if any of the produced identifiers are
62+
* reserved, the actual number of identifiers returned will be less than
63+
* the max.
64+
*
65+
* An integer greater than or equal to 1.
66+
* Defaults to Infinity.
67+
*/
68+
maxCount?: number;
69+
};
4970
}
5071

5172
export const DEFAULT_OPTIONS = Object.freeze<OptiCSSOptions>({

packages/opticss/src/OptimizationPass.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export class OptimizationPass {
1414
this.styleMapping = new StyleMapping(templateOptions);
1515
this.cache = new SelectorCache();
1616
this.actions = new Actions();
17-
this.identGenerators = new IdentGenerators(options.css!.caseInsensitiveSelectors!, "id", "class");
17+
this.identGenerators = new IdentGenerators({
18+
caseInsensitive: options.css!.caseInsensitiveSelectors!,
19+
namespaces: ["id", "class"],
20+
startValue: options.identifiers && options.identifiers.startValue,
21+
maxIdentCount: options.identifiers && options.identifiers.maxCount,
22+
});
1823
}
1924
}

packages/opticss/src/util/IdentGenerator.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,94 @@ function increment(insensitive: boolean, counters: Array<number>, i: number) {
2424
return carry;
2525
}
2626

27+
function countersForInteger(caseInsensitive: boolean, integer: number): Array<number> {
28+
integer = Math.round(integer);
29+
let firstDigitBase = caseInsensitive ? 26 : 52;
30+
let otherDigitBase = caseInsensitive ? 38 : 64;
31+
let numDigits = 0;
32+
let maxNumber = 0;
33+
let penultimateMax = 0;
34+
// We need to know the maximum number for one less than the number of places
35+
// in our high-base number; numbers above that can be converted using the
36+
// standard base-conversion algorithm in the base of the non-first digits.
37+
// (These maximums are only dependent on the number of places so in theory
38+
// this could be replaced with a lookup table.)
39+
while (integer > maxNumber) {
40+
penultimateMax = maxNumber;
41+
numDigits++;
42+
maxNumber += firstDigitBase * Math.pow(otherDigitBase, numDigits - 1);
43+
}
44+
let counters = new Array(numDigits);
45+
// tbh I don't understand why we have to subtract an extra 1 here.
46+
// I think it's because our leading digit has no zero.
47+
integer -= penultimateMax + 1;
48+
for (let c = 0; c < numDigits; c++) {
49+
// Because we've subtracted penultimateMax, counters[0] is actually 1 less
50+
// than the number it should be. But we don't have to add 1 to our leading
51+
// digit because the base of our leading digit doesn't have a "zero".
52+
counters[numDigits - c - 1] = (integer % otherDigitBase);
53+
integer = Math.floor(integer / otherDigitBase);
54+
}
55+
return counters;
56+
}
57+
58+
function integerForCounters(counters: Array<number>, caseInsensitive: boolean): number {
59+
let firstDigitBase = caseInsensitive ? 26 : 52;
60+
let otherDigitBase = caseInsensitive ? 38 : 64;
61+
let total = 0;
62+
// We first calculate this like our leading digit has the same base as the other digits.
63+
for (let i = 0; i < counters.length; i++) {
64+
total += (counters[i] + (i === 0 ? 1 : 0)) * Math.pow(otherDigitBase, counters.length - i - 1);
65+
}
66+
// Then we subtract the numeric gaps at the digit boundaries.
67+
// (This only varies by the number of digits in our number so in theory it
68+
// could be replaced by a lookup table.)
69+
for (let i = 0; i < counters.length - 1; i++) {
70+
total -= (otherDigitBase - firstDigitBase - 1) * Math.pow(otherDigitBase, i);
71+
}
72+
return total;
73+
}
74+
2775
export class IdentGenerator {
76+
private caseInsensitive: boolean;
2877
lastIdent: string;
78+
private maxIdentCount: number;
79+
private startValue: number;
2980
returnedIdents: Array<string>;
3081
reservedIdents: Set<string>;
3182
private counters: Array<number>;
3283
private identChar: (c: number, i: number) => string;
3384
private increment: (counters: Array<number>, i: number) => boolean;
34-
constructor(caseInsensitive = false) {
35-
this.counters = [0];
85+
constructor(caseInsensitive = false, startValue = 1, maxIdentCount = Infinity) {
86+
if (startValue < 1) {
87+
throw new RangeError("startValue must be at least 1");
88+
}
89+
this.caseInsensitive = caseInsensitive;
90+
this.startValue = startValue;
91+
this.maxIdentCount = maxIdentCount;
92+
this.counters = countersForInteger(caseInsensitive, startValue);
3693
this.returnedIdents = [];
3794
this.reservedIdents = new Set();
3895
this.identChar = identChar.bind(null, caseInsensitive);
3996
this.increment = increment.bind(null, caseInsensitive);
4097
}
98+
99+
get currentValue(): number {
100+
return integerForCounters(this.counters, this.caseInsensitive);
101+
}
102+
41103
nextIdent(): string {
42104
if (this.returnedIdents.length > 0) {
43105
return this.returnedIdents.pop()!;
44106
}
45107
let ident: string;
46108
while (this.isReserved(ident = this.generateNextIdent())) {}
109+
if (this.maxIdentCount !== Infinity) {
110+
let identCount = this.currentValue - this.startValue;
111+
if (identCount > this.maxIdentCount) {
112+
throw new Error(`Too many identifiers were generated (Max: ${this.maxIdentCount}).`);
113+
}
114+
}
47115
return this.lastIdent = ident;
48116
}
49117
private generateNextIdent() {
@@ -81,14 +149,39 @@ export class IdentGenerator {
81149
}
82150
}
83151

152+
interface IdentGeneratorOptions<Namespace extends string = string> {
153+
namespaces: Array<Namespace>;
154+
/**
155+
* Whether to use case-insensitive identifiers.
156+
*/
157+
caseInsensitive?: boolean;
158+
/**
159+
* Sets the starting value. This is a standard base-10 number that is
160+
* converted to a corresponding identifier.
161+
*
162+
* An integer greater than or equal to 1.
163+
* Defaults to 1.
164+
*/
165+
startValue?: number;
166+
/**
167+
* How many identifiers each ident generator should be allowed to produce.
168+
* Note that if any of the produced identifiers are reserved, the actual
169+
* number of identifiers returned will be less than the max.
170+
* Defaults to Infinity.
171+
*/
172+
maxIdentCount?: number;
173+
}
174+
84175
export class IdentGenerators<Namespace extends string = string> {
85176
namespaces: {
86177
[name: string]: IdentGenerator;
87178
};
88-
constructor(caseInsensitive: boolean, ...namespaces: Array<Namespace>) {
179+
constructor(options: IdentGeneratorOptions<Namespace>) {
89180
this.namespaces = {};
90-
namespaces.forEach(ns => {
91-
this.namespaces[ns] = new IdentGenerator(caseInsensitive);
181+
options.namespaces.forEach(ns => {
182+
this.namespaces[ns] = new IdentGenerator(!!options.caseInsensitive,
183+
options.startValue || 1,
184+
options.maxIdentCount || Infinity);
92185
});
93186
}
94187
/**

packages/opticss/test/optimizations/rewrite-idents-test.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import * as path from "path";
1919
import { documentToString } from "resolve-cascade";
2020

21+
import { OptiCSSOptions } from "../../src/OpticssOptions";
2122
import {
2223
IdentGenerator,
2324
IdentGenerators,
@@ -27,9 +28,14 @@ import {
2728
testOptimizationCascade,
2829
} from "../util/assertCascade";
2930

30-
function testRewriteIdents(templateRewriteOpts: RewritableIdents, ...stylesAndTemplates: Array<string | TestTemplate>): Promise<CascadeTestResult> {
31+
function testRewriteIdents(templateRewriteOpts: RewritableIdents & Pick<OptiCSSOptions, "identifiers">, ...stylesAndTemplates: Array<string | TestTemplate>): Promise<CascadeTestResult> {
32+
let identifiers = templateRewriteOpts.identifiers;
33+
delete templateRewriteOpts.identifiers;
3134
return testOptimizationCascade(
32-
{ only: ["rewriteIdents"] },
35+
{
36+
only: ["rewriteIdents"],
37+
identifiers,
38+
},
3339
{
3440
rewriteIdents: templateRewriteOpts,
3541
analyzedAttributes: [],
@@ -40,22 +46,51 @@ function testRewriteIdents(templateRewriteOpts: RewritableIdents, ...stylesAndTe
4046

4147
@suite("Rewrite idents")
4248
export class RewriteIdentsTest {
49+
@test "Can select a starting value"() {
50+
for (let startValue = 1; startValue < 20_000_000; startValue += Math.round(Math.random() * 50000)) {
51+
const idGen = new IdentGenerator(false, startValue);
52+
assert.equal(idGen.currentValue, startValue);
53+
idGen.nextIdent();
54+
assert.equal(idGen.currentValue, startValue + 1);
55+
}
56+
}
57+
@test "can specify a max number of idents to generate"() {
58+
let startValue = Math.round(Math.random() * 20_000_000);
59+
let maxIdentCount = 100;
60+
const idGen = new IdentGenerator(false, startValue, maxIdentCount);
61+
for (let i = 0; i < maxIdentCount; i++) {
62+
idGen.nextIdent();
63+
}
64+
assert.throws(() => {
65+
idGen.nextIdent();
66+
});
67+
}
4368
@test "has an ident generator"() {
4469
const idGen = new IdentGenerator();
70+
let currentValue = 1;
71+
assert.equal(idGen.currentValue, currentValue++);
4572
assert.equal(idGen.nextIdent(), "a");
73+
assert.equal(idGen.currentValue, currentValue++);
4674
assert.equal(idGen.nextIdent(), "b");
75+
assert.equal(idGen.currentValue, currentValue++);
4776
for (let i = 2; i < 52; i++) {
4877
idGen.nextIdent();
78+
currentValue++;
4979
}
5080
assert.equal(idGen.nextIdent(), "a0");
81+
assert.equal(idGen.currentValue, currentValue++);
5182
for (let i = 1; i < 64; i++) {
5283
idGen.nextIdent();
84+
currentValue++;
5385
}
5486
assert.equal(idGen.nextIdent(), "b0");
87+
assert.equal(idGen.currentValue, currentValue++);
5588
for (let i = 1; i < 64 * 51; i++) {
5689
idGen.nextIdent();
90+
currentValue++;
5791
}
5892
assert.equal(idGen.nextIdent(), "a00");
93+
assert.equal(idGen.currentValue, currentValue++);
5994
}
6095

6196
@test "has an case-insensitive ident generator"() {
@@ -84,7 +119,7 @@ export class RewriteIdentsTest {
84119
}
85120

86121
@test "ident generator set"() {
87-
const idGens = new IdentGenerators(false, "id", "class", "state");
122+
const idGens = new IdentGenerators({namespaces: ["id", "class", "state"]});
88123
assert.equal(idGens.nextIdent("id"), "a");
89124
assert.equal(idGens.nextIdent("class"), "a");
90125
assert.equal(idGens.nextIdent("state"), "a");
@@ -93,7 +128,7 @@ export class RewriteIdentsTest {
93128
assert.equal(idGens.nextIdent("class"), "b");
94129
assert.equal(idGens.nextIdent("state"), "b");
95130
try {
96-
const errorProneGen = new IdentGenerators<string>(false, "id", "class", "state");
131+
const errorProneGen = new IdentGenerators<string>({namespaces: ["id", "class", "state"]});
97132
errorProneGen.nextIdent("foo");
98133
assert.fail("error expected");
99134
} catch (e) {
@@ -234,4 +269,55 @@ export class RewriteIdentsTest {
234269
}
235270
});
236271
}
272+
@test "can configure ident start value"() {
273+
let css1 = `
274+
#id3 { border-width: 2px; }
275+
#a { color: blue; }
276+
.a { color: red; }
277+
#id2 { width: 50%; }
278+
.thing2 { border: 1px solid blue; }
279+
.thing3 { background: red; }
280+
div { background-color: white; }
281+
#id3.thing4 { border-color: black; }
282+
`;
283+
let template = new TestTemplate("test", clean`
284+
<div class="(thing3 | a)" id="(a | id2)"></div>
285+
<div class="(--- | thing2 | thing4)" id="id3"></div>
286+
`);
287+
return testRewriteIdents({ id: true, class: true, identifiers: {startValue: 100, maxCount: 100} }, css1, template).then(result => {
288+
// debugResult(css1, result);
289+
assert.equal(result.optimization.output.content, `
290+
#aL { border-width: 2px; }
291+
#aM { color: blue; }
292+
.aL { color: red; }
293+
#aN { width: 50%; }
294+
.aM { border: 1px solid blue; }
295+
.aN { background: red; }
296+
div { background-color: white; }
297+
#aL.aO { border-color: black; }
298+
`);
299+
});
300+
}
301+
@test async "rejects if there's not enough idents"() {
302+
let css1 = `
303+
#id3 { border-width: 2px; }
304+
#a { color: blue; }
305+
.a { color: red; }
306+
#id2 { width: 50%; }
307+
.thing2 { border: 1px solid blue; }
308+
.thing3 { background: red; }
309+
div { background-color: white; }
310+
#id3.thing4 { border-color: black; }
311+
`;
312+
let template = new TestTemplate("test", clean`
313+
<div class="(thing3 | a)" id="(a | id2)"></div>
314+
<div class="(--- | thing2 | thing4)" id="id3"></div>
315+
`);
316+
try {
317+
await testRewriteIdents({ id: true, class: true, identifiers: {startValue: 100, maxCount: 2} }, css1, template);
318+
throw new Error("Didn't reject promise");
319+
} catch (e) {
320+
assert.equal(e.message, "Too many identifiers were generated (Max: 2).");
321+
}
322+
}
237323
}

0 commit comments

Comments
 (0)