@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
@@ -3,12 +3,20 @@ import { buildMapSvg } from './dom-views';
3
3
  import { evaluateSettlement } from './evaluator';
4
4
  import { DEFAULT_CONFIG, generateSettlement } from './logic';
5
5
  import type { SettlementMapUI } from './ui';
6
- import './rpg-settlement-exploration-map-generator.css';
7
6
  interface Props { ui: SettlementMapUI; }
8
7
  const { ui } = Astro.props;
9
8
  const initialMap = generateSettlement(DEFAULT_CONFIG);
10
9
  const initialEvaluation = evaluateSettlement(initialMap, ui);
11
10
  const builtInServices = ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall'] as const;
11
+ const styleOptions = [
12
+ { value: 'medieval', label: ui.styleMedieval },
13
+ { value: 'edo', label: ui.styleEdo },
14
+ { value: 'sahelian', label: ui.styleSahelian },
15
+ { value: 'timber', label: ui.styleTimber },
16
+ { value: 'stone', label: ui.styleStone },
17
+ { value: 'coastal', label: ui.styleCoastal },
18
+ { value: 'highland', label: ui.styleHighland },
19
+ ] as const;
12
20
  ---
13
21
  <div class="rsm" data-settlement-tool>
14
22
  <script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)} />
@@ -28,7 +36,7 @@ const builtInServices = ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall
28
36
  <div class="rsm-field">
29
37
  <span class="rsm-label">{ui.styleLabel}</span>
30
38
  <small>{ui.styleHint}</small>
31
- <div class="rsm-choice-row" data-styles>{(['medieval', 'edo', 'sahelian', 'timber', 'stone', 'coastal', 'highland'] as const).map((style) => <button type="button" class:list={{ 'rsm-choice': true, 'is-active': style === DEFAULT_CONFIG.style }} data-style={style} aria-pressed={style === DEFAULT_CONFIG.style}>{ui[`style${style.charAt(0).toUpperCase()}${style.slice(1)}`]}</button>)}</div>
39
+ <div class="rsm-choice-row" data-styles>{styleOptions.map((option) => <button type="button" class:list={{ 'rsm-choice': true, 'is-active': option.value === DEFAULT_CONFIG.style }} data-style={option.value} aria-pressed={option.value === DEFAULT_CONFIG.style}>{option.label}</button>)}</div>
32
40
  </div>
33
41
  <div class="rsm-field">
34
42
  <span class="rsm-label">{ui.sizeLabel}</span>
@@ -46,15 +54,17 @@ const builtInServices = ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall
46
54
  <fieldset class="rsm-field rsm-services">
47
55
  <legend>{ui.servicesLabel}</legend>
48
56
  <small>{ui.serviceListHint}</small>
49
- <div class="rsm-service-row" data-service-list>{builtInServices.map((service) => <div class="rsm-service-entry" data-service-entry={service}><label class="rsm-service-chip"><input type="checkbox" value={service} data-service-option checked={DEFAULT_CONFIG.services.includes(service)} /><span>{ui[`service${service.charAt(0).toUpperCase()}${service.slice(1)}`]}</span></label></div>)}</div>
57
+ <div class="rsm-service-row" data-service-list aria-label={ui.serviceSelectLabel}>{builtInServices.map((service) => <div class="rsm-service-entry" data-service-entry={service}><label class="rsm-service-chip"><input type="checkbox" value={service} data-service-option checked={DEFAULT_CONFIG.services.includes(service)} /><span>{ui[`service${service.charAt(0).toUpperCase()}${service.slice(1)}`]}</span></label></div>)}</div>
50
58
  <div class="rsm-add-service"><input id="rsm-new-service" type="text" data-new-service aria-label={ui.newServicePlaceholder} placeholder={ui.newServicePlaceholder} maxlength="28" /><button type="button" data-add-service>{ui.addService}</button></div>
51
59
  </fieldset>
52
60
  <button type="button" class="rsm-generate" data-generate>{ui.generate}</button>
