@doany-ai/app-core

中文文档
0.2.7发布于 2026/11/13MITTypeScriptESM + CJS近 30 天下载 5006

What every Doany site shares: the SDK client and query cache, sign-in state and forms, base UI, theme, images and uploads

原始使用文档
文档目录18 节
  1. Entries
  2. Set up
  3. Customizing
  4. Reference
  5. Form controls: @doany-ai/app-core/form
  6. Phone country: @doany-ai/app-core/form
  7. Sign-in: @doany-ai/app-core/auth , @doany-ai/app-core/router
  8. Signed-in state
  9. The website editor
  10. Account: @doany-ai/app-core/account
  11. Media: @doany-ai/app-core/media
  12. Base UI: @doany-ai/app-core/ui
  13. Embed : the same content, four ways
  14. Auth labels
  15. Develop
  16. Account profile
  17. Where it came from
  18. Build and test

@doany-ai/app-core

What every Doany site shares, as a package instead of scaffold source: the SDK client and query cache for the whole page, sign-in state and forms, base UI components, the theme, and images and uploads.

It does not create an SDK client or import @doany-ai/sdk: the site creates one client and hands it to <DoanyProvider>, and every package on the page uses that one — one sign-in, one query cache. It needs a client from @doany-ai/sdk 0.5.22 or later.

Entries

ImportWhatNeeds
@doany-ai/app-coreDoanyProvider, useDoany, createQueryClient, readAppParams, cn, formatMoneyreact, @tanstack/react-query
@doany-ai/app-core/authAuthProvider, useAuth, useTestSite, AuthGate; LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, AuthLayout; the hooks the forms are built on: useLogin, useRegister, useForgotPassword, useResetPassword; defaultAuthLabels—
@doany-ai/app-core/routerProtectedRoute, ScrollToTop, LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPagereact-router-dom 6.4+
@doany-ai/app-core/accountuseMyContact, useUpdateMyContactAuthProvider
@doany-ai/app-core/mediaImage (media-worker resizing, private files), FileUpload—
@doany-ai/app-core/formshared form fields, phone/country helpers, useVisitorCountry—
@doany-ai/app-core/uithe base components (button, card, dialog, form, sidebar, toast…), also one by one: @doany-ai/app-core/ui/button—
@doany-ai/app-core/styles.cssthe theme variables (also as theme.css)—
@doany-ai/app-core/tailwind-presetthe theme as a Tailwind 3 preset, with the page-wide base rulestailwindcss 3

SheetContent completes same-tab fragment navigation after its exit animation: when a link closes the sheet and the URL matches an existing section, it moves focus and scrolls to that section. Ordinary dismissal still returns focus to the trigger. A custom onCloseAutoFocus that prevents default keeps control. The sheet does not close links automatically; the site's navigation owns that.

formatMoney(amount, currency, locale?) accepts the integer minor units from the platform and formats them in the given locale. For example, 1,200,000 paisa is PKR 12,000. A locale's display defaults do not change that conversion.

Set up

The general scaffold (src/App.jsx, src/main.jsx, tailwind.config.js) already does this; keep it.

// main.jsx
import "@doany-ai/app-core/styles.css"; // the theme variables; the site's CSS comes after and wins
import "./index.css";
import { createClient } from "@doany-ai/sdk";
import { DoanyProvider, readAppParams } from "@doany-ai/app-core";
import { AuthGate, AuthProvider } from "@doany-ai/app-core/auth";
import { LoginPage, ProtectedRoute, RegisterPage } from "@doany-ai/app-core/router";
import { Toaster } from "@doany-ai/app-core/ui";

const params = readAppParams({ appId: import.meta.env.VITE_DOANY_APP_ID });
const doany = createClient({ appId: params.appId, token: params.token, serverUrl: "" });

root.render(
  <DoanyProvider client={doany}>
    <AuthProvider token={params.token}>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<LoginPage authenticatedHome="/" />} />
          <Route path="/register" element={<RegisterPage />} />
          <Route path="*" element={<AuthGate><SiteRoutes /></AuthGate>} />
        </Routes>
        <Toaster />
      </BrowserRouter>
    </AuthProvider>
  </DoanyProvider>
);
// tailwind.config.cjs — one build for the site's classes and the packages'
module.exports = {
  presets: [require("@doany-ai/app-core/tailwind-preset")],
  content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}", "./node_modules/@doany-ai/*/dist/**/*.js"],
};

