@jjlmoya/utils-developer 1.31.0 → 1.33.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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/en.ts +1 -1
  3. package/src/category/i18n/es.ts +1 -1
  4. package/src/category/index.ts +2 -1
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +1 -0
  7. package/src/pages/[locale]/[slug].astro +3 -3
  8. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  9. package/src/tests/category_seo_quality.test.ts +76 -0
  10. package/src/tests/locale_completeness.test.ts +1 -1
  11. package/src/tests/seo_translation_completeness.test.ts +69 -0
  12. package/src/tests/seo_wellformed_export.test.ts +65 -0
  13. package/src/tests/tool_validation.test.ts +12 -7
  14. package/src/tool/dualOsIconPreview/assets/day-check.webp +0 -0
  15. package/src/tool/dualOsIconPreview/assets/fast-task.webp +0 -0
  16. package/src/tool/dualOsIconPreview/assets/fortune-cookie.webp +0 -0
  17. package/src/tool/dualOsIconPreview/assets/lexi-crash.webp +0 -0
  18. package/src/tool/dualOsIconPreview/assets/pizzametrics.webp +0 -0
  19. package/src/tool/dualOsIconPreview/assets/sauce-lab.webp +0 -0
  20. package/src/tool/dualOsIconPreview/assets/vesp.webp +0 -0
  21. package/src/tool/dualOsIconPreview/bibliography.astro +6 -0
  22. package/src/tool/dualOsIconPreview/bibliography.ts +7 -0
  23. package/src/tool/dualOsIconPreview/component.astro +117 -0
  24. package/src/tool/dualOsIconPreview/controller.ts +157 -0
  25. package/src/tool/dualOsIconPreview/dom-views.ts +82 -0
  26. package/src/tool/dualOsIconPreview/dual-ios-android-app-icon-preview.css +861 -0
  27. package/src/tool/dualOsIconPreview/entry.ts +31 -0
  28. package/src/tool/dualOsIconPreview/evaluator.ts +20 -0
  29. package/src/tool/dualOsIconPreview/i18n/de.ts +56 -0
  30. package/src/tool/dualOsIconPreview/i18n/en.ts +46 -0
  31. package/src/tool/dualOsIconPreview/i18n/es.ts +56 -0
  32. package/src/tool/dualOsIconPreview/i18n/fr.ts +56 -0
  33. package/src/tool/dualOsIconPreview/i18n/id.ts +56 -0
  34. package/src/tool/dualOsIconPreview/i18n/it.ts +56 -0
  35. package/src/tool/dualOsIconPreview/i18n/ja.ts +56 -0
  36. package/src/tool/dualOsIconPreview/i18n/ko.ts +56 -0
  37. package/src/tool/dualOsIconPreview/i18n/nl.ts +56 -0
  38. package/src/tool/dualOsIconPreview/i18n/pl.ts +56 -0
  39. package/src/tool/dualOsIconPreview/i18n/pt.ts +56 -0
  40. package/src/tool/dualOsIconPreview/i18n/ru.ts +56 -0
  41. package/src/tool/dualOsIconPreview/i18n/sv.ts +56 -0
  42. package/src/tool/dualOsIconPreview/i18n/tr.ts +43 -0
  43. package/src/tool/dualOsIconPreview/i18n/zh.ts +43 -0
  44. package/src/tool/dualOsIconPreview/index.ts +11 -0
  45. package/src/tool/dualOsIconPreview/logic.test.ts +63 -0
  46. package/src/tool/dualOsIconPreview/logic.ts +135 -0
  47. package/src/tool/dualOsIconPreview/seo.astro +15 -0
  48. package/src/tool/dualOsIconPreview/storage.ts +18 -0
  49. package/src/tool/dualOsIconPreview/ui.ts +52 -0
  50. package/src/tool/promoteThisWebsite/i18n/pl.ts +1 -1
  51. package/src/tool/promoteThisWebsite/i18n/ru.ts +1 -1
  52. package/src/tool/promoteThisWebsite/i18n/tr.ts +1 -1
  53. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-developer",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -27,7 +27,7 @@ export const content: CategoryLocaleContent = {
