@tomako/tools-runtime 0.1.0 → 0.1.1
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomako/tools-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Shared interactive tools runtime for Tomako-FE workspace dialogs and Tomako-SEO tool pages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -579,7 +579,6 @@
|
|
|
579
579
|
],
|
|
580
580
|
"sideEffects": false,
|
|
581
581
|
"peerDependencies": {
|
|
582
|
-
"@tomako/public-chrome": ">=0.1.0",
|
|
583
582
|
"@tomako/ui": ">=0.1.0",
|
|
584
583
|
"next": ">=15",
|
|
585
584
|
"next-intl": ">=4",
|
|
@@ -619,8 +618,7 @@
|
|
|
619
618
|
"react-dom": "^19.0.0",
|
|
620
619
|
"typescript": "^5.8.0",
|
|
621
620
|
"zod": "^4.4.3",
|
|
622
|
-
"@tomako/ui": "0.1.0"
|
|
623
|
-
"@tomako/public-chrome": "0.1.0"
|
|
621
|
+
"@tomako/ui": "0.1.0"
|
|
624
622
|
},
|
|
625
623
|
"license": "UNLICENSED",
|
|
626
624
|
"publishConfig": {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
export type TomatoLogoProps = {
|
|
4
|
+
href: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
showUnionMark?: boolean;
|
|
8
|
+
inverted?: boolean;
|
|
9
|
+
target?: "_blank" | "_self" | "_parent" | "_top";
|
|
10
|
+
rel?: string;
|
|
11
|
+
/**
|
|
12
|
+
* When running behind an asset host / CDN.
|
|
13
|
+
* Keep it optional since SEO/public routes typically serve assets from `/`.
|
|
14
|
+
*/
|
|
15
|
+
assetPrefix?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function TomatoLogo({
|
|
19
|
+
href,
|
|
20
|
+
className,
|
|
21
|
+
label = "Tomako",
|
|
22
|
+
showUnionMark,
|
|
23
|
+
inverted = false,
|
|
24
|
+
target,
|
|
25
|
+
rel,
|
|
26
|
+
assetPrefix = "",
|
|
27
|
+
}: TomatoLogoProps) {
|
|
28
|
+
const shouldShowUnionMark = showUnionMark ?? className === undefined;
|
|
29
|
+
const resolvedClassName =
|
|
30
|
+
className ??
|
|
31
|
+
"group flex w-[172px] shrink-0 cursor-pointer items-center justify-start gap-3 border-r border-[#DDD8CF] px-5 transition-colors hover:bg-white";
|
|
32
|
+
|
|
33
|
+
const backgroundImageBase: CSSProperties = {
|
|
34
|
+
backgroundRepeat: "no-repeat",
|
|
35
|
+
backgroundPosition: "center",
|
|
36
|
+
backgroundSize: "contain",
|
|
37
|
+
imageRendering: "pixelated" as CSSProperties["imageRendering"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<a
|
|
42
|
+
href={href}
|
|
43
|
+
className={resolvedClassName}
|
|
44
|
+
target={target}
|
|
45
|
+
rel={rel}
|
|
46
|
+
aria-label={label}
|
|
47
|
+
>
|
|
48
|
+
<span
|
|
49
|
+
aria-hidden
|
|
50
|
+
className="block h-[29px] w-[35px] bg-contain bg-center bg-no-repeat [image-rendering:pixelated]"
|
|
51
|
+
style={{
|
|
52
|
+
...backgroundImageBase,
|
|
53
|
+
backgroundImage: `url('${assetPrefix}/landing/tomato-mark.svg')`,
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
{shouldShowUnionMark ? (
|
|
57
|
+
<span
|
|
58
|
+
aria-hidden
|
|
59
|
+
className={`block h-[18px] w-[88px] bg-contain bg-left bg-no-repeat ${inverted ? "invert" : ""}`}
|
|
60
|
+
style={{
|
|
61
|
+
...backgroundImageBase,
|
|
62
|
+
backgroundImage: `url('${assetPrefix}/landing/tomako-union-mark.svg')`,
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
) : null}
|
|
66
|
+
</a>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default TomatoLogo;
|
|
71
|
+
|
|
@@ -3,18 +3,8 @@ import Image from "next/image";
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import { relatedToolSlugsByTool } from "../../../constants/related-tools";
|
|
6
|
-
import {
|
|
7
|
-
RelatedTools,
|
|
8
|
-
SiteFooter,
|
|
9
|
-
SiteNavigation,
|
|
10
|
-
getAuthClientMessages,
|
|
11
|
-
siteConfig,
|
|
12
|
-
type SiteNavigationCopy,
|
|
13
|
-
} from "../../../host/server";
|
|
14
|
-
import { bcp47ByLocale } from "../../../i18n/locale-fallback";
|
|
6
|
+
import { RelatedTools } from "../../../host/server";
|
|
15
7
|
import type { AppLocale } from "../../../i18n/locale";
|
|
16
|
-
import { localizedPath } from "../../../lib/routing/locale-paths";
|
|
17
|
-
import { serializeJsonLd } from "../../../lib/seo-meta";
|
|
18
8
|
import { resolveToolLocaleCopy } from "../../../lib/tools/resolve-tool-locale-copy";
|
|
19
9
|
|
|
20
10
|
import type { ToolSpec } from "../types";
|
|
@@ -62,136 +52,20 @@ export async function ToolPageShell({
|
|
|
62
52
|
handoffSlot?: ReactNode;
|
|
63
53
|
children: React.ReactNode;
|
|
64
54
|
}) {
|
|
55
|
+
// ToolMarketingShell 在 SEO route 层输出 JSON-LD / 导航 / 页脚;
|
|
56
|
+
// runtime 仅保留 hero / 内容 / related tools 的布局。
|
|
57
|
+
void faqItems;
|
|
58
|
+
void handoffSlot;
|
|
59
|
+
|
|
65
60
|
const copy = resolveToolLocaleCopy(spec, locale);
|
|
66
61
|
const relatedToolSlugs = relatedToolSlugsByTool[spec.slug];
|
|
67
62
|
const displayTitle = copy.displayTitle ?? copy.title;
|
|
68
63
|
// Full messages: public getRequestConfig omits toolsPages; client tools layout alone is not enough for RSC.
|
|
69
64
|
const messages = await loadToolMessages(locale);
|
|
70
65
|
const t = createToolMessageTranslator(locale, messages, "toolsPages.shared");
|
|
71
|
-
const navT = createToolMessageTranslator(locale, messages, "page.landing.hero");
|
|
72
|
-
const navigationCopy: SiteNavigationCopy = {
|
|
73
|
-
languageLabel: navT("languageLabel"),
|
|
74
|
-
productCapabilities: navT.raw("atomicCapabilities") as SiteNavigationCopy["productCapabilities"],
|
|
75
|
-
nav: {
|
|
76
|
-
tools: navT("nav.tools"),
|
|
77
|
-
viewAllTools: navT("nav.viewAllTools"),
|
|
78
|
-
pricing: navT("nav.pricing"),
|
|
79
|
-
blog: navT("nav.blog"),
|
|
80
|
-
skills: navT("nav.skills"),
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
const authMessages = await getAuthClientMessages(locale);
|
|
84
|
-
const toolUrl = new URL(
|
|
85
|
-
localizedPath(locale, `tools/${spec.slug}`),
|
|
86
|
-
siteConfig.url,
|
|
87
|
-
).href;
|
|
88
|
-
const toolJsonLd = {
|
|
89
|
-
"@context": "https://schema.org",
|
|
90
|
-
"@type": "WebApplication",
|
|
91
|
-
name: copy.title,
|
|
92
|
-
description: copy.description,
|
|
93
|
-
url: toolUrl,
|
|
94
|
-
applicationCategory: "BusinessApplication",
|
|
95
|
-
operatingSystem: "Web",
|
|
96
|
-
inLanguage: bcp47ByLocale[locale],
|
|
97
|
-
isAccessibleForFree: true,
|
|
98
|
-
offers: {
|
|
99
|
-
"@type": "Offer",
|
|
100
|
-
price: "0",
|
|
101
|
-
priceCurrency: "USD",
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const breadcrumbJsonLd = {
|
|
106
|
-
"@context": "https://schema.org",
|
|
107
|
-
"@type": "BreadcrumbList",
|
|
108
|
-
itemListElement: [
|
|
109
|
-
{
|
|
110
|
-
"@type": "ListItem",
|
|
111
|
-
position: 1,
|
|
112
|
-
name: siteConfig.name,
|
|
113
|
-
item: new URL(localizedPath(locale), siteConfig.url).href,
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
"@type": "ListItem",
|
|
117
|
-
position: 2,
|
|
118
|
-
name: t("breadcrumbTools"),
|
|
119
|
-
item: new URL(localizedPath(locale, "tools"), siteConfig.url).href,
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
"@type": "ListItem",
|
|
123
|
-
position: 3,
|
|
124
|
-
name: copy.title,
|
|
125
|
-
item: toolUrl,
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
// FAQ 来源优先级:显式 faqItems(package 工具)→ toolsPages.{camelSlug}.guide.faqItems;都缺失时不输出 FAQPage
|
|
131
|
-
let resolvedFaqItems: Array<{ question: string; answer: string }> =
|
|
132
|
-
faqItems ?? [];
|
|
133
|
-
if (resolvedFaqItems.length === 0) {
|
|
134
|
-
const camelSlug = spec.slug.replace(/-([a-z0-9])/g, (_, char: string) =>
|
|
135
|
-
char.toUpperCase(),
|
|
136
|
-
);
|
|
137
|
-
try {
|
|
138
|
-
const guideT = createToolMessageTranslator(
|
|
139
|
-
locale,
|
|
140
|
-
messages,
|
|
141
|
-
`toolsPages.${camelSlug}.guide`,
|
|
142
|
-
);
|
|
143
|
-
const rawFaqItems = guideT.raw("faqItems") as unknown;
|
|
144
|
-
resolvedFaqItems = Array.isArray(rawFaqItems)
|
|
145
|
-
? rawFaqItems.filter(
|
|
146
|
-
(item): item is { question: string; answer: string } =>
|
|
147
|
-
typeof item?.question === "string" &&
|
|
148
|
-
typeof item?.answer === "string",
|
|
149
|
-
)
|
|
150
|
-
: [];
|
|
151
|
-
} catch {
|
|
152
|
-
resolvedFaqItems = [];
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const faqJsonLd: Record<string, unknown> | null =
|
|
156
|
-
resolvedFaqItems.length > 0
|
|
157
|
-
? {
|
|
158
|
-
"@context": "https://schema.org",
|
|
159
|
-
"@type": "FAQPage",
|
|
160
|
-
mainEntity: resolvedFaqItems.map((item) => ({
|
|
161
|
-
"@type": "Question",
|
|
162
|
-
name: item.question,
|
|
163
|
-
acceptedAnswer: {
|
|
164
|
-
"@type": "Answer",
|
|
165
|
-
text: item.answer,
|
|
166
|
-
},
|
|
167
|
-
})),
|
|
168
|
-
}
|
|
169
|
-
: null;
|
|
170
66
|
|
|
171
67
|
return (
|
|
172
68
|
<div className="min-h-screen overflow-x-clip bg-white text-[#111111]">
|
|
173
|
-
<script
|
|
174
|
-
type="application/ld+json"
|
|
175
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(toolJsonLd) }}
|
|
176
|
-
/>
|
|
177
|
-
<script
|
|
178
|
-
type="application/ld+json"
|
|
179
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
180
|
-
/>
|
|
181
|
-
{faqJsonLd ? (
|
|
182
|
-
<script
|
|
183
|
-
type="application/ld+json"
|
|
184
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqJsonLd) }}
|
|
185
|
-
/>
|
|
186
|
-
) : null}
|
|
187
|
-
<SiteNavigation
|
|
188
|
-
locale={locale}
|
|
189
|
-
copy={navigationCopy}
|
|
190
|
-
authMessages={authMessages}
|
|
191
|
-
surface="solid"
|
|
192
|
-
className="sticky top-0 z-50 border-t-0"
|
|
193
|
-
/>
|
|
194
|
-
{handoffSlot}
|
|
195
69
|
<main className="overflow-x-clip">
|
|
196
70
|
{heroVariant === "immersive" ? (
|
|
197
71
|
<>
|
|
@@ -342,7 +216,6 @@ export async function ToolPageShell({
|
|
|
342
216
|
</div>
|
|
343
217
|
) : null}
|
|
344
218
|
</main>
|
|
345
|
-
<SiteFooter locale={locale} />
|
|
346
219
|
</div>
|
|
347
220
|
);
|
|
348
221
|
}
|
|
@@ -2,16 +2,12 @@ import {
|
|
|
2
2
|
ArrowRight,
|
|
3
3
|
CopyCheck,
|
|
4
4
|
} from "lucide-react";
|
|
5
|
-
import { TomatoLogo } from "
|
|
5
|
+
import { TomatoLogo } from "../../../components/tomato-logo";
|
|
6
6
|
import { createToolGuideTranslator } from "../shared/tool-message-translator";
|
|
7
7
|
import type { CSSProperties } from "react";
|
|
8
|
-
import { siteConfig } from "../../../host/server";
|
|
9
8
|
import { isZhRouteLocale } from "../../../i18n/locale-fallback";
|
|
10
9
|
import type { AppLocale } from "../../../i18n/locale";
|
|
11
10
|
import { buildAppHomeUrl } from "../../../lib/app-origin";
|
|
12
|
-
import { localizedPath } from "../../../lib/routing/locale-paths";
|
|
13
|
-
import { serializeJsonLd } from "../../../lib/seo-meta";
|
|
14
|
-
import { resolveToolLocaleCopy } from "../../../lib/tools/resolve-tool-locale-copy";
|
|
15
11
|
import { TarotReadingWidget } from "./widget";
|
|
16
12
|
import { ToolCta } from "../shared/tool-cta";
|
|
17
13
|
import {
|
|
@@ -288,33 +284,16 @@ function TimelineRows({ items }: { items: readonly TextCard[] }) {
|
|
|
288
284
|
|
|
289
285
|
export async function TarotReadingContainer({ locale, spec }: ToolPageProps) {
|
|
290
286
|
const t = await createToolGuideTranslator(locale, "toolsPages.tarotReading.guide");
|
|
291
|
-
const copy = resolveToolLocaleCopy(spec, locale);
|
|
292
287
|
const valueItems = t.raw("valueItems") as readonly TextCard[];
|
|
293
288
|
const flowItems = t.raw("flowItems") as readonly TextCard[];
|
|
294
289
|
const marketingItems = t.raw("marketingItems") as readonly TextCard[];
|
|
295
290
|
const boundaryItems = t.raw("boundaryItems") as readonly TextCard[];
|
|
296
291
|
const faqItems = t.raw("faqItems") as readonly FaqItem[];
|
|
297
|
-
const toolUrl = new URL(localizedPath(locale, `tools/${spec.slug}`), siteConfig.url).href;
|
|
298
|
-
const toolJsonLd = {
|
|
299
|
-
"@context": "https://schema.org",
|
|
300
|
-
"@type": "WebApplication",
|
|
301
|
-
name: copy.title,
|
|
302
|
-
description: copy.description,
|
|
303
|
-
url: toolUrl,
|
|
304
|
-
applicationCategory: "LifestyleApplication",
|
|
305
|
-
operatingSystem: "Web",
|
|
306
|
-
inLanguage: locale,
|
|
307
|
-
isAccessibleForFree: true,
|
|
308
|
-
};
|
|
309
292
|
|
|
310
293
|
return (
|
|
311
294
|
<main className="min-h-screen bg-[#F7F6F2] text-[#111111]">
|
|
312
295
|
{/* eslint-disable-next-line @next/next/no-css-tags -- keep tarot-only styles off every /tools/[slug] page. */}
|
|
313
296
|
<link rel="stylesheet" href="/tools/tarot-reading.css" />
|
|
314
|
-
<script
|
|
315
|
-
type="application/ld+json"
|
|
316
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(toolJsonLd) }}
|
|
317
|
-
/>
|
|
318
297
|
<section className="relative min-h-svh overflow-hidden bg-black text-[#F7F0DE]">
|
|
319
298
|
<TarotAtmosphere />
|
|
320
299
|
<HeroChrome locale={locale} />
|