@smalk/nextjs-ads 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -82,6 +82,27 @@ Then put `<div smalk-ads></div>` anywhere in your page HTML — middleware regex
82
82
  - Hash compare: when API returns a hash that differs from the last cached hash for that URL, the package calls `revalidatePath()` to bust the Full Route Cache
83
83
  - HTTP: callers should not add CDN caching to ad-bearing pages (`Cache-Control: private, no-cache, must-revalidate` recommended at the edge)
84
84
 
85
+ ## Dynamic rendering — important
86
+
87
+ For ads to refresh between deploys, the route hosting `<SmalkAd>` must render dynamically (server-side per request), not be baked into the static build output.
88
+
89
+ The package handles this automatically: `<SmalkAd>` calls `headers()` (built-in Next 13.4+ opt-out) and `unstable_noStore()` (Next 14+ explicit opt-out). This makes the surrounding route dynamic by default.
90
+
91
+ You only need to act manually in two edge cases:
92
+
93
+ 1. **`output: 'export'` in `next.config.js`** — full static export, no Node server at runtime. Server Components cannot run on requests; the ad HTML is frozen at build time. Not supported by this plugin. Remove `output: 'export'` (or switch the affected routes off it).
94
+
95
+ 2. **Custom `dynamic` export overriding the heuristic** — if your `page.tsx` or a parent `layout.tsx` exports `export const dynamic = 'force-static'` or `'error'`, that overrides the plugin's opt-out. Either remove that line, or explicitly mark the route as dynamic:
96
+
97
+ ```tsx
98
+ // app/blog/layout.tsx (or page.tsx)
99
+ export const dynamic = 'force-dynamic';
100
+ ```
101
+
102
+ Putting it on `layout.tsx` is the lowest-friction option — it applies to every page under that folder without per-page edits.
103
+
104
+ If you're not sure, leave the plugin to handle it and check `view-source` of your deployed page: if you see `<!-- smalk: no ad -->` updating between deploys (and our dashboard registers a new AdPlacement after first request), you're good.
105
+
85
106
  ## Trust Boundary
86
107
 
87
108
  Ad HTML is rendered via React's raw-HTML escape hatch inside package-owned components only. The publisher never calls the unsafe React API directly. We do **not** ship a sanitizer (DOMPurify): ads include `<script type="application/ld+json">` JSON-LD that AI crawlers parse for citation freshness, and the default DOMPurify config strips it. Smalk vets ad content server-side; this is the same trust model used by the WordPress (`smalk-ai-ads-pro`) and Drupal (`smalk_d8`) plugins.
