@realfavicongenerator/check-favicon 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/helper.ts ADDED
@@ -0,0 +1,170 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path';
3
+ import { Fetcher, pathToMimeType } from '.';
4
+ import sharp, { FormatEnum } from 'sharp';
5
+
6
+ export const filePathToReadableStream = async (path: string): Promise<ReadableStream> => {
7
+ const file = await fs.open(path, 'r');
8
+ const stream = file.createReadStream();
9
+
10
+ return new ReadableStream({
11
+ start(controller) {
12
+ stream.on('data', (chunk) => {
13
+ controller.enqueue(chunk);
14
+ });
15
+ stream.on('close', () => {
16
+ controller.close();
17
+ });
18
+ }
19
+ });
20
+ }
21
+
22
+ export const filePathToString = async (path: string): Promise<string> => (
23
+ fs.readFile(path, 'utf-8')
24
+ )
25
+
26
+ export const stringToReadableStream = (str: string): ReadableStream => {
27
+ return new ReadableStream({
28
+ start(controller) {
29
+ controller.enqueue(str);
30
+ controller.close();
31
+ }
32
+ });
33
+ }
34
+
35
+ export const readableStreamToString = async (readableStream: ReadableStream): Promise<string> => {
36
+ const reader = readableStream.getReader();
37
+ const chunks: Uint8Array[] = [];
38
+ let done = false;
39
+ while (!done) {
40
+ const { value, done: doneValue } = await reader.read();
41
+ done = doneValue;
42
+ if (value) {
43
+ chunks.push(value);
44
+ }
45
+ }
46
+ const concatenatedChunks = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
47
+ let offset = 0;
48
+ for (const chunk of chunks) {
49
+ concatenatedChunks.set(chunk, offset);
50
+ offset += chunk.length;
51
+ }
52
+ return new TextDecoder("utf-8").decode(concatenatedChunks);
53
+ }
54
+
55
+ export const readableStreamToBuffer = async (readableStream: ReadableStream): Promise<Buffer> => {
56
+ const reader = readableStream.getReader();
57
+ const chunks: Uint8Array[] = [];
58
+ let done = false;
59
+ while (!done) {
60
+ const { value, done: doneValue } = await reader.read();
61
+ done = doneValue;
62
+ if (value) {
63
+ chunks.push(value);
64
+ }
65
+ }
66
+ const concatenatedChunks = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
67
+ let offset = 0;
68
+ for (const chunk of chunks) {
69
+ concatenatedChunks.set(chunk, offset);
70
+ offset += chunk.length;
71
+ }
72
+ return Buffer.from(concatenatedChunks);
73
+ }
74
+
75
+ export type CheckIconProcessor = {
76
+ noHref: () => void,
77
+ icon404: () => void,
78
+ cannotGet: (httpStatusCode: number) => void,
79
+ downloadable: () => void,
80
+ square: (widthHeight: number) => void,
81
+ notSquare: (width: number, Height: number) => void,
82
+ rightSize: (widthHeight: number) => void,
83
+ wrongSize: (widthHeight: number) => void
84
+ }
85
+
86
+ export const checkIcon = async (
87
+ iconUrl: string | undefined,
88
+ processor: CheckIconProcessor,
89
+ fetcher: Fetcher,
90
+ mimeType: string | undefined,
91
+ expectedWidthHeight?: number
92
+ ): Promise<string | null> => {
93
+ if (!iconUrl) {
94
+ processor.noHref();
95
+ return null;
96
+ }
97
+
98
+ const res = await fetcher(iconUrl, mimeType);
99
+ if (res.status === 404) {
100
+ processor.icon404();
101
+ } else if (res.status >= 300) {
102
+ processor.cannotGet(res.status);
103
+ } else if (res.readableStream) {
104
+ processor.downloadable();
105
+
106
+ const content = await readableStreamToBuffer(res.readableStream);
107
+ const meta = await sharp(content).metadata();
108
+
109
+ const contentType = res.contentType || pathToMimeType(iconUrl);
110
+
111
+ if (meta.width && meta.height) {
112
+ if (meta.width !== meta.height) {
113
+ processor.notSquare(meta.width, meta.height);
114
+ return null;
115
+ } else {
116
+ processor.square(meta.width);
117
+
118
+ if (expectedWidthHeight) {
119
+ if (meta.width === expectedWidthHeight) {
120
+ processor.rightSize(meta.width);
121
+ } else {
122
+ processor.wrongSize(meta.width);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return bufferToDataUrl(content, contentType);
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ export const mergeUrlAndPath = (baseUrl: string, absoluteOrRelativePath: string): string => {
135
+ // If the path is a full URL, return it as is
136
+ if (absoluteOrRelativePath.startsWith('http://') || absoluteOrRelativePath.startsWith('https://')) {
137
+ return absoluteOrRelativePath;
138
+ }
139
+
140
+ const url = new URL(baseUrl);
141
+
142
+ // If the path starts with a slash, replace the pathname
143
+ if (absoluteOrRelativePath.startsWith('/')) {
144
+ return `${url.origin}${absoluteOrRelativePath}`;
145
+ } else {
146
+ // Otherwise, append the path to the existing pathname
147
+ return `${url.href}${url.href.endsWith('/') ? '' : '/'}${absoluteOrRelativePath}`;
148
+ }
149
+ }
150
+
151
+ export const parseSizesAttribute = (sizes: string | undefined | null): number | null => {
152
+ if (!sizes) {
153
+ return null;
154
+ }
155
+
156
+ const match = sizes.match(/(\d+)x(\d+)/);
157
+ if (match) {
158
+ if (match[1] !== match[2]) {
159
+ return null;
160
+ }
161
+
162
+ return parseInt(match[1]);
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ export const bufferToDataUrl = (buffer: Buffer, mimeType: string): string => {
169
+ return `data:${mimeType};base64,${buffer.toString('base64')}`;
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+
2
+ export enum CheckerStatus {
3
+ Ok = 'Ok',
4
+ Error = 'Error',
5
+ Warning = 'Warning'
6
+ }
7
+
8
+ export enum MessageId {
9
+ noHead,
10
+
11
+ svgFaviconDeclared,
12
+ noSvgFavicon,
13
+ multipleSvgFavicons,
14
+ noSvgFaviconHref,
15
+ svgFavicon404,
16
+ svgFaviconCannotGet,
17
+ svgFaviconDownloadable,
18
+ svgFaviconSquare,
19
+ svgFaviconNotSquare,
20
+
21
+ noIcoFavicon,
22
+ multipleIcoFavicons,
23
+ icoFaviconDeclared,
24
+ noIcoFaviconHref,
25
+ icoFavicon404,
26
+ icoFaviconCannotGet,
27
+ icoFaviconDownloadable,
28
+ icoFaviconExtraSizes,
29
+ icoFaviconMissingSizes,
30
+ icoFaviconExpectedSizes,
31
+
32
+ noDesktopPngFavicon,
33
+ no48x48DesktopPngFavicon,
34
+ desktopPngFaviconDeclared,
35
+ noDesktopPngFaviconHref,
36
+ desktopPngFaviconCannotGet,
37
+ desktopPngFaviconDownloadable,
38
+ desktopPngFavicon404,
39
+ desktopPngFaviconWrongSize,
40
+ desktopPngFaviconRightSize,
41
+
42
+ noTouchWebAppTitle,
43
+ multipleTouchWebAppTitles,
44
+ emptyTouchWebAppTitle,
45
+ touchWebAppTitleDeclared,
46
+ noTouchIcon,
47
+ duplicatedTouchIconSizes,
48
+ touchIconWithSize,
49
+ touchIconDeclared,
50
+ noTouchIconHref,
51
+ touchIcon404,
52
+ touchIconCannotGet,
53
+ touchIconDownloadable,
54
+ touchIconSquare,
55
+ touchIcon180x180,
56
+ touchIconNotSquare,
57
+ touchIconWrongSize,
58
+
59
+ noManifest,
60
+ noManifestHref,
61
+ manifest404,
62
+ manifestCannotGet,
63
+ manifestInvalidJson,
64
+ manifestName,
65
+ noManifestName,
66
+ manifestShortName,
67
+ noManifestShortName,
68
+ manifestBackgroundColor,
69
+ noManifestBackgroundColor,
70
+ manifestThemeColor,
71
+ noManifestThemeColor,
72
+ noManifestIcons,
73
+ noManifestIcon,
74
+ manifestIconDeclared,
75
+ manifestIconCannotGet,
76
+ manifestIconDownloadable,
77
+ manifestIcon404,
78
+ manifestIconNoHref,
79
+ manifestIconNotSquare,
80
+ manifestIconRightSize,
81
+ manifestIconSquare,
82
+ manifestIconWrongSize
83
+ }
84
+
85
+ export type CheckerMessage = {
86
+ status: CheckerStatus,
87
+ id: MessageId,
88
+ text: string
89
+ }
90
+
91
+ export type FetchResponse = {
92
+ status: number,
93
+ contentType: string | null,
94
+ readableStream?: ReadableStream | null
95
+ }
96
+
97
+ export type Fetcher = (url: string, contentType?: string) => Promise<FetchResponse>;
98
+
99
+ export const fetchFetcher: Fetcher = async (url, contentType) => {
100
+ const res = await fetch(url, {
101
+ headers: {
102
+ 'Content-Type': contentType || pathToMimeType(url),
103
+ 'user-agent': 'RealFaviconGenerator Favicon Checker'
104
+ }
105
+ });
106
+
107
+ return {
108
+ status: res.status,
109
+ contentType: res.headers.get('Content-Type') || null,
110
+ readableStream: res.body
111
+ }
112
+ }
113
+
114
+ export type DesktopFaviconReport = {
115
+ messages: CheckerMessage[],
116
+ icon: string | null,
117
+ }
118
+
119
+ export type TouchIconTitleReport = {
120
+ messages: CheckerMessage[],
121
+ appTitle?: string
122
+ }
123
+
124
+ export type TouchIconIconReport = {
125
+ messages: CheckerMessage[],
126
+ touchIcon: string | null,
127
+ }
128
+
129
+ export type WebManifestReport = {
130
+ messages: CheckerMessage[],
131
+ name?: string,
132
+ shortName?: string,
133
+ backgroundColor?: string,
134
+ themeColor?: string,
135
+ icon: string | null
136
+ }
137
+
138
+ export type FaviconReport = {
139
+ desktop: DesktopFaviconReport,
140
+ touchIcon: TouchIconReport,
141
+ webAppManifest: WebManifestReport
142
+ }
143
+
144
+ export type TouchIconReport = TouchIconIconReport & TouchIconTitleReport;
145
+
146
+ export const pathToMimeType = (path: string): string => {
147
+ const ext = path.split('.').pop();
148
+ switch (ext) {
149
+ case 'png':
150
+ return 'image/png';
151
+ case 'svg':
152
+ return 'image/svg+xml';
153
+ case 'ico':
154
+ return 'image/x-icon';
155
+ case 'jpg':
156
+ case 'jpeg':
157
+ return 'image/jpeg';
158
+ default:
159
+ return 'application/octet-stream';
160
+ }
161
+ }
@@ -0,0 +1,12 @@
1
+ import { CheckerMessage, FetchResponse, Fetcher } from ".";
2
+ import { parse } from 'node-html-parser'
3
+
4
+ export const testFetcher = (database: { [url: string]: FetchResponse }): Fetcher => {
5
+ return async (url, contentType) => {
6
+ const res = database[url];
7
+ if (!res) {
8
+ return { status: 404, contentType: contentType || 'application/octet-stream' };
9
+ }
10
+ return res;
11
+ }
12
+ }
@@ -0,0 +1,219 @@
1
+ import { parse } from 'node-html-parser'
2
+ import { CheckerMessage, CheckerStatus, FetchResponse, MessageId } from ".";
3
+ import { checkTouchIcon, checkTouchIconIcon, checkTouchIconTitle, getDuplicatedSizes } from "./touch-icon";
4
+ import { testFetcher } from './test-helper';
5
+ import { bufferToDataUrl, filePathToReadableStream, readableStreamToBuffer } from './helper';
6
+
7
+ type TestOutput = {
8
+ messages: Pick<CheckerMessage, 'id' | 'status'>[],
9
+ appTitle?: string,
10
+ touchIcon?: string | null
11
+ }
12
+
13
+ const runCheckTouchIconTitleTest = async (
14
+ headFragment: string | null,
15
+ output: TestOutput,
16
+ fetchDatabase: { [url: string]: FetchResponse } = {}
17
+ ) => {
18
+ const root = headFragment ? parse(headFragment) : null;
19
+ const result = await checkTouchIconTitle('https://example.com/', root, testFetcher(fetchDatabase));
20
+ const filteredMessages = result.messages.map(m => ({ status: m.status, id: m.id }));
21
+ expect({
22
+ messages: filteredMessages,
23
+ appTitle: result.appTitle
24
+ }).toEqual(output);
25
+ }
26
+
27
+ test('checkTouchIconTitle - noHead', async () => {
28
+ await runCheckTouchIconTitleTest(null, { messages: [{
29
+ status: CheckerStatus.Error,
30
+ id: MessageId.noHead,
31
+ }]});
32
+ })
33
+
34
+ test('checkTouchIconTitle - noTouchWebAppTitle', async () => {
35
+ await runCheckTouchIconTitleTest('<title>Some text</title>', { messages: [{
36
+ status: CheckerStatus.Warning,
37
+ id: MessageId.noTouchWebAppTitle,
38
+ }]});
39
+ })
40
+
41
+ test('checkTouchIconTitle - multipleTouchWebAppTitles', async () => {
42
+ await runCheckTouchIconTitleTest(`
43
+ <meta name="apple-mobile-web-app-title" content="First title">
44
+ <meta name="apple-mobile-web-app-title" content="Second title">
45
+ `, { messages: [{
46
+ status: CheckerStatus.Error,
47
+ id: MessageId.multipleTouchWebAppTitles,
48
+ }]});
49
+ })
50
+
51
+ test('checkTouchIconTitle - touchWebAppTitleDeclared', async () => {
52
+ await runCheckTouchIconTitleTest(`
53
+ <meta name="apple-mobile-web-app-title" content="The App Name">
54
+ `, { messages: [{
55
+ status: CheckerStatus.Ok,
56
+ id: MessageId.touchWebAppTitleDeclared,
57
+ }], appTitle: 'The App Name' });
58
+ })
59
+
60
+ const runCheckTouchIconTest = async (
61
+ headFragment: string | null,
62
+ output: TestOutput,
63
+ fetchDatabase: { [url: string]: FetchResponse } = {}
64
+ ) => {
65
+ const root = headFragment ? parse(headFragment) : null;
66
+ const result = await checkTouchIconIcon('https://example.com/', root, testFetcher(fetchDatabase));
67
+ const filteredMessages = result.messages.map(m => ({ status: m.status, id: m.id }));
68
+ expect({
69
+ messages: filteredMessages,
70
+ touchIcon: result.touchIcon
71
+ }).toEqual({
72
+ ...output,
73
+ touchIcon: output.touchIcon || null
74
+ });
75
+ }
76
+
77
+ test('checkTouchIcon - noHead', async () => {
78
+ await runCheckTouchIconTest(null, { messages: [{
79
+ status: CheckerStatus.Error,
80
+ id: MessageId.noHead,
81
+ }]});
82
+ })
83
+
84
+ test('checkTouchIcon - noTouchIcon', async () => {
85
+ await runCheckTouchIconTest('<title>Some text</title>', { messages: [{
86
+ status: CheckerStatus.Error,
87
+ id: MessageId.noTouchIcon,
88
+ }]});
89
+ })
90
+
91
+ test('checkTouchIcon - touchIconWithSize', async () => {
92
+ await runCheckTouchIconTest(`
93
+ <link rel="apple-touch-icon" sizes="152x152" href="some-other-icon.png">
94
+ `, { messages: [{
95
+ status: CheckerStatus.Ok,
96
+ id: MessageId.touchIconDeclared,
97
+ }, {
98
+ status: CheckerStatus.Warning,
99
+ id: MessageId.touchIconWithSize,
100
+ }]}, {
101
+ 'https://example.com/some-other-icon.png': {
102
+ status: 200,
103
+ contentType: 'image/png',
104
+ readableStream: null
105
+ }
106
+ });
107
+ })
108
+
109
+ test('checkTouchIcon - multipleTouchIcon - no size', async () => {
110
+ await runCheckTouchIconTest(`
111
+ <link rel="apple-touch-icon" href="some-icon.png">
112
+ <link rel="apple-touch-icon" href="some-other-icon.png">
113
+ `, { messages: [{
114
+ status: CheckerStatus.Ok,
115
+ id: MessageId.touchIconDeclared,
116
+ }, {
117
+ status: CheckerStatus.Error,
118
+ id: MessageId.duplicatedTouchIconSizes,
119
+ }]}, {
120
+ 'https://example.com/some-icon.png': {
121
+ status: 200,
122
+ contentType: 'image/png',
123
+ readableStream: null
124
+ },
125
+ 'https://example.com/some-other-icon.png': {
126
+ status: 200,
127
+ contentType: 'image/png',
128
+ readableStream: null
129
+ }
130
+ });
131
+ })
132
+
133
+ test('checkTouchIcon - multipleTouchIcon - specific size', async () => {
134
+ await runCheckTouchIconTest(`
135
+ <link rel="apple-touch-icon" sizes="180x180" href="some-icon.png">
136
+ <link rel="apple-touch-icon" sizes="180x180" href="some-other-icon.png">
137
+ `, { messages: [{
138
+ status: CheckerStatus.Ok,
139
+ id: MessageId.touchIconDeclared,
140
+ }, {
141
+ status: CheckerStatus.Warning,
142
+ id: MessageId.touchIconWithSize,
143
+ }, {
144
+ status: CheckerStatus.Error,
145
+ id: MessageId.duplicatedTouchIconSizes,
146
+ }]}, {
147
+ 'https://example.com/some-icon.png': {
148
+ status: 200,
149
+ contentType: 'image/png',
150
+ readableStream: null
151
+ },
152
+ 'https://example.com/some-other-icon.png': {
153
+ status: 200,
154
+ contentType: 'image/png',
155
+ readableStream: null
156
+ }
157
+ });
158
+ })
159
+
160
+ test('checkTouchIcon - touchIconWithSize', async () => {
161
+ await runCheckTouchIconTest(`
162
+ <link rel="apple-touch-icon" sizes="180x180" href="some-other-icon.png">
163
+ `, { messages: [{
164
+ status: CheckerStatus.Ok,
165
+ id: MessageId.touchIconDeclared,
166
+ }, {
167
+ status: CheckerStatus.Warning,
168
+ id: MessageId.touchIconWithSize,
169
+ }]}, {
170
+ 'https://example.com/some-other-icon.png': {
171
+ status: 200,
172
+ contentType: 'image/png',
173
+ readableStream: null
174
+ }
175
+ });
176
+ })
177
+
178
+ const testIcon = './fixtures/180x180.png';
179
+
180
+ test('checkTouchIcon - Regular case', async () => {
181
+ await runCheckTouchIconTest(`
182
+ <link rel="apple-touch-icon" href="some-other-icon.png">
183
+ `, { messages: [{
184
+ status: CheckerStatus.Ok,
185
+ id: MessageId.touchIconDeclared,
186
+ }, {
187
+ status: CheckerStatus.Ok,
188
+ id: MessageId.touchIconDownloadable,
189
+ },{
190
+ status: CheckerStatus.Ok,
191
+ id: MessageId.touchIconSquare
192
+ }], touchIcon: bufferToDataUrl(await readableStreamToBuffer(await filePathToReadableStream(testIcon)), 'image/png')
193
+ }, {
194
+ 'https://example.com/some-other-icon.png': {
195
+ status: 200,
196
+ contentType: 'image/png',
197
+ readableStream: await filePathToReadableStream(testIcon)
198
+ }
199
+ });
200
+ })
201
+
202
+
203
+
204
+ test('getDuplicatedSizes', () => {
205
+ // No duplicates
206
+ expect(getDuplicatedSizes([])).toEqual([]);
207
+ expect(getDuplicatedSizes([ undefined ])).toEqual([]);
208
+ expect(getDuplicatedSizes([ '180x180' ])).toEqual([]);
209
+ expect(getDuplicatedSizes([ undefined, '180x180' ])).toEqual([]);
210
+
211
+ // Duplicates
212
+ expect(getDuplicatedSizes([ '152x152', '180x180', '180x180' ])).toEqual([ '180x180' ]);
213
+ expect(getDuplicatedSizes([ undefined, '180x180', undefined, undefined ])).toEqual([ undefined ]);
214
+ expect(getDuplicatedSizes([
215
+ '152x152', '180x180', '152x152', undefined, '152x152', undefined
216
+ ])).toEqual([
217
+ '152x152', undefined
218
+ ]);
219
+ })