@jjlmoya/utils-finance 1.36.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.
- package/package.json +17 -5
- package/scripts/postinstall.mjs +2 -4
- package/src/components/ProductionBreadcrumb.astro +134 -0
- package/src/components/ProductionStructuredData.astro +46 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/i18n/header-ui.ts +15 -0
- package/src/i18n/language-ui.ts +9 -0
- package/src/i18n/languages.ts +24 -0
- package/src/identity/brands.ts +5 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +218 -0
- package/src/layouts/ProductionUtilityPage.astro +283 -0
- package/src/mfe/assets.ts +39 -0
- package/src/mfe/category-ui.ts +34 -0
- package/src/mfe/manifest.ts +45 -0
- package/src/mfe/routes.ts +50 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
- package/src/pages/index.astro +1 -2
- package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/mfe_cache_contract.test.ts +11 -0
- package/src/tests/mfe_manifest.test.ts +28 -0
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/script_density.test.ts +94 -94
- package/src/tests/translation_copy.test.ts +2 -2
- package/src/tool/compoundInterest/index.ts +1 -0
- package/src/tool/courtFeeCalculator/index.ts +1 -0
- package/src/tool/debtSnowball/index.ts +1 -0
- package/src/tool/fireCalculator/index.ts +1 -0
- package/src/tool/ibanBicSwiftConverter/index.ts +1 -0
- package/src/tool/inflation/index.ts +1 -0
- package/src/tool/lateInterest/index.ts +1 -0
- package/src/tool/legalInterestRate/index.ts +1 -0
- package/src/tool/lotteryOptimizer/index.ts +1 -0
- package/src/tool/mortgage/index.ts +1 -0
- package/src/tool/percentageCalculator/index.ts +1 -0
- package/src/tool/rentIncreaseCalculator/index.ts +1 -0
- package/src/types.ts +2 -4
- package/src/worker.ts +25 -0
- package/src/pages/[locale]/[slug].astro +0 -161
- package/src/pages/[locale].astro +0 -251
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
|
|
4
|
-
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
-
|
|
6
|
-
const SCRIPT_RULES = {
|
|
7
|
-
ja: {
|
|
8
|
-
language: 'Japanese',
|
|
9
|
-
scriptName: 'kana/kanji',
|
|
10
|
-
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
-
minScriptRatio: 0.45,
|
|
12
|
-
},
|
|
13
|
-
ko: {
|
|
14
|
-
language: 'Korean',
|
|
15
|
-
scriptName: 'hangul',
|
|
16
|
-
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
-
minScriptRatio: 0.55,
|
|
18
|
-
},
|
|
19
|
-
ru: {
|
|
20
|
-
language: 'Russian',
|
|
21
|
-
scriptName: 'cyrillic',
|
|
22
|
-
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
-
minScriptRatio: 0.65,
|
|
24
|
-
},
|
|
25
|
-
zh: {
|
|
26
|
-
language: 'Chinese',
|
|
27
|
-
scriptName: 'han',
|
|
28
|
-
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
-
minScriptRatio: 0.45,
|
|
30
|
-
},
|
|
31
|
-
} as const;
|
|
32
|
-
|
|
33
|
-
const LETTERS = /\p{L}/gu;
|
|
34
|
-
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
-
|
|
36
|
-
function collectStrings(value: unknown): string[] {
|
|
37
|
-
if (typeof value === 'string') return [value];
|
|
38
|
-
if (!value || typeof value !== 'object') return [];
|
|
39
|
-
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
-
return Object.values(value).flatMap(collectStrings);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeText(value: unknown): string {
|
|
44
|
-
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
-
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function letterCount(text: string): number {
|
|
52
|
-
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
-
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
-
const letters = letterCount(text);
|
|
61
|
-
if (letters === 0) return 0;
|
|
62
|
-
return scriptCount(text, locale) / letters;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe('Native script density validation', () => {
|
|
66
|
-
ALL_TOOLS.forEach((tool) => {
|
|
67
|
-
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
-
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
-
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
-
const typedLocale = locale as ScriptLocale;
|
|
71
|
-
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
-
if (!loader) return;
|
|
73
|
-
|
|
74
|
-
const content = await loader();
|
|
75
|
-
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
77
|
-
const letters = letterCount(text);
|
|
78
|
-
const matches = scriptCount(text, typedLocale);
|
|
79
|
-
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
-
|
|
81
|
-
expect(
|
|
82
|
-
ratio,
|
|
83
|
-
[
|
|
84
|
-
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
-
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
-
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
-
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
-
].join(' '),
|
|
89
|
-
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -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 similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
112
|
|
|
113
113
|
if (similarity >= COPY_THRESHOLD) {
|
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' | 'id' | '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;
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
|
|
7
|
+
export const getCacheControl = (pathname: string): string | undefined => {
|
|
8
|
+
if (pathname.endsWith("/manifest.json")) return LONG_LIVED_ASSET_CACHE;
|
|
9
|
+
if (pathname.startsWith("/_utilities/")) {
|
|
10
|
+
return LONG_LIVED_ASSET_CACHE;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
|
|
17
|
+
const response = await environment.ASSETS.fetch(request);
|
|
18
|
+
const cacheControl = getCacheControl(new URL(request.url).pathname);
|
|
19
|
+
if (!cacheControl) return response;
|
|
20
|
+
|
|
21
|
+
const headers = new Headers(response.headers);
|
|
22
|
+
headers.set("Cache-Control", cacheControl);
|
|
23
|
+
return new Response(response.body, { status: response.status, headers });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -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} locale={locale} />
|
|
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.faqTitle} inLanguage={locale} />
|
|
136
|
-
</section>
|
|
137
|
-
|
|
138
|
-
<section class="section-bibliography">
|
|
139
|
-
<Bibliography links={content.bibliography} />
|
|
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>
|