53
61
  </div>
54
62
  <div class="rsm-map-column">
55
63
  <div class="rsm-map-heading"><div><span class="rsm-eyebrow">{ui.mapSummary}</span><strong data-terrain>{initialEvaluation.terrainLabel}</strong></div><span class="rsm-badge" data-badge>{initialEvaluation.badge}</span></div>
56
- <div class="rsm-map-frame"><div class="rsm-map-host" data-map-host set:html={buildMapSvg(initialMap, ui)}></div><div class="rsm-map-help">{ui.clickHint}</div><div class="rsm-context-menu" data-context-menu hidden role="menu" aria-label={ui.contextMenuLabel}><button type="button" data-context-tool="select" role="menuitem">{ui.contextInspect}</button><button type="button" data-context-tool="building" role="menuitem">{ui.contextBuilding}</button><button type="button" data-context-tool="path" role="menuitem">{ui.contextPath}</button><button type="button" data-context-tool="water" role="menuitem">{ui.contextWater}</button><button type="button" data-context-tool="tree" role="menuitem">{ui.contextTree}</button><button type="button" data-context-tool="erase" role="menuitem">{ui.contextErase}</button></div></div>
64
+ <div class="rsm-map-tools" aria-label={ui.editLabel}><span class="rsm-label">{ui.editLabel}</span><div class="rsm-tool-group"><button type="button" class="rsm-tool is-active" data-tool="select" aria-pressed="true">{ui.toolSelect}</button><button type="button" class="rsm-tool" data-tool="building" aria-pressed="false">{ui.toolBuilding}</button><button type="button" class="rsm-tool" data-tool="path" aria-pressed="false">{ui.toolPath}</button><button type="button" class="rsm-tool" data-tool="water" aria-pressed="false">{ui.toolWater}</button><button type="button" class="rsm-tool" data-tool="tree" aria-pressed="false">{ui.toolTree}</button><button type="button" class="rsm-tool" data-tool="erase" aria-pressed="false">{ui.toolErase}</button></div></div>
65
+ <div class="rsm-map-frame"><div class="rsm-map-host" data-map-host set:html={buildMapSvg(initialMap, ui)}></div><div class="rsm-map-help">{ui.clickHint} {ui.selectedHint}: {ui.serviceNone}</div><div class="rsm-context-menu" data-context-menu hidden role="menu" aria-label={ui.contextMenuLabel}><button type="button" data-context-tool="select" role="menuitem">{ui.contextInspect}</button><button type="button" data-context-tool="building" role="menuitem">{ui.contextBuilding}</button><button type="button" data-context-tool="path" role="menuitem">{ui.contextPath}</button><button type="button" data-context-tool="water" role="menuitem">{ui.contextWater}</button><button type="button" data-context-tool="tree" role="menuitem">{ui.contextTree}</button><button type="button" data-context-tool="erase" role="menuitem">{ui.contextErase}</button></div></div>
57
66
  <div class="rsm-stats" aria-live="polite"><div><span>{ui.buildings}</span><strong data-building-count>{initialEvaluation.buildingCount}</strong></div><div><span>{ui.paths}</span><strong data-path-count>{initialEvaluation.pathCount}</strong></div><div><span>{ui.services}</span><strong data-service-count>{initialEvaluation.serviceCount}</strong></div><div><span>{ui.terrain}</span><strong data-water-count>{initialEvaluation.waterCount}</strong></div></div>
67
+ <div class="rsm-legend" aria-label={ui.legendLabel}><span><i class="rsm-legend-home" aria-hidden="true"></i>{ui.legendHome}</span><span><i class="rsm-legend-service" aria-hidden="true"></i>{ui.legendService}</span><span><i class="rsm-legend-path" aria-hidden="true"></i>{ui.legendPath}</span><span><i class="rsm-legend-water" aria-hidden="true"></i>{ui.legendWater}</span><span><i class="rsm-legend-wild" aria-hidden="true"></i>{ui.legendWild}</span></div>
58
68
  <div class="rsm-actions"><button type="button" class="rsm-action-primary" data-share>{ui.shareLink}</button><button type="button" data-export-png>{ui.exportPng}</button><button type="button" data-export-svg>{ui.exportSvg}</button><details class="rsm-data-menu"><summary>{ui.mapData}</summary><div><button type="button" data-export-json>{ui.exportJson}</button><label><span>{ui.importJson}</span><input type="file" accept="application/json,.json" data-import-json /></label></div></details></div>
