@stratal/inertia 0.0.21 → 0.0.23
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/dist/build-seo-tags-DBsHKxX9.mjs +123 -0
- package/dist/build-seo-tags-DBsHKxX9.mjs.map +1 -0
- package/dist/decorate-B7nr7eBl.mjs +9 -0
- package/dist/generator/type-generator.worker.mjs +1 -1
- package/dist/generator/type-generator.worker.mjs.map +1 -1
- package/dist/index.d.mts +177 -105
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +215 -389
- package/dist/index.mjs.map +1 -1
- package/dist/quarry.d.mts +45 -0
- package/dist/quarry.d.mts.map +1 -0
- package/dist/quarry.mjs +392 -0
- package/dist/quarry.mjs.map +1 -0
- package/dist/react.d.mts +12 -31
- package/dist/react.d.mts.map +1 -1
- package/dist/react.mjs +29 -48
- package/dist/react.mjs.map +1 -1
- package/dist/seo-runtime.d.mts +1 -0
- package/dist/seo-runtime.mjs +56 -0
- package/dist/seo-runtime.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/{type-generator-o_PxETTs.mjs → type-generator-DFpha_Fp.mjs} +237 -35
- package/dist/type-generator-DFpha_Fp.mjs.map +1 -0
- package/dist/types--_iJ04lT.d.mts +148 -0
- package/dist/types--_iJ04lT.d.mts.map +1 -0
- package/dist/vite.d.mts +19 -0
- package/dist/vite.d.mts.map +1 -1
- package/dist/vite.mjs +67 -8
- package/dist/vite.mjs.map +1 -1
- package/package.json +28 -25
- package/dist/type-generator-o_PxETTs.mjs.map +0 -1
package/dist/react.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.mjs","names":[],"sources":["../src/react/use-i18n.ts","../src/react/use-route.ts"],"sourcesContent":["/**\n * React hook for using Stratal's i18n translations on the frontend.\n *\n * Reads `locale` and `translations` from Inertia shared props (injected by\n * the `i18n` option on {@link InertiaModuleOptions}) and provides a type-safe\n * `t()` function powered by `@intlify/core-base`.\n *\n * @module\n */\n\nimport type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport { compile, createCoreContext, registerMessageCompiler, translate } from '@intlify/core-base'\nimport { useMemo } from 'react'\nimport type { MessageKeys, MessageParams } from 'stratal/i18n'\n\n// Register JIT message compiler from the SAME @intlify/core-base instance that provides\n// createCoreContext/translate. Importing setupI18nCompiler from stratal/i18n/utils can\n// resolve a different @intlify/core-base copy (duplicate modules in node_modules),\n// causing the compiler registration to be invisible to this module's createCoreContext.\nregisterMessageCompiler(compile)\n\ninterface I18nPageProps extends PageProps {\n locale: string\n translations: Record<string, string>\n}\n\n/**\n * Hook that provides i18n translation capabilities in React components.\n *\n * Consumes `locale` and `translations` from Inertia shared props and returns\n * a `t()` function that translates message keys with optional interpolation.\n *\n * Requires the `i18n` option to be set on `InertiaModule.forRoot()` to inject\n * the shared props.\n *\n * @returns An object with:\n * - `t` — Translation function accepting a message key and optional params\n * - `locale` — The current locale string\n *\n * @example\n * ```tsx\n * import { useI18n } from '@stratal/inertia/react'\n *\n * export default function Header() {\n * const { t, locale } = useI18n()\n *\n * return (\n * <header>\n * <h1>{t('common.title')}</h1>\n * <p>{t('common.greeting', { name: 'World' })}</p>\n * <span>Locale: {locale}</span>\n * </header>\n * )\n * }\n * ```\n */\nexport function useI18n() {\n const { locale, translations } = usePage<I18nPageProps>().props\n\n const context = useMemo(\n () => createCoreContext({\n locale,\n messages: { [locale]: translations },\n missingWarn: !import.meta.env.PROD,\n fallbackWarn: !import.meta.env.PROD,\n }),\n [locale, translations],\n )\n\n const t = useMemo(\n () => (key: MessageKeys, params?: MessageParams): string => {\n const result = params !== undefined\n ? translate(context, key, params)\n : translate(context, key)\n return typeof result === 'string' ? result : key\n },\n [context],\n )\n\n return { t, locale }\n}\n","/**\n * React hook for Ziggy-like client-side URL generation.\n *\n * Reads serialized routes and the current request's matched-route snapshot\n * (injected by the `routes` option on {@link InertiaModuleOptions}) and\n * provides a type-safe `route()` function that mirrors the server-side\n * `buildRouteUrl()`, plus `current()` and `params` for current-route\n * introspection.\n *\n * @module\n */\n\nimport type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport { useMemo } from 'react'\nimport type { CurrentRoute, RouteMatcher, RouteName, RouteParams, SerializedRoute, SerializedRoutes, TrailingSlashMode } from 'stratal/router'\n\ninterface RoutesPageProps extends PageProps {\n routes: SerializedRoutes\n trailingSlash?: TrailingSlashMode\n route: CurrentRoute\n}\n\n/**\n * Apply a trailing-slash mode to a URL or path.\n *\n * Pure reimplementation of `applyTrailingSlash()` from `stratal/router` —\n * mirrored here to keep the React bundle decoupled from server-only deps.\n *\n * - `'ignore'` — return as-is.\n * - `'always'` — append `/` unless path is root or last segment is file-like (`.json`, etc.).\n * - `'never'` — strip a single trailing `/` from the pathname (skip root).\n *\n * Preserves query string and hash. Handles relative paths and absolute URLs.\n */\nexport function applyTrailingSlash(url: string, mode: TrailingSlashMode): string {\n if (mode === 'ignore') return url\n\n const isAbsolute = /^https?:\\/\\//i.test(url)\n const parsed = isAbsolute ? new URL(url) : new URL(url, 'http://placeholder.local')\n const path = parsed.pathname\n if (path === '/') return url\n const hasTrailing = path.endsWith('/')\n\n if (mode === 'always' && !hasTrailing) {\n const lastSegment = path.slice(path.lastIndexOf('/') + 1)\n if (lastSegment.includes('.')) return url\n parsed.pathname = `${path}/`\n } else if (mode === 'never' && hasTrailing) {\n parsed.pathname = path.slice(0, -1)\n } else {\n return url\n }\n\n return isAbsolute\n ? parsed.toString()\n : `${parsed.pathname}${parsed.search}${parsed.hash}`\n}\n\n/**\n * Encode a path-param value while preserving forward slashes so catch-all\n * params (`:slug{.+}`) round-trip cleanly. Mirrors the server-side\n * `encodePathParam()` in `stratal/router`.\n */\nfunction encodePathParam(value: string): string {\n return value.split('/').map(encodeURIComponent).join('/')\n}\n\n/**\n * Build a URL from a serialized route definition.\n *\n * Mirrors `buildRouteUrl()` from `stratal/router` (pure reimplementation to\n * avoid pulling server-side dependencies into the browser bundle).\n */\nfunction buildUrl(route: SerializedRoute, name: string, params?: Record<string, string>): string {\n const allParams = { ...params }\n const consumedKeys = new Set<string>()\n let url = route.path\n\n if (allParams.locale && route.localePaths?.length) {\n url = `/${allParams.locale}${url === '/' ? '' : url}`\n consumedKeys.add('locale')\n }\n\n for (const paramName of route.paramNames) {\n const value = allParams[paramName]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${paramName}\" for route \"${name}\" (path: ${route.path})`)\n }\n url = url.replace(\n new RegExp(`:${paramName}(\\\\{[^}]*\\\\})?`),\n encodePathParam(value),\n )\n consumedKeys.add(paramName)\n }\n\n let domain: string | undefined\n if (route.domain) {\n domain = route.domain\n for (const domainParam of route.domainParamNames) {\n const value = allParams[domainParam]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${domainParam}\" for route \"${name}\" (domain: ${route.domain})`)\n }\n domain = domain.replace(`{${domainParam}}`, encodeURIComponent(value))\n consumedKeys.add(domainParam)\n }\n }\n\n const queryEntries = Object.entries(allParams).filter(([key]) => !consumedKeys.has(key))\n if (queryEntries.length > 0) {\n const queryString = queryEntries\n .filter(([, v]) => Boolean(v))\n .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)\n .join('&')\n url = `${url}${queryString.length ? `?${queryString}` : ''}`\n }\n\n if (domain) {\n url = `https://${domain}${url}`\n }\n\n return url\n}\n\n/**\n * Filter a param bag down to the keys the target route actually declares —\n * so a `companyId` carried over from the current URL never leaks into the\n * query string of an unrelated route.\n */\nfunction filterCarryover(carryover: Record<string, string>, route: SerializedRoute): Record<string, string> {\n const allowed = new Set<string>([...route.paramNames, ...route.domainParamNames])\n if (route.localePaths?.length) allowed.add('locale')\n if (allowed.size === 0) return {}\n\n const filtered: Record<string, string> = {}\n for (const [key, value] of Object.entries(carryover)) {\n if (allowed.has(key)) filtered[key] = value\n }\n return filtered\n}\n\n/**\n * Pure URL resolver. Mirrors what {@link useRoute}'s `route()` does, but\n * without React — exposed for testing and for non-hook callers.\n *\n * Merges params in order (last wins): sticky `defaults`, current-route\n * carryover (filtered to the target's declared params), explicit params.\n */\nexport function resolveUrl<N extends RouteName>(\n name: N,\n explicitParams: RouteParams<N> | undefined,\n routes: SerializedRoutes,\n currentRoute: CurrentRoute,\n trailingSlash: TrailingSlashMode = 'ignore',\n): string {\n const target = routes[name]\n if (!target) {\n throw new Error(`Route \"${name}\" not found.`)\n }\n\n const merged = {\n ...currentRoute.defaults,\n ...filterCarryover(currentRoute.params, target),\n ...explicitParams,\n } as Record<string, string>\n\n return applyTrailingSlash(buildUrl(target, name, merged), trailingSlash)\n}\n\n/**\n * Pure overload signatures for {@link matchCurrent} / `useRoute().current()`.\n *\n * - No arg → matched route name (or `null`).\n * - With a name → `true`/`false`. Strict-typed: only real route names and\n * dotted wildcard prefixes (`'users.*'`) are accepted.\n */\nexport function matchCurrent(currentRoute: CurrentRoute): RouteName | null\nexport function matchCurrent(currentRoute: CurrentRoute, name: RouteMatcher): boolean\nexport function matchCurrent(currentRoute: CurrentRoute, name?: RouteMatcher): RouteName | null | boolean {\n if (name === undefined) return currentRoute.name\n if (currentRoute.name === null) return false\n if (typeof name === 'string' && name.endsWith('.*')) {\n const prefix = name.slice(0, -1)\n return currentRoute.name.startsWith(prefix)\n }\n return currentRoute.name === name\n}\n\n/**\n * Hook that provides Ziggy-like route URL generation in React components.\n *\n * Consumes `routes` and the current-request snapshot (`route`) from Inertia\n * shared props. Route names and params are strictly typed from\n * `StratalRouteMap` (generated by `quarry route:types`).\n *\n * Requires the `routes` option to be set on `InertiaModule.forRoot()`.\n *\n * Sticky params — anything in `defaults` (set server-side via `Uri.defaults()`)\n * and anything in the current route's extracted `params` (filtered to the\n * target route's declared params) — are merged into every `route()` call.\n * Explicit params always win.\n *\n * @returns\n * - `route(name, params?)` — URL builder\n * - `current()` / `current(name)` — matched route name (or wildcard match)\n * - `params` — extracted params for the current request URL\n *\n * @example\n * ```tsx\n * import { useRoute } from '@stratal/inertia/react'\n *\n * export default function UserProfile({ user }) {\n * const { route, current, currentRoute } = useRoute()\n *\n * return (\n * <nav>\n * <a href={route('users.index')}>All Users</a>\n * <a href={route('users.show', { id: user.id })}>{user.name}</a>\n * {current('users.*') && <span>On a users page</span>}\n * {currentRoute.name === 'users.show' && <span>#{currentRoute.params.id}</span>}\n * </nav>\n * )\n * }\n * ```\n */\nexport function useRoute() {\n const page = usePage<RoutesPageProps>()\n const { routes, trailingSlash = 'ignore', route: currentRoute } = page.props\n\n const route = useMemo(\n () => <N extends RouteName>(name: N, params?: RouteParams<N>): string =>\n resolveUrl(name, params, routes, currentRoute, trailingSlash),\n [routes, trailingSlash, currentRoute],\n )\n\n const current = useMemo(\n () => {\n function impl(): RouteName | null\n function impl(name: RouteMatcher): boolean\n function impl(name?: RouteMatcher): RouteName | null | boolean {\n return name === undefined ? matchCurrent(currentRoute) : matchCurrent(currentRoute, name)\n }\n return impl\n },\n [currentRoute],\n )\n\n return { route, current, currentRoute, params: currentRoute.params }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"react.mjs","names":[],"sources":["../src/react/seo.ts","../src/react/use-i18n.ts","../src/react/use-route.ts"],"sourcesContent":["import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport type { SeoData } from '../seo/types'\n\ninterface SeoPageProps extends PageProps {\n seo?: SeoData\n}\n\n/**\n * Returns the resolved SEO data shared by the backend for the current page.\n *\n * The document head is kept in sync automatically (server injection on the\n * initial paint + the auto-injected client runtime on navigation); use this\n * hook only when you want to read the metadata inside a component.\n */\nexport function useSeo(): SeoData {\n return usePage<SeoPageProps>().props.seo ?? {}\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport IntlMessageFormat from 'intl-messageformat'\nimport { useMemo } from 'react'\nimport type { MessageParams } from 'stratal/i18n'\nimport type { InertiaTranslationKeys } from '../types'\n\ninterface I18nPageProps extends PageProps {\n locale: string\n translations: Record<string, string>\n}\n\nexport function useI18n() {\n const { locale, translations } = usePage<I18nPageProps>().props\n\n const t = useMemo(() => {\n const compiled = new Map<string, IntlMessageFormat>()\n\n for (const [key, value] of Object.entries(translations)) {\n compiled.set(key, new IntlMessageFormat(value, locale))\n }\n\n return (key: InertiaTranslationKeys, params?: MessageParams): string => {\n const msg = compiled.get(key)\n if (!msg) return key\n return String(msg.format(params as Record<string, string | number | boolean>))\n }\n }, [locale, translations])\n\n return { t, locale }\n}\n","/**\n * React hook for Ziggy-like client-side URL generation.\n *\n * Reads serialized routes and the current request's matched-route snapshot\n * (injected by the `routes` option on {@link InertiaModuleOptions}) and\n * provides a type-safe `route()` function that mirrors the server-side\n * `buildRouteUrl()`, plus `current()` and `params` for current-route\n * introspection.\n *\n * @module\n */\n\nimport type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport { useMemo } from 'react'\nimport type { CurrentRoute, LocaleUrlConfig, RouteMatcher, RouteName, RouteParams, SerializedRoute, SerializedRoutes, TrailingSlashMode } from 'stratal/router'\n\ninterface RoutesPageProps extends PageProps {\n routes: SerializedRoutes\n trailingSlash?: TrailingSlashMode\n route: CurrentRoute\n localeConfig?: LocaleUrlConfig\n}\n\n/**\n * Apply a trailing-slash mode to a URL or path.\n *\n * Pure reimplementation of `applyTrailingSlash()` from `stratal/router` —\n * mirrored here to keep the React bundle decoupled from server-only deps.\n *\n * - `'ignore'` — return as-is.\n * - `'always'` — append `/` unless path is root or last segment is file-like (`.json`, etc.).\n * - `'never'` — strip a single trailing `/` from the pathname (skip root).\n *\n * Preserves query string and hash. Handles relative paths and absolute URLs.\n */\nexport function applyTrailingSlash(url: string, mode: TrailingSlashMode): string {\n if (mode === 'ignore') return url\n\n const isAbsolute = /^https?:\\/\\//i.test(url)\n const parsed = isAbsolute ? new URL(url) : new URL(url, 'http://placeholder.local')\n const path = parsed.pathname\n if (path === '/') return url\n const hasTrailing = path.endsWith('/')\n\n if (mode === 'always' && !hasTrailing) {\n const lastSegment = path.slice(path.lastIndexOf('/') + 1)\n if (lastSegment.includes('.')) return url\n parsed.pathname = `${path}/`\n } else if (mode === 'never' && hasTrailing) {\n parsed.pathname = path.slice(0, -1)\n } else {\n return url\n }\n\n return isAbsolute\n ? parsed.toString()\n : `${parsed.pathname}${parsed.search}${parsed.hash}`\n}\n\n/**\n * Encode a path-param value while preserving forward slashes so catch-all\n * params (`:slug{.+}`) round-trip cleanly. Mirrors the server-side\n * `encodePathParam()` in `stratal/router`.\n */\nfunction encodePathParam(value: string): string {\n return value.split('/').map(encodeURIComponent).join('/')\n}\n\n/**\n * Build a URL from a serialized route definition.\n *\n * Mirrors `buildRouteUrl()` from `stratal/router` (pure reimplementation to\n * avoid pulling server-side dependencies into the browser bundle).\n */\nfunction buildUrl(route: SerializedRoute, name: string, params?: Record<string, string>, localeConfig?: LocaleUrlConfig): string {\n const allParams = { ...params }\n const consumedKeys = new Set<string>()\n let url = route.path\n\n if (allParams.locale && route.localePaths?.length) {\n const shouldPrefix = !localeConfig\n || localeConfig.prefixDefaultLocale === true\n || allParams.locale !== localeConfig.defaultLocale\n if (shouldPrefix) {\n url = `/${allParams.locale}${url === '/' ? '' : url}`\n }\n consumedKeys.add('locale')\n }\n\n for (const paramName of route.paramNames) {\n const value = allParams[paramName]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${paramName}\" for route \"${name}\" (path: ${route.path})`)\n }\n url = url.replace(\n new RegExp(`:${paramName}(\\\\{[^}]*\\\\})?`),\n encodePathParam(value),\n )\n consumedKeys.add(paramName)\n }\n\n let domain: string | undefined\n if (route.domain) {\n domain = route.domain\n for (const domainParam of route.domainParamNames) {\n const value = allParams[domainParam]\n if (value === undefined) {\n throw new Error(`Missing required parameter \"${domainParam}\" for route \"${name}\" (domain: ${route.domain})`)\n }\n domain = domain.replace(`{${domainParam}}`, encodeURIComponent(value))\n consumedKeys.add(domainParam)\n }\n }\n\n const queryEntries = Object.entries(allParams).filter(([key]) => !consumedKeys.has(key))\n if (queryEntries.length > 0) {\n const queryString = queryEntries\n .filter(([, v]) => Boolean(v))\n .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)\n .join('&')\n url = `${url}${queryString.length ? `?${queryString}` : ''}`\n }\n\n if (domain) {\n url = `https://${domain}${url}`\n }\n\n return url\n}\n\n/**\n * Filter a param bag down to the keys the target route actually declares —\n * so a `companyId` carried over from the current URL never leaks into the\n * query string of an unrelated route.\n */\nfunction filterCarryover(carryover: Record<string, string>, route: SerializedRoute): Record<string, string> {\n const allowed = new Set<string>([...route.paramNames, ...route.domainParamNames])\n if (route.localePaths?.length) allowed.add('locale')\n if (allowed.size === 0) return {}\n\n const filtered: Record<string, string> = {}\n for (const [key, value] of Object.entries(carryover)) {\n if (allowed.has(key)) filtered[key] = value\n }\n return filtered\n}\n\n/**\n * Pure URL resolver. Mirrors what {@link useRoute}'s `route()` does, but\n * without React — exposed for testing and for non-hook callers.\n *\n * Merges params in order (last wins): sticky `defaults`, current-route\n * carryover (filtered to the target's declared params), explicit params.\n */\nexport function resolveUrl<N extends RouteName>(\n name: N,\n explicitParams: RouteParams<N> | undefined,\n routes: SerializedRoutes,\n currentRoute: CurrentRoute,\n trailingSlash: TrailingSlashMode = 'ignore',\n localeConfig?: LocaleUrlConfig,\n): string {\n const target = routes[name]\n if (!target) {\n throw new Error(`Route \"${name}\" not found.`)\n }\n\n const merged = {\n ...currentRoute.defaults,\n ...filterCarryover(currentRoute.params, target),\n ...explicitParams,\n } as Record<string, string>\n\n return applyTrailingSlash(buildUrl(target, name, merged, localeConfig), trailingSlash)\n}\n\n/**\n * Pure overload signatures for {@link matchCurrent} / `useRoute().current()`.\n *\n * - No arg → matched route name (or `null`).\n * - With a name → `true`/`false`. Strict-typed: only real route names and\n * dotted wildcard prefixes (`'users.*'`) are accepted.\n */\nexport function matchCurrent(currentRoute: CurrentRoute): RouteName | null\nexport function matchCurrent(currentRoute: CurrentRoute, name: RouteMatcher): boolean\nexport function matchCurrent(currentRoute: CurrentRoute, name?: RouteMatcher): RouteName | null | boolean {\n if (name === undefined) return currentRoute.name\n if (currentRoute.name === null) return false\n if (typeof name === 'string' && name.endsWith('.*')) {\n const prefix = name.slice(0, -1)\n return currentRoute.name.startsWith(prefix)\n }\n return currentRoute.name === name\n}\n\n/**\n * Hook that provides Ziggy-like route URL generation in React components.\n *\n * Consumes `routes` and the current-request snapshot (`route`) from Inertia\n * shared props. Route names and params are strictly typed from\n * `StratalRouteMap` (generated by `quarry route:types`).\n *\n * Requires the `routes` option to be set on `InertiaModule.forRoot()`.\n *\n * Sticky params — anything in `defaults` (set server-side via `Uri.defaults()`)\n * and anything in the current route's extracted `params` (filtered to the\n * target route's declared params) — are merged into every `route()` call.\n * Explicit params always win.\n *\n * @returns\n * - `route(name, params?)` — URL builder\n * - `current()` / `current(name)` — matched route name (or wildcard match)\n * - `params` — extracted params for the current request URL\n *\n * @example\n * ```tsx\n * import { useRoute } from '@stratal/inertia/react'\n *\n * export default function UserProfile({ user }) {\n * const { route, current, currentRoute } = useRoute()\n *\n * return (\n * <nav>\n * <a href={route('users.index')}>All Users</a>\n * <a href={route('users.show', { id: user.id })}>{user.name}</a>\n * {current('users.*') && <span>On a users page</span>}\n * {currentRoute.name === 'users.show' && <span>#{currentRoute.params.id}</span>}\n * </nav>\n * )\n * }\n * ```\n */\nexport function useRoute() {\n const page = usePage<RoutesPageProps>()\n const { routes, trailingSlash = 'ignore', route: currentRoute, localeConfig } = page.props\n\n const route = useMemo(\n () => <N extends RouteName>(name: N, params?: RouteParams<N>): string =>\n resolveUrl(name, params, routes, currentRoute, trailingSlash, localeConfig),\n [routes, trailingSlash, currentRoute, localeConfig],\n )\n\n const current = useMemo(\n () => {\n function impl(): RouteName | null\n function impl(name: RouteMatcher): boolean\n function impl(name?: RouteMatcher): RouteName | null | boolean {\n return name === undefined ? matchCurrent(currentRoute) : matchCurrent(currentRoute, name)\n }\n return impl\n },\n [currentRoute],\n )\n\n return { route, current, currentRoute, params: currentRoute.params }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { n as buildSeoTags, t as DATA_SEO_ATTR } from "./build-seo-tags-DBsHKxX9.mjs";
|
|
2
|
+
import { router } from "@inertiajs/core";
|
|
3
|
+
//#region src/seo/apply-seo-to-head.ts
|
|
4
|
+
/**
|
|
5
|
+
* Reconciles `document.head` with the resolved SEO data (client-side).
|
|
6
|
+
*
|
|
7
|
+
* Removes only the previously managed `[data-seo]` tags and re-creates them
|
|
8
|
+
* from {@link buildSeoTags}; the title is applied via `doc.title` so the single
|
|
9
|
+
* `<title>` element is updated in place rather than duplicated, and the
|
|
10
|
+
* {@link DATA_SEO_ATTR} marker is re-stamped on it so the next reconcile finds
|
|
11
|
+
* and replaces it instead of leaving a stale title behind.
|
|
12
|
+
*
|
|
13
|
+
* Pure and DOM-only (no React) so it can be unit-tested under jsdom.
|
|
14
|
+
*/
|
|
15
|
+
function applySeoToHead(seo, doc = document) {
|
|
16
|
+
const head = doc.head;
|
|
17
|
+
head.querySelectorAll(`[${DATA_SEO_ATTR}]`).forEach((el) => el.remove());
|
|
18
|
+
for (const descriptor of buildSeoTags(seo)) {
|
|
19
|
+
if (descriptor.tag === "title") {
|
|
20
|
+
doc.title = descriptor.content ?? "";
|
|
21
|
+
doc.head.querySelector("title")?.setAttribute(DATA_SEO_ATTR, "");
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const el = doc.createElement(descriptor.tag);
|
|
25
|
+
for (const [key, value] of Object.entries(descriptor.attrs)) try {
|
|
26
|
+
el.setAttribute(key, value);
|
|
27
|
+
} catch {}
|
|
28
|
+
head.appendChild(el);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/seo-runtime.ts
|
|
33
|
+
/**
|
|
34
|
+
* Client-side SEO head sync. Side-effect module: importing it registers a
|
|
35
|
+
* single Inertia `navigate` listener that reconciles `document.head` from the
|
|
36
|
+
* shared `seo` prop on every SPA visit.
|
|
37
|
+
*
|
|
38
|
+
* Consumers never import this directly — the `stratalInertia()` Vite plugin
|
|
39
|
+
* injects it into the client entry, so backend `ctx.seo()` metadata stays in
|
|
40
|
+
* sync across navigations with zero app wiring. The server still injects the
|
|
41
|
+
* tags for the initial paint; this only runs on subsequent client visits.
|
|
42
|
+
*/
|
|
43
|
+
const INSTALLED_KEY = "__stratalInertiaSeoInstalled";
|
|
44
|
+
const globalScope = globalThis;
|
|
45
|
+
if (!globalScope[INSTALLED_KEY]) {
|
|
46
|
+
globalScope[INSTALLED_KEY] = true;
|
|
47
|
+
router.on("navigate", (event) => {
|
|
48
|
+
const props = event.detail.page.props;
|
|
49
|
+
if (!("seo" in props)) return;
|
|
50
|
+
applySeoToHead(props.seo ?? {});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export {};
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=seo-runtime.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seo-runtime.mjs","names":[],"sources":["../src/seo/apply-seo-to-head.ts","../src/seo-runtime.ts"],"sourcesContent":["/// <reference lib=\"dom\" />\nimport { DATA_SEO_ATTR, buildSeoTags } from './build-seo-tags'\nimport type { SeoData } from './types'\n\n/**\n * Reconciles `document.head` with the resolved SEO data (client-side).\n *\n * Removes only the previously managed `[data-seo]` tags and re-creates them\n * from {@link buildSeoTags}; the title is applied via `doc.title` so the single\n * `<title>` element is updated in place rather than duplicated, and the\n * {@link DATA_SEO_ATTR} marker is re-stamped on it so the next reconcile finds\n * and replaces it instead of leaving a stale title behind.\n *\n * Pure and DOM-only (no React) so it can be unit-tested under jsdom.\n */\nexport function applySeoToHead(seo: SeoData, doc: Document = document): void {\n const head = doc.head\n // Remove only previously SEO-managed tags; unmanaged head content is untouched.\n head.querySelectorAll(`[${DATA_SEO_ATTR}]`).forEach((el) => el.remove())\n\n for (const descriptor of buildSeoTags(seo)) {\n if (descriptor.tag === 'title') {\n // `doc.title` updates the single <title> in place. Re-stamp the marker so\n // the element is tracked as managed and replaced on the next navigation.\n doc.title = descriptor.content ?? ''\n doc.head.querySelector('title')?.setAttribute(DATA_SEO_ATTR, '')\n continue\n }\n const el = doc.createElement(descriptor.tag)\n for (const [key, value] of Object.entries(descriptor.attrs)) {\n // A single malformed attribute name must not abort the reconcile and\n // leave the head half-updated. `setAttribute` throws on invalid names,\n // so isolate each one and skip the offending attribute only.\n try {\n el.setAttribute(key, value)\n } catch {\n // Invalid attribute name — drop this attribute, keep building the tag.\n }\n }\n head.appendChild(el)\n }\n}\n","/**\n * Client-side SEO head sync. Side-effect module: importing it registers a\n * single Inertia `navigate` listener that reconciles `document.head` from the\n * shared `seo` prop on every SPA visit.\n *\n * Consumers never import this directly — the `stratalInertia()` Vite plugin\n * injects it into the client entry, so backend `ctx.seo()` metadata stays in\n * sync across navigations with zero app wiring. The server still injects the\n * tags for the initial paint; this only runs on subsequent client visits.\n */\nimport { router } from '@inertiajs/core'\nimport { applySeoToHead } from './seo/apply-seo-to-head'\nimport type { SeoData } from './seo/types'\n\n// Guard against duplicate registration when the module is re-evaluated (e.g.\n// dev-server HMR, or the runtime injected into more than one client entry).\nconst INSTALLED_KEY = '__stratalInertiaSeoInstalled'\nconst globalScope = globalThis as Record<string, unknown>\n\nif (!globalScope[INSTALLED_KEY]) {\n globalScope[INSTALLED_KEY] = true\n router.on('navigate', (event) => {\n const props = event.detail.page.props as { seo?: SeoData }\n // The backend shares `seo` as an always-evaluated prop, so it is present on\n // every response — including partial reloads. Only reconcile the head when\n // the key is actually present; never act on a guessed-empty value, which\n // would wipe managed tags a partial reload didn't intend to touch.\n if (!('seo' in props)) return\n applySeoToHead(props.seo ?? {})\n })\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,eAAe,KAAc,MAAgB,UAAgB;CAC3E,MAAM,OAAO,IAAI;CAEjB,KAAK,iBAAiB,IAAI,cAAc,EAAE,EAAE,SAAS,OAAO,GAAG,OAAO,CAAC;CAEvE,KAAK,MAAM,cAAc,aAAa,GAAG,GAAG;EAC1C,IAAI,WAAW,QAAQ,SAAS;GAG9B,IAAI,QAAQ,WAAW,WAAW;GAClC,IAAI,KAAK,cAAc,OAAO,GAAG,aAAa,eAAe,EAAE;GAC/D;EACF;EACA,MAAM,KAAK,IAAI,cAAc,WAAW,GAAG;EAC3C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,WAAW,KAAK,GAIxD,IAAI;GACF,GAAG,aAAa,KAAK,KAAK;EAC5B,QAAQ,CAER;EAEF,KAAK,YAAY,EAAE;CACrB;AACF;;;;;;;;;;;;;ACzBA,MAAM,gBAAgB;AACtB,MAAM,cAAc;AAEpB,IAAI,CAAC,YAAY,gBAAgB;CAC/B,YAAY,iBAAiB;CAC7B,OAAO,GAAG,aAAa,UAAU;EAC/B,MAAM,QAAQ,MAAM,OAAO,KAAK;EAKhC,IAAI,EAAE,SAAS,QAAQ;EACvB,eAAe,MAAM,OAAO,CAAC,CAAC;CAChC,CAAC;AACH"}
|
package/dist/testing.d.mts
CHANGED
package/dist/testing.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.mjs","names":[],"sources":["../src/augment/test-response.ts","../src/testing.ts"],"sourcesContent":["import type { Page } from '@inertiajs/core'\nimport { getValueAtPath, hasValueAtPath, TestResponse } from '@stratal/testing'\nimport { expect } from 'vitest'\n\ndeclare module '@stratal/testing' {\n interface TestResponse {\n /** Assert the response is an Inertia response. Optionally run a callback with the page object for custom assertions. */\n assertInertia(callback?: (page: Page) => void): Promise<this>\n /** Assert the Inertia page component matches the expected name. */\n assertInertiaComponent(component: string): Promise<this>\n /** Assert the Inertia page prop at the given dot-path equals the expected value. */\n assertInertiaProp(path: string, expected: unknown): Promise<this>\n /** Assert the Inertia page prop at the given dot-path exists. */\n assertInertiaPropExists(path: string): Promise<this>\n /** Assert the Inertia page prop at the given dot-path does not exist. */\n assertInertiaPropMissing(path: string): Promise<this>\n /** Assert the Inertia page URL matches the expected value. */\n assertInertiaUrl(url: string): Promise<this>\n /** Assert the Inertia page version matches the expected value. */\n assertInertiaVersion(version: string | null): Promise<this>\n /** Assert the Inertia page flash data contains the given key with the expected value. */\n assertInertiaFlash(key: string, value: unknown): Promise<this>\n /** Assert a prop is listed as deferred in the given group. */\n assertInertiaDeferredProp(prop: string, group: string): Promise<this>\n /** Assert a prop is listed as a merge prop. */\n assertInertiaMergeProp(prop: string): Promise<this>\n /** Assert a prop is listed as a shared prop. */\n assertInertiaSharedProp(prop: string): Promise<this>\n /** Assert the response is a successful precognition response (204 with precognition headers). */\n assertSuccessfulPrecognition(): this\n /** Assert the response is a precognition validation error (422 with precognition headers). Optionally assert specific errors. */\n assertPrecognitionValidationErrors(errors?: Record<string, string>): Promise<this>\n }\n}\n\nexport function augmentTestResponse(): void {\n TestResponse.macro('assertInertia', async function (this: TestResponse, callback?: (page: Page) => void) {\n this.assertHeader('x-inertia', 'true')\n this.assertOk()\n\n if (callback) {\n const page = await this.json<Page>()\n callback(page)\n }\n\n return this\n })\n\n TestResponse.macro('assertInertiaComponent', async function (this: TestResponse, component: string) {\n const page = await this.json<Page>()\n\n expect(\n page.component,\n `Expected Inertia component \"${component}\", got \"${page.component}\"`,\n ).toBe(component)\n\n return this\n })\n\n TestResponse.macro('assertInertiaProp', async function (this: TestResponse, path: string, expected: unknown) {\n const page = await this.json<Page>()\n const actual = getValueAtPath(page.props, path)\n\n expect(\n actual,\n `Expected Inertia prop \"${path}\" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,\n ).toStrictEqual(expected)\n\n return this\n })\n\n TestResponse.macro('assertInertiaPropExists', async function (this: TestResponse, path: string) {\n const page = await this.json<Page>()\n const exists = hasValueAtPath(page.props, path)\n\n expect(\n exists,\n `Expected Inertia prop \"${path}\" to exist`,\n ).toBe(true)\n\n return this\n })\n\n TestResponse.macro('assertInertiaPropMissing', async function (this: TestResponse, path: string) {\n const page = await this.json<Page>()\n const exists = hasValueAtPath(page.props, path)\n\n expect(\n exists,\n `Expected Inertia prop \"${path}\" to not exist`,\n ).toBe(false)\n\n return this\n })\n\n TestResponse.macro('assertInertiaUrl', async function (this: TestResponse, url: string) {\n const page = await this.json<Page>()\n\n expect(\n page.url,\n `Expected Inertia URL \"${url}\", got \"${page.url}\"`,\n ).toBe(url)\n\n return this\n })\n\n TestResponse.macro('assertInertiaVersion', async function (this: TestResponse, version: string | null) {\n const page = await this.json<Page>()\n\n expect(\n page.version,\n `Expected Inertia version \"${version}\", got \"${page.version}\"`,\n ).toBe(version)\n\n return this\n })\n\n TestResponse.macro('assertInertiaFlash', async function (this: TestResponse, key: string, value: unknown) {\n const page = await this.json<Page>()\n const actual = page.flash?.[key]\n\n expect(\n actual,\n `Expected Inertia flash \"${key}\" to be ${JSON.stringify(value)}, got ${JSON.stringify(actual)}`,\n ).toStrictEqual(value)\n\n return this\n })\n\n TestResponse.macro('assertInertiaDeferredProp', async function (this: TestResponse, prop: string, group: string) {\n const page = await this.json<Page>()\n\n expect(\n page.deferredProps?.[group],\n `Expected Inertia deferred group \"${group}\" to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertInertiaMergeProp', async function (this: TestResponse, prop: string) {\n const page = await this.json<Page>()\n\n expect(\n page.mergeProps,\n `Expected Inertia mergeProps to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertInertiaSharedProp', async function (this: TestResponse, prop: string) {\n const page = await this.json<Page>()\n\n expect(\n page.sharedProps,\n `Expected Inertia sharedProps to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertSuccessfulPrecognition', function (this: TestResponse) {\n this.assertNoContent()\n this.assertHeader('Precognition', 'true')\n this.assertHeader('Precognition-Success', 'true')\n\n return this\n })\n\n TestResponse.macro('assertPrecognitionValidationErrors', async function (this: TestResponse, errors?: Record<string, string>) {\n this.assertUnprocessable()\n this.assertHeader('Precognition', 'true')\n\n if (errors) {\n const body = await this.json<{ errors: Record<string, string> }>()\n\n expect(\n body.errors,\n `Expected precognition errors to match ${JSON.stringify(errors)}, got ${JSON.stringify(body.errors)}`,\n ).toStrictEqual(errors)\n }\n\n return this\n })\n}\n","import { augmentTestResponse } from './augment/test-response'\n\n// Augmentation (side-effect import: augments TestResponse types)\nimport './augment/test-response'\n\n// Patch TestResponse.prototype with Inertia assertion methods\naugmentTestResponse()\n\n// Re-export useful types for test authors\nexport type { Page as InertiaPage } from '@inertiajs/core'\n"],"mappings":";;;AAmCA,SAAgB,sBAA4B;CAC1C,aAAa,MAAM,iBAAiB,eAAoC,UAAiC;EACvG,KAAK,aAAa,aAAa,
|
|
1
|
+
{"version":3,"file":"testing.mjs","names":[],"sources":["../src/augment/test-response.ts","../src/testing.ts"],"sourcesContent":["import type { Page } from '@inertiajs/core'\nimport { getValueAtPath, hasValueAtPath, TestResponse } from '@stratal/testing'\nimport { expect } from 'vitest'\n\ndeclare module '@stratal/testing' {\n interface TestResponse {\n /** Assert the response is an Inertia response. Optionally run a callback with the page object for custom assertions. */\n assertInertia(callback?: (page: Page) => void): Promise<this>\n /** Assert the Inertia page component matches the expected name. */\n assertInertiaComponent(component: string): Promise<this>\n /** Assert the Inertia page prop at the given dot-path equals the expected value. */\n assertInertiaProp(path: string, expected: unknown): Promise<this>\n /** Assert the Inertia page prop at the given dot-path exists. */\n assertInertiaPropExists(path: string): Promise<this>\n /** Assert the Inertia page prop at the given dot-path does not exist. */\n assertInertiaPropMissing(path: string): Promise<this>\n /** Assert the Inertia page URL matches the expected value. */\n assertInertiaUrl(url: string): Promise<this>\n /** Assert the Inertia page version matches the expected value. */\n assertInertiaVersion(version: string | null): Promise<this>\n /** Assert the Inertia page flash data contains the given key with the expected value. */\n assertInertiaFlash(key: string, value: unknown): Promise<this>\n /** Assert a prop is listed as deferred in the given group. */\n assertInertiaDeferredProp(prop: string, group: string): Promise<this>\n /** Assert a prop is listed as a merge prop. */\n assertInertiaMergeProp(prop: string): Promise<this>\n /** Assert a prop is listed as a shared prop. */\n assertInertiaSharedProp(prop: string): Promise<this>\n /** Assert the response is a successful precognition response (204 with precognition headers). */\n assertSuccessfulPrecognition(): this\n /** Assert the response is a precognition validation error (422 with precognition headers). Optionally assert specific errors. */\n assertPrecognitionValidationErrors(errors?: Record<string, string>): Promise<this>\n }\n}\n\nexport function augmentTestResponse(): void {\n TestResponse.macro('assertInertia', async function (this: TestResponse, callback?: (page: Page) => void) {\n this.assertHeader('x-inertia', 'true')\n this.assertOk()\n\n if (callback) {\n const page = await this.json<Page>()\n callback(page)\n }\n\n return this\n })\n\n TestResponse.macro('assertInertiaComponent', async function (this: TestResponse, component: string) {\n const page = await this.json<Page>()\n\n expect(\n page.component,\n `Expected Inertia component \"${component}\", got \"${page.component}\"`,\n ).toBe(component)\n\n return this\n })\n\n TestResponse.macro('assertInertiaProp', async function (this: TestResponse, path: string, expected: unknown) {\n const page = await this.json<Page>()\n const actual = getValueAtPath(page.props, path)\n\n expect(\n actual,\n `Expected Inertia prop \"${path}\" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,\n ).toStrictEqual(expected)\n\n return this\n })\n\n TestResponse.macro('assertInertiaPropExists', async function (this: TestResponse, path: string) {\n const page = await this.json<Page>()\n const exists = hasValueAtPath(page.props, path)\n\n expect(\n exists,\n `Expected Inertia prop \"${path}\" to exist`,\n ).toBe(true)\n\n return this\n })\n\n TestResponse.macro('assertInertiaPropMissing', async function (this: TestResponse, path: string) {\n const page = await this.json<Page>()\n const exists = hasValueAtPath(page.props, path)\n\n expect(\n exists,\n `Expected Inertia prop \"${path}\" to not exist`,\n ).toBe(false)\n\n return this\n })\n\n TestResponse.macro('assertInertiaUrl', async function (this: TestResponse, url: string) {\n const page = await this.json<Page>()\n\n expect(\n page.url,\n `Expected Inertia URL \"${url}\", got \"${page.url}\"`,\n ).toBe(url)\n\n return this\n })\n\n TestResponse.macro('assertInertiaVersion', async function (this: TestResponse, version: string | null) {\n const page = await this.json<Page>()\n\n expect(\n page.version,\n `Expected Inertia version \"${version}\", got \"${page.version}\"`,\n ).toBe(version)\n\n return this\n })\n\n TestResponse.macro('assertInertiaFlash', async function (this: TestResponse, key: string, value: unknown) {\n const page = await this.json<Page>()\n const actual = page.flash?.[key]\n\n expect(\n actual,\n `Expected Inertia flash \"${key}\" to be ${JSON.stringify(value)}, got ${JSON.stringify(actual)}`,\n ).toStrictEqual(value)\n\n return this\n })\n\n TestResponse.macro('assertInertiaDeferredProp', async function (this: TestResponse, prop: string, group: string) {\n const page = await this.json<Page>()\n\n expect(\n page.deferredProps?.[group],\n `Expected Inertia deferred group \"${group}\" to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertInertiaMergeProp', async function (this: TestResponse, prop: string) {\n const page = await this.json<Page>()\n\n expect(\n page.mergeProps,\n `Expected Inertia mergeProps to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertInertiaSharedProp', async function (this: TestResponse, prop: string) {\n const page = await this.json<Page>()\n\n expect(\n page.sharedProps,\n `Expected Inertia sharedProps to contain \"${prop}\"`,\n ).toContain(prop)\n\n return this\n })\n\n TestResponse.macro('assertSuccessfulPrecognition', function (this: TestResponse) {\n this.assertNoContent()\n this.assertHeader('Precognition', 'true')\n this.assertHeader('Precognition-Success', 'true')\n\n return this\n })\n\n TestResponse.macro('assertPrecognitionValidationErrors', async function (this: TestResponse, errors?: Record<string, string>) {\n this.assertUnprocessable()\n this.assertHeader('Precognition', 'true')\n\n if (errors) {\n const body = await this.json<{ errors: Record<string, string> }>()\n\n expect(\n body.errors,\n `Expected precognition errors to match ${JSON.stringify(errors)}, got ${JSON.stringify(body.errors)}`,\n ).toStrictEqual(errors)\n }\n\n return this\n })\n}\n","import { augmentTestResponse } from './augment/test-response'\n\n// Augmentation (side-effect import: augments TestResponse types)\nimport './augment/test-response'\n\n// Patch TestResponse.prototype with Inertia assertion methods\naugmentTestResponse()\n\n// Re-export useful types for test authors\nexport type { Page as InertiaPage } from '@inertiajs/core'\n"],"mappings":"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"}
|
|
@@ -99,10 +99,8 @@ function unwrapWrapperType(type, tsObj, fallbackLocation) {
|
|
|
99
99
|
return widenLiteralType(type, tsObj, fallbackLocation);
|
|
100
100
|
}
|
|
101
101
|
function unwrapPromise(type, tsObj, fallbackLocation) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (typeArgs.length > 0) return stripReadonly(typeArgs[0], tsObj, fallbackLocation);
|
|
105
|
-
}
|
|
102
|
+
const awaited = type.getAwaitedType?.();
|
|
103
|
+
if (awaited && awaited !== type) return stripReadonly(awaited, tsObj, fallbackLocation);
|
|
106
104
|
return stripReadonly(type, tsObj, fallbackLocation);
|
|
107
105
|
}
|
|
108
106
|
function stripReadonly(type, tsObj, fallbackLocation) {
|
|
@@ -137,24 +135,217 @@ function extractShareCallTypes(project, SK, tsObj, srcDir) {
|
|
|
137
135
|
}
|
|
138
136
|
return shareTypes;
|
|
139
137
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Given the first argument of `Module.forRoot(...)` or `Module.forRootAsync(...)`,
|
|
140
|
+
* return the object literal where downstream options actually live.
|
|
141
|
+
*
|
|
142
|
+
* - For `forRoot({...})` the literal IS the first arg.
|
|
143
|
+
* - For `forRootAsync({ inject, useFactory: (...) => ({...}) })` we drill into
|
|
144
|
+
* the `useFactory`'s return value:
|
|
145
|
+
* `() => ({ … })` — ParenthesizedExpression → ObjectLiteral
|
|
146
|
+
* `() => ({ ... } as Foo)` — AsExpression → ObjectLiteral
|
|
147
|
+
* `() => { return { … } }` — Block → ReturnStatement → ObjectLiteral
|
|
148
|
+
*
|
|
149
|
+
* Returns `null` when nothing usable is found.
|
|
150
|
+
*/
|
|
151
|
+
function resolveModuleOptionsLiteral(optionsArg, SK) {
|
|
152
|
+
if (!optionsArg.isKind(SK.ObjectLiteralExpression)) return null;
|
|
153
|
+
const useFactoryProp = optionsArg.getProperty("useFactory");
|
|
154
|
+
if (useFactoryProp?.isKind(SK.PropertyAssignment)) {
|
|
155
|
+
const initializer = useFactoryProp.getInitializer();
|
|
156
|
+
if (initializer?.isKind(SK.ArrowFunction) || initializer?.isKind(SK.FunctionExpression)) {
|
|
157
|
+
const body = initializer.getBody();
|
|
158
|
+
if (body.isKind(SK.ParenthesizedExpression)) {
|
|
159
|
+
const inner = unwrapAs(body.getExpression(), SK);
|
|
160
|
+
if (inner?.isKind(SK.ObjectLiteralExpression)) return inner;
|
|
161
|
+
}
|
|
162
|
+
const unwrapped = unwrapAs(body, SK);
|
|
163
|
+
if (unwrapped?.isKind(SK.ObjectLiteralExpression)) return unwrapped;
|
|
164
|
+
if (body.isKind(SK.Block)) {
|
|
165
|
+
const returnStatements = body.getDescendantsOfKind(SK.ReturnStatement);
|
|
166
|
+
for (let i = returnStatements.length - 1; i >= 0; i--) {
|
|
167
|
+
const expr = returnStatements[i].getExpression();
|
|
168
|
+
if (!expr) continue;
|
|
169
|
+
if (expr.isKind(SK.ParenthesizedExpression)) {
|
|
170
|
+
const inner = unwrapAs(expr.getExpression(), SK);
|
|
171
|
+
if (inner?.isKind(SK.ObjectLiteralExpression)) return inner;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const direct = unwrapAs(expr, SK);
|
|
175
|
+
if (direct?.isKind(SK.ObjectLiteralExpression)) return direct;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
156
179
|
}
|
|
157
|
-
return
|
|
180
|
+
return optionsArg;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Strip a single `as Foo` cast if present, otherwise return the node as-is.
|
|
184
|
+
* `useFactory: (env) => ({ ... } as Options)` is common in TypeScript.
|
|
185
|
+
*/
|
|
186
|
+
function unwrapAs(node, SK) {
|
|
187
|
+
if (!node) return void 0;
|
|
188
|
+
if (node.isKind(SK.AsExpression) || node.isKind(SK.TypeAssertionExpression)) return node.getExpression();
|
|
189
|
+
if (node.isKind(SK.SatisfiesExpression)) return node.getExpression();
|
|
190
|
+
return node;
|
|
191
|
+
}
|
|
192
|
+
function detectI18nConfig(project, SK, srcDir) {
|
|
193
|
+
const none = {
|
|
194
|
+
enabled: false,
|
|
195
|
+
only: []
|
|
196
|
+
};
|
|
197
|
+
const normalizedSrcDir = srcDir.replace(/\\/g, "/");
|
|
198
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
199
|
+
const filePath = sourceFile.getFilePath();
|
|
200
|
+
if (!filePath.startsWith(normalizedSrcDir)) continue;
|
|
201
|
+
if (filePath.includes("__tests__") || filePath.includes(".spec.") || filePath.includes(".test.")) continue;
|
|
202
|
+
const callExpressions = sourceFile.getDescendantsOfKind(SK.CallExpression);
|
|
203
|
+
for (const call of callExpressions) {
|
|
204
|
+
const expr = call.getExpression();
|
|
205
|
+
if (!expr.isKind(SK.PropertyAccessExpression)) continue;
|
|
206
|
+
const propName = expr.getName();
|
|
207
|
+
if (propName !== "forRoot" && propName !== "forRootAsync") continue;
|
|
208
|
+
const objExpr = expr.getExpression();
|
|
209
|
+
if (!objExpr.isKind(SK.Identifier) || objExpr.getText() !== "InertiaModule") continue;
|
|
210
|
+
const args = call.getArguments();
|
|
211
|
+
if (args.length === 0) continue;
|
|
212
|
+
const optionsLiteral = resolveModuleOptionsLiteral(args[0], SK);
|
|
213
|
+
if (optionsLiteral?.isKind(SK.ObjectLiteralExpression)) {
|
|
214
|
+
const i18nProp = optionsLiteral.getProperty("i18n");
|
|
215
|
+
if (!i18nProp) continue;
|
|
216
|
+
return {
|
|
217
|
+
enabled: true,
|
|
218
|
+
only: extractOnlyFromLiteral(i18nProp, SK)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const configLiteral = resolveConfigLiteralFromAsProvider(args[0], SK);
|
|
222
|
+
if (configLiteral?.isKind(SK.ObjectLiteralExpression)) {
|
|
223
|
+
const i18nProp = configLiteral.getProperty("i18n");
|
|
224
|
+
if (i18nProp) return {
|
|
225
|
+
enabled: true,
|
|
226
|
+
only: extractOnlyFromLiteral(i18nProp, SK)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const optionsType = resolveOptionsType(args[0], propName);
|
|
230
|
+
if (optionsType?.getProperty("i18n")) return {
|
|
231
|
+
enabled: true,
|
|
232
|
+
only: extractOnlyFromType(optionsType, args[0])
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return none;
|
|
237
|
+
}
|
|
238
|
+
function resolveConfigLiteralFromAsProvider(arg, SK) {
|
|
239
|
+
if (!arg.isKind(SK.CallExpression)) return null;
|
|
240
|
+
const callExpr = arg.getExpression();
|
|
241
|
+
if (!callExpr.isKind(SK.PropertyAccessExpression)) return null;
|
|
242
|
+
if (callExpr.getName() !== "asProvider") return null;
|
|
243
|
+
const configIdentifier = callExpr.getExpression();
|
|
244
|
+
if (!configIdentifier.isKind(SK.Identifier)) return null;
|
|
245
|
+
const varDecl = resolveToVariableDeclaration(configIdentifier, SK);
|
|
246
|
+
if (!varDecl) return null;
|
|
247
|
+
return extractLiteralFromRegisterAs(varDecl, SK);
|
|
248
|
+
}
|
|
249
|
+
function resolveToVariableDeclaration(identifier, SK) {
|
|
250
|
+
const symbol = identifier.getSymbol();
|
|
251
|
+
if (!symbol) return null;
|
|
252
|
+
for (const decl of symbol.getDeclarations()) {
|
|
253
|
+
if (decl.isKind(SK.VariableDeclaration)) return decl;
|
|
254
|
+
if (decl.isKind(SK.ImportSpecifier)) {
|
|
255
|
+
const sourceFile = decl.getImportDeclaration().getModuleSpecifierSourceFile();
|
|
256
|
+
if (!sourceFile) continue;
|
|
257
|
+
const exportName = decl.getName();
|
|
258
|
+
const exported = sourceFile.getExportedDeclarations().get(exportName);
|
|
259
|
+
if (!exported) continue;
|
|
260
|
+
for (const exportDecl of exported) if (exportDecl.isKind(SK.VariableDeclaration)) return exportDecl;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
function extractLiteralFromRegisterAs(varDecl, SK) {
|
|
266
|
+
if (!varDecl.isKind(SK.VariableDeclaration)) return null;
|
|
267
|
+
const init = varDecl.getInitializer();
|
|
268
|
+
if (!init?.isKind(SK.CallExpression)) return null;
|
|
269
|
+
const factoryArgs = init.getArguments();
|
|
270
|
+
if (factoryArgs.length < 2) return null;
|
|
271
|
+
const factory = factoryArgs[1];
|
|
272
|
+
if (!factory.isKind(SK.ArrowFunction) && !factory.isKind(SK.FunctionExpression)) return null;
|
|
273
|
+
const body = factory.getBody();
|
|
274
|
+
if (body.isKind(SK.ParenthesizedExpression)) {
|
|
275
|
+
const inner = unwrapAs(body.getExpression(), SK);
|
|
276
|
+
if (inner?.isKind(SK.ObjectLiteralExpression)) return inner;
|
|
277
|
+
}
|
|
278
|
+
const unwrapped = unwrapAs(body, SK);
|
|
279
|
+
if (unwrapped?.isKind(SK.ObjectLiteralExpression)) return unwrapped;
|
|
280
|
+
if (body.isKind(SK.Block)) {
|
|
281
|
+
const returnStatements = body.getDescendantsOfKind(SK.ReturnStatement);
|
|
282
|
+
for (let i = returnStatements.length - 1; i >= 0; i--) {
|
|
283
|
+
const retExpr = returnStatements[i].getExpression();
|
|
284
|
+
if (!retExpr) continue;
|
|
285
|
+
if (retExpr.isKind(SK.ParenthesizedExpression)) {
|
|
286
|
+
const inner = unwrapAs(retExpr.getExpression(), SK);
|
|
287
|
+
if (inner?.isKind(SK.ObjectLiteralExpression)) return inner;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const direct = unwrapAs(retExpr, SK);
|
|
291
|
+
if (direct?.isKind(SK.ObjectLiteralExpression)) return direct;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function extractOnlyFromLiteral(i18nProp, SK) {
|
|
297
|
+
const only = [];
|
|
298
|
+
if (!i18nProp.isKind(SK.PropertyAssignment)) return only;
|
|
299
|
+
const init = i18nProp.getInitializer();
|
|
300
|
+
if (!init?.isKind(SK.ObjectLiteralExpression)) return only;
|
|
301
|
+
const onlyProp = init.getProperty("only");
|
|
302
|
+
if (!onlyProp?.isKind(SK.PropertyAssignment)) return only;
|
|
303
|
+
const onlyInit = onlyProp.getInitializer();
|
|
304
|
+
if (!onlyInit?.isKind(SK.ArrayLiteralExpression)) return only;
|
|
305
|
+
for (const el of onlyInit.getElements()) if (el.isKind(SK.StringLiteral)) only.push(el.getLiteralValue());
|
|
306
|
+
return only;
|
|
307
|
+
}
|
|
308
|
+
function resolveOptionsType(arg, methodName) {
|
|
309
|
+
const argType = arg.getType();
|
|
310
|
+
if (methodName === "forRoot") return argType;
|
|
311
|
+
const useFactorySymbol = argType.getProperty("useFactory");
|
|
312
|
+
if (!useFactorySymbol) return null;
|
|
313
|
+
const signatures = useFactorySymbol.getTypeAtLocation(arg).getCallSignatures();
|
|
314
|
+
if (signatures.length === 0) return null;
|
|
315
|
+
const returnType = signatures[0].getReturnType();
|
|
316
|
+
if (returnType.isUnion()) {
|
|
317
|
+
for (const member of returnType.getUnionTypes()) if (member.getProperty("i18n")) return member;
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
return returnType;
|
|
321
|
+
}
|
|
322
|
+
function extractOnlyFromType(optionsType, locationNode) {
|
|
323
|
+
const i18nSymbol = optionsType.getProperty("i18n");
|
|
324
|
+
if (!i18nSymbol) return [];
|
|
325
|
+
const onlySymbol = i18nSymbol.getTypeAtLocation(locationNode).getProperty("only");
|
|
326
|
+
if (!onlySymbol) return [];
|
|
327
|
+
const elementType = onlySymbol.getTypeAtLocation(locationNode).getNumberIndexType();
|
|
328
|
+
if (!elementType) return [];
|
|
329
|
+
const result = [];
|
|
330
|
+
if (elementType.isUnion()) {
|
|
331
|
+
for (const member of elementType.getUnionTypes()) if (member.isStringLiteral()) result.push(member.getLiteralValue());
|
|
332
|
+
} else if (elementType.isStringLiteral()) result.push(elementType.getLiteralValue());
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Collect every string-literal value a flash-key argument can resolve to.
|
|
337
|
+
*
|
|
338
|
+
* A direct string literal yields a single key. A conditional expression
|
|
339
|
+
* (`cond ? 'a' : 'b'`, including nested ternaries) contributes the literals
|
|
340
|
+
* from each branch. Non-literal keys (variables, template strings) yield
|
|
341
|
+
* nothing — they can't be statically known.
|
|
342
|
+
*/
|
|
343
|
+
function collectFlashKeyLiterals(node, SK) {
|
|
344
|
+
const stringLiteral = node.asKind(SK.StringLiteral);
|
|
345
|
+
if (stringLiteral) return [stringLiteral.getLiteralValue()];
|
|
346
|
+
const conditional = node.asKind(SK.ConditionalExpression);
|
|
347
|
+
if (conditional) return [...collectFlashKeyLiterals(conditional.getWhenTrue(), SK), ...collectFlashKeyLiterals(conditional.getWhenFalse(), SK)];
|
|
348
|
+
return [];
|
|
158
349
|
}
|
|
159
350
|
function extractFlashTypes(project, SK, tsObj, srcDir) {
|
|
160
351
|
const flashMembers = /* @__PURE__ */ new Map();
|
|
@@ -169,12 +360,13 @@ function extractFlashTypes(project, SK, tsObj, srcDir) {
|
|
|
169
360
|
if (expr.getName() !== "flash") continue;
|
|
170
361
|
const args = call.getArguments();
|
|
171
362
|
if (args.length < 2) continue;
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
const key = keyArg.getLiteralValue();
|
|
175
|
-
if (flashMembers.has(key)) continue;
|
|
363
|
+
const keys = collectFlashKeyLiterals(args[0], SK);
|
|
364
|
+
if (keys.length === 0) continue;
|
|
176
365
|
const valueType = widenLiteralType(args[1].getType(), tsObj);
|
|
177
|
-
|
|
366
|
+
for (const key of keys) {
|
|
367
|
+
if (flashMembers.has(key)) continue;
|
|
368
|
+
flashMembers.set(key, valueType);
|
|
369
|
+
}
|
|
178
370
|
}
|
|
179
371
|
}
|
|
180
372
|
if (flashMembers.size === 0) return null;
|
|
@@ -194,9 +386,9 @@ function extractSharedDataType(project, SK, tsObj, moduleFilePath) {
|
|
|
194
386
|
if (!objExpr.isKind(SK.Identifier) || objExpr.getText() !== "InertiaModule") continue;
|
|
195
387
|
const args = call.getArguments();
|
|
196
388
|
if (args.length === 0) continue;
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
199
|
-
const sharedDataProp =
|
|
389
|
+
const optionsLiteral = resolveModuleOptionsLiteral(args[0], SK);
|
|
390
|
+
if (!optionsLiteral || !optionsLiteral.isKind(SK.ObjectLiteralExpression)) continue;
|
|
391
|
+
const sharedDataProp = optionsLiteral.getProperty("sharedData");
|
|
200
392
|
if (!sharedDataProp) continue;
|
|
201
393
|
if (!sharedDataProp.isKind(SK.PropertyAssignment)) continue;
|
|
202
394
|
const initializer = sharedDataProp.getInitializer();
|
|
@@ -243,7 +435,7 @@ function resolvePagePropsTypeNames(pages) {
|
|
|
243
435
|
return result;
|
|
244
436
|
}
|
|
245
437
|
function generateInertiaTypes(input) {
|
|
246
|
-
const { pages, sharedData, shareCallTypes,
|
|
438
|
+
const { pages, sharedData, shareCallTypes, i18n, flashTypes } = input;
|
|
247
439
|
const typeNames = resolvePagePropsTypeNames(pages);
|
|
248
440
|
const lines = ["// Auto-generated by @stratal/inertia. Do not edit."];
|
|
249
441
|
if (pages.length > 0) {
|
|
@@ -262,6 +454,12 @@ function generateInertiaTypes(input) {
|
|
|
262
454
|
lines.push(` '${page.componentName}': ${typeName}`);
|
|
263
455
|
}
|
|
264
456
|
lines.push(" }");
|
|
457
|
+
if (i18n.enabled && i18n.only.length > 0) {
|
|
458
|
+
const prefixUnion = i18n.only.map((p) => `'${p}'`).join(" | ");
|
|
459
|
+
lines.push(" interface InertiaI18nConfig {");
|
|
460
|
+
lines.push(` translationKeys: import('stratal/i18n').FilterByPrefix<import('stratal/i18n').MessageKeys, ${prefixUnion}>`);
|
|
461
|
+
lines.push(" }");
|
|
462
|
+
}
|
|
265
463
|
lines.push("}");
|
|
266
464
|
const configMembers = [];
|
|
267
465
|
if (flashTypes && flashTypes.members.length > 0) {
|
|
@@ -270,7 +468,7 @@ function generateInertiaTypes(input) {
|
|
|
270
468
|
}
|
|
271
469
|
const sharedMembers = [];
|
|
272
470
|
if (sharedData) for (const member of sharedData.members) sharedMembers.push(` ${member.name}${member.optional ? "?" : ""}: ${member.type}`);
|
|
273
|
-
if (
|
|
471
|
+
if (i18n.enabled) {
|
|
274
472
|
sharedMembers.push(" locale: string");
|
|
275
473
|
sharedMembers.push(" translations: Record<string, string>");
|
|
276
474
|
}
|
|
@@ -297,6 +495,7 @@ function widenLiteralType(type, tsObj, fallbackLocation) {
|
|
|
297
495
|
return typeToString(type, tsObj, fallbackLocation);
|
|
298
496
|
}
|
|
299
497
|
function typeToString(type, tsObj, fallbackLocation) {
|
|
498
|
+
if (type.isUnion() && type.getAliasSymbol?.()?.getName() === "MessageKeys") return "import('@stratal/inertia').InertiaTranslationKeys";
|
|
300
499
|
if (type.isObject() || type.isUnion() || type.isIntersection()) return expandTypeToInline(type, tsObj, fallbackLocation);
|
|
301
500
|
const text = type.getText(void 0, tsObj.TypeFormatFlags.NoTruncation | tsObj.TypeFormatFlags.UseFullyQualifiedType);
|
|
302
501
|
if (text.includes("import(")) return expandTypeToInline(type, tsObj, fallbackLocation);
|
|
@@ -341,7 +540,10 @@ function expandTypeToInline(type, tsObj, fallbackLocation, visiting = /* @__PURE
|
|
|
341
540
|
return type.isReadonlyArray() ? `ReadonlyArray<${inner}>` : `Array<${inner}>`;
|
|
342
541
|
}
|
|
343
542
|
}
|
|
344
|
-
if (type.isUnion())
|
|
543
|
+
if (type.isUnion()) {
|
|
544
|
+
if (type.getAliasSymbol?.()?.getName() === "MessageKeys") return "import('@stratal/inertia').InertiaTranslationKeys";
|
|
545
|
+
return type.getUnionTypes().map((t) => expandTypeToInline(t, tsObj, fallbackLocation, visiting)).join(" | ");
|
|
546
|
+
}
|
|
345
547
|
if (type.isIntersection()) return type.getIntersectionTypes().map((t) => expandTypeToInline(t, tsObj, fallbackLocation, visiting)).join(" & ");
|
|
346
548
|
const text = type.getText(void 0, tsObj.TypeFormatFlags.NoTruncation);
|
|
347
549
|
if (text.includes("import(")) return "unknown";
|
|
@@ -381,12 +583,12 @@ async function runTypeGeneration(cwd) {
|
|
|
381
583
|
const { project, SyntaxKind, ts } = await createProject(findTsConfigPath(cwd));
|
|
382
584
|
const pages = extractControllerPageTypes(project, SyntaxKind, ts, srcDir, pagesDir);
|
|
383
585
|
const sharedData = moduleFilePath ? extractSharedDataType(project, SyntaxKind, ts, moduleFilePath) : null;
|
|
384
|
-
const
|
|
586
|
+
const i18n = detectI18nConfig(project, SyntaxKind, srcDir);
|
|
385
587
|
writeInertiaTypes(outputPath, generateInertiaTypes({
|
|
386
588
|
pages,
|
|
387
589
|
sharedData,
|
|
388
590
|
shareCallTypes: extractShareCallTypes(project, SyntaxKind, ts, srcDir),
|
|
389
|
-
|
|
591
|
+
i18n,
|
|
390
592
|
flashTypes: extractFlashTypes(project, SyntaxKind, ts, srcDir)
|
|
391
593
|
}));
|
|
392
594
|
return {
|
|
@@ -397,4 +599,4 @@ async function runTypeGeneration(cwd) {
|
|
|
397
599
|
//#endregion
|
|
398
600
|
export { runTypeGeneration as n, findPagesDir as t };
|
|
399
601
|
|
|
400
|
-
//# sourceMappingURL=type-generator-
|
|
602
|
+
//# sourceMappingURL=type-generator-DFpha_Fp.mjs.map
|