@jjlmoya/utils-books 1.1.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 (115) hide show
  1. package/.github/workflows/npm-publish.yml +40 -0
  2. package/.gitignore +6 -0
  3. package/.stylelintrc.json +98 -0
  4. package/astro.config.mjs +19 -0
  5. package/eslint.config.js +201 -0
  6. package/package.json +79 -0
  7. package/prompts/create_tool.md +98 -0
  8. package/prompts/i18n/de.md +16 -0
  9. package/prompts/i18n/en.md +16 -0
  10. package/prompts/i18n/es.md +16 -0
  11. package/prompts/i18n/fr.md +16 -0
  12. package/prompts/i18n/id.md +16 -0
  13. package/prompts/i18n/it.md +16 -0
  14. package/prompts/i18n/ja.md +16 -0
  15. package/prompts/i18n/ko.md +16 -0
  16. package/prompts/i18n/nl.md +16 -0
  17. package/prompts/i18n/pl.md +16 -0
  18. package/prompts/i18n/pt.md +16 -0
  19. package/prompts/i18n/ru.md +16 -0
  20. package/prompts/i18n/sv.md +16 -0
  21. package/prompts/i18n/tr.md +16 -0
  22. package/prompts/i18n/zh.md +16 -0
  23. package/prompts/seo.md +58 -0
  24. package/prompts/translations/french.md +33 -0
  25. package/scripts/postinstall.mjs +27 -0
  26. package/src/category/BooksCategorySEO.astro +9 -0
  27. package/src/category/i18n/de.ts +21 -0
  28. package/src/category/i18n/en.ts +21 -0
  29. package/src/category/i18n/es.ts +21 -0
  30. package/src/category/i18n/fr.ts +21 -0
  31. package/src/category/i18n/id.ts +21 -0
  32. package/src/category/i18n/it.ts +21 -0
  33. package/src/category/i18n/ja.ts +21 -0
  34. package/src/category/i18n/ko.ts +21 -0
  35. package/src/category/i18n/nl.ts +21 -0
  36. package/src/category/i18n/pl.ts +21 -0
  37. package/src/category/i18n/pt.ts +21 -0
  38. package/src/category/i18n/ru.ts +21 -0
  39. package/src/category/i18n/sv.ts +21 -0
  40. package/src/category/i18n/tr.ts +21 -0
  41. package/src/category/i18n/zh.ts +21 -0
  42. package/src/category/index.ts +24 -0
  43. package/src/components/PreviewNavSidebar.astro +116 -0
  44. package/src/components/PreviewToolbar.astro +143 -0
  45. package/src/data.ts +10 -0
  46. package/src/entries.ts +6 -0
  47. package/src/env.d.ts +5 -0
  48. package/src/index.ts +20 -0
  49. package/src/layouts/PreviewLayout.astro +118 -0
  50. package/src/pages/[locale]/[slug].astro +164 -0
  51. package/src/pages/[locale].astro +251 -0
  52. package/src/pages/index.astro +4 -0
  53. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  54. package/src/tests/diacritics_density.test.ts +118 -0
  55. package/src/tests/faq_count.test.ts +18 -0
  56. package/src/tests/i18n_coverage.test.ts +34 -0
  57. package/src/tests/inverted_punctuation.test.ts +84 -0
  58. package/src/tests/locale_completeness.test.ts +23 -0
  59. package/src/tests/mocks/astro_mock.js +2 -0
  60. package/src/tests/no_em_dash.test.ts +47 -0
  61. package/src/tests/no_en_dash.test.ts +70 -0
  62. package/src/tests/no_h1_in_components.test.ts +48 -0
  63. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  64. package/src/tests/qa-test-helpers.ts +32 -0
  65. package/src/tests/qa_bibliography_links.test.ts +54 -0
  66. package/src/tests/qa_claim_evidence.test.ts +69 -0
  67. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  68. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  69. package/src/tests/schemas_fulfillment.test.ts +23 -0
  70. package/src/tests/script_density.test.ts +94 -0
  71. package/src/tests/seo_length.test.ts +23 -0
  72. package/src/tests/seo_parity.test.ts +60 -0
  73. package/src/tests/seo_translation_completeness.test.ts +69 -0
  74. package/src/tests/seo_wellformed_export.test.ts +65 -0
  75. package/src/tests/shared-test-helpers.ts +56 -0
  76. package/src/tests/slug_language_code_format.test.ts +23 -0
  77. package/src/tests/slug_uniqueness.test.ts +81 -0
  78. package/src/tests/spanish_leakage.test.ts +175 -0
  79. package/src/tests/title_quality.test.ts +55 -0
  80. package/src/tests/tool_exports.test.ts +34 -0
  81. package/src/tests/tool_validation.test.ts +16 -0
  82. package/src/tests/translation_copy.test.ts +127 -0
  83. package/src/tool/book-pagination-and-spine-calculator/bibliography.astro +16 -0
  84. package/src/tool/book-pagination-and-spine-calculator/bibliography.ts +7 -0
  85. package/src/tool/book-pagination-and-spine-calculator/book-pagination-and-spine-calculator.css +298 -0
  86. package/src/tool/book-pagination-and-spine-calculator/component.astro +40 -0
  87. package/src/tool/book-pagination-and-spine-calculator/controller.ts +87 -0
  88. package/src/tool/book-pagination-and-spine-calculator/dom-views.ts +21 -0
  89. package/src/tool/book-pagination-and-spine-calculator/entry.ts +27 -0
  90. package/src/tool/book-pagination-and-spine-calculator/evaluator.ts +12 -0
  91. package/src/tool/book-pagination-and-spine-calculator/i18n/de.ts +78 -0
  92. package/src/tool/book-pagination-and-spine-calculator/i18n/en.ts +78 -0
  93. package/src/tool/book-pagination-and-spine-calculator/i18n/es.ts +78 -0
  94. package/src/tool/book-pagination-and-spine-calculator/i18n/fr.ts +78 -0
  95. package/src/tool/book-pagination-and-spine-calculator/i18n/id.ts +78 -0
  96. package/src/tool/book-pagination-and-spine-calculator/i18n/it.ts +78 -0
  97. package/src/tool/book-pagination-and-spine-calculator/i18n/ja.ts +78 -0
  98. package/src/tool/book-pagination-and-spine-calculator/i18n/ko.ts +78 -0
  99. package/src/tool/book-pagination-and-spine-calculator/i18n/nl.ts +78 -0
  100. package/src/tool/book-pagination-and-spine-calculator/i18n/pl.ts +78 -0
  101. package/src/tool/book-pagination-and-spine-calculator/i18n/pt.ts +78 -0
  102. package/src/tool/book-pagination-and-spine-calculator/i18n/ru.ts +78 -0
  103. package/src/tool/book-pagination-and-spine-calculator/i18n/sv.ts +78 -0
  104. package/src/tool/book-pagination-and-spine-calculator/i18n/tr.ts +78 -0
  105. package/src/tool/book-pagination-and-spine-calculator/i18n/zh.ts +78 -0
  106. package/src/tool/book-pagination-and-spine-calculator/index.ts +11 -0
  107. package/src/tool/book-pagination-and-spine-calculator/logic.test.ts +21 -0
  108. package/src/tool/book-pagination-and-spine-calculator/logic.ts +52 -0
  109. package/src/tool/book-pagination-and-spine-calculator/seo.astro +16 -0
  110. package/src/tool/book-pagination-and-spine-calculator/storage.ts +22 -0
  111. package/src/tool/book-pagination-and-spine-calculator/ui.ts +35 -0
  112. package/src/tools.ts +5 -0
  113. package/src/types.ts +69 -0
  114. package/tsconfig.json +15 -0
  115. package/vitest.config.ts +20 -0