The packages ship no utilities: the site's Tailwind build scans them. Two builds on one page break the components — each is ordered on its own, so a utility the site also uses (px-3) lands after a component's more specific one (pl-10, the login form's room for its icon) and overrides it. One build orders them together.

Customizing

Three levels, each without touching the package's code. Try them in this order.

1. CSS only — the theme. Set the variables on :root, after the package's CSS. Every component of every Doany package follows, dialogs included. On :root, not on a wrapper: dialogs and menus render at the end of <body>, outside any wrapper.

:root { --primary: 221 83% 53%; --radius: 1rem; --font-body: "Inter", sans-serif; }

The variables: --background, --foreground, --card, --popover, --primary, --secondary, --muted, --accent, --destructive, --success (paid, confirmed), --border, --input, --ring (focus), each with its -foreground where text sits on it; --radius; --font-body, --font-heading. The defaults are in src/styles/theme.css, with a .dark set.

The site's own CSS reaches the components too. A rule on p, label, input, select, textarea or button, alone or under a wrapper (.contact p, .form-card input), also restyles what the components render there: field labels, the fields, help text and error messages (<p>), and checkboxes and radio choices (<button>). An error message recoloured that way no longer reads as an error; a checkbox given a button's size stretches. Give the site's own elements a class and style the class; change a component only through the variables above or its className.

2. Props only — words, parts, links, behavior. The sign-in forms take title, subtitle, icon, providers ([] hides Google), the other pages' hrefs, authenticatedHome, renderLink, and onSignedIn / onAccountCreated / onReset in place of the default page load. Every word they show is in labels — pass a part of AuthLabels and the rest stay the default; one object can carry a whole translation to every form:

<LoginForm title="Mori" providers={[]} labels={{ email: "Work email", logIn: "Sign in", loginFailed: "Check your email and password" }} />

A failure is said in the platform's words (too many attempts, a taken email) unless the site sets its label (loginFailed, registerFailed, …): then always in the site's — a translated site shows no English error.

AuthGate takes loading and notRegistered; ProtectedRoute takes fallback, unauthenticatedElement, notRegistered and loginPath; UserNotRegisteredError and FileUpload take labels too.

3. Hooks only — your own markup. The forms are markup over hooks that keep their behavior: where a sign-in comes back to (same origin only, never a sign-in page), how a new account gets its session, when the site learns who signed in.

const { login, loginWithProvider, pending, error } = useLogin({ onSignedIn: (to) => navigate(to) });
// error: { reason: "rejected" | "account_unavailable" | …, message } — say it in your words

useRegister (with its verify step), useForgotPassword, useResetPassword, and useAuth() / useDoany() for the state and the client.

Reference

Form controls: @doany-ai/app-core/form

fieldClass, textareaClass and selectClass share left-aligned content; inputs and selects are 44px tall. NativeSelect takes native single-select props and a ref; its inset arrow leaves 12px at the right and reserves room for long labels. It keeps native keyboard selection, required validation and form values. selectClass remains available for an unwrapped native select. IconInput reserves room for its leading icon; CountrySelect uses the same inset arrow as NativeSelect. The base Input with type="date" keeps the browser's date entry and picker, with the date on the left and picker on the right. Date inputs have a soft shadow and a subdued picker icon. A controlled empty date uses the placeholder color until focused; a selected date uses normal text.

Phone country: @doany-ai/app-core/form

useVisitorCountry(enabled = true) returns the visitor's country for an empty phone field. It starts with an explicit region in the browser's language preferences, or US, then uses the supported loc country from the same origin's /cdn-cgi/trace response. The request runs once per page, bypasses caches and times out after three seconds; an unavailable endpoint keeps the browser fallback. It stores no location or trace in browser storage. The page's language does not decide the visitor's country.

Form and AttendeeForm apply this default only until the visitor chooses a country or starts typing. Saved phone values keep their country. A custom form using the hook must keep the same boundary and use the selected country when converting a local number with phoneFrom(value, dialOf(country)).

Sign-in: @doany-ai/app-core/auth, @doany-ai/app-core/router

The four sign-in pages are route components over the forms: LoginPage, RegisterPage, ForgotPasswordPage and ResetPasswordPage (/router) take the same props as LoginForm, RegisterForm, ForgotPasswordForm and ResetPasswordForm (/auth), and link between each other through the router. Each form already includes AuthLayout: render it directly, passing title, subtitle and className as needed. Wrapping a form or route page in another AuthLayout nests two full-height cards and compresses the fields. The shared layout uses one responsive card, capped at 30rem, with page gutters on phones. Keep them at /login, /register, /forgot-password and /reset-password: the reset email links to /reset-password?token=.

Every form takes icon, title, subtitle (wins over the labels), labels and className, and:

FormProps
LoginFormauthenticatedHome (where to go when there is no from_url; default /), fromUrl (default: the page's ?from_url=), registerHref, forgotPasswordHref (defaults /register and /forgot-password, carrying a local from_url), providers (default ["google"]; [] hides Google), renderLink, onSignedIn(destination) (default: load it)
RegisterFormfromUrl (default: the page's ?from_url=; the new account lands there), authenticatedHome, loginHref (default /login, carrying from_url), providers, renderLink, onAccountCreated() (already verified: sign in next), onSignedIn(destination)
ForgotPasswordFormloginHref (default /login, carrying the page's from_url), renderLink
ResetPasswordFormresetToken (default: the page's ?token=), loginHref, forgotPasswordHref, renderLink, onReset() (default: load loginHref)

The hooks the forms are built on:

HookReturns
useLogin({ fromUrl?, authenticatedHome?, onSignedIn? })login(email, password) → true when signed in, loginWithProvider("google"), destination, pending, error, clearError()
useRegister({ fromUrl?, authenticatedHome?, loginHref?, onAccountCreated?, onSignedIn? })step (details → verify), email, register({ email, password, confirmPassword? }) → "verify" (a code was emailed) / "created" (no code needed) / null, verify(code), resend(), loginWithProvider, destination, pending, error, clearError()
useForgotPassword()request(email), sent, pending
useResetPassword({ resetToken?, loginHref?, onReset? })token, reset(newPassword, confirmPassword?), pending, error, clearError()

error is { reason, message }: reason is rejected, account_unavailable, passwords_differ or resend_failed; message is the platform's words. Say it in the site's words.

Signed-in state

useAuth() (needs AuthProvider) returns user ({ id, email, full_name, role } or null), isAuthenticated, isLoadingAuth, authChecked, appPublicSettings, logout(returnTo?) (goes through the platform and comes back; default the current page), navigateToLogin(), and refresh(token?) (read the sign-in again after signing in without a page load).

The website editor

In the website editor what a visitor does is saved as a test: it shows in the business's records with a Test tag, counts for nothing and reaches no client. useTestSite() (under AuthProvider, from the platform's public settings) is the one way a component learns it. The site itself shows only what its clients see, in the editor too: the editor says above the page what was saved.

An account made in the editor is a test account: it signs in only there, and a real account does not sign in there. RegisterForm and LoginForm are the same there; a real account signing in in the editor is refused in the platform's words ("Sign in with a test account made here.").

ComponentProps
AuthGatewraps the site's routes: shows them once the site's settings and the sign-in are known, and sends a guest to /login on a login-only site. loading, notRegistered
ProtectedRoute (/router)a layout route for pages that need a signed-in account: <Route element={<ProtectedRoute />}>…</Route>. fallback (while checking settings or sign-in), labels (loadError, retry for a recoverable read failure), unauthenticatedElement (instead of going to the login page; null counts), loginPath (default /login), notRegistered
ScrollToTop (/router)mount once inside the router. It also tells the platform each page a visitor is on (usePageViews: the page a visit lands on, then once per navigation to another path, each with the title the page set; the SDK, 0.5.22 or later, waits for the first so the landing page is recorded once, and decides whether anything is collected). New page navigation scrolls to the top; an initial or newly navigated #id waits for its section to render (including behind AuthGate), then scrolls there. Back/forward keeps the browser's scroll restoration. Waiting ends when the target is found, navigation changes, or the component unmounts.
UserNotRegisteredErrorthe screen for a signed-in visitor who is not an account of this site. title, message, labels
AuthLayoutthe card the sign-in forms sit in. icon, title (required), subtitle, footer

A failed settings or account read (for example, a network error or HTTP 500) keeps the protected page hidden and shows an error with a Try again button. The button calls the existing checkAppState() to read settings and sign-in again; the recheck itself submits no business action. A transient failure does not discard the session. Only active reads use fallback; authentication refusals and not-registered screens keep their existing behavior. The error uses fixed text or labels, without exposing the backend error message.

Account: @doany-ai/app-core/account

HookReturns
useMyContact()a query; data is the signed-in account's contact: display_name, given_name, family_name, email, phone, address (line, city, region, postal_code, country), timezone, preferred_language, version. Nothing for a guest
useUpdateMyContact()a mutation: mutateAsync({ version, display_name?, given_name?, family_name?, phone?, address?, timezone?, preferred_language? }). Send the version last read; a 409 means it changed meanwhile: read again and retry. The email follows the sign-in and cannot be changed here

Media: @doany-ai/app-core/media

imageDisplayUrl(src, width, height?) returns a display-sized WebP URL for an existing platform image without changing its stored original. Width and height are output pixels; pass both for a centered crop or only width to preserve the image. Existing private signatures are retained, GIFs retain animation, and SVG and external URLs are unchanged. It does not sign or modify vendor thumbnails.

Use Image for editable site imagery: it serves the image resized to its box, and signs a private file's media:// URI before showing it. Size the box with className. The loading placeholder stays inside the image box and follows its border radius. Add shadows to the box with className or style. For fixed course thumbnails and controls that take a poster URL, use imageDisplayUrl to request the appropriate output size directly.

ComponentProps
Imageevery <img> prop, and fittingType (fill, default, crops; fit shows all), originWidth / originHeight (hold the aspect ratio before loading), focalPointX / focalPointY (0–1, kept in view when cropping), quality (1–100, default 90)
FileUploadevery file <input> prop except value, onChange, type, and: value (the stored file's URL), onChange(url) (called once the upload finishes, so a form's submit never waits for it), privateFile (store privately; the value is a media:// URI), onUploadError, children (shown once there is a value: a preview, a Remove button), labels (uploading, uploadFailed)

Base UI: @doany-ai/app-core/ui

The shadcn components (new-york), with their usual props: accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb, button, calendar, card, carousel, chart, checkbox, collapsible, command, context-menu, dialog, drawer, dropdown-menu, form, hover-card, input-otp, input, label, menubar, navigation-menu, pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner, switch, table, tabs, textarea, toast / toaster (useToast), toggle-group, toggle, tooltip. Import from @doany-ai/app-core/ui, or one by one from @doany-ai/app-core/ui/<name>. cn is in @doany-ai/app-core. Icons come from lucide-react. There is no @/components/ui in the site.

Two composed ones beside them: DataTable (columns: { key, header, cell?, align? }[], rows, pageSize (10), empty (what to show without rows), rowKey, labels) draws rows in a table and turns pages on the page itself; EmptyState (icon, title, description, action) is what a list shows before it has anything. The sidebar's colors are the --sidebar-* variables in styles.css (background, foreground, primary, primary-foreground, accent, accent-foreground, border, ring), set for light and .dark.

Long CTA labels

Button keeps its usual single-line, fixed-height default (including icon buttons). For a long call to action use wrap: the label can wrap, the button cannot exceed its container, and its height grows from the selected size's minimum height. This also works with asChild links.

<Button wrap size="lg" className="min-h-12 rounded-full">
  See a cleaning-business example
</Button>

Use min-h-*, not a fixed h-*, when customizing a wrapping button. Keep its grid/flex parents shrinkable (min-w-0); do not give the label whitespace-nowrap.

Card grids and full-width rows

cardGrid(count, size = "narrow", layout = "cards") (from the root entry) returns the shared grid classes. cards uses the available container width to choose columns, caps each tile at 24rem (wide: 28rem), and centers one or two tiles. list is one full-width column with no tile or small-count width cap; use it for custom service rows and multi-column content. CardGridLayout is the exported "cards" | "list" type.

Neither mode can infer the layout inside arbitrary custom markup. Inside a row, choose columns from the row's available space, not the viewport. For example, grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))] with min-w-0 children stacks when the row is narrow, even on a desktop screen. For unequal columns, use a CSS container query on a wrapper around the row. Do not override a narrow card to two columns with a viewport lg: rule.

Embed: the same content, four ways

Embed (in @doany-ai/app-core/ui) puts what it wraps — a form, a booking flow — in the page (inline, the default), in a dialog (popup), in a panel fixed to a corner that minimizes to a tab (sidebar), or in a panel that slides in from a side and can be closed (slide-in). trigger says when it opens: { type: "always" } (default), { type: "after", seconds }, { type: "scroll", percent }. deactivate says when it stops opening: { afterShows: n }, { onceSubmitted: true } — counted per storageKey in the browser's localStorage. Inside, useEmbed() gives close() and markSubmitted().

<Embed layout="popup" label="Get in touch" trigger={{ type: "after", seconds: 5 }} deactivate={{ afterShows: 2 }}>
  <Form formId={siteConfig.contactFormId} showTitle={false} />
</Embed>

Auth labels

Every word the sign-in forms and screens show is in AuthLabels; defaultAuthLabels holds the defaults. A form takes a part and keeps the defaults for the rest.

WhereKeys
Sharedemail, emailPlaceholder, password, confirmPassword, or, continueWithGoogle
LoginloginTitle, loginSubtitle, logIn, loggingIn, forgotPassword, noAccount, createOne, loginFailed, accountUnavailable
RegisterregisterTitle, registerSubtitle, createAccount, creatingAccount, haveAccount, logInLink, passwordsDiffer, registerFailed, accountCreated, accountCreatedBody
Email codeverifyTitle, codeSentTo, verify, verifying, noCode, resend, codeResent, codeResentBody, invalidCode, resendFailed, newAccountUnavailable
Forgot passwordforgotTitle, forgotSubtitle, emailAddress, sendResetLink, sending, resetLinkSent, backToLogin
Reset passwordresetTitle, resetSubtitle, newPassword, resetPassword, resetting, resetFailed, invalidResetLink, invalidResetLinkSubtitle, invalidResetLinkBody, requestNewLink
Not an account of this sitenotRegisteredTitle, notRegisteredMessage, notRegisteredHelp, notRegisteredSteps

Never edit this package under node_modules or copy its source into the site.

Develop

Account profile

@doany-ai/app-core/account exports ProfileForm and useProfileForm. Both use the existing contacts.me / versioned contacts.updateMe contract. The form edits the contact display name; the sign-in email is read-only. Loading, missing profiles, save failures and version conflicts are visible. A conflict requires reloading before another save. Drafts and feedback follow the current account, so an account switch never displays the preceding account's draft.

Use labels, footer, className and classNames for supported customization. ProfileFormExample demonstrates mode="default" | "css" | "props" | "hook"; each owns an isolated in-memory account and cannot write to a real account. The CSS example changes theme tokens, the props example changes labels/footer, and the hook example draws custom markup on the same useProfileForm owner.

Where it came from

Extracted from the v5 scaffold's src (since removed; doany-general-scaffold is built on these packages instead), keeping its behavior:

HereScaffold
DoanyProvider, createQueryClientApp.jsx's QueryClientProvider, lib/query-client.js; the client came from api/doanyClient.js
readAppParamslib/app-params.js (defaults passed in instead of read from import.meta.env)
auth/lib/AuthContext.jsx, lib/login-code.js, lib/login-navigation.js, pages/*, components/AuthLayout.jsx, GoogleIcon.jsx, UserNotRegisteredError.jsx, App.jsx's auth gate
router/components/ProtectedRoute.jsx, components/ScrollToTop.jsx
media/components/ui/image.jsx, file-upload.jsx, lib/image-layout.js, hooks/use-size.jsx
ui/components/ui/*, hooks/use-mobile.jsx

AuthProvider reads the site's settings with doany.app.getPublicSettings() and trades a sign-in code with doany.auth.exchangeLoginCode() instead of building its own axios clients from SDK internals.

The ui/ components are shadcn's new-york TypeScript sources with the scaffold's differences applied, so each file, with its types stripped, matches the scaffold's .jsx — class strings, TOAST_LIMIT = 20, SidebarTrigger's asChild. Kept from upstream where it is only safer: FormMessage and ChartTooltipContent guard an undefined value, and the mobile sidebar carries a screen-reader title.

Build and test

npm ci
npm test          # vitest + jsdom
npm run typecheck
npm run build     # dist/: ESM + .d.ts per module, styles.css (the theme variables)

版本与发布记录

@doany-ai/app-core 共发布 27 个版本,首次发布于 2026/09/24;过去 12 个月发布了 27 个版本。

版本发布日期距今
0.2.7latest2026/11/13今天
0.2.62026/11/13今天
0.2.52026/11/067 天前
0.2.42026/11/049 天前
0.2.32026/10/3014 天前
0.2.22026/10/2915 天前

其他发布通道:alpha → 0.1.0-alpha.5。

关于 @doany-ai/app-core 的常见问题

@doany-ai/app-core 是什么?

What every Doany site shares: the SDK client and query cache, sign-in state and forms, base UI, theme, images and uploads

如何安装 @doany-ai/app-core?

在项目目录运行 npm install @doany-ai/app-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.2.0 || ^19.0.0)、react-dom(^18.2.0 || ^19.0.0)、@tanstack/react-query(^5.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@doany-ai/app-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@doany-ai/app-core 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@doany-ai/app-core 可以商用吗?

@doany-ai/app-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@doany-ai/app-core 的下载量有多少?

根据 npm 官方下载统计,@doany-ai/app-core 在 2026/10/13 – 2026/11/11 这 30 天内被下载 5,006 次,日均约 167 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@doany-ai/app-core 还在维护吗?

最新版本 0.2.7 发布于 2026/11/13(今天);至今共发布 27 个版本,其中过去 12 个月发布了 27 个;首次发布于 2026/09/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。