@ship-it-ui/next 0.0.11 → 0.0.13

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/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  ThemeBootstrap: () => ThemeBootstrap,
28
28
  ThemeToggle: () => ThemeToggle,
29
29
  buildBootstrapScript: () => buildBootstrapScript,
30
+ buildMetadata: () => buildMetadata,
30
31
  getThemeFromCookies: () => getThemeFromCookies,
31
32
  parseThemeCookie: () => parseThemeCookie,
32
33
  readThemeCookie: () => readThemeCookie,
@@ -103,6 +104,54 @@ function ThemeToggle({
103
104
  ] });
104
105
  }
105
106
  ThemeToggle.displayName = "ThemeToggle";
107
+
108
+ // src/metadata.ts
109
+ function normalizeTwitterHandle(handle) {
110
+ return handle.startsWith("@") ? handle : `@${handle}`;
111
+ }
112
+ function normalizeImage(image) {
113
+ if (image === void 0) return void 0;
114
+ if (typeof image === "string") return { url: image };
115
+ return image;
116
+ }
117
+ function buildMetadata(input) {
118
+ const {
119
+ title,
120
+ description,
121
+ url,
122
+ ogImage,
123
+ twitterHandle,
124
+ siteName,
125
+ locale = "en_US",
126
+ noIndex
127
+ } = input;
128
+ const image = normalizeImage(ogImage);
129
+ const images = image ? [image] : void 0;
130
+ const twitter = twitterHandle ? normalizeTwitterHandle(twitterHandle) : void 0;
131
+ const metadata = {
132
+ title,
133
+ ...description ? { description } : {},
134
+ openGraph: {
135
+ title,
136
+ ...description ? { description } : {},
137
+ ...url ? { url } : {},
138
+ ...siteName ? { siteName } : {},
139
+ ...images ? { images } : {},
140
+ locale,
141
+ type: "website"
142
+ },
143
+ twitter: {
144
+ card: image ? "summary_large_image" : "summary",
145
+ title,
146
+ ...description ? { description } : {},
147
+ ...images ? { images } : {},
148
+ ...twitter ? { creator: twitter, site: twitter } : {}
149
+ },
150
+ ...url ? { alternates: { canonical: url } } : {},
151
+ robots: noIndex ? { index: false, follow: false } : { index: true, follow: true }
152
+ };
153
+ return metadata;
154
+ }
106
155
  // Annotate the CommonJS export names for ESM import in node:
