@jjlmoya/utils-tabletop 1.34.0 → 1.37.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 (94) hide show
  1. package/package.json +18 -4
  2. package/scripts/postbuild.mjs +17 -0
  3. package/scripts/postinstall.mjs +2 -4
  4. package/src/category/index.ts +3 -1
  5. package/src/components/ProductionBreadcrumb.astro +132 -0
  6. package/src/components/ProductionWidget.astro +182 -0
  7. package/src/entries.ts +2 -0
  8. package/src/i18n/header-ui.ts +15 -0
  9. package/src/i18n/language-ui.ts +9 -0
  10. package/src/i18n/languages.ts +24 -0
  11. package/src/identity/brands.ts +5 -0
  12. package/src/index.ts +1 -0
  13. package/src/layouts/ProductionCategoryPage.astro +184 -0
  14. package/src/layouts/ProductionPage.astro +209 -0
  15. package/src/layouts/ProductionUtilityPage.astro +237 -0
  16. package/src/mfe/assets.ts +15 -0
  17. package/src/mfe/category-ui.ts +26 -0
  18. package/src/mfe/routes.ts +50 -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 -1
  22. package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
  23. package/src/pages/utilidades/[slug].astro +90 -0
  24. package/src/pages/utilidades/categorias/[category].astro +38 -0
  25. package/src/tests/category_ui.test.ts +16 -0
  26. package/src/tests/locale_completeness.test.ts +1 -1
  27. package/src/tests/mfe_assets_contract.test.ts +44 -0
  28. package/src/tests/qa-test-helpers.ts +32 -0
  29. package/src/tests/qa_bibliography_links.test.ts +46 -0
  30. package/src/tests/qa_claim_evidence.test.ts +69 -0
  31. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  32. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  33. package/src/tests/registry_contract.test.ts +67 -0
  34. package/src/tests/routing_contract.test.ts +41 -0
  35. package/src/tests/tool_validation.test.ts +1 -1
  36. package/src/tests/translation_copy.test.ts +0 -1
  37. package/src/tool/board-game-timer/component.astro +0 -1
  38. package/src/tool/board-game-timer/i18n/en.ts +1 -2
  39. package/src/tool/board-game-timer/i18n/es.ts +2 -2
  40. package/src/tool/card-draw-odds-calculator/bibliography.astro +16 -0
  41. package/src/tool/card-draw-odds-calculator/bibliography.ts +7 -0
  42. package/src/tool/card-draw-odds-calculator/card-draw-odds-calculator.css +382 -0
  43. package/src/tool/card-draw-odds-calculator/component.astro +88 -0
  44. package/src/tool/card-draw-odds-calculator/controller.ts +118 -0
  45. package/src/tool/card-draw-odds-calculator/dom-views.ts +90 -0
  46. package/src/tool/card-draw-odds-calculator/entry.ts +26 -0
  47. package/src/tool/card-draw-odds-calculator/evaluator.ts +30 -0
  48. package/src/tool/card-draw-odds-calculator/i18n/de.ts +48 -0
  49. package/src/tool/card-draw-odds-calculator/i18n/en.ts +76 -0
  50. package/src/tool/card-draw-odds-calculator/i18n/es.ts +48 -0
  51. package/src/tool/card-draw-odds-calculator/i18n/fr.ts +48 -0
  52. package/src/tool/card-draw-odds-calculator/i18n/id.ts +27 -0
  53. package/src/tool/card-draw-odds-calculator/i18n/it.ts +15 -0
  54. package/src/tool/card-draw-odds-calculator/i18n/ja.ts +15 -0
  55. package/src/tool/card-draw-odds-calculator/i18n/ko.ts +15 -0
  56. package/src/tool/card-draw-odds-calculator/i18n/nl.ts +15 -0
  57. package/src/tool/card-draw-odds-calculator/i18n/pl.ts +15 -0
  58. package/src/tool/card-draw-odds-calculator/i18n/pt.ts +15 -0
  59. package/src/tool/card-draw-odds-calculator/i18n/ru.ts +15 -0
  60. package/src/tool/card-draw-odds-calculator/i18n/sv.ts +15 -0
  61. package/src/tool/card-draw-odds-calculator/i18n/tr.ts +15 -0
  62. package/src/tool/card-draw-odds-calculator/i18n/zh.ts +15 -0
  63. package/src/tool/card-draw-odds-calculator/index.ts +11 -0
  64. package/src/tool/card-draw-odds-calculator/logic.test.ts +50 -0
  65. package/src/tool/card-draw-odds-calculator/logic.ts +100 -0
  66. package/src/tool/card-draw-odds-calculator/seo.astro +16 -0
  67. package/src/tool/card-draw-odds-calculator/storage.ts +22 -0
  68. package/src/tool/card-draw-odds-calculator/ui.ts +37 -0
  69. package/src/tool/dice-roller-simulator/i18n/es.ts +3 -3
  70. package/src/tool/dungeon-map-generator/component.astro +16 -12
  71. package/src/tool/dungeon-map-generator/dom-views.ts +5 -4
  72. package/src/tool/dungeon-map-generator/i18n/es.ts +2 -2
  73. package/src/tool/encounter-difficulty-calculator/dom-views.ts +8 -1
  74. package/src/tool/fantasy-runes-translator/component.astro +0 -1
  75. package/src/tool/fantasy-runes-translator/logic.test.ts +32 -0
  76. package/src/tool/first-player-selector/component.astro +0 -1
  77. package/src/tool/first-player-selector/i18n/en.ts +1 -1
  78. package/src/tool/first-player-selector/i18n/es.ts +3 -3
  79. package/src/tool/hidden-role-dealer/component.astro +0 -1
  80. package/src/tool/lunar-tide-tracker/component.astro +0 -1
  81. package/src/tool/mus-scoreboard/component.astro +3 -4
  82. package/src/tool/mus-scoreboard/controller.ts +2 -1
  83. package/src/tool/mus-scoreboard/dom-views.ts +6 -1
  84. package/src/tool/rpg-initiative-tracker/component.astro +0 -1
  85. package/src/tool/rpg-settlement-exploration-map-generator/component.astro +14 -4
  86. package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +38 -6
  87. package/src/tool/scatter-direction-selector/component.astro +0 -1
  88. package/src/tool/score-tracker/component.astro +0 -1
  89. package/src/tool/token-stamp-studio/component.astro +0 -1
  90. package/src/tools.ts +2 -0
  91. package/src/types.ts +6 -8
  92. package/src/worker.ts +26 -0
  93. package/src/pages/[locale]/[slug].astro +0 -162
  94. package/src/pages/[locale].astro +0 -251
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ interface VisibleLiteral {
13
+ line: number;
14
+ value: string;
15
+ }
16
+
17
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
18
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
19
+
20
+ function visibleWords(value: string): string {
21
+ return value
22
+ .replace(/\$\{[^}]*\}/g, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/&[a-z]+;/gi, ' ')
25
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function isTechnicalLiteral(value: string): boolean {
31
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
32
+ || /^data-[a-z0-9-]+$/i.test(value);
33
+ }
34
+
35
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
36
+ const failures: VisibleLiteral[] = [];
37
+
38
+ source.split(/\r?\n/).forEach((line, index) => {
39
+ if (!domTextAssignment.test(line)) return;
40
+
41
+ const assignment = line.slice(line.search(domTextAssignment));
42
+ for (const match of assignment.matchAll(quotedLiteral)) {
43
+ const value = match[2];
44
+ if (!value || isTechnicalLiteral(value)) continue;
45
+ const words = visibleWords(value);
46
+ if (words && /\p{L}/u.test(words)) {
47
+ failures.push({ line: index + 1, value });
48
+ }
49
+ }
50
+ });
51
+
52
+ return failures;
53
+ }
54
+
55
+ function runtimeSources(toolDirectory: string): string {
56
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
57
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
58
+ .filter((path) => basename(path) !== 'ui.ts')
59
+ .map(read)
60
+ .join('\n');
61
+ }
62
+
63
+ describe('QA: runtime copy is localized', () => {
64
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
65
+
66
+ it('does not write user-facing string literals directly into the DOM', () => {
67
+ const failures = componentFiles.flatMap((path) =>
68
+ hardcodedVisibleLiterals(read(path)).map(
69
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
70
+ ),
71
+ );
72
+
73
+ expect(
74
+ failures,
75
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it('uses every string declared by each UI locale contract', () => {
80
+ const failures: string[] = [];
81
+
82
+ for (const directory of listToolDirectories()) {
83
+ const uiPath = join(directory, 'ui.ts');
84
+ if (!existsSync(uiPath)) continue;
85
+
86
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
87
+ const runtime = runtimeSources(directory);
88
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
89
+
90
+ if (unused.length > 0) {
91
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
92
+ }
93
+ }
94
+
95
+ expect(
96
+ failures,
97
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
98
+ ).toEqual([]);
99
+ });
100
+ });
@@ -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
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ UTILITY_LOCALES,
4
+ getCategoryNamespace,
5
+ getCategoryPath,
6
+ getUtilitiesPath,
7
+ getUtilityPath,
8
+ getUtilityNamespace,
9
+ } from '@jjlmoya/utils-shared/routing';
10
+
11
+ describe('Public utility routing contract', () => {
12
+ it('keeps the complete fifteen-locale utility set', () => {
13
+ expect(UTILITY_LOCALES).toHaveLength(15);
14
+ expect(new Set(UTILITY_LOCALES).size).toBe(15);
15
+ });
16
+
17
+ it('keeps Spanish on the external jjlmoya host', () => {
18
+ expect(getUtilitiesPath('es')).toBe('https://www.jjlmoya.es/utilidades/');
19
+ expect(getCategoryPath('es', 'juegos-de-mesa')).toBe(
20
+ 'https://www.jjlmoya.es/utilidades/categorias/juegos-de-mesa/',
21
+ );
22
+ expect(getUtilityPath('es', 'juegos-de-mesa', 'tirador-de-dados')).toBe(
23
+ 'https://www.jjlmoya.es/utilidades/tirador-de-dados/',
24
+ );
25
+ });
26
+
27
+ it('serializes every internal locale with both translated namespaces', () => {
28
+ for (const locale of UTILITY_LOCALES.filter((candidate) => candidate !== 'es')) {
29
+ expect(getUtilitiesPath(locale)).toBe(`/${locale}/${getUtilityNamespace(locale)}/`);
30
+ const category = getCategoryPath(locale, 'tabletop');
31
+ const utility = getUtilityPath(locale, 'tabletop', 'dice-roller-simulator');
32
+
33
+ expect(category).toBe(
34
+ `/${locale}/${getUtilityNamespace(locale)}/${getCategoryNamespace(locale)}/tabletop/`,
35
+ );
36
+ expect(utility).toBe(
37
+ `/${locale}/${getUtilityNamespace(locale)}/${getCategoryNamespace(locale)}/tabletop/dice-roller-simulator/`,
38
+ );
39
+ }
40
+ });
41
+ });
@@ -5,7 +5,7 @@ import { tabletopCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(16);
8
+ expect(ALL_TOOLS.length).toBe(17);
9
9
  });
10
10
 
11
11
  it('tabletopCategory should be defined', () => {
@@ -121,4 +121,3 @@ describe('Locales must not copy another locale wholesale', () => {
121
121
  });
122
122
  });
123
123
 
124
-
@@ -1,5 +1,4 @@
1
1
  ---
2
- import './board-game-timer.css';
3
2
  import SetupPanel from './components/SetupPanel.astro';
4
3
  import DuelTimer from './components/DuelTimer.astro';
5
4
  import MultiplayerTimer from './components/MultiplayerTimer.astro';
@@ -60,7 +60,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
60
60
  ui,
61
61
  seo: [
62
62
  { type: 'title', text: 'Mastering Tabletop Pace: How Professional Time Controls Enhance Tabletop Gaming', level: 2 },
63
- { type: 'paragraph', html: 'Time management is a vital component in modern tabletop gaming. Whether you are contesting a fast-paced party challenge or engaging in a deep strategic wargame, regulating thinking time ensures that all participants have a fair share of the spotlight. Utilizing professional timing methods like Fischer increment, Bronstein delay, or Hourglass modes eliminates analysis paralysis, guarantees game pace consistency, and adds an extra layer of strategic decision-making to the game board.' },
63
+ { type: 'paragraph', html: 'Time management is a vital component in modern tabletop gaming. Whether you are contesting a fast-paced party challenge or engaging in a deep strategic wargame, regulating thinking time helps give all participants a fair share of the spotlight. Utilizing professional timing methods like Fischer increment, Bronstein delay, or Hourglass modes can reduce analysis paralysis, support a consistent game pace, and add an extra layer of strategic decision-making to the game board.' },
64
64
  {
65
65
  type: 'stats',
66
66
  items: [
@@ -301,4 +301,3 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
301
301
  }
302
302
  ],
303
303
  };
304
-
@@ -60,7 +60,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
60
60
  ui,
61
61
  seo: [
62
62
  { type: 'title', text: 'Dominando el ritmo de juego: Cómo los controles de tiempo profesionales mejoran las partidas', level: 2 },
63
- { type: 'paragraph', html: 'La gestión del tiempo es un componente vital en los juegos de mesa modernos. Ya sea que estés disputando una partida rápida o participando en un juego de estrategia complejo, regular el tiempo de reflexión garantiza que todos tengan una participación justa. El uso de métodos de cronometraje profesional como el incremento Fischer, el retraso Bronstein o el reloj de arena elimina el análisis por parálisis y añade una capa estratégica adicional al tablero.' },
63
+ { type: 'paragraph', html: 'La gestión del tiempo es un componente vital en los juegos de mesa modernos. Ya sea que estés disputando una partida rápida o participando en un juego de estrategia complejo, regular el tiempo de reflexión ayuda a repartir el tiempo de forma justa. El uso de métodos de cronometraje profesional como el incremento Fischer, el retraso Bronstein o el reloj de arena puede reducir el análisis por parálisis y añadir una capa estratégica adicional al tablero.' },
64
64
  {
65
65
  type: 'stats',
66
66
  items: [
@@ -147,7 +147,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
147
147
  },
148
148
  {
149
149
  title: 'Partidas competitivas en duelo',
150
- description: 'Los duelos estrictos con reloj de ajedrez requieren pantallas rotables para garantizar la integridad competitiva y un reparto de tiempo equilibrado entre los jugadores.',
150
+ description: 'Los duelos estrictos con reloj de ajedrez requieren pantallas rotables para favorecer la integridad competitiva y un reparto de tiempo equilibrado entre los jugadores.',
151
151
  icon: 'mdi:trophy',
152
152
  highlight: false,
153
153
  points: [
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { cardDrawOddsCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = cardDrawOddsCalculator.i18n[locale] || cardDrawOddsCalculator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,7 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'NIST Engineering Statistics Handbook: Hypergeometric CDF', url: 'https://www.itl.nist.gov/div898/software/dataplot/refman2/ch8/hypcdf.pdf' },
5
+ { name: 'Penn State STAT 414: Hypergeometric Distribution', url: 'https://online.stat.psu.edu/stat414/Lesson07' },
6
+ { name: 'UNAM: Distribución hipergeométrica', url: 'https://prometeo.matem.unam.mx/recursos/VariosNiveles/iCartesiLibri/recursos/Estadistica_Probabilidad_e_Inferencia/index.html' },
7
+ ];