@jjlmoya/utils-social 1.20.0 → 1.22.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 (45) hide show
  1. package/package.json +18 -5
  2. package/scripts/postbuild.mjs +17 -0
  3. package/scripts/postinstall.mjs +2 -4
  4. package/src/components/ProductionBreadcrumb.astro +134 -0
  5. package/src/components/ProductionStructuredData.astro +46 -0
  6. package/src/components/ProductionWidget.astro +182 -0
  7. package/src/i18n/header-ui.ts +15 -0
  8. package/src/i18n/language-ui.ts +9 -0
  9. package/src/i18n/languages.ts +24 -0
  10. package/src/identity/brands.ts +5 -0
  11. package/src/layouts/ProductionCategoryPage.astro +184 -0
  12. package/src/layouts/ProductionPage.astro +218 -0
  13. package/src/layouts/ProductionUtilityPage.astro +283 -0
  14. package/src/mfe/assets.ts +39 -0
  15. package/src/mfe/category-ui.ts +34 -0
  16. package/src/mfe/manifest.ts +45 -0
  17. package/src/mfe/routes.ts +50 -0
  18. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  21. package/src/pages/index.astro +1 -2
  22. package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
  23. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  24. package/src/pages/utilidades/[slug].astro +90 -0
  25. package/src/pages/utilidades/categorias/[category].astro +38 -0
  26. package/src/tests/diacritics_density.test.ts +1 -1
  27. package/src/tests/inverted_punctuation.test.ts +1 -1
  28. package/src/tests/mfe_cache_contract.test.ts +14 -0
  29. package/src/tests/mfe_manifest.test.ts +28 -0
  30. package/src/tests/registry_contract.test.ts +67 -0
  31. package/src/tests/script_density.test.ts +1 -1
  32. package/src/tests/seo_length.test.ts +1 -0
  33. package/src/tests/seo_translation_completeness.test.ts +18 -12
  34. package/src/tests/translation_copy.test.ts +2 -2
  35. package/src/tool/redditFormatter/index.ts +1 -0
  36. package/src/tool/safeZones/index.ts +1 -0
  37. package/src/tool/socialImageResizer/index.ts +1 -0
  38. package/src/tool/socialValueCalculator/index.ts +1 -0
  39. package/src/tool/tinderPhotoOptimizer/index.ts +1 -0
  40. package/src/tool/youtubeThumbnail/index.ts +1 -0
  41. package/src/tool/youtubeThumbnailPreviewer/index.ts +1 -0
  42. package/src/types.ts +5 -7
  43. package/src/worker.ts +27 -0
  44. package/src/pages/[locale]/[slug].astro +0 -161
  45. package/src/pages/[locale].astro +0 -251
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createUtilityManifestResponse } from "../mfe/manifest";
3
+
4
+ describe("MFE utility manifest", () => {
5
+ it("returns a versioned installable manifest for the current utility", async () => {
6
+ const response = createUtilityManifestResponse({
7
+ title: "Card draw odds calculator",
8
+ description: "Calculate card draw probabilities.",
9
+ startUrl: "/en/utilities/categories/social/card-draw-odds-calculator/",
10
+ englishSlug: "card-draw-odds-calculator",
11
+ });
12
+ const manifest = await response.json() as {
13
+ name: string;
14
+ start_url: string;
15
+ scope: string;
16
+ icons: { src: string; sizes: string; type: string; purpose: string }[];
17
+ };
18
+
19
+ expect(response.headers.get("Content-Type")).toContain("application/manifest+json");
20
+ expect(response.headers.get("Cache-Control")).toContain("immutable");
21
+ expect(manifest.name).toBe("Card draw odds calculator");
22
+ expect(manifest.start_url).toBe(manifest.scope);
23
+ expect(manifest.icons[0]?.src).toContain("/_utilities/social/images/card-draw-odds-calculator.webp?version=");
24
+ expect(manifest.icons[0]?.sizes).toBe("512x512");
25
+ expect(manifest.icons[0]?.type).toBe("image/webp");
26
+ expect(manifest.icons[0]?.purpose).toBe("any");
27
+ });
28
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import { ALL_ENTRIES } from '../entries';
6
+ import { ALL_TOOLS } from '../tools';
7
+ import type { ToolDefinition } from '../types';
8
+
9
+ function isToolDefinition(value: unknown): value is ToolDefinition {
10
+ if (!value || typeof value !== 'object') return false;
11
+
12
+ const candidate = value as Record<string, unknown>;
13
+ return Boolean(
14
+ candidate.entry &&
15
+ typeof candidate.entry === 'object' &&
16
+ typeof candidate.Component === 'function' &&
17
+ typeof candidate.SEOComponent === 'function' &&
18
+ typeof candidate.BibliographyComponent === 'function',
19
+ );
20
+ }
21
+
22
+ const toolRoot = join(process.cwd(), 'src', 'tool');
23
+ const toolDirectories = readdirSync(toolRoot, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory() && existsSync(join(toolRoot, entry.name, 'index.ts')))
25
+ .map((entry) => entry.name)
26
+ .sort();
27
+
28
+ describe('Library registry contract', () => {
29
+ it('keeps ALL_ENTRIES and ALL_TOOLS synchronized by id', () => {
30
+ const entryIds = ALL_ENTRIES.map((entry) => entry.id);
31
+ const toolIds = ALL_TOOLS.map((tool) => tool.entry.id);
32
+
33
+ expect(new Set(entryIds).size).toBe(entryIds.length);
34
+ expect(new Set(toolIds).size).toBe(toolIds.length);
35
+ expect([...toolIds].sort()).toEqual([...entryIds].sort());
36
+ });
37
+
38
+ it('registers every tool runtime index in both public registries', async () => {
39
+ const failures: string[] = [];
40
+
41
+ for (const directory of toolDirectories) {
42
+ const runtimeModule = await import(
43
+ pathToFileURL(join(toolRoot, directory, 'index.ts')).href,
44
+ );
45
+ const definitions = [...new Set(Object.values(runtimeModule).filter(isToolDefinition))];
46
+
47
+ if (definitions.length !== 1) {
48
+ failures.push(`${directory}: expected exactly one ToolDefinition export, found ${definitions.length}`);
49
+ continue;
50
+ }
51
+
52
+ const [definition] = definitions;
53
+ if (!definition) {
54
+ failures.push(`${directory}: runtime definition is undefined`);
55
+ continue;
56
+ }
57
+ if (!ALL_TOOLS.some((tool) => tool.entry.id === definition.entry.id)) {
58
+ failures.push(`${directory}: runtime definition is missing from ALL_TOOLS`);
59
+ }
60
+ if (!ALL_ENTRIES.some((entry) => entry.id === definition.entry.id)) {
61
+ failures.push(`${directory}: runtime entry is missing from ALL_ENTRIES`);
62
+ }
63
+ }
64
+
65
+ expect(failures).toEqual([]);
66
+ });
67
+ });
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
73
73
 
74
74
  const content = await loader();
75
75
  const rule = SCRIPT_RULES[typedLocale];
76
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
76
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
77
77
  const letters = letterCount(text);
78
78
  const matches = scriptCount(text, typedLocale);
79
79
  const ratio = scriptRatio(text, typedLocale);
@@ -11,6 +11,7 @@ describe('SEO Content Length Validation', () => {
11
11
  Object.keys(entry.i18n).forEach((locale) => {
12
12
  it(`${locale}: SEO section should exist`, async () => {
13
13
  const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
+ if (!loader) return;
14
15
  const content = await loader();
15
16
  if (!content.seo) return;
16
17
  expect(Array.isArray(content.seo)).toBe(true);
@@ -28,23 +28,21 @@ function calculateSeoTextLength(seoSections: any[]): number {
28
28
  return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
29
  }
30
30
 
31
- function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): string | null {
32
32
  const isAsian = ASIAN_LOCALES.includes(locale);
33
- const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
33
+ const minLength = isAsian ? 120 : Math.max(240, Math.floor(enLen * 0.35));
34
34
  const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
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);
36
+ if (localeLen >= minLength) return null;
37
+ return `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`;
40
38
  }
41
39
 
42
- async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
- if (locale === 'en' || !loader) return;
40
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<string | null> {
41
+ if (locale === 'en' || !loader) return null;
44
42
  const content = await loader();
45
- if (!content.seo || !Array.isArray(content.seo)) return;
43
+ if (!content.seo || !Array.isArray(content.seo)) return null;
46
44
 
47
- checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
45
+ return checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
46
  }
49
47
 
50
48
  describe('SEO Translation Completeness & Laziness Audit', () => {
@@ -58,12 +56,20 @@ describe('SEO Translation Completeness & Laziness Audit', () => {
58
56
  if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
57
 
60
58
  const enSeoLength = calculateSeoTextLength(enContent.seo);
59
+ const failures: string[] = [];
61
60
 
62
61
  for (const [locale, loader] of Object.entries(entry.i18n)) {
63
- await auditSingleLocale(entry.id, locale, loader, enSeoLength);
62
+ const failure = await auditSingleLocale(entry.id, locale, loader, enSeoLength);
63
+ if (failure) failures.push(failure);
64
64
  }
65
+
66
+ expect(
67
+ failures,
68
+ failures.length > 0
69
+ ? `SEO translation completeness failures for "${entry.id}":\n${failures.map((failure, index) => `${index + 1}. ${failure}`).join('\n')}`
70
+ : undefined,
71
+ ).toEqual([]);
65
72
  });
66
73
  });
67
74
  });
68
75
  });
69
-
@@ -106,8 +106,8 @@ describe('Locales must not copy another locale wholesale', () => {
106
106
 
107
107
  for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
108
  for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
- const left = locales[leftIndex];
110
- const right = locales[rightIndex];
109
+ const left = locales[leftIndex]!;
110
+ const right = locales[rightIndex]!;
111
111
  const leftCorpus = corpora.get(left) ?? '';
112
112
  const rightCorpus = corpora.get(right) ?? '';
113
113
  const similarity = copySimilarity(leftCorpus, rightCorpus);
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { redditFormatter } from './entry';
2
3
  export * from './entry';
3
4
  export const REDDIT_FORMATTER_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { safeZones } from './entry';
2
3
  export * from './entry';
3
4
  export const SAFE_ZONES_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { socialImageResizer } from './entry';
2
3
  export * from './entry';
3
4
  export const SOCIAL_IMAGE_RESIZER_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { socialValueCalculator } from './entry';
2
3
  export * from './entry';
3
4
  export const SOCIAL_VALUE_CALCULATOR_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { tinderPhotoOptimizer } from './entry';
2
3
  export * from './entry';
3
4
  export const TINDER_PHOTO_OPTIMIZER_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { youtubeThumbnail } from './entry';
2
3
  export * from './entry';
3
4
  export const YOUTUBE_THUMBNAIL_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { youtubeThumbnailPreviewer } from './entry';
2
3
  export * from './entry';
3
4
  export const YOUTUBE_THUMBNAIL_PREVIEWER_TOOL: ToolDefinition = {
package/src/types.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import type { SEOSection } from '@jjlmoya/utils-shared';
2
+ import type { UtilityLocale } from '@jjlmoya/utils-shared/routing';
2
3
  import type { WithContext, Thing } from 'schema-dts';
3
4
 
4
5
  export type { SEOSection };
5
6
 
6
- export type KnownLocale =
7
- | 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
8
- | 'fr' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
9
- | 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
7
+ export type KnownLocale = UtilityLocale;
10
8
 
11
9
  export interface FAQItem {
12
10
  question: string;
@@ -23,7 +21,7 @@ export interface HowToStep {
23
21
  text: string;
24
22
  }
25
23
 
26
- export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
24
+ export interface ToolLocaleContent<TUI extends object = Record<string, string>> {
27
25
  slug: string;
28
26
  title: string;
29
27
  description: string;
@@ -46,7 +44,7 @@ export type LocaleLoader<T> = () => Promise<T>;
46
44
 
47
45
  export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
48
46
 
49
- export interface SocialToolEntry<TUI extends Record<string, string> = Record<string, string>> {
47
+ export interface SocialToolEntry<TUI extends object = Record<string, string>> {
50
48
  id: string;
51
49
  icons: {
52
50
  bg: string;
@@ -67,4 +65,4 @@ export interface ToolDefinition {
67
65
  SEOComponent: unknown;
68
66
  BibliographyComponent: unknown;
69
67
  }
70
-
68
+
package/src/worker.ts ADDED
@@ -0,0 +1,27 @@
1
+ interface UtilityMfeEnvironment {
2
+ ASSETS: { fetch(request: Request): Promise<Response> };
3
+ }
4
+
5
+ export const LONG_LIVED_ASSET_CACHE = "public, max-age=31536000, immutable";
6
+ export const SITEMAP_CACHE = "public, max-age=3600, s-maxage=3600, must-revalidate";
7
+
8
+ export const getCacheControl = (pathname: string): string | undefined => {
9
+ if (pathname.endsWith("/sitemap.xml")) return SITEMAP_CACHE;
10
+ if (pathname.endsWith("/manifest.json")) return LONG_LIVED_ASSET_CACHE;
11
+ if (pathname.startsWith("/_utilities/")) {
12
+ return LONG_LIVED_ASSET_CACHE;
13
+ }
14
+ return undefined;
15
+ };
16
+
17
+ export default {
18
+ async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
19
+ const response = await environment.ASSETS.fetch(request);
20
+ const cacheControl = getCacheControl(new URL(request.url).pathname);
21
+ if (!cacheControl) return response;
22
+
23
+ const headers = new Headers(response.headers);
24
+ headers.set("Cache-Control", cacheControl);
25
+ return new Response(response.body, { status: response.status, headers });
26
+ },
27
+ };
@@ -1,161 +0,0 @@
1
- ---
2
- import PreviewLayout from "../../layouts/PreviewLayout.astro";
3
- import PreviewNavSidebar from "../../components/PreviewNavSidebar.astro";
4
- import { ALL_TOOLS } from "../../index";
5
- import {
6
- UtilityHeader,
7
- FAQSection,
8
- Bibliography,
9
- SEORenderer,
10
- } from "@jjlmoya/utils-shared";
11
- import type { KnownLocale, ToolLocaleContent } from "../../types";
12
- import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
13
-
14
- export async function getStaticPaths() {
15
- const paths = [];
16
-
17
- for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const { default: Component } = await lazyComp();
19
- const localeEntries = Object.entries(entry.i18n) as [
20
- KnownLocale,
21
- () => Promise<ToolLocaleContent>,
22
- ][];
23
- const localeContents = await Promise.all(
24
- localeEntries.map(async ([locale, loader]) => ({
25
- locale,
26
- content: await loader(),
27
- })),
28
- );
29
-
30
- const localeUrls = Object.fromEntries(
31
- localeContents.map(({ locale, content }) => [
32
- locale,
33
- `/${locale}/${content.slug}`,
34
- ]),
35
- ) as Partial<Record<KnownLocale, string>>;
36
-
37
- const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
38
- const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
39
-
40
- for (const { locale, content } of localeContents) {
41
- const allToolsNav = (
42
- await Promise.all(
43
- ALL_TOOLS.map(async ({ entry: navEntry }) => {
44
- const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
45
- if (!loader) return null;
46
- const navContent = await loader();
47
- return {
48
- id: navEntry.id,
49
- title: navContent.title,
50
- href: `/${locale}/${navContent.slug}`,
51
- isActive: navEntry.id === entry.id,
52
- };
53
- }),
54
- )
55
- ).filter(Boolean) as NavItem[];
56
- paths.push({
57
- params: { locale, slug: content.slug },
58
- props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
59
- });
60
- }
61
- }
62
-
63
- return paths;
64
- }
65
-
66
- interface NavItem {
67
- id: string;
68
- title: string;
69
- href: string;
70
- isActive?: boolean;
71
- }
72
-
73
- interface Props {
74
- Component: unknown;
75
- locale: KnownLocale;
76
- content: ToolLocaleContent;
77
- localeUrls: Partial<Record<KnownLocale, string>>;
78
- allToolsNav: NavItem[];
79
- englishSlug: string;
80
- }
81
-
82
- const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
83
-
84
- const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
85
- const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
- const toolCss = cssKey ? await cssFiles[cssKey]() as string : "";
87
-
88
- const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
89
-
90
- const words = content.title.split(" ");
91
- const titleHighlight = words[0] || "";
92
- const titleBase = words.slice(1).join(" ") || "";
93
- ---
94
-
95
- <PreviewLayout
96
- title={content.title}
97
- currentLocale={locale}
98
- localeUrls={localeUrls}
99
- hasSidebar={true}
100
- >
101
- <PreviewNavSidebar
102
- slot="sidebar"
103
- categoryTitle="Tools"
104
- tools={allToolsNav}
105
- />
106
- <Fragment slot="head">
107
- {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
108
- {
109
- ( content.schemas ?? []).map((schema: unknown) => (
110
- <script
111
- is:inline
112
- type="application/ld+json"
113
- set:html={JSON.stringify(schema)}
114
- />
115
- ))
116
- }
117
- </Fragment>
118
-
119
- <div class="tool-page">
120
- <UtilityHeader
121
- titleHighlight={titleHighlight}
122
- titleBase={titleBase}
123
- description={content.description}
124
- />
125
-
126
- <section class="section-tool">
127
- <Component ui={content.ui} />
128
- </section>
129
-
130
- <section class="section-seo">
131
- <SEORenderer content={seoContent} />
132
- </section>
133
-
134
- <section class="section-faq">
135
- <FAQSection items={content.faq} title={content.ui.faqTitle} inLanguage={locale} />
136
- </section>
137
-
138
- <section class="section-bibliography">
139
- <Bibliography links={content.bibliography} title={content.ui.bibliographyTitle} />
140
- </section>
141
- </div>
142
- </PreviewLayout>
143
-
144
- <style>
145
- .tool-page {
146
- display: flex;
147
- flex-direction: column;
148
- gap: 2rem;
149
- }
150
- .section-tool {
151
- max-width: 1200px;
152
- margin: 0 auto;
153
- width: 100%;
154
- }
155
- .section-seo,
156
- .section-faq,
157
- .section-bibliography {
158
- padding-top: 2rem;
159
- border-top: 1px solid var(--border-color);
160
- }
161
- </style>