59
69
  <p class="rsm-status" data-status aria-live="polite"></p>
60
70
  </div>
@@ -5,6 +5,12 @@ interface CellPoint {
5
5
  y: number;
6
6
  }
7
7
 
8
+ function bindToolButtons(root: HTMLElement, setActiveTool: (tool: EditTool) => void): void {
9
+ root.querySelectorAll<HTMLButtonElement>('[data-tool]').forEach((button) => button.addEventListener('click', () => {
10
+ setActiveTool(button.dataset.tool as EditTool);
11
+ }));
12
+ }
13
+
8
14
  type EditHandler = (point: CellPoint, tool: EditTool) => void;
9
15
 
10
16
  function svgCell(event: MouseEvent, svg: SVGSVGElement): CellPoint | null {
@@ -60,10 +66,17 @@ function bindPointerEvents(opts: PointerOptions): void {
60
66
  opts.host.addEventListener('pointerleave', cancel);
61
67
  }
62
68
 
63
- export function bindContextEditor(root: HTMLElement, onEdit: EditHandler): void {
64
- const host = root.querySelector<HTMLElement>('[data-map-host]');
65
- const menu = root.querySelector<HTMLElement>('[data-context-menu]');
66
- if (!host || !menu) return;
69
+ function bindMapSelection(host: HTMLElement, getActiveTool: () => EditTool, onEdit: EditHandler): void {
70
+ host.addEventListener('click', (event) => {
71
+ const tool = getActiveTool();
72
+ if (tool === 'select') return;
73
+ const svg = (event.target as Element).closest<SVGSVGElement>('[data-settlement-map]');
74
+ const cell = svg ? svgCell(event, svg) : null;
75
+ if (cell) onEdit(cell, tool);
76
+ });
77
+ }
78
+
79
+ function bindContextMenu(root: HTMLElement, host: HTMLElement, menu: HTMLElement, onEdit: EditHandler): void {
67
80
  let selected: CellPoint | null = null;
68
81
  const flags: PointerFlags = { ignoreNextContext: false, ignoreNextClick: false };
69
82
  host.addEventListener('contextmenu', (event) => {
@@ -72,8 +85,8 @@ export function bindContextEditor(root: HTMLElement, onEdit: EditHandler): void
72
85
  selected = openContextMenu(root, menu, event);
73
86
  });
74
87
  bindPointerEvents({ host, root, menu, flags, onOpen: (cell) => { selected = cell; } });
75
- menu.querySelectorAll<HTMLButtonElement>('[data-context-tool]').forEach((btn) => btn.addEventListener('click', () => {
76
- if (selected) onEdit(selected, btn.dataset.contextTool as EditTool);
88
+ menu.querySelectorAll<HTMLButtonElement>('[data-context-tool]').forEach((button) => button.addEventListener('click', () => {
89
+ if (selected) onEdit(selected, button.dataset.contextTool as EditTool);
77
90
  menu.hidden = true;
78
91
  }));
79
92
  document.addEventListener('click', (event) => {
@@ -83,3 +96,22 @@ export function bindContextEditor(root: HTMLElement, onEdit: EditHandler): void
83
96
  window.addEventListener('resize', () => { menu.hidden = true; });
84
97
  window.addEventListener('scroll', () => { menu.hidden = true; }, true);
85
98
  }
99
+
100
+ export function bindContextEditor(root: HTMLElement, onEdit: EditHandler): void {
101
+ const host = root.querySelector<HTMLElement>('[data-map-host]');
102
+ const menu = root.querySelector<HTMLElement>('[data-context-menu]');
103
+ if (!host || !menu) return;
104
+ let activeTool: EditTool = 'select';
105
+ const toolButtons = root.querySelectorAll<HTMLButtonElement>('[data-tool]');
106
+ const setActiveTool = (tool: EditTool): void => {
107
+ activeTool = tool;
108
+ toolButtons.forEach((button) => {
109
+ const active = button.dataset.tool === tool;
110
+ button.classList.toggle('is-active', active);
111
+ button.setAttribute('aria-pressed', String(active));
112
+ });
113
+ };
114
+ bindToolButtons(root, setActiveTool);
115
+ bindMapSelection(host, () => activeTool, onEdit);
116
+ bindContextMenu(root, host, menu, onEdit);
117
+ }
@@ -1,5 +1,4 @@
1
1
  ---
2
- import './scatter-direction-selector.css';
3
2
 
4
3
  interface Props {
5
4
  ui: Record<string, string>;
@@ -1,5 +1,4 @@
1
1
  ---
2
- import './score-tracker.css';
3
2
  import PlayerSetup from './components/PlayerSetup.astro';
4
3
  import RoundScoring from './components/RoundScoring.astro';
5
4
  import Leaderboard from './components/Leaderboard.astro';
@@ -1,5 +1,4 @@
1
1
  ---
2
- import './token-stamp-studio.css';
3
2
  import type { TokenStampUI } from './ui';
4
3
 
5
4
  const { ui } = Astro.props as { ui: TokenStampUI };
package/src/tools.ts CHANGED
@@ -16,6 +16,7 @@ import { ENCOUNTER_DIFFICULTY_CALCULATOR_TOOL } from './tool/encounter-difficult
16
16
  import { TOKEN_STAMP_STUDIO_TOOL } from './tool/token-stamp-studio';
17
17
  import { MUS_SCOREBOARD_TOOL } from './tool/mus-scoreboard';
18
18
  import { RPG_SETTLEMENT_EXPLORATION_MAP_GENERATOR_TOOL } from './tool/rpg-settlement-exploration-map-generator';
19
+ import { CARD_DRAW_ODDS_CALCULATOR_TOOL } from './tool/card-draw-odds-calculator';
19
20
 
20
21
  export const ALL_TOOLS: ToolDefinition[] = [
21
22
  DICE_ROLLER_SIMULATOR_TOOL,
@@ -34,5 +35,6 @@ export const ALL_TOOLS: ToolDefinition[] = [
34
35
  TOKEN_STAMP_STUDIO_TOOL,
35
36
  MUS_SCOREBOARD_TOOL,
36
37
  RPG_SETTLEMENT_EXPLORATION_MAP_GENERATOR_TOOL,
38
+ CARD_DRAW_ODDS_CALCULATOR_TOOL,
37
39
  ];
38
40
 
package/src/types.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import type { SEOSection } from '@jjlmoya/utils-shared';
2
+ import type { UtilityLocale } from '@jjlmoya/utils-shared/routing';
3
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
2
4
 
3
5
  export type { SEOSection };
4
6
 
5
- export type KnownLocale =
6
- | 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
7
- | 'fr' | 'id' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
8
- | 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
7
+ export type KnownLocale = UtilityLocale;
9
8
 
10
9
  export interface FAQItem {
11
10
  question: string;
@@ -62,8 +61,7 @@ export interface TabletopCategoryEntry {
62
61
 
63
62
  export interface ToolDefinition {
64
63
  entry: TabletopToolEntry;
65
- Component: unknown;
66
- SEOComponent: unknown;
67
- BibliographyComponent: unknown;
64
+ Component: () => Promise<{ default: AstroComponentFactory }>;
65
+ SEOComponent: () => Promise<{ default: AstroComponentFactory }>;
66
+ BibliographyComponent: () => Promise<{ default: AstroComponentFactory }>;
68
67
  }
69
-
package/src/worker.ts ADDED
@@ -0,0 +1,26 @@
1
+ interface UtilityMfeEnvironment {
2
+ ASSETS: { fetch(request: Request): Promise<Response> };
3
+ }
4
+
5
+ const LONG_LIVED_ASSET_CACHE = "public, max-age=31536000, immutable";
6
+ const SITEMAP_CACHE = "public, max-age=3600, s-maxage=3600, must-revalidate";
7
+
8
+ const getCacheControl = (pathname: string): string | undefined => {
9
+ if (pathname.endsWith("/sitemap.xml")) return SITEMAP_CACHE;
10
+ if (pathname.startsWith("/_utilities/")) {
11
+ return LONG_LIVED_ASSET_CACHE;
12
+ }
13
+ return undefined;
14
+ };
15
+
16
+ export default {
17
+ async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
18
+ const response = await environment.ASSETS.fetch(request);
19
+ const cacheControl = getCacheControl(new URL(request.url).pathname);
20
+ if (!cacheControl) return response;
21
+
22
+ const headers = new Headers(response.headers);
23
+ headers.set("Cache-Control", cacheControl);
24
+ return new Response(response.body, { status: response.status, headers });
25
+ },
26
+ };
@@ -1,162 +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 as () => Promise<{ default: unknown }>)();
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
- type PageProps = {
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 as PageProps;
83
-
84
- const cssFiles = import.meta.glob<{ default: string }>("../../tool/*/*.css", { query: "?raw", import: "default" });
85
- const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
- const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
87
- const toolCss = cssLoader ? await cssLoader() as string : "";
88
-
89
- const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
90
-
91
- const words = content.title.split(" ");
92
- const titleHighlight = words[0] || "";
93
- const titleBase = words.slice(1).join(" ") || "";
94
- ---
95
-
96
- <PreviewLayout
97
- title={content.title}
98
- currentLocale={locale}
99
- localeUrls={localeUrls}
100
- hasSidebar={true}
101
- >
102
- <PreviewNavSidebar
103
- slot="sidebar"
104
- categoryTitle="Tools"
105
- tools={allToolsNav}
106
- />
107
- <Fragment slot="head">
108
- {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
109
- {
110
- ( content.schemas ?? []).map((schema: unknown) => (
111
- <script
112
- is:inline
113
- type="application/ld+json"
114
- set:html={JSON.stringify(schema)}
115
- />
116
- ))
117
- }
118
- </Fragment>
119
-
120
- <div class="tool-page">
121
- <UtilityHeader
122
- titleHighlight={titleHighlight}
123
- titleBase={titleBase}
124
- description={content.description}
125
- />
126
-
127
- <section class="section-tool">
128
- <Component ui={content.ui} />
129
- </section>
130
-
131
- <section class="section-seo">
132
- <SEORenderer content={seoContent} />
133
- </section>
134
-
135
- <section class="section-faq">
136
- <FAQSection items={content.faq} />
137
- </section>
138
-
139
- <section class="section-bibliography">
140
- <Bibliography links={content.bibliography} />
141
- </section>
142
- </div>
143
- </PreviewLayout>
144
-
145
- <style>
146
- .tool-page {
147
- display: flex;
148
- flex-direction: column;
149
- gap: 2rem;
150
- }
151
- .section-tool {
152
- max-width: 1200px;
153
- margin: 0 auto;
154
- width: 100%;
155
- }
156
- .section-seo,
157
- .section-faq,
158
- .section-bibliography {
159
- padding-top: 2rem;
160
- border-top: 1px solid var(--border-color);
161
- }
162
- </style>
@@ -1,251 +0,0 @@
1
- ---
2
- import PreviewLayout from '../layouts/PreviewLayout.astro';
3
- import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
4
- import { tabletopCategory, ALL_TOOLS } from '../index';
5
- import { Icon } from 'astro-icon/components';
6
- import type { KnownLocale, ToolLocaleContent } from '../types';
7
-
8
- export async function getStaticPaths() {
9
- const locales = ['en', 'es', 'fr'] as KnownLocale[];
10
- return locales.map(locale => ({ params: { locale } }));
11
- }
12
-
13
- const { locale: currentLocale } = Astro.params as { locale: KnownLocale };
14
-
15
- const categoryContent = await tabletopCategory.i18n[currentLocale]!();
16
-
17
- const tools = ALL_TOOLS || [];
18
-
19
- const toolsWithContent = tools.length > 0
20
- ? await Promise.all(
21
- tools.map(async ({ entry, Component }) => {
22
- const languages = Object.keys(entry.i18n);
23
- const localeEntries = await Promise.all(
24
- languages.map(async (l) => {
25
- const content = await entry.i18n[l as KnownLocale]!();
26
- return [l, content];
27
- })
28
- );
29
-
30
- const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
31
-
32
- const currentLocaleContent = localeContents[currentLocale] || localeContents['en'] || localeContents['es'];
33
- const availableLocales: Record<string, string> = {};
34
-
35
- for (const l of languages) {
36
- const lCont = localeContents[l];
37
- if (lCont) {
38
- availableLocales[l] = `/${l}/${lCont.slug}`;
39
- }
40
- }
41
-
42
- return { entry, Component, locale: currentLocaleContent, availableLocales };
43
- })
44
- )
45
- : [];
46
- ---
47
-
48
- <PreviewLayout
49
- title={categoryContent.title}
50
- currentLocale={currentLocale}
51
- hasSidebar={true}
52
- >
53
- <PreviewNavSidebar
54
- slot="sidebar"
55
- categoryTitle={categoryContent.title}
56
- tools={toolsWithContent.map(({ entry, locale, availableLocales }) => {
57
- const href = availableLocales[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#');
58
- return {
59
- id: entry.id,
60
- title: locale?.title || entry.id,
61
- href: href,
62
- };
63
- })}
64
- />
65
- <div class="dashboard">
66
- <header class="preview-header">
67
- <span class="badge">preview · @jjlmoya/utils-tabletop</span>
68
- <h1>{categoryContent.title}</h1>
69
- <p>{categoryContent.description}</p>
70
- </header>
71
-
72
- <div class="tool-list">
73
- {toolsWithContent?.map(({ entry, locale, availableLocales }) => (
74
- <article class="tool-card">
75
- <a href={availableLocales?.[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#')} class="tool-card-link">
76
- <div class="tool-icons">
77
- <div class="icon-wrapper bg">
78
- <Icon name={entry.icons.bg} />
79
- </div>
80
- <div class="icon-wrapper fg">
81
- <Icon name={entry.icons.fg} />
82
- </div>
83
- </div>
84
- <div class="tool-card-content">
85
- <h2 class="tool-title">{locale?.title}</h2>
86
- <p class="tool-description">{locale?.description}</p>
87
- </div>
88
- <div class="tool-card-meta">
89
- <span class="tool-id">{entry.id}</span>
90
- </div>
91
- </a>
92
-
93
- {availableLocales && Object.keys(availableLocales).length > 1 && (
94
- <div class="tool-locales">
95
- {Object.entries(availableLocales).map(([l, url]) => (
96
- <a href={url} class="locale-badge" title={`Ver en ${l.toUpperCase()}`} class:list={{ active: l === currentLocale }}>
97
- {l.toUpperCase()}
98
- </a>
99
- ))}
100
- </div>
101
- )}
102
- </article>
103
- ))}
104
- </div>
105
- </div>
106
- </PreviewLayout>
107
-
108
- <style>
109
- .dashboard {
110
- display: flex;
111
- flex-direction: column;
112
- gap: 5rem;
113
- }
114
- .preview-header {
115
- text-align: center;
116
- padding-bottom: 3rem;
117
- border-bottom: 1px solid var(--border-color);
118
- }
119
- .badge {
120
- display: inline-block;
121
- padding: 0.25rem 0.75rem;
122
- background: var(--accent);
123
- border-radius: 99px;
124
- font-size: 0.7rem;
125
- font-weight: 800;
126
- margin-bottom: 1.5rem;
127
- color: var(--text-base);
128
- letter-spacing: 0.05em;
129
- }
130
- h1 {
131
- font-size: clamp(2rem, 6vw, 3.5rem);
132
- font-weight: 900;
133
- margin: 0 0 1rem;
134
- background: linear-gradient(to bottom, var(--text-base), var(--text-muted));
135
- -webkit-background-clip: text;
136
- -webkit-text-fill-color: transparent;
137
- background-clip: text;
138
- }
139
- .preview-header p {
140
- color: var(--text-muted);
141
- font-size: 1.1rem;
142
- margin: 0;
143
- }
144
- .tool-list {
145
- display: grid;
146
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
147
- gap: 2rem;
148
- }
149
- .tool-card {
150
- display: flex;
151
- flex-direction: column;
152
- gap: 1rem;
153
- }
154
- .tool-card-link {
155
- flex: 1;
156
- display: flex;
157
- flex-direction: column;
158
- padding: 1.5rem;
159
- background: var(--bg-surface);
160
- border: 1px solid var(--border-color);
161
- border-radius: 0.75rem;
162
- text-decoration: none;
163
- transition: all 0.2s ease;
164
- }
165
- .tool-card-link:hover {
166
- border-color: var(--accent);
167
- background: rgba(244, 63, 94, 0.05);
168
- transform: translateY(-2px);
169
- }
170
- .tool-icons {
171
- display: flex;
172
- align-items: center;
173
- gap: 1rem;
174
- margin-bottom: 1.25rem;
175
- }
176
- .icon-wrapper {
177
- display: flex;
178
- align-items: center;
179
- justify-content: center;
180
- width: 3rem;
181
- height: 3rem;
182
- border-radius: 0.5rem;
183
- font-size: 1.5rem;
184
- }
185
- .icon-wrapper.bg {
186
- background: var(--accent);
187
- color: var(--text-base);
188
- }
189
- .icon-wrapper.fg {
190
- background: var(--bg-page);
191
- border: 1px solid var(--border-color);
192
- color: var(--accent);
193
- }
194
- .tool-card-content {
195
- flex: 1;
196
- display: flex;
197
- flex-direction: column;
198
- }
199
- .tool-title {
200
- font-size: 1.25rem;
201
- font-weight: 700;
202
- margin: 0 0 0.5rem;
203
- color: var(--text-base);
204
- }
205
- .tool-description {
206
- font-size: 0.9375rem;
207
- color: var(--text-muted);
208
- line-height: 1.5;
209
- margin: 0;
210
- }
211
- .tool-card-meta {
212
- padding-top: 1rem;
213
- border-top: 1px solid var(--border-color);
214
- margin-top: 1.5rem;
215
- }
216
- .tool-id {
217
- display: inline-block;
218
- font-size: 0.7rem;
219
- background: var(--bg-page);
220
- border: 1px solid var(--border-color);
221
- padding: 0.35rem 0.75rem;
222
- border-radius: 0.4rem;
223
- color: var(--accent);
224
- font-weight: 600;
225
- }
226
- .tool-locales {
227
- display: flex;
228
- gap: 0.5rem;
229
- flex-wrap: wrap;
230
- }
231
- .locale-badge {
232
- display: inline-block;
233
- padding: 0.4rem 0.85rem;
234
- background: var(--bg-page);
235
- border: 1px solid var(--border-color);
236
- border-radius: 0.4rem;
237
- color: var(--text-muted);
238
- text-decoration: none;
239
- font-size: 0.75rem;
240
- font-weight: 600;
241
- text-transform: uppercase;
242
- letter-spacing: 0.05em;
243
- transition: all 0.15s ease;
244
- }
245
- .locale-badge:hover,
246
- .locale-badge.active {
247
- color: var(--accent);
248
- border-color: var(--accent);
249
- background: rgba(244, 63, 94, 0.1);
250
- }
251
- </style>