107
156
  0 && (module.exports = {
108
157
  THEME_COOKIE_MAX_AGE,
@@ -110,6 +159,7 @@ ThemeToggle.displayName = "ThemeToggle";
110
159
  ThemeBootstrap,
111
160
  ThemeToggle,
112
161
  buildBootstrapScript,
162
+ buildMetadata,
113
163
  getThemeFromCookies,
114
164
  parseThemeCookie,
115
165
  readThemeCookie,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/theme-cookie.ts","../src/ThemeBootstrap.tsx","../src/ThemeToggle.tsx"],"sourcesContent":["/**\n * @ship-it-ui/next — Next.js (App Router) helpers for the Ship-It design\n * system. SSR-safe theme persistence with FOUC prevention, cookie helpers,\n * and a token-styled theme toggle that reuses `useTheme` from\n * `@ship-it-ui/ui`.\n */\n\nexport { ThemeBootstrap, buildBootstrapScript } from './ThemeBootstrap';\nexport { ThemeToggle, type ThemeToggleProps } from './ThemeToggle';\nexport {\n getThemeFromCookies,\n parseThemeCookie,\n readThemeCookie,\n writeThemeCookie,\n THEME_COOKIE_NAME,\n THEME_COOKIE_MAX_AGE,\n type CookieGetter,\n} from './theme-cookie';\n","/**\n * Theme cookie helpers shared between the server-side `getThemeFromCookies`\n * and the client-side `ThemeBootstrap` / `ThemeToggle`. The cookie value is\n * the literal string `'dark'` or `'light'`; anything else falls back to\n * undefined and the consumer's default (typically dark) applies.\n */\n\nimport type { Theme } from '@ship-it-ui/ui';\n\nexport const THEME_COOKIE_NAME = 'ship-it-theme';\n\n/** One year. The cookie is non-sensitive, so a long lifetime is fine. */\nexport const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;\n\n/** Parse a raw cookie string value into a `Theme` or `undefined`. */\nexport function parseThemeCookie(value: string | null | undefined): Theme | undefined {\n if (value === 'dark' || value === 'light') return value;\n return undefined;\n}\n\n/**\n * Server helper for the App Router `cookies()` API. Pass `cookies()` (or any\n * object with `.get(name)` that returns `{ value: string }`) and receive the\n * stored theme.\n *\n * ```ts\n * import { cookies } from 'next/headers';\n * const theme = getThemeFromCookies(await cookies());\n * ```\n */\nexport interface CookieGetter {\n get(name: string): { value: string } | undefined;\n}\n\nexport function getThemeFromCookies(cookieStore: CookieGetter): Theme | undefined {\n return parseThemeCookie(cookieStore.get(THEME_COOKIE_NAME)?.value);\n}\n\n/**\n * Client-side cookie writer. Sets a path-`/` cookie with a year-long TTL and\n * `SameSite=Lax`, which is safe for theme preferences (non-sensitive, not\n * cross-site). No-op on the server.\n */\nexport function writeThemeCookie(theme: Theme): void {\n if (typeof document === 'undefined') return;\n document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=lax`;\n}\n\n/** Read the theme cookie from `document.cookie`. No-op on the server. */\nexport function readThemeCookie(): Theme | undefined {\n if (typeof document === 'undefined') return undefined;\n const match = document.cookie\n .split(';')\n .map((part) => part.trim())\n .find((part) => part.startsWith(`${THEME_COOKIE_NAME}=`));\n if (!match) return undefined;\n return parseThemeCookie(match.slice(THEME_COOKIE_NAME.length + 1));\n}\n","import type { JSX } from 'react';\n\nimport { THEME_COOKIE_NAME } from './theme-cookie';\n\n/**\n * ThemeBootstrap — render inside the `<head>` of your App Router root layout.\n * Injects a synchronous inline script that reads the theme cookie and sets\n * `<html data-theme>` *before* the first paint, which kills the dark→light\n * flash of unstyled content for users on the light theme.\n *\n * Pair with `getThemeFromCookies(cookies())` to seed the server-rendered\n * `data-theme` on `<html>` for the very first frame.\n *\n * ```tsx\n * import { cookies } from 'next/headers';\n * import { ThemeBootstrap, getThemeFromCookies } from '@ship-it-ui/next';\n *\n * export default function RootLayout({ children }) {\n * const theme = getThemeFromCookies(cookies());\n * return (\n * <html lang=\"en\" data-theme={theme}>\n * <head><ThemeBootstrap /></head>\n * <body>{children}</body>\n * </html>\n * );\n * }\n * ```\n */\nexport function ThemeBootstrap(): JSX.Element {\n return (\n <script\n // The script must be synchronous and inline — there's no place else to\n // intercept the document before paint. Build the cookie name into the\n // script so consumers don't have to mirror it.\n dangerouslySetInnerHTML={{ __html: buildBootstrapScript() }}\n />\n );\n}\n\nThemeBootstrap.displayName = 'ThemeBootstrap';\n\n/**\n * Returns the inline-script body that `ThemeBootstrap` injects. Exported so\n * tests (and consumers with a custom layout) can inspect or reuse the source.\n */\nexport function buildBootstrapScript(): string {\n return `(function(){try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(dark|light)/);var t=m&&m[1];if(t==='light'){document.documentElement.setAttribute('data-theme','light');}else if(t==='dark'){document.documentElement.removeAttribute('data-theme');}}catch(_){}})();`;\n}\n","'use client';\n\nimport { Switch, useTheme, type Theme } from '@ship-it-ui/ui';\nimport { useCallback, type JSX, type ReactNode } from 'react';\n\nimport { writeThemeCookie } from './theme-cookie';\n\n/**\n * ThemeToggle — token-styled `Switch` bound to the active theme. Reuses the\n * shared `useTheme` hook from `@ship-it-ui/ui` for in-page state and writes\n * a year-long cookie so the next request's `ThemeBootstrap` can render the\n * correct theme synchronously.\n *\n * Pass `label` for a visible labelled control, or supply your own wrapper if\n * you want a chip / menu-item layout.\n */\n\nexport interface ThemeToggleProps {\n /** Visible label rendered next to the switch. */\n label?: ReactNode;\n /** Override the accessible name. Falls back to `label` when it is a string. */\n 'aria-label'?: string;\n /** Fires after the theme has been persisted. Useful for analytics. */\n onThemeChange?: (next: Theme) => void;\n}\n\nexport function ThemeToggle({\n label,\n 'aria-label': ariaLabel,\n onThemeChange,\n}: ThemeToggleProps): JSX.Element {\n const { theme, setTheme } = useTheme();\n\n const handleChange = useCallback(\n (next: boolean) => {\n const target: Theme = next ? 'light' : 'dark';\n setTheme(target);\n writeThemeCookie(target);\n onThemeChange?.(target);\n },\n [setTheme, onThemeChange],\n );\n\n const accessibleName = ariaLabel ?? (typeof label === 'string' ? label : 'Toggle theme');\n\n return (\n <span className=\"inline-flex items-center gap-2\">\n <Switch\n checked={theme === 'light'}\n onCheckedChange={handleChange}\n aria-label={accessibleName}\n />\n {label && <span className=\"text-text-muted text-[12px]\">{label}</span>}\n </span>\n );\n}\n\nThemeToggle.displayName = 'ThemeToggle';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,IAAM,oBAAoB;AAG1B,IAAM,uBAAuB,KAAK,KAAK,KAAK;AAG5C,SAAS,iBAAiB,OAAqD;AACpF,MAAI,UAAU,UAAU,UAAU,QAAS,QAAO;AAClD,SAAO;AACT;AAgBO,SAAS,oBAAoB,aAA8C;AAChF,SAAO,iBAAiB,YAAY,IAAI,iBAAiB,GAAG,KAAK;AACnE;AAOO,SAAS,iBAAiB,OAAoB;AACnD,MAAI,OAAO,aAAa,YAAa;AACrC,WAAS,SAAS,GAAG,iBAAiB,IAAI,KAAK,qBAAqB,oBAAoB;AAC1F;AAGO,SAAS,kBAAqC;AACnD,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,QAAM,QAAQ,SAAS,OACpB,MAAM,GAAG,EACT,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,KAAK,CAAC,SAAS,KAAK,WAAW,GAAG,iBAAiB,GAAG,CAAC;AAC1D,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,iBAAiB,MAAM,MAAM,kBAAkB,SAAS,CAAC,CAAC;AACnE;;;AC3BI;AAFG,SAAS,iBAA8B;AAC5C,SACE;AAAA,IAAC;AAAA;AAAA,MAIC,yBAAyB,EAAE,QAAQ,qBAAqB,EAAE;AAAA;AAAA,EAC5D;AAEJ;AAEA,eAAe,cAAc;AAMtB,SAAS,uBAA+B;AAC7C,SAAO,wDAAwD,iBAAiB;AAClF;;;AC7CA,gBAA6C;AAC7C,mBAAsD;AA2ClD,IAAAA,sBAAA;AApBG,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,cAAc;AAAA,EACd;AACF,GAAkC;AAChC,QAAM,EAAE,OAAO,SAAS,QAAI,oBAAS;AAErC,QAAM,mBAAe;AAAA,IACnB,CAAC,SAAkB;AACjB,YAAM,SAAgB,OAAO,UAAU;AACvC,eAAS,MAAM;AACf,uBAAiB,MAAM;AACvB,sBAAgB,MAAM;AAAA,IACxB;AAAA,IACA,CAAC,UAAU,aAAa;AAAA,EAC1B;AAEA,QAAM,iBAAiB,cAAc,OAAO,UAAU,WAAW,QAAQ;AAEzE,SACE,8CAAC,UAAK,WAAU,kCACd;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,SAAS,UAAU;AAAA,QACnB,iBAAiB;AAAA,QACjB,cAAY;AAAA;AAAA,IACd;AAAA,IACC,SAAS,6CAAC,UAAK,WAAU,+BAA+B,iBAAM;AAAA,KACjE;AAEJ;AAEA,YAAY,cAAc;","names":["import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/theme-cookie.ts","../src/ThemeBootstrap.tsx","../src/ThemeToggle.tsx","../src/metadata.ts"],"sourcesContent":["/**\n * @ship-it-ui/next — Next.js (App Router) helpers for the Ship-It design\n * system. SSR-safe theme persistence with FOUC prevention, cookie helpers,\n * and a token-styled theme toggle that reuses `useTheme` from\n * `@ship-it-ui/ui`.\n */\n\nexport { ThemeBootstrap, buildBootstrapScript } from './ThemeBootstrap';\nexport { ThemeToggle, type ThemeToggleProps } from './ThemeToggle';\nexport {\n getThemeFromCookies,\n parseThemeCookie,\n readThemeCookie,\n writeThemeCookie,\n THEME_COOKIE_NAME,\n THEME_COOKIE_MAX_AGE,\n type CookieGetter,\n} from './theme-cookie';\nexport { buildMetadata, type BuildMetadataInput } from './metadata';\n","/**\n * Theme cookie helpers shared between the server-side `getThemeFromCookies`\n * and the client-side `ThemeBootstrap` / `ThemeToggle`. The cookie value is\n * the literal string `'dark'` or `'light'`; anything else falls back to\n * undefined and the consumer's default (typically dark) applies.\n */\n\nimport type { Theme } from '@ship-it-ui/ui';\n\nexport const THEME_COOKIE_NAME = 'ship-it-theme';\n\n/** One year. The cookie is non-sensitive, so a long lifetime is fine. */\nexport const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;\n\n/** Parse a raw cookie string value into a `Theme` or `undefined`. */\nexport function parseThemeCookie(value: string | null | undefined): Theme | undefined {\n if (value === 'dark' || value === 'light') return value;\n return undefined;\n}\n\n/**\n * Server helper for the App Router `cookies()` API. Pass `cookies()` (or any\n * object with `.get(name)` that returns `{ value: string }`) and receive the\n * stored theme.\n *\n * ```ts\n * import { cookies } from 'next/headers';\n * const theme = getThemeFromCookies(await cookies());\n * ```\n */\nexport interface CookieGetter {\n get(name: string): { value: string } | undefined;\n}\n\nexport function getThemeFromCookies(cookieStore: CookieGetter): Theme | undefined {\n return parseThemeCookie(cookieStore.get(THEME_COOKIE_NAME)?.value);\n}\n\n/**\n * Client-side cookie writer. Sets a path-`/` cookie with a year-long TTL and\n * `SameSite=Lax`, which is safe for theme preferences (non-sensitive, not\n * cross-site). No-op on the server.\n */\nexport function writeThemeCookie(theme: Theme): void {\n if (typeof document === 'undefined') return;\n document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=lax`;\n}\n\n/** Read the theme cookie from `document.cookie`. No-op on the server. */\nexport function readThemeCookie(): Theme | undefined {\n if (typeof document === 'undefined') return undefined;\n const match = document.cookie\n .split(';')\n .map((part) => part.trim())\n .find((part) => part.startsWith(`${THEME_COOKIE_NAME}=`));\n if (!match) return undefined;\n return parseThemeCookie(match.slice(THEME_COOKIE_NAME.length + 1));\n}\n","import type { JSX } from 'react';\n\nimport { THEME_COOKIE_NAME } from './theme-cookie';\n\n/**\n * ThemeBootstrap — render inside the `<head>` of your App Router root layout.\n * Injects a synchronous inline script that reads the theme cookie and sets\n * `<html data-theme>` *before* the first paint, which kills the dark→light\n * flash of unstyled content for users on the light theme.\n *\n * Pair with `getThemeFromCookies(cookies())` to seed the server-rendered\n * `data-theme` on `<html>` for the very first frame.\n *\n * ```tsx\n * import { cookies } from 'next/headers';\n * import { ThemeBootstrap, getThemeFromCookies } from '@ship-it-ui/next';\n *\n * export default function RootLayout({ children }) {\n * const theme = getThemeFromCookies(cookies());\n * return (\n * <html lang=\"en\" data-theme={theme}>\n * <head><ThemeBootstrap /></head>\n * <body>{children}</body>\n * </html>\n * );\n * }\n * ```\n */\nexport function ThemeBootstrap(): JSX.Element {\n return (\n <script\n // The script must be synchronous and inline — there's no place else to\n // intercept the document before paint. Build the cookie name into the\n // script so consumers don't have to mirror it.\n dangerouslySetInnerHTML={{ __html: buildBootstrapScript() }}\n />\n );\n}\n\nThemeBootstrap.displayName = 'ThemeBootstrap';\n\n/**\n * Returns the inline-script body that `ThemeBootstrap` injects. Exported so\n * tests (and consumers with a custom layout) can inspect or reuse the source.\n */\nexport function buildBootstrapScript(): string {\n return `(function(){try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(dark|light)/);var t=m&&m[1];if(t==='light'){document.documentElement.setAttribute('data-theme','light');}else if(t==='dark'){document.documentElement.removeAttribute('data-theme');}}catch(_){}})();`;\n}\n","'use client';\n\nimport { Switch, useTheme, type Theme } from '@ship-it-ui/ui';\nimport { useCallback, type JSX, type ReactNode } from 'react';\n\nimport { writeThemeCookie } from './theme-cookie';\n\n/**\n * ThemeToggle — token-styled `Switch` bound to the active theme. Reuses the\n * shared `useTheme` hook from `@ship-it-ui/ui` for in-page state and writes\n * a year-long cookie so the next request's `ThemeBootstrap` can render the\n * correct theme synchronously.\n *\n * Pass `label` for a visible labelled control, or supply your own wrapper if\n * you want a chip / menu-item layout.\n */\n\nexport interface ThemeToggleProps {\n /** Visible label rendered next to the switch. */\n label?: ReactNode;\n /** Override the accessible name. Falls back to `label` when it is a string. */\n 'aria-label'?: string;\n /** Fires after the theme has been persisted. Useful for analytics. */\n onThemeChange?: (next: Theme) => void;\n}\n\nexport function ThemeToggle({\n label,\n 'aria-label': ariaLabel,\n onThemeChange,\n}: ThemeToggleProps): JSX.Element {\n const { theme, setTheme } = useTheme();\n\n const handleChange = useCallback(\n (next: boolean) => {\n const target: Theme = next ? 'light' : 'dark';\n setTheme(target);\n writeThemeCookie(target);\n onThemeChange?.(target);\n },\n [setTheme, onThemeChange],\n );\n\n const accessibleName = ariaLabel ?? (typeof label === 'string' ? label : 'Toggle theme');\n\n return (\n <span className=\"inline-flex items-center gap-2\">\n <Switch\n checked={theme === 'light'}\n onCheckedChange={handleChange}\n aria-label={accessibleName}\n />\n {label && <span className=\"text-text-muted text-[12px]\">{label}</span>}\n </span>\n );\n}\n\nThemeToggle.displayName = 'ThemeToggle';\n","import type { Metadata } from 'next';\n\n/**\n * Inputs for `buildMetadata`. Everything except `title` is optional —\n * sensible defaults fill in the rest. Pass `noIndex: true` on\n * staging/preview deploys to keep them out of search results.\n */\nexport interface BuildMetadataInput {\n /** Page title. Used for `<title>`, OG title, and Twitter title. */\n title: string;\n /** Page description. Used for the meta description, OG description, and Twitter description. */\n description?: string;\n /** Absolute or root-relative URL. Used as `alternates.canonical` and as the OG `url`. */\n url?: string;\n /**\n * Absolute URL to a social-share image (1200×630 is the recommended OG size).\n * Pass an object to supply `width`, `height`, and `alt`.\n */\n ogImage?: string | { url: string; width?: number; height?: number; alt?: string };\n /** Twitter handle (with or without the leading `@`). Used as `twitter.creator`. */\n twitterHandle?: string;\n /** Site name shown next to the OG title on Facebook/LinkedIn/Slack. */\n siteName?: string;\n /** Locale string for OG (e.g. `'en_US'`). Default `'en_US'`. */\n locale?: string;\n /**\n * When `true`, emits `robots: { index: false, follow: false }` so search\n * engines skip the page. Use for preview/staging deploys.\n */\n noIndex?: boolean;\n}\n\nfunction normalizeTwitterHandle(handle: string): string {\n return handle.startsWith('@') ? handle : `@${handle}`;\n}\n\nfunction normalizeImage(\n image: BuildMetadataInput['ogImage'],\n): { url: string; width?: number; height?: number; alt?: string } | undefined {\n if (image === undefined) return undefined;\n if (typeof image === 'string') return { url: image };\n return image;\n}\n\n/**\n * Builds a Next.js App Router `Metadata` object from a small, opinionated\n * input shape — title, description, social-share image, canonical URL,\n * Twitter handle, robots posture. Output is suitable for direct assignment\n * to a `page.tsx` or `layout.tsx` `export const metadata`.\n *\n * @example\n * export const metadata = buildMetadata({\n * title: 'Pricing',\n * description: 'Plans that scale with your team.',\n * url: 'https://ship.it/pricing',\n * ogImage: 'https://ship.it/og/pricing.png',\n * twitterHandle: 'shipit',\n * });\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n const {\n title,\n description,\n url,\n ogImage,\n twitterHandle,\n siteName,\n locale = 'en_US',\n noIndex,\n } = input;\n\n const image = normalizeImage(ogImage);\n const images = image ? [image] : undefined;\n const twitter = twitterHandle ? normalizeTwitterHandle(twitterHandle) : undefined;\n\n const metadata: Metadata = {\n title,\n ...(description ? { description } : {}),\n openGraph: {\n title,\n ...(description ? { description } : {}),\n ...(url ? { url } : {}),\n ...(siteName ? { siteName } : {}),\n ...(images ? { images } : {}),\n locale,\n type: 'website',\n },\n twitter: {\n card: image ? 'summary_large_image' : 'summary',\n title,\n ...(description ? { description } : {}),\n ...(images ? { images } : {}),\n ...(twitter ? { creator: twitter, site: twitter } : {}),\n },\n ...(url ? { alternates: { canonical: url } } : {}),\n robots: noIndex ? { index: false, follow: false } : { index: true, follow: true },\n };\n\n return metadata;\n}\n"],"mappings":"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","names":["import_jsx_runtime"]}
package/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { JSX, ReactNode } from 'react';
2
2
  import { Theme } from '@ship-it-ui/ui';
3
3
  export { C as CookieGetter, T as THEME_COOKIE_MAX_AGE, a as THEME_COOKIE_NAME, g as getThemeFromCookies, p as parseThemeCookie, r as readThemeCookie, w as writeThemeCookie } from './server-DE2Fqp12.cjs';
4
+ import { Metadata } from 'next';
4
5
 
5
6
  /**
6
7
  * ThemeBootstrap — render inside the `<head>` of your App Router root layout.
@@ -58,4 +59,55 @@ declare namespace ThemeToggle {
58
59
  var displayName: string;
59
60
  }
60
61
 
61
- export { ThemeBootstrap, ThemeToggle, type ThemeToggleProps, buildBootstrapScript };
62
+ /**
63
+ * Inputs for `buildMetadata`. Everything except `title` is optional —
64
+ * sensible defaults fill in the rest. Pass `noIndex: true` on
65
+ * staging/preview deploys to keep them out of search results.
66
+ */
67
+ interface BuildMetadataInput {
68
+ /** Page title. Used for `<title>`, OG title, and Twitter title. */
69
+ title: string;
70
+ /** Page description. Used for the meta description, OG description, and Twitter description. */
71
+ description?: string;
72
+ /** Absolute or root-relative URL. Used as `alternates.canonical` and as the OG `url`. */
73
+ url?: string;
74
+ /**
75
+ * Absolute URL to a social-share image (1200×630 is the recommended OG size).
76
+ * Pass an object to supply `width`, `height`, and `alt`.
77
+ */
78
+ ogImage?: string | {
79
+ url: string;
80
+ width?: number;
81
+ height?: number;
82
+ alt?: string;
83
+ };
84
+ /** Twitter handle (with or without the leading `@`). Used as `twitter.creator`. */
85
+ twitterHandle?: string;
86
+ /** Site name shown next to the OG title on Facebook/LinkedIn/Slack. */
87
+ siteName?: string;
88
+ /** Locale string for OG (e.g. `'en_US'`). Default `'en_US'`. */
89
+ locale?: string;
90
+ /**
91
+ * When `true`, emits `robots: { index: false, follow: false }` so search
92
+ * engines skip the page. Use for preview/staging deploys.
93
+ */
94
+ noIndex?: boolean;
95
+ }
96
+ /**
97
+ * Builds a Next.js App Router `Metadata` object from a small, opinionated
98
+ * input shape — title, description, social-share image, canonical URL,
99
+ * Twitter handle, robots posture. Output is suitable for direct assignment
100
+ * to a `page.tsx` or `layout.tsx` `export const metadata`.
101
+ *
102
+ * @example
103
+ * export const metadata = buildMetadata({
104
+ * title: 'Pricing',
105
+ * description: 'Plans that scale with your team.',
106
+ * url: 'https://ship.it/pricing',
107
+ * ogImage: 'https://ship.it/og/pricing.png',
108
+ * twitterHandle: 'shipit',
109
+ * });
110
+ */
111
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
112
+
113
+ export { type BuildMetadataInput, ThemeBootstrap, ThemeToggle, type ThemeToggleProps, buildBootstrapScript, buildMetadata };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { JSX, ReactNode } from 'react';
2
2
  import { Theme } from '@ship-it-ui/ui';
3
3
  export { C as CookieGetter, T as THEME_COOKIE_MAX_AGE, a as THEME_COOKIE_NAME, g as getThemeFromCookies, p as parseThemeCookie, r as readThemeCookie, w as writeThemeCookie } from './server-DE2Fqp12.js';
4
+ import { Metadata } from 'next';
4
5
 
5
6
  /**
6
7
  * ThemeBootstrap — render inside the `<head>` of your App Router root layout.
@@ -58,4 +59,55 @@ declare namespace ThemeToggle {
58
59
  var displayName: string;
59
60
  }
60
61
 
61
- export { ThemeBootstrap, ThemeToggle, type ThemeToggleProps, buildBootstrapScript };
62
+ /**
63
+ * Inputs for `buildMetadata`. Everything except `title` is optional —
64
+ * sensible defaults fill in the rest. Pass `noIndex: true` on
65
+ * staging/preview deploys to keep them out of search results.
66
+ */
67
+ interface BuildMetadataInput {
68
+ /** Page title. Used for `<title>`, OG title, and Twitter title. */
69
+ title: string;
70
+ /** Page description. Used for the meta description, OG description, and Twitter description. */
71
+ description?: string;
72
+ /** Absolute or root-relative URL. Used as `alternates.canonical` and as the OG `url`. */
73
+ url?: string;
74
+ /**
75
+ * Absolute URL to a social-share image (1200×630 is the recommended OG size).
76
+ * Pass an object to supply `width`, `height`, and `alt`.
77
+ */
78
+ ogImage?: string | {
79
+ url: string;
80
+ width?: number;
81
+ height?: number;
82
+ alt?: string;
83
+ };
84
+ /** Twitter handle (with or without the leading `@`). Used as `twitter.creator`. */
85
+ twitterHandle?: string;
86
+ /** Site name shown next to the OG title on Facebook/LinkedIn/Slack. */
87
+ siteName?: string;
88
+ /** Locale string for OG (e.g. `'en_US'`). Default `'en_US'`. */
89
+ locale?: string;
90
+ /**
91
+ * When `true`, emits `robots: { index: false, follow: false }` so search
92
+ * engines skip the page. Use for preview/staging deploys.
93
+ */
94
+ noIndex?: boolean;
95
+ }
96
+ /**
97
+ * Builds a Next.js App Router `Metadata` object from a small, opinionated
98
+ * input shape — title, description, social-share image, canonical URL,
99
+ * Twitter handle, robots posture. Output is suitable for direct assignment
100
+ * to a `page.tsx` or `layout.tsx` `export const metadata`.
101
+ *
102
+ * @example
103
+ * export const metadata = buildMetadata({
104
+ * title: 'Pricing',
105
+ * description: 'Plans that scale with your team.',
106
+ * url: 'https://ship.it/pricing',
107
+ * ogImage: 'https://ship.it/og/pricing.png',
108
+ * twitterHandle: 'shipit',
109
+ * });
110
+ */
111
+ declare function buildMetadata(input: BuildMetadataInput): Metadata;
112
+
113
+ export { type BuildMetadataInput, ThemeBootstrap, ThemeToggle, type ThemeToggleProps, buildBootstrapScript, buildMetadata };
package/dist/index.js CHANGED
@@ -57,12 +57,61 @@ function ThemeToggle({
57
57
  ] });
58
58
  }
59
59
  ThemeToggle.displayName = "ThemeToggle";
60
+
61
+ // src/metadata.ts
62
+ function normalizeTwitterHandle(handle) {
63
+ return handle.startsWith("@") ? handle : `@${handle}`;
64
+ }
65
+ function normalizeImage(image) {
66
+ if (image === void 0) return void 0;
67
+ if (typeof image === "string") return { url: image };
68
+ return image;
69
+ }
70
+ function buildMetadata(input) {
71
+ const {
72
+ title,
73
+ description,
74
+ url,
75
+ ogImage,
76
+ twitterHandle,
77
+ siteName,
78
+ locale = "en_US",
79
+ noIndex
80
+ } = input;
81
+ const image = normalizeImage(ogImage);
82
+ const images = image ? [image] : void 0;
83
+ const twitter = twitterHandle ? normalizeTwitterHandle(twitterHandle) : void 0;
84
+ const metadata = {
85
+ title,
86
+ ...description ? { description } : {},
87
+ openGraph: {
88
+ title,
89
+ ...description ? { description } : {},
90
+ ...url ? { url } : {},
91
+ ...siteName ? { siteName } : {},
92
+ ...images ? { images } : {},
93
+ locale,
94
+ type: "website"
95
+ },
96
+ twitter: {
97
+ card: image ? "summary_large_image" : "summary",
98
+ title,
99
+ ...description ? { description } : {},
100
+ ...images ? { images } : {},
101
+ ...twitter ? { creator: twitter, site: twitter } : {}
102
+ },
103
+ ...url ? { alternates: { canonical: url } } : {},
104
+ robots: noIndex ? { index: false, follow: false } : { index: true, follow: true }
105
+ };
106
+ return metadata;
107
+ }
60
108
  export {
61
109
  THEME_COOKIE_MAX_AGE,
62
110
  THEME_COOKIE_NAME,
63
111
  ThemeBootstrap,
64
112
  ThemeToggle,
65
113
  buildBootstrapScript,
114
+ buildMetadata,
66
115
  getThemeFromCookies,
67
116
  parseThemeCookie,
68
117
  readThemeCookie,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ThemeBootstrap.tsx","../src/ThemeToggle.tsx"],"sourcesContent":["import type { JSX } from 'react';\n\nimport { THEME_COOKIE_NAME } from './theme-cookie';\n\n/**\n * ThemeBootstrap — render inside the `<head>` of your App Router root layout.\n * Injects a synchronous inline script that reads the theme cookie and sets\n * `<html data-theme>` *before* the first paint, which kills the dark→light\n * flash of unstyled content for users on the light theme.\n *\n * Pair with `getThemeFromCookies(cookies())` to seed the server-rendered\n * `data-theme` on `<html>` for the very first frame.\n *\n * ```tsx\n * import { cookies } from 'next/headers';\n * import { ThemeBootstrap, getThemeFromCookies } from '@ship-it-ui/next';\n *\n * export default function RootLayout({ children }) {\n * const theme = getThemeFromCookies(cookies());\n * return (\n * <html lang=\"en\" data-theme={theme}>\n * <head><ThemeBootstrap /></head>\n * <body>{children}</body>\n * </html>\n * );\n * }\n * ```\n */\nexport function ThemeBootstrap(): JSX.Element {\n return (\n <script\n // The script must be synchronous and inline — there's no place else to\n // intercept the document before paint. Build the cookie name into the\n // script so consumers don't have to mirror it.\n dangerouslySetInnerHTML={{ __html: buildBootstrapScript() }}\n />\n );\n}\n\nThemeBootstrap.displayName = 'ThemeBootstrap';\n\n/**\n * Returns the inline-script body that `ThemeBootstrap` injects. Exported so\n * tests (and consumers with a custom layout) can inspect or reuse the source.\n */\nexport function buildBootstrapScript(): string {\n return `(function(){try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(dark|light)/);var t=m&&m[1];if(t==='light'){document.documentElement.setAttribute('data-theme','light');}else if(t==='dark'){document.documentElement.removeAttribute('data-theme');}}catch(_){}})();`;\n}\n","'use client';\n\nimport { Switch, useTheme, type Theme } from '@ship-it-ui/ui';\nimport { useCallback, type JSX, type ReactNode } from 'react';\n\nimport { writeThemeCookie } from './theme-cookie';\n\n/**\n * ThemeToggle — token-styled `Switch` bound to the active theme. Reuses the\n * shared `useTheme` hook from `@ship-it-ui/ui` for in-page state and writes\n * a year-long cookie so the next request's `ThemeBootstrap` can render the\n * correct theme synchronously.\n *\n * Pass `label` for a visible labelled control, or supply your own wrapper if\n * you want a chip / menu-item layout.\n */\n\nexport interface ThemeToggleProps {\n /** Visible label rendered next to the switch. */\n label?: ReactNode;\n /** Override the accessible name. Falls back to `label` when it is a string. */\n 'aria-label'?: string;\n /** Fires after the theme has been persisted. Useful for analytics. */\n onThemeChange?: (next: Theme) => void;\n}\n\nexport function ThemeToggle({\n label,\n 'aria-label': ariaLabel,\n onThemeChange,\n}: ThemeToggleProps): JSX.Element {\n const { theme, setTheme } = useTheme();\n\n const handleChange = useCallback(\n (next: boolean) => {\n const target: Theme = next ? 'light' : 'dark';\n setTheme(target);\n writeThemeCookie(target);\n onThemeChange?.(target);\n },\n [setTheme, onThemeChange],\n );\n\n const accessibleName = ariaLabel ?? (typeof label === 'string' ? label : 'Toggle theme');\n\n return (\n <span className=\"inline-flex items-center gap-2\">\n <Switch\n checked={theme === 'light'}\n onCheckedChange={handleChange}\n aria-label={accessibleName}\n />\n {label && <span className=\"text-text-muted text-[12px]\">{label}</span>}\n </span>\n );\n}\n\nThemeToggle.displayName = 'ThemeToggle';\n"],"mappings":";;;;;;;;;;AA8BI;AAFG,SAAS,iBAA8B;AAC5C,SACE;AAAA,IAAC;AAAA;AAAA,MAIC,yBAAyB,EAAE,QAAQ,qBAAqB,EAAE;AAAA;AAAA,EAC5D;AAEJ;AAEA,eAAe,cAAc;AAMtB,SAAS,uBAA+B;AAC7C,SAAO,wDAAwD,iBAAiB;AAClF;;;AC7CA,SAAS,QAAQ,gBAA4B;AAC7C,SAAS,mBAA6C;AA2ClD,SACE,OAAAA,MADF;AApBG,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,cAAc;AAAA,EACd;AACF,GAAkC;AAChC,QAAM,EAAE,OAAO,SAAS,IAAI,SAAS;AAErC,QAAM,eAAe;AAAA,IACnB,CAAC,SAAkB;AACjB,YAAM,SAAgB,OAAO,UAAU;AACvC,eAAS,MAAM;AACf,uBAAiB,MAAM;AACvB,sBAAgB,MAAM;AAAA,IACxB;AAAA,IACA,CAAC,UAAU,aAAa;AAAA,EAC1B;AAEA,QAAM,iBAAiB,cAAc,OAAO,UAAU,WAAW,QAAQ;AAEzE,SACE,qBAAC,UAAK,WAAU,kCACd;AAAA,oBAAAA;AAAA,MAAC;AAAA;AAAA,QACC,SAAS,UAAU;AAAA,QACnB,iBAAiB;AAAA,QACjB,cAAY;AAAA;AAAA,IACd;AAAA,IACC,SAAS,gBAAAA,KAAC,UAAK,WAAU,+BAA+B,iBAAM;AAAA,KACjE;AAEJ;AAEA,YAAY,cAAc;","names":["jsx"]}
1
+ {"version":3,"sources":["../src/ThemeBootstrap.tsx","../src/ThemeToggle.tsx","../src/metadata.ts"],"sourcesContent":["import type { JSX } from 'react';\n\nimport { THEME_COOKIE_NAME } from './theme-cookie';\n\n/**\n * ThemeBootstrap — render inside the `<head>` of your App Router root layout.\n * Injects a synchronous inline script that reads the theme cookie and sets\n * `<html data-theme>` *before* the first paint, which kills the dark→light\n * flash of unstyled content for users on the light theme.\n *\n * Pair with `getThemeFromCookies(cookies())` to seed the server-rendered\n * `data-theme` on `<html>` for the very first frame.\n *\n * ```tsx\n * import { cookies } from 'next/headers';\n * import { ThemeBootstrap, getThemeFromCookies } from '@ship-it-ui/next';\n *\n * export default function RootLayout({ children }) {\n * const theme = getThemeFromCookies(cookies());\n * return (\n * <html lang=\"en\" data-theme={theme}>\n * <head><ThemeBootstrap /></head>\n * <body>{children}</body>\n * </html>\n * );\n * }\n * ```\n */\nexport function ThemeBootstrap(): JSX.Element {\n return (\n <script\n // The script must be synchronous and inline — there's no place else to\n // intercept the document before paint. Build the cookie name into the\n // script so consumers don't have to mirror it.\n dangerouslySetInnerHTML={{ __html: buildBootstrapScript() }}\n />\n );\n}\n\nThemeBootstrap.displayName = 'ThemeBootstrap';\n\n/**\n * Returns the inline-script body that `ThemeBootstrap` injects. Exported so\n * tests (and consumers with a custom layout) can inspect or reuse the source.\n */\nexport function buildBootstrapScript(): string {\n return `(function(){try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(dark|light)/);var t=m&&m[1];if(t==='light'){document.documentElement.setAttribute('data-theme','light');}else if(t==='dark'){document.documentElement.removeAttribute('data-theme');}}catch(_){}})();`;\n}\n","'use client';\n\nimport { Switch, useTheme, type Theme } from '@ship-it-ui/ui';\nimport { useCallback, type JSX, type ReactNode } from 'react';\n\nimport { writeThemeCookie } from './theme-cookie';\n\n/**\n * ThemeToggle — token-styled `Switch` bound to the active theme. Reuses the\n * shared `useTheme` hook from `@ship-it-ui/ui` for in-page state and writes\n * a year-long cookie so the next request's `ThemeBootstrap` can render the\n * correct theme synchronously.\n *\n * Pass `label` for a visible labelled control, or supply your own wrapper if\n * you want a chip / menu-item layout.\n */\n\nexport interface ThemeToggleProps {\n /** Visible label rendered next to the switch. */\n label?: ReactNode;\n /** Override the accessible name. Falls back to `label` when it is a string. */\n 'aria-label'?: string;\n /** Fires after the theme has been persisted. Useful for analytics. */\n onThemeChange?: (next: Theme) => void;\n}\n\nexport function ThemeToggle({\n label,\n 'aria-label': ariaLabel,\n onThemeChange,\n}: ThemeToggleProps): JSX.Element {\n const { theme, setTheme } = useTheme();\n\n const handleChange = useCallback(\n (next: boolean) => {\n const target: Theme = next ? 'light' : 'dark';\n setTheme(target);\n writeThemeCookie(target);\n onThemeChange?.(target);\n },\n [setTheme, onThemeChange],\n );\n\n const accessibleName = ariaLabel ?? (typeof label === 'string' ? label : 'Toggle theme');\n\n return (\n <span className=\"inline-flex items-center gap-2\">\n <Switch\n checked={theme === 'light'}\n onCheckedChange={handleChange}\n aria-label={accessibleName}\n />\n {label && <span className=\"text-text-muted text-[12px]\">{label}</span>}\n </span>\n );\n}\n\nThemeToggle.displayName = 'ThemeToggle';\n","import type { Metadata } from 'next';\n\n/**\n * Inputs for `buildMetadata`. Everything except `title` is optional —\n * sensible defaults fill in the rest. Pass `noIndex: true` on\n * staging/preview deploys to keep them out of search results.\n */\nexport interface BuildMetadataInput {\n /** Page title. Used for `<title>`, OG title, and Twitter title. */\n title: string;\n /** Page description. Used for the meta description, OG description, and Twitter description. */\n description?: string;\n /** Absolute or root-relative URL. Used as `alternates.canonical` and as the OG `url`. */\n url?: string;\n /**\n * Absolute URL to a social-share image (1200×630 is the recommended OG size).\n * Pass an object to supply `width`, `height`, and `alt`.\n */\n ogImage?: string | { url: string; width?: number; height?: number; alt?: string };\n /** Twitter handle (with or without the leading `@`). Used as `twitter.creator`. */\n twitterHandle?: string;\n /** Site name shown next to the OG title on Facebook/LinkedIn/Slack. */\n siteName?: string;\n /** Locale string for OG (e.g. `'en_US'`). Default `'en_US'`. */\n locale?: string;\n /**\n * When `true`, emits `robots: { index: false, follow: false }` so search\n * engines skip the page. Use for preview/staging deploys.\n */\n noIndex?: boolean;\n}\n\nfunction normalizeTwitterHandle(handle: string): string {\n return handle.startsWith('@') ? handle : `@${handle}`;\n}\n\nfunction normalizeImage(\n image: BuildMetadataInput['ogImage'],\n): { url: string; width?: number; height?: number; alt?: string } | undefined {\n if (image === undefined) return undefined;\n if (typeof image === 'string') return { url: image };\n return image;\n}\n\n/**\n * Builds a Next.js App Router `Metadata` object from a small, opinionated\n * input shape — title, description, social-share image, canonical URL,\n * Twitter handle, robots posture. Output is suitable for direct assignment\n * to a `page.tsx` or `layout.tsx` `export const metadata`.\n *\n * @example\n * export const metadata = buildMetadata({\n * title: 'Pricing',\n * description: 'Plans that scale with your team.',\n * url: 'https://ship.it/pricing',\n * ogImage: 'https://ship.it/og/pricing.png',\n * twitterHandle: 'shipit',\n * });\n */\nexport function buildMetadata(input: BuildMetadataInput): Metadata {\n const {\n title,\n description,\n url,\n ogImage,\n twitterHandle,\n siteName,\n locale = 'en_US',\n noIndex,\n } = input;\n\n const image = normalizeImage(ogImage);\n const images = image ? [image] : undefined;\n const twitter = twitterHandle ? normalizeTwitterHandle(twitterHandle) : undefined;\n\n const metadata: Metadata = {\n title,\n ...(description ? { description } : {}),\n openGraph: {\n title,\n ...(description ? { description } : {}),\n ...(url ? { url } : {}),\n ...(siteName ? { siteName } : {}),\n ...(images ? { images } : {}),\n locale,\n type: 'website',\n },\n twitter: {\n card: image ? 'summary_large_image' : 'summary',\n title,\n ...(description ? { description } : {}),\n ...(images ? { images } : {}),\n ...(twitter ? { creator: twitter, site: twitter } : {}),\n },\n ...(url ? { alternates: { canonical: url } } : {}),\n robots: noIndex ? { index: false, follow: false } : { index: true, follow: true },\n };\n\n return metadata;\n}\n"],"mappings":";;;;;;;;;;AA8BI;AAFG,SAAS,iBAA8B;AAC5C,SACE;AAAA,IAAC;AAAA;AAAA,MAIC,yBAAyB,EAAE,QAAQ,qBAAqB,EAAE;AAAA;AAAA,EAC5D;AAEJ;AAEA,eAAe,cAAc;AAMtB,SAAS,uBAA+B;AAC7C,SAAO,wDAAwD,iBAAiB;AAClF;;;AC7CA,SAAS,QAAQ,gBAA4B;AAC7C,SAAS,mBAA6C;AA2ClD,SACE,OAAAA,MADF;AApBG,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,cAAc;AAAA,EACd;AACF,GAAkC;AAChC,QAAM,EAAE,OAAO,SAAS,IAAI,SAAS;AAErC,QAAM,eAAe;AAAA,IACnB,CAAC,SAAkB;AACjB,YAAM,SAAgB,OAAO,UAAU;AACvC,eAAS,MAAM;AACf,uBAAiB,MAAM;AACvB,sBAAgB,MAAM;AAAA,IACxB;AAAA,IACA,CAAC,UAAU,aAAa;AAAA,EAC1B;AAEA,QAAM,iBAAiB,cAAc,OAAO,UAAU,WAAW,QAAQ;AAEzE,SACE,qBAAC,UAAK,WAAU,kCACd;AAAA,oBAAAA;AAAA,MAAC;AAAA;AAAA,QACC,SAAS,UAAU;AAAA,QACnB,iBAAiB;AAAA,QACjB,cAAY;AAAA;AAAA,IACd;AAAA,IACC,SAAS,gBAAAA,KAAC,UAAK,WAAU,+BAA+B,iBAAM;AAAA,KACjE;AAEJ;AAEA,YAAY,cAAc;;;ACzB1B,SAAS,uBAAuB,QAAwB;AACtD,SAAO,OAAO,WAAW,GAAG,IAAI,SAAS,IAAI,MAAM;AACrD;AAEA,SAAS,eACP,OAC4E;AAC5E,MAAI,UAAU,OAAW,QAAO;AAChC,MAAI,OAAO,UAAU,SAAU,QAAO,EAAE,KAAK,MAAM;AACnD,SAAO;AACT;AAiBO,SAAS,cAAc,OAAqC;AACjE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT;AAAA,EACF,IAAI;AAEJ,QAAM,QAAQ,eAAe,OAAO;AACpC,QAAM,SAAS,QAAQ,CAAC,KAAK,IAAI;AACjC,QAAM,UAAU,gBAAgB,uBAAuB,aAAa,IAAI;AAExE,QAAM,WAAqB;AAAA,IACzB;AAAA,IACA,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,IACrC,WAAW;AAAA,MACT;AAAA,MACA,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,MACrC,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;AAAA,MACrB,GAAI,WAAW,EAAE,SAAS,IAAI,CAAC;AAAA,MAC/B,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR;AAAA,IACA,SAAS;AAAA,MACP,MAAM,QAAQ,wBAAwB;AAAA,MACtC;AAAA,MACA,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,MACrC,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,MAC3B,GAAI,UAAU,EAAE,SAAS,SAAS,MAAM,QAAQ,IAAI,CAAC;AAAA,IACvD;AAAA,IACA,GAAI,MAAM,EAAE,YAAY,EAAE,WAAW,IAAI,EAAE,IAAI,CAAC;AAAA,IAChD,QAAQ,UAAU,EAAE,OAAO,OAAO,QAAQ,MAAM,IAAI,EAAE,OAAO,MAAM,QAAQ,KAAK;AAAA,EAClF;AAEA,SAAO;AACT;","names":["jsx"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-it-ui/next",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Next.js (App Router) helpers for the Ship-It design system: SSR-safe theme persistence with FOUC prevention, cookie helpers, and a token-styled theme toggle.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ship-it-ops.github.io/ship-it-design/",
@@ -50,7 +50,7 @@
50
50
  "next": "^15.0.0 || ^16.0.0",
51
51
  "react": "^19.0.0",
52
52
  "react-dom": "^19.0.0",
53
- "@ship-it-ui/ui": "0.0.13"
53
+ "@ship-it-ui/ui": "0.0.15"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "next": {
@@ -61,13 +61,13 @@
61
61
  "@testing-library/jest-dom": "^6.6.3",
62
62
  "@testing-library/react": "^16.0.1",
63
63
  "@testing-library/user-event": "^14.5.2",
64
- "@types/react": "^19.2.0",
64
+ "@types/react": "^19.2.16",
65
65
  "@types/react-dom": "^19.2.0",
66
66
  "axe-core": "^4.10.2",
67
67
  "esbuild-plugin-preserve-directives": "^0.0.11",
68
68
  "jsdom": "^29.1.1",
69
- "react": "^19.2.0",
70
- "react-dom": "^19.2.0",
69
+ "react": "^19.2.7",
70
+ "react-dom": "^19.2.7",
71
71
  "tsup": "^8.3.0",
72
72
  "typescript": "^5.6.3",
73
73
  "vitest": "^2.1.3",
@@ -75,7 +75,7 @@
75
75
  "@ship-it-ui/eslint-config": "0.0.1",
76
76
  "@ship-it-ui/tokens": "0.0.7",
77
77
  "@ship-it-ui/tsconfig": "0.0.1",
78
- "@ship-it-ui/ui": "0.0.13"
78
+ "@ship-it-ui/ui": "0.0.15"
79
79
  },
80
80
  "scripts": {
81
81
  "build": "tsup",