27
27
  },
28
28
  {
29
29
  type: 'tip',
30
- title: 'Privacy guaranteed',
30
+ title: 'Local privacy',
31
31
  html: 'All tools process data locally in your browser. No code, data or files are sent to any external server.',
32
32
  },
33
33
  ],
@@ -27,7 +27,7 @@ export const content: CategoryLocaleContent = {
27
27
  },
28
28
  {
29
29
  type: 'tip',
30
- title: 'Privacidad garantizada',
30
+ title: 'Privacidad local',
31
31
  html: 'Todas las herramientas procesan los datos localmente en tu navegador. Ningún código, dato ni archivo se envía a ningún servidor externo.',
32
32
  },
33
33
  ],
@@ -22,10 +22,11 @@ import { urlCleaner } from '../tool/urlCleaner/entry';
22
22
  import { serpPixelSimulator } from '../tool/serpPixelSimulator/entry';
23
23
  import { jwtDecoder } from '../tool/jwtDecoder/entry';
24
24
  import { visualCssGridFlexboxGenerator } from '../tool/visualCssGridFlexboxGenerator/entry';
25
+ import { dualOsIconPreview } from '../tool/dualOsIconPreview/entry';
25
26
 
26
27
  export const developerCategory: DeveloperCategoryEntry = {
27
28
  icon: 'mdi:code-tags',
28
- tools: [jsonFormatter, svgToCss, aspectRatio, placeholderGenerator, urlEncoderDecoder, duplicateCssRemover, cssToInlineConverter, cssSpecificityCalculator, cronGenerator, keycode, llmCostCalculator, musicalTypography, mobileMockupGenerator, hashGenerator, promptLibrary, colorConverter, readabilityCalculator, svgSanitizer, utmGenerator, urlCleaner, serpPixelSimulator, jwtDecoder, visualCssGridFlexboxGenerator],
29
+ tools: [jsonFormatter, svgToCss, aspectRatio, placeholderGenerator, urlEncoderDecoder, duplicateCssRemover, cssToInlineConverter, cssSpecificityCalculator, cronGenerator, keycode, llmCostCalculator, musicalTypography, mobileMockupGenerator, hashGenerator, promptLibrary, colorConverter, readabilityCalculator, svgSanitizer, utmGenerator, urlCleaner, serpPixelSimulator, jwtDecoder, visualCssGridFlexboxGenerator, dualOsIconPreview],
29
30
  i18n: {
30
31
  de: () => import('./i18n/de').then((m) => m.content),
31
32
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -56,6 +56,8 @@ export { cssBoxShadowGenerator } from './tool/cssBoxShadowGenerator/entry';
56
56
  export type { CssBoxShadowGeneratorLocaleContent } from './tool/cssBoxShadowGenerator/entry';
57
57
  export { promoteThisWebsite } from './tool/promoteThisWebsite/entry';
58
58
  export type { PromoteThisWebsiteLocaleContent } from './tool/promoteThisWebsite/entry';
59
+ export { dualOsIconPreview } from './tool/dualOsIconPreview/entry';
60
+ export type { DualOsIconPreviewLocaleContent } from './tool/dualOsIconPreview/entry';
59
61
  export { developerCategory } from './category';
60
62
  import { aspectRatio } from './tool/aspectRatio/entry';
61
63
  import { calculadoraTiempoDatos } from './tool/calculadoraTiempoDatos/entry';
@@ -86,4 +88,5 @@ import { jwtDecoder } from './tool/jwtDecoder/entry';
86
88
  import { visualCssGridFlexboxGenerator } from './tool/visualCssGridFlexboxGenerator/entry';
87
89
  import { cssBoxShadowGenerator } from './tool/cssBoxShadowGenerator/entry';
88
90
  import { promoteThisWebsite } from './tool/promoteThisWebsite/entry';
89
- export const ALL_ENTRIES = [aspectRatio, calculadoraTiempoDatos, colorConverter, conversorExcelCsvHtml, cronGenerator, cssSpecificityCalculator, cssToInlineConverter, duplicateCssRemover, generadorSecurityTxt, hashGenerator, inspectorCertificadosSsl, jsonFormatter, keycode, llmCostCalculator, mobileMockupGenerator, musicalTypography, placeholderGenerator, promptLibrary, readabilityCalculator, svgSanitizer, svgToCss, urlCleaner, urlEncoderDecoder, utmGenerator, serpPixelSimulator, jwtDecoder, visualCssGridFlexboxGenerator, cssBoxShadowGenerator, promoteThisWebsite];
91
+ import { dualOsIconPreview } from './tool/dualOsIconPreview/entry';
92
+ export const ALL_ENTRIES = [aspectRatio, calculadoraTiempoDatos, colorConverter, conversorExcelCsvHtml, cronGenerator, cssSpecificityCalculator, cssToInlineConverter, duplicateCssRemover, generadorSecurityTxt, hashGenerator, inspectorCertificadosSsl, jsonFormatter, keycode, llmCostCalculator, mobileMockupGenerator, musicalTypography, placeholderGenerator, promptLibrary, readabilityCalculator, svgSanitizer, svgToCss, urlCleaner, urlEncoderDecoder, utmGenerator, serpPixelSimulator, jwtDecoder, visualCssGridFlexboxGenerator, cssBoxShadowGenerator, promoteThisWebsite, dualOsIconPreview];
package/src/index.ts CHANGED
@@ -71,3 +71,4 @@ export { JWT_DECODER_TOOL } from './tool/jwtDecoder/index';
71
71
 
72
72
  export { VISUAL_CSS_GRID_FLEXBOX_GENERATOR_TOOL } from './tool/visualCssGridFlexboxGenerator/index';
73
73
  export { CSS_BOX_SHADOW_GENERATOR_TOOL } from './tool/cssBoxShadowGenerator/index';
74
+ export { DUAL_OS_ICON_PREVIEW_TOOL } from './tool/dualOsIconPreview/index';
@@ -132,12 +132,12 @@ const titleBase = words.slice(1).join(" ") || "";
132
132
  <SEORenderer content={seoContent} />
133
133
  </section>
134
134
 
135
- <section class="section-faq">
136
- <FAQSection items={content.faq} title={content.ui.faqTitle} inLanguage={locale} />
135
+ <section class="section-faq">
136
+ <FAQSection items={content.faq} title={content.faqTitle} inLanguage={locale} />
137
137
  </section>
138
138
 
139
139
  <section class="section-bibliography">
140
- <Bibliography links={content.bibliography} title={content.ui.bibliographyTitle} />
140
+ <Bibliography links={content.bibliography} title={content.bibliographyTitle} />
141
141
  </section>
142
142
  </div>
143
143
  </PreviewLayout>
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CategoryLocaleContent } from '../types';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ] as const;
7
+ const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
8
+
9
+ const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
10
+ string,
11
+ { content: CategoryLocaleContent }
12
+ >;
13
+
14
+ function textFrom(value: unknown): string {
15
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
16
+ if (Array.isArray(value)) return value.map(textFrom).join(' ');
17
+ if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
18
+ return '';
19
+ }
20
+
21
+ const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
22
+
23
+ function getContents(): Map<string, CategoryLocaleContent> {
24
+ return new Map(
25
+ Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
26
+ );
27
+ }
28
+
29
+ function validateContent(locale: string, content: CategoryLocaleContent): string[] {
30
+ const failures: string[] = [];
31
+ const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
32
+ const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
33
+ const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
34
+ check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
35
+ check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
36
+ check(content.seo.length >= 2, 'SEO needs at least two sections');
37
+
38
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
39
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
40
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
41
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
42
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
43
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
44
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
45
+
46
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
47
+ return failures;
48
+ }
49
+
50
+ describe('Category SEO quality contract', () => {
51
+ it('has one content file for every configured locale', () => {
52
+ const contents = getContents();
53
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
54
+ });
55
+
56
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
57
+ const contents = getContents();
58
+ const english = contents.get('en');
59
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
60
+
61
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
62
+ const content = contents.get(locale);
63
+ return content ? validateContent(locale, content) : [];
64
+ });
65
+
66
+ const englishShape = english?.seo.map((section) => section.type);
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
70
+ failures.push(`${locale}: SEO section types must match English`);
71
+ }
72
+ }
73
+
74
+ expect(failures, 'category SEO quality failures').toEqual([]);
75
+ }, 30000);
76
+ });
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all 28 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(29);
39
+ expect(ALL_TOOLS.length).toBe(30);
40
40
  });