package/dist/app.cjs CHANGED
@@ -144,13 +144,15 @@ async function sha256Hex(input) {
144
144
  // src/app.tsx
145
145
  var import_jsx_runtime = require("react/jsx-runtime");
146
146
  async function SmalkAd(props = {}) {
147
+ await noStoreIfAvailable();
147
148
  const h = await readHeadersCompat();
148
149
  const userAgent = h.get("user-agent") ?? "";
149
150
  const referer = h.get("referer") ?? "";
150
151
  const clientIp = pickClientIp(h);
151
152
  const host = h.get("host") ?? "";
152
153
  const pathname = props.pathname ?? "/";
153
- const pageUrl = host ? `https://${host}${pathname}` : pathname;
154
+ if (!host) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SmalkAdComment, { reason: "no host" });
155
+ const pageUrl = `https://${host}${pathname}`;
154
156
  const ad = await fetchAd(
155
157
  { pageUrl, userAgent, referer, clientIp },
156
158
  { revalidate: props.revalidate, preview: props.preview }
@@ -170,6 +172,15 @@ async function SmalkAd(props = {}) {
170
172
  function SmalkAdComment({ reason }) {
171
173
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-smalk": "comment", dangerouslySetInnerHTML: { __html: `<!-- smalk: ${reason} -->` } });
172
174
  }
175
+ async function noStoreIfAvailable() {
176
+ try {
177
+ const mod = await import("next/cache");
178
+ if (typeof mod.unstable_noStore === "function") {
179
+ mod.unstable_noStore();
180
+ }
181
+ } catch {
182
+ }
183
+ }
173
184
  async function readHeadersCompat() {
174
185
  const result = (0, import_headers.headers)();
175
186
  if (result && typeof result.then === "function") {
package/dist/app.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/app.tsx","../src/index.ts","../src/client.ts"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n const pageUrl = host ? `https://${host}${pathname}` : pathname;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkAdsConfig {\n projectKey: string;\n apiKey: string;\n apiBaseUrl: string;\n}\n\nexport interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/api/v1/transform/ads/content/';\n\nexport function loadConfig(): SmalkAdsConfig {\n const projectKey = process.env.SMALK_PROJECT_KEY;\n const apiKey = process.env.SMALK_API_KEY;\n const apiBaseUrl = process.env.SMALK_API_BASE_URL || 'https://api.smalk.ai';\n const isProd = process.env.NODE_ENV === 'production';\n\n if (!projectKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production');\n console.warn('[smalk] SMALK_PROJECT_KEY not set — ad fetch will be skipped');\n }\n if (!apiKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_API_KEY env var is required in production');\n console.warn('[smalk] SMALK_API_KEY not set — ad fetch will be skipped');\n }\n\n return {\n projectKey: projectKey ?? '',\n apiKey: apiKey ?? '',\n apiBaseUrl,\n };\n}\n","import { loadConfig, AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = loadConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBaseUrl}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/app.tsx","../src/index.ts","../src/client.ts"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n // Opt the surrounding route out of Static Generation so the ad fetch runs at\n // request time, not at build time. Two layers:\n // 1) `headers()` below — opts out since Next 13.4 (built-in heuristic).\n // 2) `unstable_noStore()` — explicit opt-out marker, added in Next 14.\n // Best-effort: dynamic import, swallow if unavailable (Next 13.x or moved).\n await noStoreIfAvailable();\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n if (!host) return <SmalkAdComment reason=\"no host\" />;\n const pageUrl = `https://${host}${pathname}`;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function noStoreIfAvailable(): Promise<void> {\n try {\n const mod = (await import('next/cache')) as unknown as { unstable_noStore?: () => void };\n if (typeof mod.unstable_noStore === 'function') {\n mod.unstable_noStore();\n }\n } catch {\n // next/cache not available (Next 13.x) — the `headers()` call below still\n // opts the route out of Static Generation in that version range.\n }\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkAdsConfig {\n projectKey: string;\n apiKey: string;\n apiBaseUrl: string;\n}\n\nexport interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/api/v1/transform/ads/content/';\n\nexport function loadConfig(): SmalkAdsConfig {\n const projectKey = process.env.SMALK_PROJECT_KEY;\n const apiKey = process.env.SMALK_API_KEY;\n const apiBaseUrl = process.env.SMALK_API_BASE_URL || 'https://api.smalk.ai';\n const isProd = process.env.NODE_ENV === 'production';\n\n if (!projectKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production');\n console.warn('[smalk] SMALK_PROJECT_KEY not set — ad fetch will be skipped');\n }\n if (!apiKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_API_KEY env var is required in production');\n console.warn('[smalk] SMALK_API_KEY not set — ad fetch will be skipped');\n }\n\n return {\n projectKey: projectKey ?? '',\n apiKey: apiKey ?? '',\n apiBaseUrl,\n };\n}\n","import { loadConfig, AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = loadConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBaseUrl}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":"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","names":[]}
package/dist/app.js CHANGED
@@ -8,13 +8,15 @@ import "server-only";
8
8
  import { headers } from "next/headers";
9
9
  import { jsx } from "react/jsx-runtime";
10
10
  async function SmalkAd(props = {}) {
11
+ await noStoreIfAvailable();
11
12
  const h = await readHeadersCompat();
12
13
  const userAgent = h.get("user-agent") ?? "";
13
14
  const referer = h.get("referer") ?? "";
14
15
  const clientIp = pickClientIp(h);
15
16
  const host = h.get("host") ?? "";
16
17
  const pathname = props.pathname ?? "/";
17
- const pageUrl = host ? `https://${host}${pathname}` : pathname;
18
+ if (!host) return /* @__PURE__ */ jsx(SmalkAdComment, { reason: "no host" });
19
+ const pageUrl = `https://${host}${pathname}`;
18
20
  const ad = await fetchAd(
19
21
  { pageUrl, userAgent, referer, clientIp },
20
22
  { revalidate: props.revalidate, preview: props.preview }
@@ -34,6 +36,15 @@ async function SmalkAd(props = {}) {
34
36
  function SmalkAdComment({ reason }) {
35
37
  return /* @__PURE__ */ jsx("span", { "data-smalk": "comment", dangerouslySetInnerHTML: { __html: `<!-- smalk: ${reason} -->` } });
36
38
  }
39
+ async function noStoreIfAvailable() {
40
+ try {
41
+ const mod = await import("next/cache");
42
+ if (typeof mod.unstable_noStore === "function") {
43
+ mod.unstable_noStore();
44
+ }
45
+ } catch {
46
+ }
47
+ }
37
48
  async function readHeadersCompat() {
38
49
  const result = headers();
39
50
  if (result && typeof result.then === "function") {
package/dist/app.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/app.tsx"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n const pageUrl = host ? `https://${host}${pathname}` : pathname;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n"],"mappings":";;;;;;AAAA,OAAO;AAEP,SAAS,eAAe;AA6Bb;AAfX,eAAsB,QAAQ,QAAsB,CAAC,GAAgC;AACnF,QAAM,IAAI,MAAM,kBAAkB;AAClC,QAAM,YAAY,EAAE,IAAI,YAAY,KAAK;AACzC,QAAM,UAAU,EAAE,IAAI,SAAS,KAAK;AACpC,QAAM,WAAW,aAAa,CAAC;AAC/B,QAAM,OAAO,EAAE,IAAI,MAAM,KAAK;AAC9B,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,UAAU,OAAO,WAAW,IAAI,GAAG,QAAQ,KAAK;AAEtD,QAAM,KAAK,MAAM;AAAA,IACf,EAAE,SAAS,WAAW,SAAS,SAAS;AAAA,IACxC,EAAE,YAAY,MAAM,YAAY,SAAS,MAAM,QAAQ;AAAA,EACzD;AAEA,MAAI,CAAC,IAAI;AACP,WAAO,oBAAC,kBAAe,QAAO,SAAQ;AAAA,EACxC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,MAAM,aAAa;AAAA,MAC9B,sBAAoB,GAAG,aAAa;AAAA,MAGpC,yBAAyB,EAAE,QAAQ,GAAG,KAAK;AAAA;AAAA,EAC7C;AAEJ;AAEA,SAAS,eAAe,EAAE,OAAO,GAA2C;AAC1E,SAAO,oBAAC,UAAK,cAAW,WAAU,yBAAyB,EAAE,QAAQ,eAAe,MAAM,OAAO,GAAG;AACtG;AAEA,eAAe,oBAAoE;AAEjF,QAAM,SAAS,QAAQ;AACvB,MAAI,UAAU,OAAQ,OAA4B,SAAS,YAAY;AACrE,WAAQ,MAAO;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,aAAa,GAAkD;AACtE,aAAW,OAAO,CAAC,oBAAoB,aAAa,iBAAiB,GAAG;AACtE,UAAM,IAAI,EAAE,IAAI,GAAG;AACnB,QAAI,EAAG,QAAO,EAAE,MAAM,GAAG,EAAE,CAAC,EAAE,KAAK;AAAA,EACrC;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/app.tsx"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n // Opt the surrounding route out of Static Generation so the ad fetch runs at\n // request time, not at build time. Two layers:\n // 1) `headers()` below — opts out since Next 13.4 (built-in heuristic).\n // 2) `unstable_noStore()` — explicit opt-out marker, added in Next 14.\n // Best-effort: dynamic import, swallow if unavailable (Next 13.x or moved).\n await noStoreIfAvailable();\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n if (!host) return <SmalkAdComment reason=\"no host\" />;\n const pageUrl = `https://${host}${pathname}`;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function noStoreIfAvailable(): Promise<void> {\n try {\n const mod = (await import('next/cache')) as unknown as { unstable_noStore?: () => void };\n if (typeof mod.unstable_noStore === 'function') {\n mod.unstable_noStore();\n }\n } catch {\n // next/cache not available (Next 13.x) — the `headers()` call below still\n // opts the route out of Static Generation in that version range.\n }\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n"],"mappings":";;;;;;AAAA,OAAO;AAEP,SAAS,eAAe;AA2BJ;AAbpB,eAAsB,QAAQ,QAAsB,CAAC,GAAgC;AAMnF,QAAM,mBAAmB;AACzB,QAAM,IAAI,MAAM,kBAAkB;AAClC,QAAM,YAAY,EAAE,IAAI,YAAY,KAAK;AACzC,QAAM,UAAU,EAAE,IAAI,SAAS,KAAK;AACpC,QAAM,WAAW,aAAa,CAAC;AAC/B,QAAM,OAAO,EAAE,IAAI,MAAM,KAAK;AAC9B,QAAM,WAAW,MAAM,YAAY;AACnC,MAAI,CAAC,KAAM,QAAO,oBAAC,kBAAe,QAAO,WAAU;AACnD,QAAM,UAAU,WAAW,IAAI,GAAG,QAAQ;AAE1C,QAAM,KAAK,MAAM;AAAA,IACf,EAAE,SAAS,WAAW,SAAS,SAAS;AAAA,IACxC,EAAE,YAAY,MAAM,YAAY,SAAS,MAAM,QAAQ;AAAA,EACzD;AAEA,MAAI,CAAC,IAAI;AACP,WAAO,oBAAC,kBAAe,QAAO,SAAQ;AAAA,EACxC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,MAAM,aAAa;AAAA,MAC9B,sBAAoB,GAAG,aAAa;AAAA,MAGpC,yBAAyB,EAAE,QAAQ,GAAG,KAAK;AAAA;AAAA,EAC7C;AAEJ;AAEA,SAAS,eAAe,EAAE,OAAO,GAA2C;AAC1E,SAAO,oBAAC,UAAK,cAAW,WAAU,yBAAyB,EAAE,QAAQ,eAAe,MAAM,OAAO,GAAG;AACtG;AAEA,eAAe,qBAAoC;AACjD,MAAI;AACF,UAAM,MAAO,MAAM,OAAO,YAAY;AACtC,QAAI,OAAO,IAAI,qBAAqB,YAAY;AAC9C,UAAI,iBAAiB;AAAA,IACvB;AAAA,EACF,QAAQ;AAAA,EAGR;AACF;AAEA,eAAe,oBAAoE;AAEjF,QAAM,SAAS,QAAQ;AACvB,MAAI,UAAU,OAAQ,OAA4B,SAAS,YAAY;AACrE,WAAQ,MAAO;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,aAAa,GAAkD;AACtE,aAAW,OAAO,CAAC,oBAAoB,aAAa,iBAAiB,GAAG;AACtE,UAAM,IAAI,EAAE,IAAI,GAAG;AACnB,QAAI,EAAG,QAAO,EAAE,MAAM,GAAG,EAAE,CAAC,EAAE,KAAK;AAAA,EACrC;AACA,SAAO;AACT;","names":[]}
package/dist/pages.cjs CHANGED
@@ -154,7 +154,12 @@ async function getSmalkAd(req, opts = {}) {
154
154
  if (preview === void 0) {
155
155
  const m = url.match(/[?&]preview=([^&]+)/);
156
156
  if (m) {
157
- const v = decodeURIComponent(m[1]);
157
+ let v;
158
+ try {
159
+ v = decodeURIComponent(m[1]);
160
+ } catch {
161
+ v = m[1];
162
+ }
158
163
  preview = v === "1" || v === "true" ? true : v;
159
164
  }
160
165
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/pages.ts","../src/index.ts","../src/client.ts"],"sourcesContent":["import * as React from 'react';\nimport type { IncomingMessage } from 'node:http';\nimport { fetchAd } from './client';\nimport type { AdResult } from './index';\n\nexport interface MinimalReq {\n url?: string;\n headers: Record<string, string | string[] | undefined>;\n}\n\nexport interface GetSmalkAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string. Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function getSmalkAd(\n req: IncomingMessage | MinimalReq,\n opts: GetSmalkAdOptions = {},\n): Promise<AdResult | null> {\n const headers = (req as MinimalReq).headers ?? {};\n const url = (req as MinimalReq).url ?? '/';\n const host = headerValue(headers, 'host') ?? '';\n const pageUrl = host ? `https://${host}${url}` : url;\n const userAgent = headerValue(headers, 'user-agent') ?? '';\n const referer = headerValue(headers, 'referer') ?? '';\n const clientIp = pickClientIp(headers);\n\n // Auto-forward `?preview=` from the page URL (so visiting /blog/x?preview=1 works without\n // a callsite change). Explicit opts.preview takes precedence.\n let preview = opts.preview;\n if (preview === undefined) {\n const m = url.match(/[?&]preview=([^&]+)/);\n if (m) {\n const v = decodeURIComponent(m[1]);\n preview = v === '1' || v === 'true' ? true : v;\n }\n }\n\n return fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: opts.revalidate, preview },\n );\n}\n\nexport interface AdHtmlProps {\n ad: AdResult | null;\n className?: string;\n}\n\nexport function AdHtml({ ad, className }: AdHtmlProps): React.ReactElement {\n if (!ad) {\n return React.createElement('span', {\n 'data-smalk': 'comment',\n dangerouslySetInnerHTML: { __html: '<!-- smalk: no ad -->' },\n });\n }\n return React.createElement('div', {\n className: className ?? 'smalk-ads',\n 'data-smalk-booking': ad.bookingId ?? undefined,\n dangerouslySetInnerHTML: { __html: ad.html },\n });\n}\n\nfunction headerValue(h: Record<string, string | string[] | undefined>, key: string): string | null {\n const v = h[key] ?? h[key.toLowerCase()];\n if (Array.isArray(v)) return v[0] ?? null;\n return v ?? null;\n}\n\nfunction pickClientIp(h: Record<string, string | string[] | undefined>): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = headerValue(h, key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkAdsConfig {\n projectKey: string;\n apiKey: string;\n apiBaseUrl: string;\n}\n\nexport interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/api/v1/transform/ads/content/';\n\nexport function loadConfig(): SmalkAdsConfig {\n const projectKey = process.env.SMALK_PROJECT_KEY;\n const apiKey = process.env.SMALK_API_KEY;\n const apiBaseUrl = process.env.SMALK_API_BASE_URL || 'https://api.smalk.ai';\n const isProd = process.env.NODE_ENV === 'production';\n\n if (!projectKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production');\n console.warn('[smalk] SMALK_PROJECT_KEY not set — ad fetch will be skipped');\n }\n if (!apiKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_API_KEY env var is required in production');\n console.warn('[smalk] SMALK_API_KEY not set — ad fetch will be skipped');\n }\n\n return {\n projectKey: projectKey ?? '',\n apiKey: apiKey ?? '',\n apiBaseUrl,\n };\n}\n","import { loadConfig, AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = loadConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBaseUrl}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/pages.ts","../src/index.ts","../src/client.ts"],"sourcesContent":["import * as React from 'react';\nimport type { IncomingMessage } from 'node:http';\nimport { fetchAd } from './client';\nimport type { AdResult } from './index';\n\nexport interface MinimalReq {\n url?: string;\n headers: Record<string, string | string[] | undefined>;\n}\n\nexport interface GetSmalkAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string. Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function getSmalkAd(\n req: IncomingMessage | MinimalReq,\n opts: GetSmalkAdOptions = {},\n): Promise<AdResult | null> {\n const headers = (req as MinimalReq).headers ?? {};\n const url = (req as MinimalReq).url ?? '/';\n const host = headerValue(headers, 'host') ?? '';\n const pageUrl = host ? `https://${host}${url}` : url;\n const userAgent = headerValue(headers, 'user-agent') ?? '';\n const referer = headerValue(headers, 'referer') ?? '';\n const clientIp = pickClientIp(headers);\n\n // Auto-forward `?preview=` from the page URL (so visiting /blog/x?preview=1 works without\n // a callsite change). Explicit opts.preview takes precedence.\n let preview = opts.preview;\n if (preview === undefined) {\n const m = url.match(/[?&]preview=([^&]+)/);\n if (m) {\n let v: string;\n try {\n v = decodeURIComponent(m[1]);\n } catch {\n v = m[1];\n }\n preview = v === '1' || v === 'true' ? true : v;\n }\n }\n\n return fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: opts.revalidate, preview },\n );\n}\n\nexport interface AdHtmlProps {\n ad: AdResult | null;\n className?: string;\n}\n\nexport function AdHtml({ ad, className }: AdHtmlProps): React.ReactElement {\n if (!ad) {\n return React.createElement('span', {\n 'data-smalk': 'comment',\n dangerouslySetInnerHTML: { __html: '<!-- smalk: no ad -->' },\n });\n }\n return React.createElement('div', {\n className: className ?? 'smalk-ads',\n 'data-smalk-booking': ad.bookingId ?? undefined,\n dangerouslySetInnerHTML: { __html: ad.html },\n });\n}\n\nfunction headerValue(h: Record<string, string | string[] | undefined>, key: string): string | null {\n const v = h[key] ?? h[key.toLowerCase()];\n if (Array.isArray(v)) return v[0] ?? null;\n return v ?? null;\n}\n\nfunction pickClientIp(h: Record<string, string | string[] | undefined>): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = headerValue(h, key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkAdsConfig {\n projectKey: string;\n apiKey: string;\n apiBaseUrl: string;\n}\n\nexport interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/api/v1/transform/ads/content/';\n\nexport function loadConfig(): SmalkAdsConfig {\n const projectKey = process.env.SMALK_PROJECT_KEY;\n const apiKey = process.env.SMALK_API_KEY;\n const apiBaseUrl = process.env.SMALK_API_BASE_URL || 'https://api.smalk.ai';\n const isProd = process.env.NODE_ENV === 'production';\n\n if (!projectKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production');\n console.warn('[smalk] SMALK_PROJECT_KEY not set — ad fetch will be skipped');\n }\n if (!apiKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_API_KEY env var is required in production');\n console.warn('[smalk] SMALK_API_KEY not set — ad fetch will be skipped');\n }\n\n return {\n projectKey: projectKey ?? '',\n apiKey: apiKey ?? '',\n apiBaseUrl,\n };\n}\n","import { loadConfig, AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = loadConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBaseUrl}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":"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","names":[]}
package/dist/pages.js CHANGED
@@ -17,7 +17,12 @@ async function getSmalkAd(req, opts = {}) {
17
17
  if (preview === void 0) {
18
18
  const m = url.match(/[?&]preview=([^&]+)/);
19
19
  if (m) {
20
- const v = decodeURIComponent(m[1]);
20
+ let v;
21
+ try {
22
+ v = decodeURIComponent(m[1]);
23
+ } catch {
24
+ v = m[1];
25
+ }
21
26
  preview = v === "1" || v === "true" ? true : v;
22
27
  }
23
28
  }
package/dist/pages.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/pages.ts"],"sourcesContent":["import * as React from 'react';\nimport type { IncomingMessage } from 'node:http';\nimport { fetchAd } from './client';\nimport type { AdResult } from './index';\n\nexport interface MinimalReq {\n url?: string;\n headers: Record<string, string | string[] | undefined>;\n}\n\nexport interface GetSmalkAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string. Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function getSmalkAd(\n req: IncomingMessage | MinimalReq,\n opts: GetSmalkAdOptions = {},\n): Promise<AdResult | null> {\n const headers = (req as MinimalReq).headers ?? {};\n const url = (req as MinimalReq).url ?? '/';\n const host = headerValue(headers, 'host') ?? '';\n const pageUrl = host ? `https://${host}${url}` : url;\n const userAgent = headerValue(headers, 'user-agent') ?? '';\n const referer = headerValue(headers, 'referer') ?? '';\n const clientIp = pickClientIp(headers);\n\n // Auto-forward `?preview=` from the page URL (so visiting /blog/x?preview=1 works without\n // a callsite change). Explicit opts.preview takes precedence.\n let preview = opts.preview;\n if (preview === undefined) {\n const m = url.match(/[?&]preview=([^&]+)/);\n if (m) {\n const v = decodeURIComponent(m[1]);\n preview = v === '1' || v === 'true' ? true : v;\n }\n }\n\n return fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: opts.revalidate, preview },\n );\n}\n\nexport interface AdHtmlProps {\n ad: AdResult | null;\n className?: string;\n}\n\nexport function AdHtml({ ad, className }: AdHtmlProps): React.ReactElement {\n if (!ad) {\n return React.createElement('span', {\n 'data-smalk': 'comment',\n dangerouslySetInnerHTML: { __html: '<!-- smalk: no ad -->' },\n });\n }\n return React.createElement('div', {\n className: className ?? 'smalk-ads',\n 'data-smalk-booking': ad.bookingId ?? undefined,\n dangerouslySetInnerHTML: { __html: ad.html },\n });\n}\n\nfunction headerValue(h: Record<string, string | string[] | undefined>, key: string): string | null {\n const v = h[key] ?? h[key.toLowerCase()];\n if (Array.isArray(v)) return v[0] ?? null;\n return v ?? null;\n}\n\nfunction pickClientIp(h: Record<string, string | string[] | undefined>): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = headerValue(h, key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n"],"mappings":";;;;;;AAAA,YAAY,WAAW;AAgBvB,eAAsB,WACpB,KACA,OAA0B,CAAC,GACD;AAC1B,QAAM,UAAW,IAAmB,WAAW,CAAC;AAChD,QAAM,MAAO,IAAmB,OAAO;AACvC,QAAM,OAAO,YAAY,SAAS,MAAM,KAAK;AAC7C,QAAM,UAAU,OAAO,WAAW,IAAI,GAAG,GAAG,KAAK;AACjD,QAAM,YAAY,YAAY,SAAS,YAAY,KAAK;AACxD,QAAM,UAAU,YAAY,SAAS,SAAS,KAAK;AACnD,QAAM,WAAW,aAAa,OAAO;AAIrC,MAAI,UAAU,KAAK;AACnB,MAAI,YAAY,QAAW;AACzB,UAAM,IAAI,IAAI,MAAM,qBAAqB;AACzC,QAAI,GAAG;AACL,YAAM,IAAI,mBAAmB,EAAE,CAAC,CAAC;AACjC,gBAAU,MAAM,OAAO,MAAM,SAAS,OAAO;AAAA,IAC/C;AAAA,EACF;AAEA,SAAO;AAAA,IACL,EAAE,SAAS,WAAW,SAAS,SAAS;AAAA,IACxC,EAAE,YAAY,KAAK,YAAY,QAAQ;AAAA,EACzC;AACF;AAOO,SAAS,OAAO,EAAE,IAAI,UAAU,GAAoC;AACzE,MAAI,CAAC,IAAI;AACP,WAAa,oBAAc,QAAQ;AAAA,MACjC,cAAc;AAAA,MACd,yBAAyB,EAAE,QAAQ,wBAAwB;AAAA,IAC7D,CAAC;AAAA,EACH;AACA,SAAa,oBAAc,OAAO;AAAA,IAChC,WAAW,aAAa;AAAA,IACxB,sBAAsB,GAAG,aAAa;AAAA,IACtC,yBAAyB,EAAE,QAAQ,GAAG,KAAK;AAAA,EAC7C,CAAC;AACH;AAEA,SAAS,YAAY,GAAkD,KAA4B;AACjG,QAAM,IAAI,EAAE,GAAG,KAAK,EAAE,IAAI,YAAY,CAAC;AACvC,MAAI,MAAM,QAAQ,CAAC,EAAG,QAAO,EAAE,CAAC,KAAK;AACrC,SAAO,KAAK;AACd;AAEA,SAAS,aAAa,GAA0D;AAC9E,aAAW,OAAO,CAAC,oBAAoB,aAAa,iBAAiB,GAAG;AACtE,UAAM,IAAI,YAAY,GAAG,GAAG;AAC5B,QAAI,EAAG,QAAO,EAAE,MAAM,GAAG,EAAE,CAAC,EAAE,KAAK;AAAA,EACrC;AACA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/pages.ts"],"sourcesContent":["import * as React from 'react';\nimport type { IncomingMessage } from 'node:http';\nimport { fetchAd } from './client';\nimport type { AdResult } from './index';\n\nexport interface MinimalReq {\n url?: string;\n headers: Record<string, string | string[] | undefined>;\n}\n\nexport interface GetSmalkAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string. Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function getSmalkAd(\n req: IncomingMessage | MinimalReq,\n opts: GetSmalkAdOptions = {},\n): Promise<AdResult | null> {\n const headers = (req as MinimalReq).headers ?? {};\n const url = (req as MinimalReq).url ?? '/';\n const host = headerValue(headers, 'host') ?? '';\n const pageUrl = host ? `https://${host}${url}` : url;\n const userAgent = headerValue(headers, 'user-agent') ?? '';\n const referer = headerValue(headers, 'referer') ?? '';\n const clientIp = pickClientIp(headers);\n\n // Auto-forward `?preview=` from the page URL (so visiting /blog/x?preview=1 works without\n // a callsite change). Explicit opts.preview takes precedence.\n let preview = opts.preview;\n if (preview === undefined) {\n const m = url.match(/[?&]preview=([^&]+)/);\n if (m) {\n let v: string;\n try {\n v = decodeURIComponent(m[1]);\n } catch {\n v = m[1];\n }\n preview = v === '1' || v === 'true' ? true : v;\n }\n }\n\n return fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: opts.revalidate, preview },\n );\n}\n\nexport interface AdHtmlProps {\n ad: AdResult | null;\n className?: string;\n}\n\nexport function AdHtml({ ad, className }: AdHtmlProps): React.ReactElement {\n if (!ad) {\n return React.createElement('span', {\n 'data-smalk': 'comment',\n dangerouslySetInnerHTML: { __html: '<!-- smalk: no ad -->' },\n });\n }\n return React.createElement('div', {\n className: className ?? 'smalk-ads',\n 'data-smalk-booking': ad.bookingId ?? undefined,\n dangerouslySetInnerHTML: { __html: ad.html },\n });\n}\n\nfunction headerValue(h: Record<string, string | string[] | undefined>, key: string): string | null {\n const v = h[key] ?? h[key.toLowerCase()];\n if (Array.isArray(v)) return v[0] ?? null;\n return v ?? null;\n}\n\nfunction pickClientIp(h: Record<string, string | string[] | undefined>): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = headerValue(h, key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n"],"mappings":";;;;;;AAAA,YAAY,WAAW;AAgBvB,eAAsB,WACpB,KACA,OAA0B,CAAC,GACD;AAC1B,QAAM,UAAW,IAAmB,WAAW,CAAC;AAChD,QAAM,MAAO,IAAmB,OAAO;AACvC,QAAM,OAAO,YAAY,SAAS,MAAM,KAAK;AAC7C,QAAM,UAAU,OAAO,WAAW,IAAI,GAAG,GAAG,KAAK;AACjD,QAAM,YAAY,YAAY,SAAS,YAAY,KAAK;AACxD,QAAM,UAAU,YAAY,SAAS,SAAS,KAAK;AACnD,QAAM,WAAW,aAAa,OAAO;AAIrC,MAAI,UAAU,KAAK;AACnB,MAAI,YAAY,QAAW;AACzB,UAAM,IAAI,IAAI,MAAM,qBAAqB;AACzC,QAAI,GAAG;AACL,UAAI;AACJ,UAAI;AACF,YAAI,mBAAmB,EAAE,CAAC,CAAC;AAAA,MAC7B,QAAQ;AACN,YAAI,EAAE,CAAC;AAAA,MACT;AACA,gBAAU,MAAM,OAAO,MAAM,SAAS,OAAO;AAAA,IAC/C;AAAA,EACF;AAEA,SAAO;AAAA,IACL,EAAE,SAAS,WAAW,SAAS,SAAS;AAAA,IACxC,EAAE,YAAY,KAAK,YAAY,QAAQ;AAAA,EACzC;AACF;AAOO,SAAS,OAAO,EAAE,IAAI,UAAU,GAAoC;AACzE,MAAI,CAAC,IAAI;AACP,WAAa,oBAAc,QAAQ;AAAA,MACjC,cAAc;AAAA,MACd,yBAAyB,EAAE,QAAQ,wBAAwB;AAAA,IAC7D,CAAC;AAAA,EACH;AACA,SAAa,oBAAc,OAAO;AAAA,IAChC,WAAW,aAAa;AAAA,IACxB,sBAAsB,GAAG,aAAa;AAAA,IACtC,yBAAyB,EAAE,QAAQ,GAAG,KAAK;AAAA,EAC7C,CAAC;AACH;AAEA,SAAS,YAAY,GAAkD,KAA4B;AACjG,QAAM,IAAI,EAAE,GAAG,KAAK,EAAE,IAAI,YAAY,CAAC;AACvC,MAAI,MAAM,QAAQ,CAAC,EAAG,QAAO,EAAE,CAAC,KAAK;AACrC,SAAO,KAAK;AACd;AAEA,SAAS,aAAa,GAA0D;AAC9E,aAAW,OAAO,CAAC,oBAAoB,aAAa,iBAAiB,GAAG;AACtE,UAAM,IAAI,YAAY,GAAG,GAAG;AAC5B,QAAI,EAAG,QAAO,EAAE,MAAM,GAAG,EAAE,CAAC,EAAE,KAAK;AAAA,EACrC;AACA,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smalk/nextjs-ads",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Smalk AI Ads — server-side ad injection for Next.js (App Router, Pages Router, middleware)",
5
5
  "license": "MIT",
6
6
  "type": "module",