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
20 changes: 20 additions & 0 deletions docs/content/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,23 @@ type Sitemap = Array<SitemapEntry>;
### useBasePath

<AutoTypeTable path="../packages/stack/src/context/provider.tsx" name="useBasePath" />

### useNotify

<AutoTypeTable path="../packages/stack/src/shared/notify-types.ts" name="StackNotifyProvider" />

Notifications are configured via the `notify` prop on `StackProvider`. Without an override, `useNotify()` routes to sonner toasts.

### useTranslate

<AutoTypeTable path="../packages/stack/src/shared/i18n-types.ts" name="StackI18nProvider" />

Without an `i18n` provider, `useTranslate()` returns the English default with `{{param}}` interpolation.

### useListState

URL-synced list state (filters, tabs, pagination) via the router's `getSearchParams` / `setSearchParams` contract. Export path: `@btst/stack/client`.

For SSR loaders, read initial state with `parseListStateFromSearchParams(namespace, schema, requestSearchParams)`.

<AutoTypeTable path="../packages/stack/src/shared/list-state.ts" name="ListStateField" />
47 changes: 47 additions & 0 deletions packages/stack/scripts/extract-i18n-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env tsx
/**
* Scan plugin sources for `t("key", "Default")` / `useTranslate()` call sites.
*
* Usage:
* pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts
*
* Output: JSON map of file → [{ key, defaultValue }] for #136 migration reference.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";

const ROOT = join(import.meta.dirname, "../src/plugins");

const CALL_PATTERN =
/\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]((?:\\.|[^"'`\\])*)["'`]/g;

function walk(dir: string, files: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, files);
} else if (/\.(tsx?|jsx?)$/.test(entry)) {
files.push(full);
}
}
return files;
}

type KeyEntry = { key: string; defaultValue: string };

const results: Record<string, KeyEntry[]> = {};

for (const file of walk(ROOT)) {
const content = readFileSync(file, "utf8");
const entries: KeyEntry[] = [];

for (const match of content.matchAll(CALL_PATTERN)) {
entries.push({ key: match[1], defaultValue: match[2] });
}

if (entries.length > 0) {
results[relative(join(import.meta.dirname, ".."), file)] = entries;
}
}

console.log(JSON.stringify(results, null, 2));
99 changes: 99 additions & 0 deletions packages/stack/src/__tests__/i18n-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
StackProvider,
useTranslate,
type StackI18nProvider,
} from "../context";

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.restoreAllMocks();
});

async function render(ui: React.ReactElement) {
await act(async () => {
root.render(ui);
});
}

describe("useTranslate", () => {
it("returns the default string when no i18n provider is configured", async () => {
let t: ReturnType<typeof useTranslate> | undefined;
function Probe() {
t = useTranslate();
return null;
}
await render(
<StackProvider basePath="/pages">
<Probe />
</StackProvider>,
);

expect(t!("blog.posts.create", "Create Post")).toBe("Create Post");
});

it("interpolates {{param}} placeholders in the default string", async () => {
let t: ReturnType<typeof useTranslate> | undefined;
function Probe() {
t = useTranslate();
return null;
}
await render(
<StackProvider basePath="/pages">
<Probe />
</StackProvider>,
);

expect(
t!("blog.posts.deleted", "Deleted {{title}}", { title: "Hello" }),
).toBe("Deleted Hello");
});

it("delegates to the consumer translate function", async () => {
const translate = vi.fn(
(key: string, defaultValue: string, params?: Record<string, unknown>) =>
`[${key}] ${defaultValue} ${params?.title ?? ""}`.trim(),
);
const i18n: StackI18nProvider = { translate };

let t: ReturnType<typeof useTranslate> | undefined;
function Probe() {
t = useTranslate();
return null;
}

await render(
<StackProvider basePath="/pages" i18n={i18n}>
<Probe />
</StackProvider>,
);

const result = t!("blog.posts.deleted", "Deleted {{title}}", {
title: "Draft",
});

expect(translate).toHaveBeenCalledWith(
"blog.posts.deleted",
"Deleted {{title}}",
{ title: "Draft" },
);
expect(result).toBe("[blog.posts.deleted] Deleted {{title}} Draft");
});
});
89 changes: 89 additions & 0 deletions packages/stack/src/__tests__/list-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
parseListStateFromSearchParams,
resolveListStateHistoryMode,
serializeListStateToSearchParams,
} from "../shared/list-state";

const schema = {
tab: { type: "string" as const, default: "pending" },
page: { type: "number" as const, default: 1 },
filter: { type: "string" as const, default: "", history: "replace" as const },
};

describe("parseListStateFromSearchParams", () => {
it("returns defaults for an empty query string", () => {
expect(
parseListStateFromSearchParams("comments-moderation", schema, ""),
).toEqual({ tab: "pending", page: 1, filter: "" });
});

it("parses non-default values from URL params", () => {
const params = new URLSearchParams("tab=spam&page=3&filter=hello");
expect(
parseListStateFromSearchParams("comments-moderation", schema, params),
).toEqual({ tab: "spam", page: 3, filter: "hello" });
});

it("falls back to defaults for invalid numbers", () => {
const params = new URLSearchParams("page=abc");
expect(
parseListStateFromSearchParams("comments-moderation", schema, params)
.page,
).toBe(1);
});
});

describe("serializeListStateToSearchParams", () => {
it("omits fields equal to their defaults", () => {
const params = serializeListStateToSearchParams(
"comments-moderation",
schema,
{ tab: "pending", page: 1, filter: "" },
);
expect(params.toString()).toBe("");
});

it("serializes only deviating fields", () => {
const params = serializeListStateToSearchParams(
"comments-moderation",
schema,
{ tab: "spam", page: 3, filter: "" },
);
expect(params.toString()).toBe("tab=spam&page=3");
});

it("preserves unrelated params in the base query string", () => {
const base = new URLSearchParams("foo=bar");
const params = serializeListStateToSearchParams(
"comments-moderation",
schema,
{ tab: "spam", page: 1, filter: "" },
base,
);
expect(params.get("foo")).toBe("bar");
expect(params.get("tab")).toBe("spam");
expect(params.has("page")).toBe(false);
});
});

describe("resolveListStateHistoryMode", () => {
it("defaults to push when no replace fields are updated", () => {
expect(resolveListStateHistoryMode(schema, { tab: "spam", page: 2 })).toBe(
false,
);
});

it("uses replace when a replace-history field is updated", () => {
expect(resolveListStateHistoryMode(schema, { filter: "x" })).toBe(true);
});

it("honors an explicit replace option", () => {
expect(resolveListStateHistoryMode(schema, { tab: "spam" }, true)).toBe(
true,
);
expect(resolveListStateHistoryMode(schema, { filter: "x" }, false)).toBe(
false,
);
});
});
100 changes: 100 additions & 0 deletions packages/stack/src/__tests__/notify-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StackProvider, useNotify, type StackNotifyProvider } from "../context";

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

const toastSuccess = vi.fn();
const toastError = vi.fn();
const toastInfo = vi.fn();
const toastWarning = vi.fn();

vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
info: (...args: unknown[]) => toastInfo(...args),
warning: (...args: unknown[]) => toastWarning(...args),
},
}));

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
toastSuccess.mockClear();
toastError.mockClear();
toastInfo.mockClear();
toastWarning.mockClear();
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.restoreAllMocks();
});

async function render(ui: React.ReactElement) {
await act(async () => {
root.render(ui);
});
}

describe("useNotify", () => {
it("uses sonner by default when no notify prop is configured", async () => {
let notify: StackNotifyProvider | undefined;
function Probe() {
notify = useNotify();
return null;
}
await render(
<StackProvider basePath="/pages">
<Probe />
</StackProvider>,
);

notify!.success!("Saved");
notify!.error!("Failed", { description: "Try again" });

expect(toastSuccess).toHaveBeenCalledWith("Saved", undefined);
expect(toastError).toHaveBeenCalledWith("Failed", {
description: "Try again",
});
});

it("routes notifications through a custom provider", async () => {
const customSuccess = vi.fn();
const customError = vi.fn();
let notify: StackNotifyProvider | undefined;

function Probe() {
notify = useNotify();
return null;
}

await render(
<StackProvider
basePath="/pages"
notify={{ success: customSuccess, error: customError }}
>
<Probe />
</StackProvider>,
);

notify!.success!("Custom saved");
notify!.error!("Custom failed");
notify!.info!("Still sonner");

expect(customSuccess).toHaveBeenCalledWith("Custom saved");
expect(customError).toHaveBeenCalledWith("Custom failed");
expect(toastInfo).toHaveBeenCalledWith("Still sonner", undefined);
expect(toastSuccess).not.toHaveBeenCalled();
});
});
Loading
Loading