@@ -0,0 +1,55 @@
1
+ import { describe, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string, ext: string[]): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) return results;
8
+ const list = fs.readdirSync(dir);
9
+ for (const file of list) {
10
+ const fullPath = path.join(dir, file);
11
+ const stat = fs.statSync(fullPath);
12
+ if (stat && stat.isDirectory()) {
13
+ results.push(...getFiles(fullPath, ext));
14
+ } else if (ext.some((e) => file.endsWith(e))) {
15
+ results.push(fullPath);
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ const SRC_DIR = path.join(process.cwd(), 'src');
22
+
23
+ describe('Project Titles - Separator Validation', () => {
24
+ const files = [
25
+ ...getFiles(path.join(SRC_DIR, 'tool'), ['.ts']),
26
+ ...getFiles(path.join(SRC_DIR, 'category'), ['.ts']),
27
+ ].filter(f => f.includes('i18n'));
28
+
29
+ it.each(files)('Verify that titles in %s do not contain | or -', (filePath) => {
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ const relativePath = path.relative(process.cwd(), filePath);
32
+
33
+ const titlePatterns = [
34
+ /const\s+title\s*=\s*['"]([^'"]+)['"]/g,
35
+ /title\s*:\s*['"]([^'"]+)['"]/g,
36
+ ];
37
+
38
+ const findings: string[] = [];
39
+
40
+ for (const pattern of titlePatterns) {
41
+ let match;
42
+ while ((match = pattern.exec(content)) !== null) {
43
+ const title = match[1]!;
44
+ if (title.includes('|') || title.includes('-')) {
45
+ findings.push(title);
46
+ }
47
+ }
48
+ }
49
+
50
+ if (findings.length > 0) {
51
+ const list = findings.map((f) => ` - "${f}"`).join('\n');
52
+ throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { validateToolExports } from './shared-test-helpers';
4
+
5
+ describe('Tool Exports Pattern Validation', () => {
6
+ describe('Component Exports Format', () => {
7
+ ALL_TOOLS.forEach((tool) => {
8
+ it(`${tool.entry.id}: Component should be a lazy-loaded function`, () => {
9
+ expect(typeof tool.Component).toBe('function');
10
+ expect(tool.Component).toBeInstanceOf(Function);
11
+ });
12
+
13
+ it(`${tool.entry.id}: SEOComponent should be a lazy-loaded function`, () => {
14
+ expect(typeof tool.SEOComponent).toBe('function');
15
+ expect(tool.SEOComponent).toBeInstanceOf(Function);
16
+ });
17
+
18
+ it(`${tool.entry.id}: BibliographyComponent should be a lazy-loaded function`, () => {
19
+ expect(typeof tool.BibliographyComponent).toBe('function');
20
+ expect(tool.BibliographyComponent).toBeInstanceOf(Function);
21
+ });
22
+ });
23
+ });
24
+
25
+ describe('Dynamic Import Validation', () => {
26
+ it('all tools must have functional dynamic imports', async () => {
27
+ const result = await validateToolExports(ALL_TOOLS);
28
+ if (!result.passed) {
29
+ throw new Error(`Tool export validation failed:\n${result.failures.join('\n')}`);
30
+ }
31
+ expect(result.passed).toBe(true);
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { booksCategory } from '../data';
4
+
5
+ describe('Tool Validation Suite', () => {
6
+ describe('Library Registration', () => {
7
+ it('should have tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(1);
9
+ });
10
+
11
+ it('booksCategory should be defined', () => {
12
+ expect(booksCategory).toBeDefined();
13
+ expect(booksCategory.i18n).toBeDefined();
14
+ });
15
+ });
16
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const COPY_THRESHOLD = 0.9;
5
+
6
+ const STRUCTURAL_KEYS = new Set([
7
+ '@context',
8
+ '@type',
9
+ 'applicationCategory',
10
+ 'columns',
11
+ 'highlight',
12
+ 'icon',
13
+ 'level',
14
+ 'operatingSystem',
15
+ 'position',
16
+ 'positive',
17
+ 'price',
18
+ 'priceCurrency',
19
+ 'slug',
20
+ 'trend',
21
+ 'type',
22
+ 'url',
23
+ 'value',
24
+ 'variant',
25
+ ]);
26
+
27
+ function normalizeText(value: string): string {
28
+ return value
29
+ .replace(/<[^>]*>/g, ' ')
30
+ .replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
31
+ .replace(/[\u2018\u2019]/g, "'")
32
+ .replace(/[\u201c\u201d]/g, '"')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .toLocaleLowerCase();
36
+ }
37
+
38
+ function collectText(value: unknown, path: string, parts: string[]): void {
39
+ if (typeof value === 'string') {
40
+ const normalized = normalizeText(value);
41
+ if (normalized.length >= 2) parts.push(normalized);
42
+ return;
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
47
+ return;
48
+ }
49
+
50
+ if (!value || typeof value !== 'object') return;
51
+
52
+ Object.entries(value).forEach(([key, child]) => {
53
+ if (STRUCTURAL_KEYS.has(key)) return;
54
+ collectText(child, `${path}.${key}`, parts);
55
+ });
56
+ }
57
+
58
+ function localeCorpus(content: unknown): string {
59
+ if (!content || typeof content !== 'object') return '';
60
+
61
+ const record = content as Record<string, unknown>;
62
+ const parts: string[] = [];
63
+ collectText(record.title, 'title', parts);
64
+ collectText(record.description, 'description', parts);
65
+ collectText(record.faqTitle, 'faqTitle', parts);
66
+ collectText(record.faq, 'faq', parts);
67
+ collectText(record.seo, 'seo', parts);
68
+ collectText(record.schemas, 'schemas', parts);
69
+ return parts.join(' ');
70
+ }
71
+
72
+ function tokenCounts(text: string): Map<string, number> {
73
+ const counts = new Map<string, number>();
74
+ for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
75
+ counts.set(token, (counts.get(token) ?? 0) + 1);
76
+ }
77
+ return counts;
78
+ }
79
+
80
+ function copySimilarity(left: string, right: string): number {
81
+ const leftCounts = tokenCounts(left);
82
+ const rightCounts = tokenCounts(right);
83
+ const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
84
+ const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
85
+ if (leftTotal === 0 || rightTotal === 0) return 0;
86
+
87
+ let shared = 0;
88
+ for (const [token, count] of leftCounts) {
89
+ shared += Math.min(count, rightCounts.get(token) ?? 0);
90
+ }
91
+ return (2 * shared) / (leftTotal + rightTotal);
92
+ }
93
+
94
+ describe('Locales must not copy another locale wholesale', () => {
95
+ ALL_ENTRIES.forEach((entry) => {
96
+ it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
97
+ const corpora = await loadCorpora(entry);
98
+ const violations = findViolations(corpora);
99
+ expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
100
+ });
101
+ });
102
+ });
103
+
104
+ async function loadCorpora(entry: typeof ALL_ENTRIES[number]): Promise<Map<string, string>> {
105
+ const corpora = new Map<string, string>();
106
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
107
+ if (loader) corpora.set(locale, localeCorpus(await loader()));
108
+ }
109
+ return corpora;
110
+ }
111
+
112
+ function findViolations(corpora: Map<string, string>): string[] {
113
+ const locales = [...corpora.keys()];
114
+ const violations: string[] = [];
115
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
116
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
117
+ addViolation(violations, corpora, locales[leftIndex], locales[rightIndex]);
118
+ }
119
+ }
120
+ return violations;
121
+ }
122
+
123
+ function addViolation(violations: string[], corpora: Map<string, string>, left: string | undefined, right: string | undefined): void {
124
+ if (!left || !right) return;
125
+ const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
126
+ if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
127
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bookPaginationAndSpineCalculator } 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 = bookPaginationAndSpineCalculator.i18n[locale] || bookPaginationAndSpineCalculator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ <SharedBibliography links={content.bibliography} />
@@ -0,0 +1,7 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'ISO 216:2007 Paper sizes', url: 'https://www.iso.org/standard/36631.html' },
5
+ { name: 'Instituto Nacional de Estadística. Producción editorial de libros', url: 'https://www.ine.es/dyngs/INEbase/es/operacion.htm?c=Estadistica_C&cid=1254736176767&idp=1254735573113&menu=ultiDatos' },
6
+ { name: 'UNESCO Global Publishing Industry report', url: 'https://doi.org/10.58337/SRUH6078' },
7
+ ];
@@ -0,0 +1,298 @@
1
+ :root {
2
+ --n-ink: #17202a;
3
+ --n-muted: #68737c;
4
+ --n-surface: #fff;
5
+ --n-soft: #f5f1e9;
6
+ --n-line: #ddd5c7;
7
+ --n-accent: #8b4b35;
8
+ --n-accent-soft: #f0d9ce;
9
+ --n-paper: #ead7b0;
10
+ --n-paper-edge: #c4a875;
11
+ --n-on-accent: #fff;
12
+ --n-shadow: rgba(23, 32, 42, 0.1);
13
+ }
14
+
15
+ .theme-dark {
16
+ --n-ink: #f5f1e9;
17
+ --n-muted: #b7c0c6;
18
+ --n-surface: #1d252c;
19
+ --n-soft: #273139;
20
+ --n-line: #46515a;
21
+ --n-accent: #e39b7c;
22
+ --n-accent-soft: #4d322c;
23
+ --n-paper: #80694a;
24
+ --n-paper-edge: #ae8d5e;
25
+ --n-on-accent: #17202a;
26
+ --n-shadow: rgba(0, 0, 0, 0.28);
27
+ }
28
+
29
+ .book-pagination-tool {
30
+ background: var(--n-surface);
31
+ border: 1px solid var(--n-line);
32
+ border-radius: 1.25rem;
33
+ box-shadow: 0 1rem 2.5rem var(--n-shadow);
34
+ color: var(--n-ink);
35
+ display: grid;
36
+ gap: 1rem;
37
+ grid-template-columns: minmax(290px, .9fr) minmax(400px, 1.1fr);
38
+ padding: clamp(1.25rem, 3vw, 2.25rem);
39
+ }
40
+
41
+ .book-editor,
42
+ .book-output {
43
+ min-width: 0;
44
+ }
45
+
46
+ .book-output {
47
+ border-left: 1px solid var(--n-line);
48
+ padding-left: clamp(1rem, 3vw, 2rem);
49
+ }
50
+
51
+ .editor-heading,
52
+ .output-heading {
53
+ margin-bottom: 1.4rem;
54
+ }
55
+
56
+ .eyebrow {
57
+ color: var(--n-accent);
58
+ font-size: .7rem;
59
+ font-weight: 900;
60
+ letter-spacing: .12em;
61
+ text-transform: uppercase;
62
+ }
63
+
64
+ h2 {
65
+ font-size: clamp(1.5rem, 3vw, 2.4rem);
66
+ letter-spacing: -.05em;
67
+ line-height: 1;
68
+ margin: .45rem 0;
69
+ }
70
+
71
+ p {
72
+ color: var(--n-muted);
73
+ line-height: 1.5;
74
+ margin: 0;
75
+ }
76
+
77
+ label,
78
+ .field-label,
79
+ .preset-heading > span,
80
+ .unit-row > span {
81
+ display: block;
82
+ font-size: .78rem;
83
+ font-weight: 800;
84
+ margin: .85rem 0 .4rem;
85
+ }
86
+
87
+ input {
88
+ background: var(--n-soft);
89
+ border: 1px solid var(--n-line);
90
+ border-radius: .6rem;
91
+ box-sizing: border-box;
92
+ color: var(--n-ink);
93
+ font: inherit;
94
+ margin-top: .35rem;
95
+ padding: .7rem;
96
+ width: 100%;
97
+ }
98
+
99
+ input:focus,
100
+ button:focus-visible {
101
+ outline: 3px solid var(--n-accent-soft);
102
+ outline-offset: 2px;
103
+ }
104
+
105
+ small {
106
+ color: var(--n-muted);
107
+ font-weight: 500;
108
+ }
109
+
110
+ .preset-chips,
111
+ .binding-chips,
112
+ .editor-actions {
113
+ display: flex;
114
+ flex-wrap: wrap;
115
+ gap: .45rem;
116
+ }
117
+
118
+ .binding-chips {
119
+ gap: .7rem;
120
+ margin-top: .55rem;
121
+ }
122
+
123
+ .editor-actions {
124
+ margin-top: .8rem;
125
+ }
126
+
127
+ button {
128
+ background: var(--n-soft);
129
+ border: 1px solid var(--n-line);
130
+ border-radius: 999px;
131
+ color: var(--n-ink);
132
+ cursor: pointer;
133
+ font: inherit;
134
+ font-size: .75rem;
135
+ font-weight: 800;
136
+ padding: .55rem .75rem;
137
+ }
138
+
139
+ button:hover,
140
+ button.active {
141
+ border-color: var(--n-accent);
142
+ }
143
+
144
+ .button-primary,
145
+ .unit-row button {
146
+ background: var(--n-accent);
147
+ border-color: var(--n-accent);
148
+ color: var(--n-on-accent);
149
+ }
150
+
151
+ .unit-row {
152
+ align-items: center;
153
+ display: flex;
154
+ justify-content: space-between;
155
+ }
156
+
157
+ .input-grid {
158
+ display: grid;
159
+ gap: 0 .7rem;
160
+ grid-template-columns: 1fr 1fr;
161
+ }
162
+
163
+ .book-results {
164
+ display: grid;
165
+ gap: .55rem;
166
+ grid-template-columns: repeat(2, 1fr);
167
+ }
168
+
169
+ .book-result-card {
170
+ background: var(--n-soft);
171
+ border-radius: .8rem;
172
+ padding: .8rem;
173
+ }
174
+
175
+ .book-result-card span,
176
+ .book-result-card small {
177
+ color: var(--n-muted);
178
+ display: block;
179
+ font-size: .64rem;
180
+ font-weight: 800;
181
+ text-transform: uppercase;
182
+ }
183
+
184
+ .book-result-card strong {
185
+ display: block;
186
+ font-size: clamp(1.2rem, 2.5vw, 1.8rem);
187
+ margin: .3rem 0;
188
+ }
189
+
190
+ .result-pages {
191
+ background: var(--n-accent-soft);
192
+ }
193
+
194
+ .live-badge {
195
+ background: var(--n-accent-soft);
196
+ border-radius: 999px;
197
+ color: var(--n-accent);
198
+ font-size: .62rem;
199
+ font-weight: 900;
200
+ letter-spacing: .1em;
201
+ padding: .4rem .6rem;
202
+ }
203
+
204
+ .output-heading {
205
+ align-items: flex-start;
206
+ display: flex;
207
+ justify-content: space-between;
208
+ }
209
+
210
+ .book-preview {
211
+ align-items: center;
212
+ background: var(--n-soft);
213
+ border-radius: 1rem;
214
+ display: flex;
215
+ flex-direction: column;
216
+ justify-content: center;
217
+ margin-top: 1rem;
218
+ min-height: 18rem;
219
+ overflow: hidden;
220
+ padding: 1rem;
221
+ position: relative;
222
+ }
223
+
224
+ .book-shadow {
225
+ background: var(--n-shadow);
226
+ border-radius: 50%;
227
+ bottom: 2.4rem;
228
+ height: 1.5rem;
229
+ position: absolute;
230
+ width: 15rem;
231
+ }
232
+
233
+ .book-mockup {
234
+ align-items: stretch;
235
+ display: flex;
236
+ height: 12rem;
237
+ perspective: 45rem;
238
+ transform: rotate(-5deg);
239
+ z-index: 1;
240
+ }
241
+
242
+ .book-cover {
243
+ align-items: center;
244
+ background: var(--n-paper);
245
+ border: 1px solid var(--n-paper-edge);
246
+ color: var(--n-ink);
247
+ display: flex;
248
+ flex-direction: column;
249
+ justify-content: space-between;
250
+ padding: 1rem;
251
+ width: 7.5rem;
252
+ }
253
+
254
+ .book-cover.front {
255
+ box-shadow: .6rem .6rem .8rem var(--n-shadow);
256
+ transform: rotateY(-8deg);
257
+ }
258
+
259
+ .book-cover.back {
260
+ transform: rotateY(8deg);
261
+ }
262
+
263
+ .book-cover span {
264
+ font-size: .55rem;
265
+ font-weight: 900;
266
+ letter-spacing: .1em;
267
+ text-transform: uppercase;
268
+ }
269
+
270
+ .book-cover strong {
271
+ font-size: 1.4rem;
272
+ text-align: center;
273
+ }
274
+
275
+ .book-spine {
276
+ background: var(--n-accent);
277
+ width: var(--book-spine);
278
+ }
279
+
280
+ .book-preview p,
281
+ .source-note {
282
+ font-size: .72rem;
283
+ margin-top: 1rem;
284
+ text-align: center;
285
+ }
286
+
287
+ @media (max-width: 760px) {
288
+ .book-pagination-tool {
289
+ grid-template-columns: 1fr;
290
+ }
291
+
292
+ .book-output {
293
+ border-left: 0;
294
+ border-top: 1px solid var(--n-line);
295
+ padding-left: 0;
296
+ padding-top: 1.5rem;
297
+ }
298
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ import './book-pagination-and-spine-calculator.css';
3
+ import { NOVEL_PRESET } from './logic';
4
+ import type { BookPaginationUI } from './ui';
5
+
6
+ interface Props {
7
+ ui: BookPaginationUI;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <div class="book-pagination-tool" data-ui={JSON.stringify(ui)}>
14
+ <div class="book-editor">
15
+ <div class="editor-heading"><span class="eyebrow">{ui.title}</span><h2>{ui.methodTitle}</h2><p>{ui.methodHint}</p></div>
16
+ <label for="scenario-name">{ui.scenarioLabel}</label>
17
+ <input id="scenario-name" type="text" value="My book" placeholder={ui.scenarioPlaceholder} />
18
+ <div class="preset-heading"><span>{ui.presetLabel}</span><div class="preset-chips"><button type="button" data-preset="novel">{ui.novelPreset}</button><button type="button" data-preset="large-print">{ui.largePrintPreset}</button><button type="button" data-preset="workbook">{ui.workbookPreset}</button></div></div>
19
+ <div class="unit-row"><span>{ui.paperLabel}</span><button type="button" data-unit-toggle>{ui.metricLabel}</button></div>
20
+ <div class="input-grid">
21
+ <label for="words">{ui.wordsLabel}<input id="words" type="number" min="0" value={NOVEL_PRESET.words} /></label>
22
+ <label for="width">{ui.widthLabel} <small data-unit-label>(mm)</small><input id="width" type="number" min="1" step="0.1" value={NOVEL_PRESET.widthMm} /></label>
23
+ <label for="height">{ui.heightLabel} <small data-unit-label>(mm)</small><input id="height" type="number" min="1" step="0.1" value={NOVEL_PRESET.heightMm} /></label>
24
+ <label for="margin">{ui.marginLabel} <small data-unit-label>(mm)</small><input id="margin" type="number" min="0" step="0.1" value={NOVEL_PRESET.marginMm} /></label>
25
+ <label for="font-size">{ui.fontSizeLabel}<input id="font-size" type="number" min="5" step="0.5" value={NOVEL_PRESET.fontSizePt} /></label>
26
+ <label for="line-height">{ui.lineHeightLabel}<input id="line-height" type="number" min="0.8" step="0.05" value={NOVEL_PRESET.lineHeight} /></label>
27
+ <label for="caliper">{ui.caliperLabel} <small data-unit-label>(mm)</small><input id="caliper" type="number" min="0.01" step="0.01" value={NOVEL_PRESET.caliperMm} /></label>
28
+ </div>
29
+ <span class="field-label">{ui.bindingLabel}</span><div class="binding-chips"><button type="button" data-binding="softcover">{ui.softcoverLabel}</button><button type="button" data-binding="hardcover">{ui.hardcoverLabel}</button><button type="button" data-binding="saddle-stitch">{ui.saddleLabel}</button></div>
30
+ <div class="editor-actions"><button type="button" class="button-primary" data-action="reset">{ui.resetLabel}</button></div>
31
+ </div>
32
+ <div class="book-output">
33
+ <div class="output-heading"><div><span class="eyebrow">{ui.previewLabel}</span><h2>{ui.pagesLabel}</h2></div><span class="live-badge">LIVE</span></div>
34
+ <div id="book-results" class="book-results"></div>
35
+ <div id="book-preview" class="book-preview" aria-live="polite"></div>
36
+ <p class="source-note">{ui.sourceLabel}</p>
37
+ </div>
38
+ </div>
39
+
40
+ <script src="./controller.ts"></script>
@@ -0,0 +1,87 @@
1
+ import { calculateBook, LARGE_PRINT_PRESET, NOVEL_PRESET, WORKBOOK_PRESET, type Binding, type BookInputs } from './logic';
2
+ import { renderPreview, renderResults } from './dom-views';
3
+ import { loadBookState, saveBookState } from './storage';
4
+ import type { BookPaginationUI } from './ui';
5
+
6
+ const root = document.querySelector<HTMLElement>('.book-pagination-tool');
7
+ if (!root) throw new Error('Book pagination tool root not found');
8
+ const toolRoot = root;
9
+ const ui = JSON.parse(root.dataset.ui ?? '{}') as BookPaginationUI;
10
+ const scenarioInput = root.querySelector<HTMLInputElement>('#scenario-name')!;
11
+ const unitButton = root.querySelector<HTMLButtonElement>('[data-unit-toggle]')!;
12
+ const result = root.querySelector<HTMLElement>('#book-results')!;
13
+ const preview = root.querySelector<HTMLElement>('#book-preview')!;
14
+ const fields = ['words', 'width', 'height', 'margin', 'font-size', 'line-height', 'caliper'].map((id) => root.querySelector<HTMLInputElement>(`#${id}`)!);
15
+ const bindingButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-binding]')];
16
+ let unit: 'metric' | 'imperial' = 'metric';
17
+ let binding: Binding = 'softcover';
18
+ const fallback = { inputs: JSON.stringify(NOVEL_PRESET), unit, scenarioName: scenarioInput.value };
19
+ const saved = loadBookState(fallback);
20
+ unit = saved.unit;
21
+ scenarioInput.value = saved.scenarioName;
22
+ loadInputs(JSON.parse(saved.inputs) as BookInputs);
23
+
24
+ function readInputs(): BookInputs {
25
+ const factor = unit === 'metric' ? 1 : 25.4;
26
+ return { words: number('words'), widthMm: number('width') * factor, heightMm: number('height') * factor, marginMm: number('margin') * factor, fontSizePt: number('font-size'), lineHeight: number('line-height'), caliperMm: number('caliper') * factor, binding };
27
+ }
28
+
29
+ function number(id: string): number {
30
+ return Math.max(Number(toolRoot.querySelector<HTMLInputElement>(`#${id}`)!.value) || 0, 0);
31
+ }
32
+
33
+ function render(): void {
34
+ const inputs = readInputs();
35
+ const calculation = calculateBook(inputs);
36
+ renderResults(result, calculation, unit, ui);
37
+ renderPreview(preview, calculation, ui);
38
+ unitButton.textContent = unit === 'metric' ? ui.metricLabel : ui.imperialLabel;
39
+ toolRoot.querySelectorAll<HTMLElement>('[data-unit-label]').forEach((label) => {
40
+ label.textContent = unit === 'metric' ? '(mm)' : '(in)';
41
+ });
42
+ bindingButtons.forEach((button) => button.classList.toggle('active', button.dataset.binding === binding));
43
+ saveBookState({ inputs: JSON.stringify(inputs), unit, scenarioName: scenarioInput.value });
44
+ }
45
+
46
+ function loadInputs(inputs: BookInputs): void {
47
+ const factor = unit === 'metric' ? 1 : 1 / 25.4;
48
+ setField('words', inputs.words);
49
+ setField('width', inputs.widthMm * factor);
50
+ setField('height', inputs.heightMm * factor);
51
+ setField('margin', inputs.marginMm * factor);
52
+ setField('font-size', inputs.fontSizePt);
53
+ setField('line-height', inputs.lineHeight);
54
+ setField('caliper', inputs.caliperMm * factor);
55
+ binding = inputs.binding;
56
+ }
57
+
58
+ function setField(id: string, value: number): void {
59
+ toolRoot.querySelector<HTMLInputElement>(`#${id}`)!.value = String(value);
60
+ }
61
+
62
+ function toggleUnit(): void {
63
+ const oldFactor = unit === 'metric' ? 1 : 25.4;
64
+ unit = unit === 'metric' ? 'imperial' : 'metric';
65
+ const newFactor = unit === 'metric' ? 1 : 25.4;
66
+ ['width', 'height', 'margin', 'caliper'].forEach((id) => setField(id, number(id) * oldFactor / newFactor));
67
+ render();
68
+ }
69
+
70
+ function applyPreset(inputs: BookInputs, name: string): void {
71
+ scenarioInput.value = name;
72
+ loadInputs(inputs);
73
+ render();
74
+ }
75
+
76
+ unitButton.addEventListener('click', toggleUnit);
77
+ fields.forEach((field) => field.addEventListener('input', render));
78
+ scenarioInput.addEventListener('input', render);
79
+ bindingButtons.forEach((button) => button.addEventListener('click', () => {
80
+ binding = button.dataset.binding as Binding;
81
+ render();
82
+ }));
83
+ root.querySelector<HTMLButtonElement>('[data-preset="novel"]')?.addEventListener('click', () => applyPreset(NOVEL_PRESET, ui.novelPreset));
84
+ root.querySelector<HTMLButtonElement>('[data-preset="large-print"]')?.addEventListener('click', () => applyPreset(LARGE_PRINT_PRESET, ui.largePrintPreset));
85
+ root.querySelector<HTMLButtonElement>('[data-preset="workbook"]')?.addEventListener('click', () => applyPreset(WORKBOOK_PRESET, ui.workbookPreset));
86
+ root.querySelector<HTMLButtonElement>('[data-action="reset"]')?.addEventListener('click', () => applyPreset(NOVEL_PRESET, 'My book'));
87
+ render();