41
41
  });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findSeoAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findSeoAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'seo.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const seoFiles = findSeoAstroFiles(toolDir);
24
+
25
+ describe('SEO Component Wellformed Export', () => {
26
+ it('found tool seo.astro components', () => {
27
+ expect(seoFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ seoFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSeoRenderer =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('SEORenderer') || content.includes('SEOArticle'));
39
+
40
+ const acquiresDynamicContent =
41
+ content.includes('.i18n') ||
42
+ content.includes('loader') ||
43
+ content.includes('await loader') ||
44
+ content.includes('.seo');
45
+
46
+ const isBrokenPattern =
47
+ /sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
48
+
49
+ expect(
50
+ usesSeoRenderer,
51
+ `File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
52
+ ).toBe(true);
53
+
54
+ expect(
55
+ isBrokenPattern,
56
+ `File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
57
+ ).toBe(false);
58
+
59
+ expect(
60
+ acquiresDynamicContent,
61
+ `File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
62
+ ).toBe(true);
63
+ });
64
+ });
65
+ });
@@ -1,17 +1,22 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
- import { developerCategory } from '../data';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { ALL_ENTRIES } from '../entries';
4
+ import { developerCategory } from '../data';
4
5
 
5
6
  describe('Tool Validation Suite', () => {
6
7
  describe('Library Registration', () => {
7
8
  it('should have 28 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(29);
9
+ expect(ALL_TOOLS.length).toBe(30);
9
10
  });
10
11
 
11
- it('developerCategory should be defined', () => {
12
- expect(developerCategory).toBeDefined();
13
- expect(developerCategory.i18n).toBeDefined();
14
- });
12
+ it('developerCategory should be defined', () => {
13
+ expect(developerCategory).toBeDefined();
14
+ expect(developerCategory.i18n).toBeDefined();
15
+ });
16
+
17
+ it('ALL_ENTRIES should contain every tool entry', () => {
18
+ expect(ALL_ENTRIES.map((entry) => entry.id).sort()).toEqual(ALL_TOOLS.map((tool) => tool.entry.id).sort());
19
+ });
15
20
  });
16
21
  });
17
22
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,7 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'App icons', url: 'https://developer.apple.com/design/human-interface-guidelines/app-icons/' },
5
+ { name: '앱 아이콘 (App icons)', url: 'https://developer.apple.com/kr/design/human-interface-guidelines/app-icons' },
6
+ { name: 'Design adaptive icons', url: 'https://developer.android.com/develop/ui/compose/system/icon_design_adaptive' },
7
+ ];
@@ -0,0 +1,117 @@
1
+ ---
2
+ import type { DualOsIconPreviewUI } from './ui';
3
+ import dayCheckIcon from './assets/day-check.webp';
4
+ import fastTaskIcon from './assets/fast-task.webp';
5
+ import fortuneCookieIcon from './assets/fortune-cookie.webp';
6
+ import lexiCrashIcon from './assets/lexi-crash.webp';
7
+ import pizzaMetricsIcon from './assets/pizzametrics.webp';
8
+ import sauceLabIcon from './assets/sauce-lab.webp';
9
+ import vespIcon from './assets/vesp.webp';
10
+
11
+ interface Props {
12
+ ui: DualOsIconPreviewUI;
13
+ }
14
+
15
+ const { ui } = Astro.props;
16
+
17
+ const appCatalog = [
18
+ { name: 'Day Check', src: dayCheckIcon.src },
19
+ { name: 'Fast Task', src: fastTaskIcon.src },
20
+ { name: 'Fortune Cookie', src: fortuneCookieIcon.src },
21
+ { name: 'LexiCrash', src: lexiCrashIcon.src },
22
+ { name: 'Pizzametrics', src: pizzaMetricsIcon.src },
23
+ { name: 'SauceLab', src: sauceLabIcon.src },
24
+ { name: 'VESP', src: vespIcon.src },
25
+ ];
26
+
27
+ const iosApps = [...appCatalog, ...appCatalog.slice(0, 4)];
28
+ const androidApps = [...appCatalog, ...appCatalog.slice(0, 3)];
29
+ const dockApps = appCatalog.slice(0, 4);
30
+ ---
31
+
32
+ <div class="dual-os-icon-preview" data-icon-preview data-ios-appearance="light" data-android-shape="squircle" data-android-themed="false">
33
+ <section class="preview-controls" aria-label="Icon audit controls">
34
+ <div class="control-block control-upload">
35
+ <div class="control-heading"><span class="step-number">01</span><div><strong>{ui.labelUpload}</strong><small>{ui.uploadHint}</small></div></div>
36
+ <input class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" data-logo-input aria-label={ui.labelUpload} />
37
+ <button class="upload-button" type="button" data-upload-action>
38
+ <span class="upload-glyph" aria-hidden="true">+</span>
39
+ <span><strong>{ui.uploadAction}</strong><small>{ui.dropHint}</small></span>
40
+ </button>
41
+ <p class="field-error" data-file-error role="status"></p>
42
+ </div>
43
+
44
+ <div class="control-block">
45
+ <div class="control-heading"><span class="step-number">02</span><div><strong>{ui.labelAppName}</strong><small>{ui.appNamePlaceholder}</small></div></div>
46
+ <input class="text-input" data-app-name-input type="text" maxlength="24" autocomplete="off" aria-label={ui.labelAppName} />
47
+ <label class="color-row"><span>{ui.labelBrandColor}</span><input data-brand-input type="color" value="#f4b942" /></label>
48
+ </div>
49
+
50
+ <div class="control-block">
51
+ <div class="control-heading"><span class="step-number">03</span><div><strong>{ui.labelIosAppearance}</strong><small>{ui.iosAppearanceHint}</small></div></div>
52
+ <div class="choice-grid" role="group" aria-label={ui.labelIosAppearance}>
53
+ <button type="button" data-ios-value="default">{ui.iosDefault}</button><button type="button" data-ios-value="dark">{ui.iosDark}</button><button type="button" data-ios-value="clear">{ui.iosClear}</button><button type="button" data-ios-value="tinted">{ui.iosTinted}</button>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="control-block">
58
+ <div class="control-heading"><span class="step-number">04</span><div><strong>{ui.labelAndroidShape}</strong><small>{ui.androidShapeHint}</small></div></div>
59
+ <div class="choice-grid" role="group" aria-label={ui.labelAndroidShape}>
60
+ <button type="button" data-android-value="circle">{ui.androidCircle}</button><button type="button" data-android-value="squircle">{ui.androidSquircle}</button><button type="button" data-android-value="rounded">{ui.androidRounded}</button><button type="button" data-android-value="teardrop">{ui.androidTeardrop}</button>
61
+ </div>
62
+ <label class="toggle-row"><input data-themed-input type="checkbox" /><span><strong>{ui.labelAndroidTheme}</strong><small>{ui.androidThemeHint}</small></span></label>
63
+ </div>
64
+ </section>
65
+
66
+ <section class="preview-stage" aria-live="polite">
67
+ <div class="stage-topline"><span class="stage-kicker">{ui.stageKicker}</span><span class="stage-note">{ui.stageNote}</span></div>
68
+ <div class="device-pair">
69
+ <article class="device-card device-ios">
70
+ <div class="device-shell"><div class="device-island"></div><div class="device-screen">
71
+ <div class="system-status"><span>9:41</span><span>|||</span></div>
72
+ <div class="home-wallpaper ios-wallpaper">
73
+ <div class="wallpaper-glow glow-one"></div><div class="wallpaper-glow glow-two"></div>
74
+ <div class="home-grid ios-grid">
75
+ <div class="launcher-item target-item"><span class="app-tile audited-icon ios-icon launcher-icon"><img data-logo-image alt="" /><span data-empty-logo>{ui.emptyLogo}</span></span><small data-app-name></small></div>
76
+ {iosApps.map((app) => <div class="launcher-item"><span class="app-tile ios-icon launcher-icon"><img src={app.src} alt="" /></span><small>{app.name}</small></div>)}
77
+ </div>
78
+ <div class="device-dock">{dockApps.map((app) => <span class="app-tile ios-icon launcher-icon"><img src={app.src} alt="" /></span>)}</div>
79
+ </div>
80
+ <div class="home-indicator"></div>
81
+ </div></div>
82
+ <div class="device-caption"><strong><span class="platform-dot dot-ios"></span>{ui.iosDeviceLabel}</strong><span>{ui.iosHomeLabel}</span></div>
83
+ </article>
84
+
85
+ <article class="device-card device-android">
86
+ <div class="device-shell"><div class="device-camera"></div><div class="device-screen">
87
+ <div class="system-status"><span>9:41</span><span>|||</span></div>
88
+ <div class="home-wallpaper android-wallpaper">
89
+ <div class="android-search"><span>G</span><small>Search</small><b>?</b></div>
90
+ <div class="home-grid android-grid">
91
+ {androidApps.slice(0, 4).map((app) => <div class="launcher-item"><span class="app-tile android-icon launcher-icon"><img src={app.src} alt="" /></span><small>{app.name}</small></div>)}
92
+ <div class="launcher-item target-item"><span class="app-tile audited-icon android-icon launcher-icon"><img data-logo-image alt="" /><span data-empty-logo>{ui.emptyLogo}</span></span><small data-app-name></small></div>
93
+ {androidApps.slice(4).map((app) => <div class="launcher-item"><span class="app-tile android-icon launcher-icon"><img src={app.src} alt="" /></span><small>{app.name}</small></div>)}
94
+ </div>
95
+ <div class="android-dock">{appCatalog.slice(0, 5).map((app) => <span class="app-tile android-icon launcher-icon"><img src={app.src} alt="" /></span>)}</div>
96
+ </div>
97
+ <div class="android-navigation"><span></span><span></span><span></span></div>
98
+ </div></div>
99
+ <div class="device-caption"><strong><span class="platform-dot dot-android"></span>{ui.androidDeviceLabel}</strong><span>{ui.androidHomeLabel}</span></div>
100
+ </article>
101
+ </div>
102
+
103
+ <section class="audit-report" data-audit-report data-audit-state="waiting">
104
+ <div class="audit-heading"><div><strong>{ui.auditTitle}</strong><small>{ui.auditHint}</small></div><span class="audit-score" data-audit-score>{ui.auditWaiting}</span></div>
105
+ <div class="audit-grid">
106
+ <div class="audit-metric"><small>{ui.auditFile}</small><strong data-audit-file>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditAspect}</small><strong data-audit-aspect>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditResolution}</small><strong data-audit-resolution>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditMargin}</small><strong data-audit-margin>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditIosMask}</small><strong data-audit-ios>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditAndroidZone}</small><strong data-audit-android>{ui.auditNotChecked}</strong></div><div class="audit-metric"><small>{ui.auditTransparency}</small><strong data-audit-transparency>{ui.auditNotChecked}</strong></div>
107
+ </div>
108
+ </section>
109
+ </section>
110
+ </div>
111
+
112
+ <script is:inline type="application/json" id="dual-os-icon-preview-ui" set:html={JSON.stringify(ui)}></script>
113
+ <script>
114
+ import { initDualOsIconPreview } from './controller';
115
+ const payload = document.querySelector('#dual-os-icon-preview-ui')?.textContent;
116
+ if (payload) initDualOsIconPreview({ ui: JSON.parse(payload) });
117
+ </script>