Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/web/__tests__/i18n-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "fs";
import { join } from "path";

const MESSAGES_DIR = join(__dirname, "..", "messages");

function collectKeys(obj: unknown, prefix = ""): string[] {
if (obj === null || typeof obj !== "object") return [];
const keys: string[] = [];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
keys.push(...collectKeys(v, path));
} else {
keys.push(path);
}
}
return keys;
}

describe("i18n messages parity", () => {
const enPath = join(MESSAGES_DIR, "en.json");
const en = JSON.parse(readFileSync(enPath, "utf-8")) as unknown;
const enKeys = new Set(collectKeys(en));

const localeFiles = readdirSync(MESSAGES_DIR).filter(
(f) => f.endsWith(".json") && f !== "en.json"
);

for (const file of localeFiles) {
it(`${file} has the same key set as en.json`, () => {
const data = JSON.parse(
readFileSync(join(MESSAGES_DIR, file), "utf-8")
) as unknown;
const keys = new Set(collectKeys(data));

const missing = [...enKeys].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !enKeys.has(k));

expect({ file, missing, extra }).toEqual({
file,
missing: [],
extra: [],
});
});
}
});
24 changes: 24 additions & 0 deletions apps/web/__tests__/lib/page-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { locales } from "../../i18n/config";
import { getAllTools, getAllCategories } from "@utils-live/tools";

// Cloudflare Pages free plan caps a single deployment at 20,000 pages.
// generateStaticParams emits (locales × (tools + categories + static + blog))
// so re-enabling locales without checking this silently breaks deploys.
// Keep a comfortable headroom below the ceiling.
const PAGE_BUDGET_CEILING = 19500;
const STATIC_PAGES_PER_LOCALE = 10; // home, about, contact, privacy, tools, etc.

describe("Cloudflare Pages page-budget guard", () => {
it("total generated static pages stays under the 20k ceiling", () => {
const toolCount = getAllTools().length;
const categoryCount = getAllCategories().length;
const estimated =
locales.length * (toolCount + categoryCount + STATIC_PAGES_PER_LOCALE);

expect({ locales: locales.length, estimated }).toMatchObject({
locales: locales.length,
});
expect(estimated).toBeLessThan(PAGE_BUDGET_CEILING);
});
});
59 changes: 31 additions & 28 deletions apps/web/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ToolDemo } from "@/components/marketing/tool-demo";
import { CategoryShowcase } from "@/components/marketing/category-showcase";
import { FeatureCards } from "@/components/marketing/feature-cards";
import { CTASection } from "@/components/marketing/cta-section";
import { MotionProvider } from "@/components/providers/motion-provider";
import { buildAlternates } from "@/lib/alternates";

