@jjlmoya/utils-developer 1.31.0 → 1.32.0
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/package.json +1 -1
- package/src/category/i18n/en.ts +1 -1
- package/src/category/i18n/es.ts +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -0
- package/src/index.ts +1 -0
- package/src/pages/[locale]/[slug].astro +3 -3
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +76 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/dualOsIconPreview/assets/day-check.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/fast-task.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/fortune-cookie.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/lexi-crash.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/pizzametrics.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/sauce-lab.webp +0 -0
- package/src/tool/dualOsIconPreview/assets/vesp.webp +0 -0
- package/src/tool/dualOsIconPreview/bibliography.astro +6 -0
- package/src/tool/dualOsIconPreview/bibliography.ts +7 -0
- package/src/tool/dualOsIconPreview/component.astro +117 -0
- package/src/tool/dualOsIconPreview/controller.ts +157 -0
- package/src/tool/dualOsIconPreview/dom-views.ts +82 -0
- package/src/tool/dualOsIconPreview/dual-ios-android-app-icon-preview.css +861 -0
- package/src/tool/dualOsIconPreview/entry.ts +31 -0
- package/src/tool/dualOsIconPreview/evaluator.ts +20 -0
- package/src/tool/dualOsIconPreview/i18n/de.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/en.ts +46 -0
- package/src/tool/dualOsIconPreview/i18n/es.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/fr.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/id.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/it.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/ja.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/ko.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/nl.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/pl.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/pt.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/ru.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/sv.ts +56 -0
- package/src/tool/dualOsIconPreview/i18n/tr.ts +43 -0
- package/src/tool/dualOsIconPreview/i18n/zh.ts +43 -0
- package/src/tool/dualOsIconPreview/index.ts +11 -0
- package/src/tool/dualOsIconPreview/logic.test.ts +63 -0
- package/src/tool/dualOsIconPreview/logic.ts +135 -0
- package/src/tool/dualOsIconPreview/seo.astro +15 -0
- package/src/tool/dualOsIconPreview/storage.ts +18 -0
- package/src/tool/dualOsIconPreview/ui.ts +52 -0
- package/src/tool/promoteThisWebsite/i18n/pl.ts +1 -1
- package/src/tool/promoteThisWebsite/i18n/ru.ts +1 -1
- package/src/tool/promoteThisWebsite/i18n/tr.ts +1 -1
- package/src/tools.ts +2 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { auditLogo, buildPreviewState, DEFAULT_SETTINGS, sanitiseSettings, type AndroidShape, type IconPreviewSettings, type IosAppearance, type LogoAudit, type LogoAuditMetrics } from './logic';
|
|
2
|
+
import { renderPreview } from './dom-views';
|
|
3
|
+
import { loadStoredSettings, saveStoredSettings } from './storage';
|
|
4
|
+
import type { DualOsIconPreviewUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface ControllerOptions {
|
|
7
|
+
ui: DualOsIconPreviewUI;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ControllerState {
|
|
11
|
+
settings: IconPreviewSettings;
|
|
12
|
+
logoUrl: string;
|
|
13
|
+
audit: LogoAudit | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getElement<T extends HTMLElement>(selector: string): T | null {
|
|
17
|
+
return document.querySelector<T>(selector);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readSettings(): IconPreviewSettings {
|
|
21
|
+
const stored = loadStoredSettings();
|
|
22
|
+
return sanitiseSettings({ ...DEFAULT_SETTINGS, ...stored });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function updateView(state: ControllerState, ui: DualOsIconPreviewUI): void {
|
|
26
|
+
renderPreview({ state: buildPreviewState(state.settings, Boolean(state.logoUrl), state.audit), ui, logoUrl: state.logoUrl });
|
|
27
|
+
saveStoredSettings(state.settings);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setPressed(selector: string, attribute: string, value: string): void {
|
|
31
|
+
document.querySelectorAll<HTMLButtonElement>(selector).forEach((button) => {
|
|
32
|
+
button.setAttribute('aria-pressed', String(button.getAttribute(attribute) === value));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bindSettingButtons(state: ControllerState, ui: DualOsIconPreviewUI): void {
|
|
37
|
+
document.querySelectorAll<HTMLButtonElement>('[data-ios-value]').forEach((button) => {
|
|
38
|
+
button.addEventListener('click', () => {
|
|
39
|
+
state.settings.iosAppearance = button.dataset.iosValue as IosAppearance;
|
|
40
|
+
setPressed('[data-ios-value]', 'data-ios-value', state.settings.iosAppearance);
|
|
41
|
+
updateView(state, ui);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
document.querySelectorAll<HTMLButtonElement>('[data-android-value]').forEach((button) => {
|
|
45
|
+
button.addEventListener('click', () => {
|
|
46
|
+
state.settings.androidShape = button.dataset.androidValue as AndroidShape;
|
|
47
|
+
setPressed('[data-android-value]', 'data-android-value', state.settings.androidShape);
|
|
48
|
+
updateView(state, ui);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bindFileInput(state: ControllerState, ui: DualOsIconPreviewUI): void {
|
|
54
|
+
const input = getElement<HTMLInputElement>('[data-logo-input]');
|
|
55
|
+
const error = getElement<HTMLElement>('[data-file-error]');
|
|
56
|
+
input?.addEventListener('change', () => {
|
|
57
|
+
const file = input.files?.[0];
|
|
58
|
+
if (!file) return;
|
|
59
|
+
if (!file.type.startsWith('image/')) {
|
|
60
|
+
if (error) error.textContent = ui.fileError;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
readLogoFile(file).then((logo) => {
|
|
64
|
+
state.logoUrl = logo.url;
|
|
65
|
+
state.audit = logo.audit;
|
|
66
|
+
if (error) error.textContent = '';
|
|
67
|
+
updateView(state, ui);
|
|
68
|
+
}).catch(() => { if (error) error.textContent = ui.fileError; });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readDataUrl(file: File): Promise<string> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const reader = new FileReader();
|
|
75
|
+
reader.addEventListener('load', () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('image-read')));
|
|
76
|
+
reader.addEventListener('error', () => reject(new Error('image-read')));
|
|
77
|
+
reader.readAsDataURL(file);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findOpaqueBounds(data: Uint8ClampedArray, width: number, height: number): LogoAuditMetrics {
|
|
82
|
+
let minX = width;
|
|
83
|
+
let minY = height;
|
|
84
|
+
let maxX = -1;
|
|
85
|
+
let maxY = -1;
|
|
86
|
+
let visiblePixels = 0;
|
|
87
|
+
for (let alphaIndex = 3; alphaIndex < data.length; alphaIndex += 4) {
|
|
88
|
+
if ((data[alphaIndex] ?? 0) <= 12) continue;
|
|
89
|
+
const pixel = (alphaIndex - 3) / 4;
|
|
90
|
+
const x = pixel % width;
|
|
91
|
+
const y = Math.floor(pixel / width);
|
|
92
|
+
minX = Math.min(minX, x);
|
|
93
|
+
minY = Math.min(minY, y);
|
|
94
|
+
maxX = Math.max(maxX, x);
|
|
95
|
+
maxY = Math.max(maxY, y);
|
|
96
|
+
visiblePixels += 1;
|
|
97
|
+
}
|
|
98
|
+
return { width, height, leftMargin: minX, rightMargin: maxX < 0 ? 0 : width - maxX - 1, topMargin: minY, bottomMargin: maxY < 0 ? 0 : height - maxY - 1, visiblePixels };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function measureLogo(url: string): Promise<LogoAuditMetrics> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const image = new Image();
|
|
104
|
+
image.addEventListener('load', () => {
|
|
105
|
+
const canvas = document.createElement('canvas');
|
|
106
|
+
canvas.width = image.naturalWidth;
|
|
107
|
+
canvas.height = image.naturalHeight;
|
|
108
|
+
const context = canvas.getContext('2d');
|
|
109
|
+
if (!context) { reject(new Error('canvas-unavailable')); return; }
|
|
110
|
+
context.drawImage(image, 0, 0);
|
|
111
|
+
resolve(findOpaqueBounds(context.getImageData(0, 0, canvas.width, canvas.height).data, canvas.width, canvas.height));
|
|
112
|
+
});
|
|
113
|
+
image.addEventListener('error', () => reject(new Error('image-load')));
|
|
114
|
+
image.src = url;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readLogoFile(file: File): Promise<{ url: string; audit: LogoAudit }> {
|
|
119
|
+
const url = await readDataUrl(file);
|
|
120
|
+
return { url, audit: auditLogo(await measureLogo(url)) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function bindTextInput(state: ControllerState, ui: DualOsIconPreviewUI): void {
|
|
124
|
+
getElement<HTMLInputElement>('[data-app-name-input]')?.addEventListener('input', (event) => {
|
|
125
|
+
state.settings.appName = (event.target as HTMLInputElement).value;
|
|
126
|
+
updateView(state, ui);
|
|
127
|
+
});
|
|
128
|
+
getElement<HTMLInputElement>('[data-brand-input]')?.addEventListener('input', (event) => {
|
|
129
|
+
state.settings.brandColor = (event.target as HTMLInputElement).value;
|
|
130
|
+
updateView(state, ui);
|
|
131
|
+
});
|
|
132
|
+
getElement<HTMLInputElement>('[data-themed-input]')?.addEventListener('change', (event) => {
|
|
133
|
+
state.settings.androidThemed = (event.target as HTMLInputElement).checked;
|
|
134
|
+
updateView(state, ui);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function syncInputs(settings: IconPreviewSettings): void {
|
|
139
|
+
const appName = getElement<HTMLInputElement>('[data-app-name-input]');
|
|
140
|
+
const brand = getElement<HTMLInputElement>('[data-brand-input]');
|
|
141
|
+
const themed = getElement<HTMLInputElement>('[data-themed-input]');
|
|
142
|
+
if (appName) appName.value = settings.appName;
|
|
143
|
+
if (brand) brand.value = settings.brandColor;
|
|
144
|
+
if (themed) themed.checked = settings.androidThemed;
|
|
145
|
+
setPressed('[data-ios-value]', 'data-ios-value', settings.iosAppearance);
|
|
146
|
+
setPressed('[data-android-value]', 'data-android-value', settings.androidShape);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function initDualOsIconPreview(options: ControllerOptions): void {
|
|
150
|
+
const state: ControllerState = { settings: readSettings(), logoUrl: '', audit: null };
|
|
151
|
+
syncInputs(state.settings);
|
|
152
|
+
bindSettingButtons(state, options.ui);
|
|
153
|
+
bindFileInput(state, options.ui);
|
|
154
|
+
bindTextInput(state, options.ui);
|
|
155
|
+
getElement<HTMLButtonElement>('[data-upload-action]')?.addEventListener('click', () => getElement<HTMLInputElement>('[data-logo-input]')?.click());
|
|
156
|
+
updateView(state, options.ui);
|
|
157
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { evaluatePreview } from './evaluator';
|
|
2
|
+
import { getIosSurface, type IconPreviewState, type LogoAudit } from './logic';
|
|
3
|
+
import type { DualOsIconPreviewUI } from './ui';
|
|
4
|
+
|
|
5
|
+
interface RenderPayload {
|
|
6
|
+
state: IconPreviewState;
|
|
7
|
+
ui: DualOsIconPreviewUI;
|
|
8
|
+
logoUrl: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function setText(selector: string, text: string): void {
|
|
12
|
+
document.querySelectorAll<HTMLElement>(selector).forEach((node) => { node.textContent = text; });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function setLogo(logoUrl: string, hasLogo: boolean): void {
|
|
16
|
+
document.querySelectorAll<HTMLImageElement>('[data-logo-image]').forEach((image) => {
|
|
17
|
+
image.src = logoUrl;
|
|
18
|
+
image.hidden = !hasLogo;
|
|
19
|
+
});
|
|
20
|
+
document.querySelectorAll<HTMLElement>('[data-empty-logo]').forEach((node) => { node.hidden = hasLogo; });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderBadges(payload: RenderPayload): void {
|
|
24
|
+
const target = document.querySelector<HTMLElement>('[data-preview-badges]');
|
|
25
|
+
if (!target) return;
|
|
26
|
+
target.replaceChildren(...evaluatePreview(payload.state, payload.ui).map((badge) => {
|
|
27
|
+
const node = document.createElement('span');
|
|
28
|
+
node.className = `preview-badge preview-badge-${badge.tone}`;
|
|
29
|
+
node.textContent = `${badge.label} / ${badge.detail}`;
|
|
30
|
+
return node;
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setAuditValue(selector: string, value: string, status: string | null): void {
|
|
35
|
+
const node = document.querySelector<HTMLElement>(selector);
|
|
36
|
+
if (!node) return;
|
|
37
|
+
node.textContent = value;
|
|
38
|
+
if (status) {
|
|
39
|
+
node.dataset.auditStatus = status;
|
|
40
|
+
} else {
|
|
41
|
+
delete node.dataset.auditStatus;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderAuditScore(audit: LogoAudit, ui: DualOsIconPreviewUI): void {
|
|
46
|
+
setAuditValue('[data-audit-score]', `${audit.score}/100`, audit.score === 100 ? 'pass' : 'review');
|
|
47
|
+
setAuditValue('[data-audit-resolution]', audit.resolution === 'pass' ? ui.statusPass : ui.statusReview, audit.resolution);
|
|
48
|
+
setAuditValue('[data-audit-ios]', audit.iosMask === 'pass' ? ui.statusPass : ui.statusReview, audit.iosMask);
|
|
49
|
+
setAuditValue('[data-audit-android]', audit.androidSafeZone === 'pass' ? ui.statusPass : ui.statusReview, audit.androidSafeZone);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderAuditMetrics(audit: LogoAudit, ui: DualOsIconPreviewUI): void {
|
|
53
|
+
setAuditValue('[data-audit-file]', audit.dimensions, null);
|
|
54
|
+
setAuditValue('[data-audit-aspect]', audit.aspect, audit.aspect === '1:1' ? 'pass' : 'review');
|
|
55
|
+
setAuditValue('[data-audit-margin]', `${Math.round(audit.minimumMargin * 100)}%`, audit.androidSafeZone);
|
|
56
|
+
setAuditValue('[data-audit-transparency]', audit.transparency === 'transparent' ? ui.auditTransparent : ui.auditFullBleed, null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderAudit(audit: LogoAudit | null, ui: DualOsIconPreviewUI): void {
|
|
60
|
+
const report = document.querySelector<HTMLElement>('[data-audit-report]');
|
|
61
|
+
if (!report) return;
|
|
62
|
+
report.dataset.auditState = audit ? 'ready' : 'waiting';
|
|
63
|
+
if (!audit) {
|
|
64
|
+
setAuditValue('[data-audit-score]', ui.auditWaiting, null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
renderAuditScore(audit, ui);
|
|
68
|
+
renderAuditMetrics(audit, ui);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderPreview(payload: RenderPayload): void {
|
|
72
|
+
const root = document.querySelector<HTMLElement>('[data-icon-preview]');
|
|
73
|
+
if (!root) return;
|
|
74
|
+
root.dataset.iosAppearance = getIosSurface(payload.state.iosAppearance);
|
|
75
|
+
root.dataset.androidShape = payload.state.androidShape;
|
|
76
|
+
root.dataset.androidThemed = String(payload.state.androidThemed);
|
|
77
|
+
root.style.setProperty('--n-brand', payload.state.brandColor);
|
|
78
|
+
setText('[data-app-name]', payload.state.appName || payload.ui.nameFallback);
|
|
79
|
+
setLogo(payload.logoUrl, payload.state.hasLogo);
|
|
80
|
+
renderBadges(payload);
|
|
81
|
+
renderAudit(payload.state.audit, payload.ui);
|
|
82
|
+
}
|