Skip to content

Commit 72c3844

Browse files
committed
feat: implement srcset parsing, validation, and stringification
- Added `parse` function for parsing srcset strings. - Added `stringify` function for serializing srcset candidates. - Added `validate` function for srcset candidate validation. - Introduced corresponding types and errors for better type safety. - Covered functionality with comprehensive tests.
1 parent 1a1e899 commit 72c3844

12 files changed

Lines changed: 933 additions & 37 deletions

commitlint.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module.exports = {
2-
extends: ["@commitlint/config-conventional"],
2+
extends: ["@commitlint/config-conventional"],
33
};

rslib.config.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
1-
import { defineConfig } from "@rslib/core";
1+
import {defineConfig} from "@rslib/core";
22

33
export default defineConfig({
4-
source: {
5-
entry: {
6-
index: "./src/index.ts",
4+
source: {
5+
entry: {
6+
index: "./src/index.ts",
7+
},
8+
tsconfigPath: "./tsconfig.build.json",
79
},
8-
tsconfigPath: "./tsconfig.build.json",
9-
},
10-
output: {
11-
target: "web",
12-
cleanDistPath: true,
13-
sourceMap: true,
14-
},
15-
lib: [
16-
{
17-
format: "esm",
18-
syntax: "es2020",
19-
dts: {
20-
autoExtension: true,
21-
},
10+
output: {
11+
target: "web",
12+
cleanDistPath: true,
13+
sourceMap: true,
2214
},
23-
{
24-
format: "cjs",
25-
syntax: "es2020",
26-
dts: {
27-
autoExtension: true,
28-
},
29-
},
30-
],
15+
lib: [
16+
{
17+
format: "esm",
18+
syntax: "es2020",
19+
dts: {
20+
autoExtension: true,
21+
},
22+
},
23+
{
24+
format: "cjs",
25+
syntax: "es2020",
26+
dts: {
27+
autoExtension: true,
28+
},
29+
},
30+
],
3131
});

rstest.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { defineConfig } from "@rstest/core";
1+
import {defineConfig} from "@rstest/core";
22

33
export default defineConfig({
4-
testEnvironment: "node",
5-
include: ["tests/**/*.test.ts"],
4+
testEnvironment: "node",
5+
include: ["tests/**/*.test.ts"],
66
});

src/errors.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {SrcsetValidationIssue} from "./types";
2+
3+
export class SrcsetError extends Error {
4+
override name = "SrcsetError";
5+
}
6+
7+
export class SrcsetParseError extends SrcsetError {
8+
override name = "SrcsetParseError";
9+
}
10+
11+
export class SrcsetValidationError extends SrcsetError {
12+
override name = "SrcsetValidationError";
13+
14+
public constructor(public readonly errors: SrcsetValidationIssue[]) {
15+
super(formatValidationMessage(errors));
16+
}
17+
}
18+
19+
function formatValidationMessage(errors: SrcsetValidationIssue[]): string {
20+
if (errors.length === 0) {
21+
return "Invalid srcset.";
22+
}
23+
24+
return `Invalid srcset: ${errors.map((error) => error.code).join(", ")}.`;
25+
}

src/index.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,28 @@
1-
// Public entrypoint. The srcset API will be added in the implementation pass.
2-
export {};
1+
import {parse} from "./parse";
2+
import {stringify} from "./stringify";
3+
import {validate} from "./validator";
4+
5+
export {SrcsetError, SrcsetParseError, SrcsetValidationError} from "./errors";
6+
export {parse} from "./parse";
7+
export {stringify} from "./stringify";
8+
export type {
9+
DensityCandidate,
10+
DescriptorType,
11+
FallbackCandidate,
12+
ParseOptions,
13+
SrcsetCandidate,
14+
SrcsetValidationIssue,
15+
StringifyOptions,
16+
ValidateOptions,
17+
ValidationResult,
18+
WidthCandidate,
19+
} from "./types";
20+
export {validate} from "./validator";
21+
22+
const srcset = {
23+
parse,
24+
stringify,
25+
validate,
26+
};
27+
28+
export default srcset;

src/parse.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {SrcsetValidationError} from "./errors";
2+
import {parseInternal} from "./parser";
3+
import type {ParseOptions, SrcsetCandidate} from "./types";
4+
import {validateParsedCandidates} from "./validator";
5+
6+
export function parse(input: string, options: ParseOptions = {}): SrcsetCandidate[] {
7+
const parsed = parseInternal(input);
8+
9+
if (options.strict) {
10+
const result = validateParsedCandidates(parsed, {
11+
inputWasEmpty: input.trim().length === 0,
12+
});
13+
14+
if (!result.valid) {
15+
throw new SrcsetValidationError(result.errors);
16+
}
17+
}
18+
19+
return parsed.map(({candidate}) => candidate);
20+
}

src/parser.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import type {SrcsetCandidate} from "./types";
2+
3+
export type InternalDescriptor =
4+
| {kind: "density"; value: number; raw: string}
5+
| {kind: "width"; value: number; raw: string}
6+
| {kind: "invalid"; raw: string};
7+
8+
export type InternalCandidate = {
9+
candidate: SrcsetCandidate;
10+
descriptors: InternalDescriptor[];
11+
index: number;
12+
raw: string;
13+
url: string;
14+
};
15+
16+
const ASCII_WHITESPACE = /[\t\n\f\r ]/u;
17+
const DENSITY_DESCRIPTOR = /^(\d+(?:\.\d+)?|\.\d+)x$/u;
18+
const WIDTH_DESCRIPTOR = /^(\d+)w$/u;
19+
20+
export function parseInternal(input: string): InternalCandidate[] {
21+
return splitCandidates(input).map((segment, index) => parseCandidateSegment(segment, index));
22+
}
23+
24+
function splitCandidates(input: string): string[] {
25+
const segments: string[] = [];
26+
const length = input.length;
27+
let index = 0;
28+
29+
while (index < length) {
30+
while (index < length && isCandidateLeadingIgnored(input[index])) {
31+
index += 1;
32+
}
33+
34+
if (index >= length) {
35+
break;
36+
}
37+
38+
const start = index;
39+
let seenUrlWhitespace = false;
40+
41+
while (index < length) {
42+
const char = input[index];
43+
44+
if (isAsciiWhitespace(char)) {
45+
seenUrlWhitespace = true;
46+
index += 1;
47+
continue;
48+
}
49+
50+
if (char === "," && (seenUrlWhitespace || isFallbackSeparator(input, index))) {
51+
break;
52+
}
53+
54+
index += 1;
55+
}
56+
57+
const segment = input.slice(start, index).trim();
58+
59+
if (segment.length > 0) {
60+
segments.push(segment);
61+
}
62+
63+
if (input[index] === ",") {
64+
index += 1;
65+
}
66+
}
67+
68+
return segments;
69+
}
70+
71+
function parseCandidateSegment(segment: string, index: number): InternalCandidate {
72+
const [url = "", ...descriptorTokens] = segment.split(ASCII_WHITESPACE).filter(Boolean);
73+
const descriptors = descriptorTokens.map(parseDescriptor);
74+
const descriptor = descriptors[0];
75+
76+
return {
77+
candidate: buildCandidate(url, descriptors.length === 1 ? descriptor : undefined),
78+
descriptors,
79+
index,
80+
raw: segment,
81+
url,
82+
};
83+
}
84+
85+
function parseDescriptor(token: string): InternalDescriptor {
86+
const width = WIDTH_DESCRIPTOR.exec(token);
87+
88+
if (width) {
89+
return {
90+
kind: "width",
91+
raw: token,
92+
value: Number(width[1]),
93+
};
94+
}
95+
96+
const density = DENSITY_DESCRIPTOR.exec(token);
97+
98+
if (density) {
99+
return {
100+
kind: "density",
101+
raw: token,
102+
value: Number(density[1]),
103+
};
104+
}
105+
106+
return {
107+
kind: "invalid",
108+
raw: token,
109+
};
110+
}
111+
112+
function buildCandidate(url: string, descriptor?: InternalDescriptor): SrcsetCandidate {
113+
if (descriptor?.kind === "density") {
114+
return {
115+
url,
116+
density: descriptor.value,
117+
};
118+
}
119+
120+
if (descriptor?.kind === "width") {
121+
return {
122+
url,
123+
width: descriptor.value,
124+
};
125+
}
126+
127+
return {url};
128+
}
129+
130+
function isAsciiWhitespace(char: string | undefined): boolean {
131+
return char !== undefined && ASCII_WHITESPACE.test(char);
132+
}
133+
134+
function isCandidateLeadingIgnored(char: string | undefined): boolean {
135+
return char === "," || isAsciiWhitespace(char);
136+
}
137+
138+
function isFallbackSeparator(input: string, commaIndex: number): boolean {
139+
let index = commaIndex + 1;
140+
141+
while (index < input.length && isAsciiWhitespace(input[index])) {
142+
index += 1;
143+
}
144+
145+
return index === input.length || index > commaIndex + 1;
146+
}

src/stringify.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {SrcsetValidationError} from "./errors";
2+
import type {SrcsetCandidate, StringifyOptions} from "./types";
3+
import {validate} from "./validator";
4+
5+
export function stringify(candidates: SrcsetCandidate[], options: StringifyOptions = {}): string {
6+
if (options.strict) {
7+
const result = validate(candidates);
8+
9+
if (!result.valid) {
10+
throw new SrcsetValidationError(result.errors);
11+
}
12+
}
13+
14+
return candidates.map(stringifyCandidate).join(", ");
15+
}
16+
17+
function stringifyCandidate(candidate: SrcsetCandidate): string {
18+
if ("density" in candidate) {
19+
return `${candidate.url} ${formatNumber(candidate.density)}x`;
20+
}
21+
22+
if ("width" in candidate) {
23+
return `${candidate.url} ${formatNumber(candidate.width)}w`;
24+
}
25+
26+
return candidate.url;
27+
}
28+
29+
function formatNumber(value: number): string {
30+
return String(value);
31+
}

src/types.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
export type DensityCandidate = {
2+
url: string;
3+
density: number;
4+
};
5+
6+
export type WidthCandidate = {
7+
url: string;
8+
width: number;
9+
};
10+
11+
export type FallbackCandidate = {
12+
url: string;
13+
};
14+
15+
export type SrcsetCandidate = DensityCandidate | WidthCandidate | FallbackCandidate;
16+
17+
export type ParseOptions = {
18+
strict?: boolean;
19+
};
20+
21+
export type ValidateOptions = {
22+
baseUrl?: string | URL;
23+
sizes?: string;
24+
};
25+
26+
export type StringifyOptions = {
27+
strict?: boolean;
28+
};
29+
30+
export type DescriptorType = "density" | "width" | "none";
31+
32+
export type SrcsetValidationIssue = {
33+
code:
34+
| "empty-srcset"
35+
| "invalid-url"
36+
| "invalid-descriptor"
37+
| "duplicate-descriptor"
38+
| "mixed-descriptors"
39+
| "missing-width-descriptor"
40+
| "multiple-descriptors";
41+
message: string;
42+
candidate?: string;
43+
index?: number;
44+
};
45+
46+
export type ValidationResult =
47+
| {
48+
valid: true;
49+
candidates: SrcsetCandidate[];
50+
descriptorType: DescriptorType;
51+
errors: [];
52+
}
53+
| {
54+
valid: false;
55+
candidates: SrcsetCandidate[];
56+
descriptorType: DescriptorType;
57+
errors: SrcsetValidationIssue[];
58+
};

0 commit comments

Comments
 (0)