const toolCountLabel = getToolCountLabel();
Expand Down Expand Up @@ -70,35 +71,37 @@ export default async function HomePage({
<Header />

<main id="main-content" className="flex-1">
<HeroSection
toolCountLabel={toolCountLabel}
categories={[
"json",
"encoding",
"text",
"crypto",
"jwt",
"regex",
"color",
"datetime",
].map((id) => ({
id,
name:
categoryMetaMessages?.[id]?.name ??
categories.find((c) => c.id === id)?.name ??
<MotionProvider>
<HeroSection
toolCountLabel={toolCountLabel}
categories={[
"json",
"encoding",
"text",
"crypto",
"jwt",
"regex",
"color",
"datetime",
].map((id) => ({
id,
}))}
/>
<ToolDemo toolCount={getRoundedToolCount()} />
<CategoryShowcase
categories={showcaseCategories}
className="bg-muted/30 border-y"
/>
<FeatureCards toolCountLabel={toolCountLabel} />
<CTASection
toolCount={getRoundedToolCount()}
categoryCount={categories.length}
/>
name:
categoryMetaMessages?.[id]?.name ??
categories.find((c) => c.id === id)?.name ??
id,
}))}
/>
<ToolDemo toolCount={getRoundedToolCount()} />
<CategoryShowcase
categories={showcaseCategories}
className="bg-muted/30 border-y"
/>
<FeatureCards toolCountLabel={toolCountLabel} />
<CTASection
toolCount={getRoundedToolCount()}
categoryCount={categories.length}
/>
</MotionProvider>
</main>

<Footer />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { ToolMeta, ToolUIConfig } from "@utils-live/tools/constants";
import { ToolTier } from "@utils-live/tools/constants";
import { DualInputLayout } from "@/components/tools/dual-input-layout";
Expand Down Expand Up @@ -207,6 +207,13 @@ export function DiffToolLayout({
[input1, reset]
);

const downloadFilename = useMemo(() => {
void result; // Recompute timestamp when a new result arrives.
const slug = tool.name.toLowerCase().replace(/\s+/g, "-");
const ext = getFileExtension(ui.outputLanguage ?? ui.inputLanguage);
return `${slug}-output-${Date.now()}${ext}`;
}, [tool.name, ui.outputLanguage, ui.inputLanguage, result]);

return (
<div className="flex flex-col gap-4">
<div
Expand Down Expand Up @@ -244,7 +251,8 @@ export function DiffToolLayout({
language={ui.outputLanguage ?? ui.inputLanguage}
isLoading={isExecuting}
isAutoMode={tool.tier === ToolTier.CLIENT}
downloadFilename={`${tool.name.toLowerCase().replace(/\s+/g, "-")}-output-${Date.now()}${getFileExtension(ui.outputLanguage ?? ui.inputLanguage)}`}
htmlPreviewAllowScripts={ui.htmlPreviewAllowScripts}
downloadFilename={downloadFilename}
onCopy={onCopy}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useCallback, useRef } from "react";
import { useEffect, useCallback, useMemo, useRef } from "react";
import type { ToolMeta, ToolUIConfig } from "@utils-live/tools/constants";
import { useTranslations } from "next-intl";
import { ToolLayout } from "@/components/tools/tool-layout";
Expand Down Expand Up @@ -135,6 +135,13 @@ export function GeneratorToolLayout({
const inputSchemaFormatted = inputSchema as unknown as FormattedSchema;
const optionsSchemaFormatted = optionsSchema as unknown as FormattedSchema;

const downloadFilename = useMemo(() => {
void result; // Recompute timestamp when a new result arrives.
const slug = tool.name.toLowerCase().replace(/\s+/g, "-");
const ext = getFileExtension(ui.outputLanguage ?? ui.inputLanguage);
return `${slug}-output-${Date.now()}${ext}`;
}, [tool.name, ui.outputLanguage, ui.inputLanguage, result]);

return (
<div
className={cn(
Expand Down Expand Up @@ -170,7 +177,8 @@ export function GeneratorToolLayout({
language={ui.outputLanguage ?? ui.inputLanguage}
isLoading={isExecuting}
isAutoMode={false}
downloadFilename={`${tool.name.toLowerCase().replace(/\s+/g, "-")}-output-${Date.now()}${getFileExtension(ui.outputLanguage ?? ui.inputLanguage)}`}
htmlPreviewAllowScripts={ui.htmlPreviewAllowScripts}
downloadFilename={downloadFilename}
onCopy={onCopy}
/>
</ToolLayout>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { ToolMeta, ToolUIConfig } from "@utils-live/tools/constants";
import { useTranslations } from "next-intl";
import { ToolLayout } from "@/components/tools/tool-layout";
import { InputPanel } from "@/components/editor/input-panel";
import { OutputPanel } from "@/components/editor/output-panel";
Expand Down Expand Up @@ -42,6 +43,7 @@ export function StandardToolLayout({
onCopy,
onExecuteReady,
}: StandardToolLayoutProps): React.ReactElement {
const t = useTranslations("tools.shell");
const isMobile = useIsMobile();
const [input, setInput] = useState("");

Expand Down Expand Up @@ -149,6 +151,17 @@ export function StandardToolLayout({
[reset]
);

// Compute the download filename once per result so the timestamp reflects
// when the output was produced, not when the component last rendered.
// Intentional dep on `result`: the identity changes each execution, which
// is when we want a fresh timestamp. `result` isn't otherwise read here.
const downloadFilename = useMemo(() => {
void result;
const slug = tool.name.toLowerCase().replace(/\s+/g, "-");
const ext = getFileExtension(ui.outputLanguage ?? ui.inputLanguage);
return `${slug}-output-${Date.now()}${ext}`;
}, [tool.name, ui.outputLanguage, ui.inputLanguage, result]);

return (
<div
className={cn(
Expand All @@ -171,7 +184,9 @@ export function StandardToolLayout({
value={input}
onChange={handleInputChange}
language={ui.inputLanguage}
placeholder={`Enter ${tool.name.toLowerCase()} input...`}
placeholder={t("inputPlaceholder", {
toolName: tool.name.toLowerCase(),
})}
allowFileUpload={ui.allowFileUpload}
acceptedFileTypes={ui.acceptedFileTypes}
maxFileSize={ui.maxFileSize}
Expand All @@ -185,7 +200,8 @@ export function StandardToolLayout({
language={ui.outputLanguage ?? ui.inputLanguage}
isLoading={isExecuting}
isAutoMode
downloadFilename={`${tool.name.toLowerCase().replace(/\s+/g, "-")}-output-${Date.now()}${getFileExtension(ui.outputLanguage ?? ui.inputLanguage)}`}
htmlPreviewAllowScripts={ui.htmlPreviewAllowScripts}
downloadFilename={downloadFilename}
onCopy={onCopy}
/>
</ToolLayout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export function ToolPageClient({
inputStr = JSON.stringify(example.input, null, 2);
}
setExampleInput((prev) => ({ value: inputStr, seq: prev.seq + 1 }));
toast.success("Example loaded");
toast.success(t("exampleLoaded"));
},
[optionsStorageKey, inputStorageKey, variant, inputSchema, t]
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 93%; /* #EDEDED - headers, subtle backgrounds */
--muted-foreground: 0 0% 45%;
--muted-foreground: 0 0% 38%; /* #616161 — WCAG AA on --muted (5.7:1) */
--muted-foreground-dim: 0 0% 35%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 9%;
Expand Down
19 changes: 11 additions & 8 deletions apps/web/components/editor/input-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { Upload, FileText, Trash2, AlertCircle, BookOpen } from "lucide-react";
import { useTranslations } from "next-intl";
Expand Down Expand Up @@ -87,6 +87,7 @@ export function InputPanel({
className,
}: InputPanelProps): React.ReactElement {
const t = useTranslations("editor.input");
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFile, setUploadedFile] = useState<{
name: string;
size: number;
Expand Down Expand Up @@ -250,27 +251,29 @@ export function InputPanel({
</Button>
)}
{allowFileUpload && (
<label>
<>
<input
ref={fileInputRef}
type="file"
className="sr-only"
accept={acceptedFileTypes?.join(",")}
onChange={handleFileChange}
disabled={disabled}
tabIndex={-1}
aria-hidden="true"
/>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={disabled}
asChild
onClick={() => fileInputRef.current?.click()}
aria-label={t("uploadButton")}
>
<span>
<Upload className="mr-1 h-3.5 w-3.5" />
{t("uploadButton")}
</span>
<Upload className="mr-1 h-3.5 w-3.5" />
{t("uploadButton")}
</Button>
</label>
</>
)}
<CopyButton value={value} size="sm" />
{value && (
Expand Down
7 changes: 7 additions & 0 deletions apps/web/components/editor/output-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ interface OutputPanelProps {
* @default false
*/
isAutoMode?: boolean;
/**
* For HTML renderer: whether the sandboxed iframe may run scripts and
* submit forms. Defaults to false; opt in only for interactive tools.
*/
htmlPreviewAllowScripts?: boolean;
/**
* Additional CSS classes
*/
Expand All @@ -216,6 +221,7 @@ export function OutputPanel({
onCopy,
onDownload,
isAutoMode = false,
htmlPreviewAllowScripts = false,
className,
}: OutputPanelProps): React.ReactElement {
const t = useTranslations("editor.output");
Expand Down Expand Up @@ -399,6 +405,7 @@ export function OutputPanel({
<HtmlPreview
content={typeof result.data === "string" ? result.data : ""}
sandboxed
allowScripts={htmlPreviewAllowScripts}
showToolbar
className="h-full"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/effects/code-rain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,5 +150,5 @@ export function CodeRain({ className }: CodeRainProps): React.ReactElement {
};
}, [prefersReducedMotion, initParticles]);

return <canvas ref={canvasRef} className={className} />;
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
}
Loading
Loading