notionic 0.0.0-stage → 0.8.3

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.js ADDED
@@ -0,0 +1,1266 @@
1
+ import { a as resolvePageLink, i as resolveLink, n as defaultPageHref, r as normalizePageId, t as resolveFileUrl } from "./files-DIc0m6LM.js";
2
+ import { n as plainText, r as warnOnce, t as cx } from "./utils-BL_oAR33.js";
3
+ import { t as pageTitle } from "./page-BmeweSIt.js";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/colors.ts
6
+ const TEXT = {
7
+ gray: "text-(--notionic-gray)",
8
+ brown: "text-(--notionic-brown)",
9
+ orange: "text-(--notionic-orange)",
10
+ yellow: "text-(--notionic-yellow)",
11
+ green: "text-(--notionic-green)",
12
+ blue: "text-(--notionic-blue)",
13
+ purple: "text-(--notionic-purple)",
14
+ pink: "text-(--notionic-pink)",
15
+ red: "text-(--notionic-red)"
16
+ };
17
+ const BACKGROUND = {
18
+ gray_background: "bg-(--notionic-gray-bg)",
19
+ brown_background: "bg-(--notionic-brown-bg)",
20
+ orange_background: "bg-(--notionic-orange-bg)",
21
+ yellow_background: "bg-(--notionic-yellow-bg)",
22
+ green_background: "bg-(--notionic-green-bg)",
23
+ blue_background: "bg-(--notionic-blue-bg)",
24
+ purple_background: "bg-(--notionic-purple-bg)",
25
+ pink_background: "bg-(--notionic-pink-bg)",
26
+ red_background: "bg-(--notionic-red-bg)"
27
+ };
28
+ /** The class for a Notion color (`red`, `red_background`…), or undefined for `default`. */
29
+ function colorClass(color) {
30
+ if (!color) return void 0;
31
+ return TEXT[color] ?? BACKGROUND[color];
32
+ }
33
+ function isBackgroundColor(color) {
34
+ return Boolean(color && color in BACKGROUND);
35
+ }
36
+ /**
37
+ * A block's color: text color, or a background that bleeds past the text, so
38
+ * the text stays on the same edge as the blocks around it. Blocks with a
39
+ * frame of their own (quote, callout) use `colorClass` instead.
40
+ */
41
+ function blockColorClass(color) {
42
+ const cls = colorClass(color);
43
+ if (!cls) return void 0;
44
+ return isBackgroundColor(color) ? `${cls} -mx-2 rounded-md px-2 py-1` : cls;
45
+ }
46
+ //#endregion
47
+ //#region src/rich-text.tsx
48
+ const CODE_CLASSES = "rounded-sm bg-muted px-1.5 py-0.5 font-mono text-[0.875em] box-decoration-clone";
49
+ const NOWRAP_CODE_LENGTH = 24;
50
+ const LINK_CLASSES = "font-medium underline underline-offset-4 decoration-from-font decoration-muted-foreground/50 transition-[text-decoration-color] hover:decoration-foreground rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring";
51
+ /** An anchor with notionic's link styling; external links open in a new tab. */
52
+ function NotionLink({ link, className, children }) {
53
+ return /* @__PURE__ */ jsxs("a", {
54
+ href: link.href,
55
+ ...link.external ? {
56
+ target: "_blank",
57
+ rel: "noopener noreferrer"
58
+ } : {},
59
+ className: cx(LINK_CLASSES, className),
60
+ children: [children, link.external ? /* @__PURE__ */ jsx("span", {
61
+ className: "sr-only",
62
+ children: " (opens in a new tab)"
63
+ }) : null]
64
+ });
65
+ }
66
+ function RichTextItem({ item, context }) {
67
+ const { annotations } = item;
68
+ let node = item.plain_text.includes("\n") ? item.plain_text.split("\n").flatMap((line, i) => i === 0 ? [line] : [/* @__PURE__ */ jsx("br", {}, i), line]) : item.plain_text;
69
+ if (item.type === "mention" && item.mention.type === "date") node = /* @__PURE__ */ jsx("time", {
70
+ dateTime: item.mention.date.start,
71
+ children: node
72
+ });
73
+ if (item.type === "equation") {
74
+ const InlineEquation = context.components.inline_equation;
75
+ if (InlineEquation) node = /* @__PURE__ */ jsx(InlineEquation, {
76
+ expression: item.equation.expression,
77
+ context
78
+ });
79
+ } else if (annotations.code) {
80
+ const short = item.plain_text.length <= NOWRAP_CODE_LENGTH;
81
+ node = /* @__PURE__ */ jsx("code", {
82
+ className: cx(CODE_CLASSES, short && "whitespace-nowrap"),
83
+ children: node
84
+ });
85
+ }
86
+ if (annotations.bold) node = /* @__PURE__ */ jsx("strong", {
87
+ className: "font-semibold",
88
+ children: node
89
+ });
90
+ if (annotations.italic) node = /* @__PURE__ */ jsx("em", { children: node });
91
+ if (annotations.strikethrough) node = /* @__PURE__ */ jsx("s", { children: node });
92
+ if (annotations.underline) node = /* @__PURE__ */ jsx("u", {
93
+ className: "underline-offset-4 decoration-from-font",
94
+ children: node
95
+ });
96
+ const color = colorClass(annotations.color);
97
+ if (color) node = isBackgroundColor(annotations.color) ? /* @__PURE__ */ jsx("mark", {
98
+ className: cx("rounded-sm px-0.5 text-inherit box-decoration-clone", color),
99
+ children: node
100
+ }) : /* @__PURE__ */ jsx("span", {
101
+ className: color,
102
+ children: node
103
+ });
104
+ const link = resolveLink(item.href, context);
105
+ if (link) node = /* @__PURE__ */ jsx(NotionLink, {
106
+ link,
107
+ children: node
108
+ });
109
+ return node;
110
+ }
111
+ /** A Notion rich text array: annotations, colors, links and mentions. */
112
+ function RichText({ text, context }) {
113
+ return text.map((item, i) => /* @__PURE__ */ jsx(RichTextItem, {
114
+ item,
115
+ context
116
+ }, i));
117
+ }
118
+ //#endregion
119
+ //#region src/blocks/code.tsx
120
+ const LANGUAGE_NAMES = {
121
+ "c#": "C#",
122
+ "c++": "C++",
123
+ coffeescript: "CoffeeScript",
124
+ css: "CSS",
125
+ glsl: "GLSL",
126
+ graphql: "GraphQL",
127
+ html: "HTML",
128
+ javascript: "JavaScript",
129
+ json: "JSON",
130
+ latex: "LaTeX",
131
+ "objective-c": "Objective-C",
132
+ ocaml: "OCaml",
133
+ php: "PHP",
134
+ powershell: "PowerShell",
135
+ scss: "SCSS",
136
+ sql: "SQL",
137
+ typescript: "TypeScript",
138
+ vb: "VB",
139
+ "vb.net": "VB.NET",
140
+ webassembly: "WebAssembly",
141
+ xml: "XML",
142
+ yaml: "YAML"
143
+ };
144
+ /** A Notion code language as people write it: "typescript" is "TypeScript". */
145
+ function languageName(language) {
146
+ return LANGUAGE_NAMES[language] ?? language.charAt(0).toUpperCase() + language.slice(1);
147
+ }
148
+ /**
149
+ * Plain code, no highlighting. For syntax highlighting and a copy button,
150
+ * install the shadcn version: `npx shadcn add @notionic/notion-code`.
151
+ */
152
+ function Code({ block, context }) {
153
+ const { language, rich_text, caption } = block.code;
154
+ const code = plainText(rich_text);
155
+ const name = languageName(language);
156
+ return /* @__PURE__ */ jsxs("figure", {
157
+ className: "overflow-hidden rounded-lg border border-border bg-muted",
158
+ children: [
159
+ /* @__PURE__ */ jsx("div", {
160
+ className: "border-b border-border px-4 py-2 text-xs text-muted-foreground",
161
+ children: name
162
+ }),
163
+ /* @__PURE__ */ jsx("pre", {
164
+ tabIndex: 0,
165
+ "aria-label": `Code, ${name}`,
166
+ className: "overflow-x-auto px-4 py-3 font-mono text-[0.8125rem] leading-relaxed focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
167
+ children: /* @__PURE__ */ jsx("code", { children: code })
168
+ }),
169
+ caption.length > 0 ? /* @__PURE__ */ jsx("figcaption", {
170
+ className: "border-t border-border px-4 py-2 text-sm text-muted-foreground",
171
+ children: /* @__PURE__ */ jsx(RichText, {
172
+ text: caption,
173
+ context
174
+ })
175
+ }) : null
176
+ ]
177
+ });
178
+ }
179
+ //#endregion
180
+ //#region src/blocks/disclosure.tsx
181
+ /**
182
+ * The chevron of a disclosure (toggle, toggleable heading): one line tall, so
183
+ * it lines up with the first line of text, and rotated while open. Put it in a
184
+ * `<summary>` inside a `<details className="group/toggle">`.
185
+ */
186
+ function DisclosureChevron() {
187
+ return /* @__PURE__ */ jsx("span", {
188
+ "aria-hidden": "true",
189
+ className: "flex h-[1lh] shrink-0 items-center text-muted-foreground",
190
+ children: /* @__PURE__ */ jsx("svg", {
191
+ "aria-hidden": "true",
192
+ viewBox: "0 0 24 24",
193
+ fill: "none",
194
+ stroke: "currentColor",
195
+ strokeWidth: "2",
196
+ strokeLinecap: "round",
197
+ strokeLinejoin: "round",
198
+ className: "size-4 transition-transform duration-150 ease-out group-open/toggle:rotate-90 motion-reduce:transition-none",
199
+ children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
200
+ })
201
+ });
202
+ }
203
+ /** Summary classes: a row with the chevron, and no native marker. */
204
+ const SUMMARY_CLASSES = "flex cursor-pointer list-none gap-1.5 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden";
205
+ /**
206
+ * `<details>` classes: the content grows from zero height and fades in, and
207
+ * shrinks back when closed. Browsers without `::details-content` or
208
+ * `interpolate-size` open instantly; reduced motion keeps only the fade.
209
+ */
210
+ const DISCLOSURE_CLASSES = "[interpolate-size:allow-keywords] details-content:h-0 details-content:overflow-x-visible details-content:overflow-y-clip details-content:opacity-0 details-content:transition-[height,opacity,content-visibility] details-content:transition-discrete details-content:duration-200 details-content:ease-[cubic-bezier(0.23,1,0.32,1)] open:details-content:h-auto open:details-content:opacity-100 motion-reduce:details-content:transition-[opacity,content-visibility]";
211
+ /** Indent for a disclosure's content: the chevron's width plus the gap, so it lines up with the summary's text. */
212
+ const DISCLOSURE_CONTENT_INDENT = "ps-5.5";
213
+ //#endregion
214
+ //#region src/blocks/heading.tsx
215
+ const LEVELS$1 = {
216
+ heading_1: {
217
+ Tag: "h2",
218
+ className: "mt-6 text-2xl leading-tight font-semibold tracking-tight"
219
+ },
220
+ heading_2: {
221
+ Tag: "h3",
222
+ className: "mt-4 text-xl leading-tight font-semibold tracking-tight"
223
+ },
224
+ heading_3: {
225
+ Tag: "h4",
226
+ className: "mt-4 text-lg leading-snug font-semibold tracking-tight"
227
+ },
228
+ heading_4: {
229
+ Tag: "h5",
230
+ className: "mt-4 text-base leading-snug font-semibold"
231
+ }
232
+ };
233
+ const ANCHOR = "relative ms-2 rounded-sm text-muted-foreground no-underline opacity-0 transition-opacity duration-150 ease-out group-hover:opacity-100 focus-visible:transition-none focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring pointer-coarse:text-[0.75em] pointer-coarse:font-normal pointer-coarse:opacity-100 after:absolute after:-inset-2 after:content-['']";
234
+ function Heading({ block, context, children }) {
235
+ const data = block.type === "heading_1" ? block.heading_1 : block.type === "heading_2" ? block.heading_2 : block.type === "heading_3" ? block.heading_3 : block.heading_4;
236
+ const text = plainText(data.rich_text);
237
+ const id = context.headings.find((heading) => heading.blockId === block.id)?.id;
238
+ if (!id) return children ?? null;
239
+ const { Tag, className } = LEVELS$1[block.type];
240
+ const heading = /* @__PURE__ */ jsx(Tag, {
241
+ id,
242
+ className: "inline scroll-mt-8",
243
+ children: /* @__PURE__ */ jsx(RichText, {
244
+ text: data.rich_text,
245
+ context
246
+ })
247
+ });
248
+ if (data.is_toggleable) return /* @__PURE__ */ jsxs("details", {
249
+ className: cx("group/toggle text-balance [hr+&]:mt-0", DISCLOSURE_CLASSES, className),
250
+ children: [/* @__PURE__ */ jsxs("summary", {
251
+ className: cx(SUMMARY_CLASSES, blockColorClass(data.color)),
252
+ children: [/* @__PURE__ */ jsx(DisclosureChevron, {}), heading]
253
+ }), children ? /* @__PURE__ */ jsx("div", {
254
+ className: cx("mt-2 flex flex-col gap-4 text-base leading-[1.6] font-normal tracking-normal text-wrap", DISCLOSURE_CONTENT_INDENT),
255
+ children
256
+ }) : null]
257
+ });
258
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
259
+ className: cx("group text-balance [hr+&]:mt-0", className, blockColorClass(data.color)),
260
+ children: [heading, /* @__PURE__ */ jsx("a", {
261
+ href: `#${id}`,
262
+ "aria-label": `Link to section: ${text}`,
263
+ className: ANCHOR,
264
+ children: /* @__PURE__ */ jsx("span", {
265
+ "aria-hidden": "true",
266
+ children: "#"
267
+ })
268
+ })]
269
+ }), children] });
270
+ }
271
+ //#endregion
272
+ //#region src/blocks/layout.tsx
273
+ function Toggle({ block, context, children }) {
274
+ const { rich_text, color } = block.toggle;
275
+ return /* @__PURE__ */ jsxs("details", {
276
+ className: cx("group/toggle", DISCLOSURE_CLASSES, blockColorClass(color)),
277
+ children: [/* @__PURE__ */ jsxs("summary", {
278
+ className: SUMMARY_CLASSES,
279
+ children: [/* @__PURE__ */ jsx(DisclosureChevron, {}), /* @__PURE__ */ jsx("span", {
280
+ className: "min-w-0",
281
+ children: /* @__PURE__ */ jsx(RichText, {
282
+ text: rich_text,
283
+ context
284
+ })
285
+ })]
286
+ }), children ? /* @__PURE__ */ jsx("div", {
287
+ className: cx("mt-2 flex flex-col gap-4", DISCLOSURE_CONTENT_INDENT),
288
+ children
289
+ }) : null]
290
+ });
291
+ }
292
+ /**
293
+ * Columns sit side by side once the space they're in is 36rem wide, whatever
294
+ * the screen: a renderer in a sidebar or card stacks them. The container is a
295
+ * wrapper inside the block flow, where its width is always defined; on the
296
+ * renderer's root, size containment would collapse it inside a flex row.
297
+ */
298
+ function ColumnList({ children }) {
299
+ return /* @__PURE__ */ jsx("div", {
300
+ className: "@container",
301
+ children: /* @__PURE__ */ jsx("div", {
302
+ className: "flex flex-col gap-4 @xl:flex-row @xl:gap-6",
303
+ children
304
+ })
305
+ });
306
+ }
307
+ function Column({ block, children }) {
308
+ const ratio = block.column.width_ratio;
309
+ return /* @__PURE__ */ jsx("div", {
310
+ className: cx("flex min-w-0 flex-col gap-4", !ratio && "@xl:flex-1"),
311
+ style: ratio ? { flex: `${ratio} 1 0%` } : void 0,
312
+ children
313
+ });
314
+ }
315
+ /** Synced blocks: their content flows into the page. */
316
+ function SyncedBlock({ children }) {
317
+ return children ?? null;
318
+ }
319
+ //#endregion
320
+ //#region src/blocks/list.tsx
321
+ function Nested({ children, className }) {
322
+ return children ? /* @__PURE__ */ jsx("div", {
323
+ className: cx("mt-2 flex flex-col gap-2", className),
324
+ children
325
+ }) : null;
326
+ }
327
+ function BulletedListItem({ block, context, children }) {
328
+ const { rich_text, color } = block.bulleted_list_item;
329
+ return /* @__PURE__ */ jsxs("li", {
330
+ className: blockColorClass(color),
331
+ children: [/* @__PURE__ */ jsx(RichText, {
332
+ text: rich_text,
333
+ context
334
+ }), /* @__PURE__ */ jsx(Nested, { children })]
335
+ });
336
+ }
337
+ function NumberedListItem({ block, context, children }) {
338
+ const { rich_text, color } = block.numbered_list_item;
339
+ return /* @__PURE__ */ jsxs("li", {
340
+ className: blockColorClass(color),
341
+ children: [/* @__PURE__ */ jsx(RichText, {
342
+ text: rich_text,
343
+ context
344
+ }), /* @__PURE__ */ jsx(Nested, { children })]
345
+ });
346
+ }
347
+ function ToDo({ block, context, children }) {
348
+ const { rich_text, checked, color } = block.to_do;
349
+ const text = /* @__PURE__ */ jsx(RichText, {
350
+ text: rich_text,
351
+ context
352
+ });
353
+ return /* @__PURE__ */ jsxs("li", {
354
+ className: blockColorClass(color),
355
+ children: [/* @__PURE__ */ jsxs("label", {
356
+ className: "flex items-start gap-2",
357
+ children: [/* @__PURE__ */ jsxs("span", {
358
+ className: "relative flex h-[1lh] shrink-0 items-center",
359
+ children: [/* @__PURE__ */ jsx("input", {
360
+ type: "checkbox",
361
+ checked,
362
+ disabled: true,
363
+ readOnly: true,
364
+ className: "peer size-4 appearance-none rounded-[4px] border border-muted-foreground/80 bg-background checked:border-primary checked:bg-primary"
365
+ }), /* @__PURE__ */ jsx("svg", {
366
+ "aria-hidden": "true",
367
+ viewBox: "0 0 24 24",
368
+ fill: "none",
369
+ stroke: "currentColor",
370
+ strokeWidth: "3",
371
+ strokeLinecap: "round",
372
+ strokeLinejoin: "round",
373
+ className: "pointer-events-none absolute inset-x-0.5 size-3 text-primary-foreground opacity-0 peer-checked:opacity-100",
374
+ children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
375
+ })]
376
+ }), /* @__PURE__ */ jsx("span", {
377
+ className: cx(checked && "text-muted-foreground line-through"),
378
+ children: text
379
+ })]
380
+ }), /* @__PURE__ */ jsx(Nested, {
381
+ className: "ps-6",
382
+ children
383
+ })]
384
+ });
385
+ }
386
+ //#endregion
387
+ //#region src/demos.ts
388
+ /**
389
+ * The demo an embed URL names, or null. A URL names a demo when its path ends
390
+ * in `/demos/<name>` and `<name>` is one of `demos`. Only the path counts, so
391
+ * the same embed works on localhost, previews and production.
392
+ */
393
+ function matchDemo(url, demos) {
394
+ if (!demos) return null;
395
+ let parsed;
396
+ let name;
397
+ try {
398
+ parsed = new URL(url);
399
+ const segments = parsed.pathname.split("/").filter(Boolean);
400
+ if (segments.length < 2 || segments[segments.length - 2] !== "demos") return null;
401
+ name = decodeURIComponent(segments[segments.length - 1] ?? "");
402
+ } catch {
403
+ return null;
404
+ }
405
+ if (!Object.hasOwn(demos, name)) return null;
406
+ const Component = demos[name];
407
+ if (!Component) return null;
408
+ return {
409
+ name,
410
+ params: Object.fromEntries(parsed.searchParams),
411
+ Component
412
+ };
413
+ }
414
+ //#endregion
415
+ //#region src/blocks/icon.tsx
416
+ const BROKEN_IMAGE = "relative before:absolute before:inset-0 before:rounded-[inherit] before:bg-muted before:content-['']";
417
+ /**
418
+ * A Notion icon (an emoji, a custom emoji or an image) in a fixed slot one
419
+ * line tall, so it centers on the first line of the text beside it and the
420
+ * text starts at the same place whatever the icon. Without an icon, the
421
+ * fallback, or nothing.
422
+ */
423
+ function IconSlot({ icon, blockId, context, fallback }) {
424
+ const url = icon?.type === "custom_emoji" ? icon.custom_emoji.url : icon?.type === "emoji" ? null : resolveFileUrl(icon, blockId, context);
425
+ const content = icon?.type === "emoji" ? icon.emoji : url ? /* @__PURE__ */ jsx("img", {
426
+ src: url,
427
+ alt: "",
428
+ className: cx("size-5 rounded-sm object-contain", BROKEN_IMAGE)
429
+ }) : fallback;
430
+ if (!content) return null;
431
+ return /* @__PURE__ */ jsx("span", {
432
+ "aria-hidden": "true",
433
+ className: "flex h-[1lh] w-5 shrink-0 items-center justify-center",
434
+ children: content
435
+ });
436
+ }
437
+ //#endregion
438
+ //#region src/blocks/media.tsx
439
+ /** An http(s) URL, or a same-site path such as a file proxy route. */
440
+ function isUrl(url) {
441
+ if (/^\/(?![/\\])/.test(url)) return true;
442
+ try {
443
+ const { protocol } = new URL(url);
444
+ return protocol === "http:" || protocol === "https:";
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ function Caption({ caption, context }) {
450
+ if (caption.length === 0) return null;
451
+ return /* @__PURE__ */ jsx("figcaption", {
452
+ className: "mt-2 text-sm text-muted-foreground",
453
+ children: /* @__PURE__ */ jsx(RichText, {
454
+ text: caption,
455
+ context
456
+ })
457
+ });
458
+ }
459
+ /** A line linking to a URL, labelled by the caption or the URL itself. */
460
+ function UrlLine({ url, label, context }) {
461
+ const link = resolveLink(url, context);
462
+ if (!link) return null;
463
+ return /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(NotionLink, {
464
+ link,
465
+ children: label || url
466
+ }) });
467
+ }
468
+ function Image({ block, context }) {
469
+ const src = resolveFileUrl(block.image, block.id, context);
470
+ if (!src || !isUrl(src)) return null;
471
+ return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsx("img", {
472
+ src,
473
+ alt: plainText(block.image.caption),
474
+ loading: "lazy",
475
+ decoding: "async",
476
+ className: cx("h-auto max-w-full", MEDIA_OUTLINE)
477
+ }), /* @__PURE__ */ jsx(Caption, {
478
+ caption: block.image.caption,
479
+ context
480
+ })] });
481
+ }
482
+ const MEDIA_OUTLINE = "rounded-lg outline outline-1 -outline-offset-1 outline-(--notionic-image-outline)";
483
+ const MEDIA_SURFACE = `${MEDIA_OUTLINE} bg-muted`;
484
+ const VIDEO_FILE = /\.(mp4|webm|mov|m4v|ogv)$/i;
485
+ function isVideoFile(url) {
486
+ try {
487
+ return VIDEO_FILE.test(new URL(url).pathname);
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+ /** The embed URL for a YouTube link, or null. */
493
+ function youTubeEmbedUrl(url) {
494
+ let parsed;
495
+ try {
496
+ parsed = new URL(url);
497
+ } catch {
498
+ return null;
499
+ }
500
+ const host = parsed.hostname.replace(/^(www\.|m\.)/, "");
501
+ let id = null;
502
+ if (host === "youtu.be") id = parsed.pathname.slice(1);
503
+ else if (host === "youtube.com" || host === "youtube-nocookie.com") id = parsed.searchParams.get("v") ?? parsed.pathname.match(/^\/(?:embed|shorts|live)\/([^/]+)/)?.[1] ?? null;
504
+ return id && /^[\w-]{6,}$/.test(id) ? `https://www.youtube-nocookie.com/embed/${id}` : null;
505
+ }
506
+ function Video({ block, context }) {
507
+ const src = resolveFileUrl(block.video, block.id, context) ?? "";
508
+ const caption = block.video.caption;
509
+ const label = plainText(caption);
510
+ const embed = youTubeEmbedUrl(src);
511
+ if (embed) return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsx("iframe", {
512
+ src: embed,
513
+ title: label || "YouTube video",
514
+ allow: "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
515
+ referrerPolicy: "strict-origin-when-cross-origin",
516
+ allowFullScreen: true,
517
+ loading: "lazy",
518
+ className: cx("aspect-video w-full", MEDIA_SURFACE)
519
+ }), /* @__PURE__ */ jsx(Caption, {
520
+ caption,
521
+ context
522
+ })] });
523
+ if (isUrl(src) && (block.video.type === "file" || isVideoFile(src))) return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsx("video", {
524
+ src,
525
+ controls: true,
526
+ preload: "metadata",
527
+ "aria-label": label || "Video",
528
+ className: cx("w-full", MEDIA_SURFACE)
529
+ }), /* @__PURE__ */ jsx(Caption, {
530
+ caption,
531
+ context
532
+ })] });
533
+ return /* @__PURE__ */ jsx(UrlLine, {
534
+ url: src,
535
+ label,
536
+ context
537
+ });
538
+ }
539
+ function Audio({ block, context }) {
540
+ const src = resolveFileUrl(block.audio, block.id, context);
541
+ if (!src || !isUrl(src)) return null;
542
+ return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsx("audio", {
543
+ src,
544
+ controls: true,
545
+ preload: "metadata",
546
+ "aria-label": plainText(block.audio.caption) || "Audio",
547
+ className: "w-full"
548
+ }), /* @__PURE__ */ jsx(Caption, {
549
+ caption: block.audio.caption,
550
+ context
551
+ })] });
552
+ }
553
+ /** The extension of a file name or URL path, upper-cased ("report.pdf" is "PDF"), or null. */
554
+ function fileExtension(name) {
555
+ const match = name.match(/\.([a-z0-9]{1,8})$/i);
556
+ return match?.[1] ? match[1].toUpperCase() : null;
557
+ }
558
+ /** The last segment of a URL's path, decoded: "…/files/report.pdf?X=1" is "report.pdf". */
559
+ function fileNameFromUrl(url) {
560
+ try {
561
+ return decodeURIComponent(new URL(url, "https://notionic.dev").pathname.split("/").pop() ?? "");
562
+ } catch {
563
+ return "";
564
+ }
565
+ }
566
+ /** The URL Notion gave for a file, before `transformFileUrl`. */
567
+ function sourceUrl(file) {
568
+ return "external" in file ? file.external.url : file.file.url;
569
+ }
570
+ /** A URL without its protocol, `www.` or a trailing slash: "github.com/vercel/next.js". */
571
+ function displayUrl(url) {
572
+ return url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "");
573
+ }
574
+ /**
575
+ * A link as a one-line bordered row: an icon, a title that truncates (the full
576
+ * title as a tooltip), an optional detail at the end, and the caption below.
577
+ */
578
+ function CardRow({ url, title, icon, detail, caption, context }) {
579
+ const link = resolveLink(url, context);
580
+ if (!link) return null;
581
+ return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsxs("a", {
582
+ href: link.href,
583
+ title,
584
+ ...link.external ? {
585
+ target: "_blank",
586
+ rel: "noopener noreferrer"
587
+ } : {},
588
+ className: "flex items-center gap-3 rounded-lg border border-border px-3 py-2.5 transition-[background-color,scale] hover:bg-muted/50 active:scale-[0.99] motion-reduce:active:scale-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
589
+ children: [
590
+ /* @__PURE__ */ jsx("svg", {
591
+ "aria-hidden": "true",
592
+ viewBox: "0 0 24 24",
593
+ fill: "none",
594
+ stroke: "currentColor",
595
+ strokeWidth: "1.5",
596
+ strokeLinecap: "round",
597
+ strokeLinejoin: "round",
598
+ className: "size-5 shrink-0 text-muted-foreground",
599
+ children: icon
600
+ }),
601
+ /* @__PURE__ */ jsx("span", {
602
+ className: "min-w-0 flex-1 truncate font-medium",
603
+ children: title
604
+ }),
605
+ detail ? /* @__PURE__ */ jsx("span", {
606
+ className: "shrink-0 text-xs text-muted-foreground",
607
+ children: detail
608
+ }) : null,
609
+ link.external ? /* @__PURE__ */ jsx("span", {
610
+ className: "sr-only",
611
+ children: " (opens in a new tab)"
612
+ }) : null
613
+ ]
614
+ }), /* @__PURE__ */ jsx(Caption, {
615
+ caption,
616
+ context
617
+ })] });
618
+ }
619
+ const FILE_ICON = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }), /* @__PURE__ */ jsx("path", { d: "M14 2v4a2 2 0 0 0 2 2h4" })] });
620
+ const LINK_ICON = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }), /* @__PURE__ */ jsx("path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" })] });
621
+ const EMBED_ICON = /* @__PURE__ */ jsxs(Fragment, { children: [
622
+ /* @__PURE__ */ jsx("rect", {
623
+ width: "20",
624
+ height: "16",
625
+ x: "2",
626
+ y: "4",
627
+ rx: "2"
628
+ }),
629
+ /* @__PURE__ */ jsx("path", { d: "M10 4v4" }),
630
+ /* @__PURE__ */ jsx("path", { d: "M2 8h20" }),
631
+ /* @__PURE__ */ jsx("path", { d: "M6 4v4" })
632
+ ] });
633
+ /** A file as a card: its name, its type ("PDF"), and the caption below. */
634
+ function FileCard({ url, name, caption, context }) {
635
+ return /* @__PURE__ */ jsx(CardRow, {
636
+ url,
637
+ title: name || url,
638
+ icon: FILE_ICON,
639
+ detail: fileExtension(name),
640
+ caption,
641
+ context
642
+ });
643
+ }
644
+ function File({ block, context }) {
645
+ return /* @__PURE__ */ jsx(FileCard, {
646
+ url: resolveFileUrl(block.file, block.id, context) ?? "",
647
+ name: block.file.name || fileNameFromUrl(sourceUrl(block.file)),
648
+ caption: block.file.caption,
649
+ context
650
+ });
651
+ }
652
+ function Pdf({ block, context }) {
653
+ return /* @__PURE__ */ jsx(FileCard, {
654
+ url: resolveFileUrl(block.pdf, block.id, context) ?? "",
655
+ name: fileNameFromUrl(sourceUrl(block.pdf)),
656
+ caption: block.pdf.caption,
657
+ context
658
+ });
659
+ }
660
+ function Bookmark({ block, context }) {
661
+ const { url, caption } = block.bookmark;
662
+ return /* @__PURE__ */ jsx(PreviewCard, {
663
+ url,
664
+ preview: block.bookmarkPreview,
665
+ icon: LINK_ICON,
666
+ caption,
667
+ context
668
+ });
669
+ }
670
+ /**
671
+ * A link with the linked page's title, description and image, as in Notion's
672
+ * bookmarks: the image sits on the right, or on top when the card is narrow (a
673
+ * phone, a column). Without a preview from `fetchBlocks` (none fetched, or the
674
+ * page had no title), a one-line card.
675
+ */
676
+ function PreviewCard({ url, preview, icon, caption, context }) {
677
+ const link = resolveLink(url, context);
678
+ if (!preview || !link) return /* @__PURE__ */ jsx(CardRow, {
679
+ url,
680
+ title: displayUrl(url),
681
+ icon,
682
+ caption,
683
+ context
684
+ });
685
+ return /* @__PURE__ */ jsxs("figure", {
686
+ className: "@container",
687
+ children: [/* @__PURE__ */ jsxs("a", {
688
+ href: link.href,
689
+ ...link.external ? {
690
+ target: "_blank",
691
+ rel: "noopener noreferrer"
692
+ } : {},
693
+ className: "flex flex-col-reverse overflow-hidden rounded-lg border border-border transition-[background-color,scale] @md:min-h-26 @md:flex-row hover:bg-muted/50 active:scale-[0.99] motion-reduce:active:scale-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
694
+ children: [
695
+ /* @__PURE__ */ jsxs("span", {
696
+ className: "flex min-w-0 flex-1 flex-col gap-1 p-3.5",
697
+ children: [
698
+ /* @__PURE__ */ jsx("span", {
699
+ className: "line-clamp-2 text-sm font-medium @md:line-clamp-1",
700
+ children: preview.title
701
+ }),
702
+ preview.description ? /* @__PURE__ */ jsx("span", {
703
+ className: "line-clamp-2 text-xs text-muted-foreground",
704
+ children: preview.description
705
+ }) : null,
706
+ /* @__PURE__ */ jsxs("span", {
707
+ className: "mt-auto flex min-w-0 items-center gap-1.5 pt-1.5 text-xs text-muted-foreground",
708
+ children: [preview.favicon ? /* @__PURE__ */ jsx("img", {
709
+ src: preview.favicon,
710
+ alt: "",
711
+ width: 16,
712
+ height: 16,
713
+ loading: "lazy",
714
+ referrerPolicy: "no-referrer",
715
+ className: cx("size-4 shrink-0 rounded-sm", BROKEN_IMAGE)
716
+ }) : null, /* @__PURE__ */ jsx("span", {
717
+ className: "truncate",
718
+ children: displayUrl(url)
719
+ })]
720
+ })
721
+ ]
722
+ }),
723
+ preview.image ? /* @__PURE__ */ jsx("img", {
724
+ src: preview.image,
725
+ alt: "",
726
+ loading: "lazy",
727
+ decoding: "async",
728
+ referrerPolicy: "no-referrer",
729
+ className: cx("aspect-[1.91/1] w-full border-b border-border bg-muted object-cover @md:aspect-auto @md:w-1/3 @md:max-w-60 @md:shrink-0 @md:border-b-0 @md:border-s", BROKEN_IMAGE)
730
+ }) : null,
731
+ link.external ? /* @__PURE__ */ jsx("span", {
732
+ className: "sr-only",
733
+ children: " (opens in a new tab)"
734
+ }) : null
735
+ ]
736
+ }), /* @__PURE__ */ jsx(Caption, {
737
+ caption,
738
+ context
739
+ })]
740
+ });
741
+ }
742
+ /** The default frame around a demo: a bordered stage with the caption below. */
743
+ function DemoFrame({ caption, context, children }) {
744
+ return /* @__PURE__ */ jsxs("figure", { children: [/* @__PURE__ */ jsx("div", {
745
+ className: "flex min-h-48 flex-col items-center justify-center gap-6 overflow-hidden rounded-xl border border-border px-6 py-10",
746
+ children
747
+ }), caption.length ? /* @__PURE__ */ jsx("figcaption", {
748
+ className: "mt-3 text-center text-sm text-muted-foreground",
749
+ children: /* @__PURE__ */ jsx(RichText, {
750
+ text: caption,
751
+ context
752
+ })
753
+ }) : null] });
754
+ }
755
+ /**
756
+ * An embed, as a preview card linking to it: embedding any site in an iframe
757
+ * is unsafe, and most refuse it. An embed of `…/demos/<name>` whose name is one of
758
+ * `demos` renders that demo component instead, in the `demo` frame.
759
+ */
760
+ function Embed({ block, context }) {
761
+ const demo = matchDemo(block.embed.url, context.demos);
762
+ if (demo) {
763
+ const Frame = context.components.demo ?? DemoFrame;
764
+ return /* @__PURE__ */ jsx(Frame, {
765
+ name: demo.name,
766
+ caption: block.embed.caption,
767
+ context,
768
+ children: /* @__PURE__ */ jsx(demo.Component, {
769
+ name: demo.name,
770
+ params: demo.params
771
+ })
772
+ });
773
+ }
774
+ return /* @__PURE__ */ jsx(PreviewCard, {
775
+ url: block.embed.url,
776
+ preview: block.bookmarkPreview,
777
+ icon: EMBED_ICON,
778
+ caption: block.embed.caption,
779
+ context
780
+ });
781
+ }
782
+ function LinkPreview({ block, context }) {
783
+ return /* @__PURE__ */ jsx(PreviewCard, {
784
+ url: block.link_preview.url,
785
+ preview: block.bookmarkPreview,
786
+ icon: LINK_ICON,
787
+ caption: [],
788
+ context
789
+ });
790
+ }
791
+ const PAGE_ICON = /* @__PURE__ */ jsx("svg", {
792
+ "aria-hidden": "true",
793
+ viewBox: "0 0 24 24",
794
+ fill: "none",
795
+ stroke: "currentColor",
796
+ strokeWidth: "1.5",
797
+ strokeLinecap: "round",
798
+ strokeLinejoin: "round",
799
+ className: "size-5 text-muted-foreground",
800
+ children: FILE_ICON
801
+ });
802
+ /**
803
+ * A page as a row: its icon (or a page icon), then its title. Rows that
804
+ * follow each other sit close together, like a list of subpages in Notion.
805
+ */
806
+ function PageRow({ pageId, title, icon, blockId, context }) {
807
+ const link = resolvePageLink(pageId, context);
808
+ const row = "flex flex-1 gap-2 rounded-md px-2 py-1";
809
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(IconSlot, {
810
+ icon,
811
+ blockId,
812
+ context,
813
+ fallback: PAGE_ICON
814
+ }), /* @__PURE__ */ jsx("span", {
815
+ className: "min-w-0 font-medium underline decoration-muted-foreground/50 decoration-from-font underline-offset-4 transition-[text-decoration-color] group-hover:decoration-foreground",
816
+ children: title || "Untitled"
817
+ })] });
818
+ return /* @__PURE__ */ jsx("p", {
819
+ "data-page-row": "",
820
+ className: "-mx-2 flex [[data-page-row]+&]:-mt-3",
821
+ children: link ? /* @__PURE__ */ jsxs("a", {
822
+ href: link.href,
823
+ ...link.external ? {
824
+ target: "_blank",
825
+ rel: "noopener noreferrer"
826
+ } : {},
827
+ className: cx("group transition-[background-color] hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", row),
828
+ children: [content, link.external ? /* @__PURE__ */ jsx("span", {
829
+ className: "sr-only",
830
+ children: " (opens in a new tab)"
831
+ }) : null]
832
+ }) : /* @__PURE__ */ jsx("span", {
833
+ className: row,
834
+ children: content
835
+ })
836
+ });
837
+ }
838
+ /** A subpage, with its icon when `fetchBlocks` attached it. */
839
+ function ChildPage({ block, context }) {
840
+ return /* @__PURE__ */ jsx(PageRow, {
841
+ pageId: block.id,
842
+ title: block.child_page.title,
843
+ icon: block.linkedPage?.icon,
844
+ blockId: block.id,
845
+ context
846
+ });
847
+ }
848
+ /** A link to another page, titled by `fetchBlocks`. Without a title, nothing renders. */
849
+ function LinkToPage({ block, context }) {
850
+ const page = block.linkedPage;
851
+ if (!page) return null;
852
+ return /* @__PURE__ */ jsx(PageRow, {
853
+ pageId: page.id,
854
+ title: page.title,
855
+ icon: page.icon,
856
+ blockId: block.id,
857
+ context
858
+ });
859
+ }
860
+ //#endregion
861
+ //#region src/blocks/table.tsx
862
+ const CELL = "max-w-64 border border-border px-4 py-2 text-start align-top";
863
+ const HEADER_CELL = `${CELL} bg-muted font-semibold`;
864
+ function cells(row) {
865
+ return row.type === "table_row" ? row.table_row.cells : [];
866
+ }
867
+ /** Renders its rows itself, from `block.children`. */
868
+ function Table({ block, context }) {
869
+ const { has_column_header, has_row_header } = block.table;
870
+ const rows = (block.children ?? []).filter((row) => row.type === "table_row");
871
+ const [first, ...rest] = rows;
872
+ if (!first) return null;
873
+ const renderRow = (row, head) => /* @__PURE__ */ jsx("tr", { children: cells(row).map((cell, i) => head ? /* @__PURE__ */ jsx("th", {
874
+ scope: "col",
875
+ className: HEADER_CELL,
876
+ children: /* @__PURE__ */ jsx(RichText, {
877
+ text: cell,
878
+ context
879
+ })
880
+ }, i) : i === 0 && has_row_header ? /* @__PURE__ */ jsx("th", {
881
+ scope: "row",
882
+ className: HEADER_CELL,
883
+ children: /* @__PURE__ */ jsx(RichText, {
884
+ text: cell,
885
+ context
886
+ })
887
+ }, i) : /* @__PURE__ */ jsx("td", {
888
+ className: CELL,
889
+ children: /* @__PURE__ */ jsx(RichText, {
890
+ text: cell,
891
+ context
892
+ })
893
+ }, i)) }, row.id);
894
+ const columns = has_column_header ? cells(first).map((cell) => plainText(cell).trim()).filter(Boolean) : [];
895
+ return /* @__PURE__ */ jsx("div", {
896
+ role: "region",
897
+ "aria-label": columns.length > 0 ? `Table: ${columns.join(", ")}` : "Table",
898
+ tabIndex: 0,
899
+ className: "overflow-x-auto rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
900
+ children: /* @__PURE__ */ jsx("table", {
901
+ className: "w-max min-w-full border-collapse text-sm tabular-nums",
902
+ children: has_column_header ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("thead", { children: renderRow(first, true) }), /* @__PURE__ */ jsx("tbody", { children: rest.map((row) => renderRow(row, false)) })] }) : /* @__PURE__ */ jsx("tbody", { children: rows.map((row) => renderRow(row, false)) })
903
+ })
904
+ });
905
+ }
906
+ //#endregion
907
+ //#region src/blocks/navigation.tsx
908
+ const INDENT = [
909
+ "",
910
+ "ps-4",
911
+ "ps-8",
912
+ "ps-12"
913
+ ];
914
+ /** Links to every heading on the page, indented by level. */
915
+ function TableOfContents({ block, context }) {
916
+ const { headings } = context;
917
+ if (headings.length === 0) return null;
918
+ const top = Math.min(...headings.map((heading) => heading.level));
919
+ return /* @__PURE__ */ jsx("nav", {
920
+ "aria-label": "Table of contents",
921
+ className: cx("text-sm", blockColorClass(block.table_of_contents.color)),
922
+ children: /* @__PURE__ */ jsx("ul", {
923
+ className: "flex flex-col",
924
+ children: headings.map((heading) => /* @__PURE__ */ jsx("li", {
925
+ className: INDENT[heading.level - top],
926
+ children: /* @__PURE__ */ jsx("a", {
927
+ href: `#${heading.id}`,
928
+ className: "block rounded-sm py-1 text-muted-foreground underline decoration-transparent underline-offset-4 transition-[color,text-decoration-color] duration-150 hover:text-foreground hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
929
+ children: heading.text
930
+ })
931
+ }, heading.blockId))
932
+ })
933
+ });
934
+ }
935
+ const SHOW_PANEL = [
936
+ "[&:has([data-tab='0']:checked)_[data-panel='0']]:flex",
937
+ "[&:has([data-tab='1']:checked)_[data-panel='1']]:flex",
938
+ "[&:has([data-tab='2']:checked)_[data-panel='2']]:flex",
939
+ "[&:has([data-tab='3']:checked)_[data-panel='3']]:flex",
940
+ "[&:has([data-tab='4']:checked)_[data-panel='4']]:flex",
941
+ "[&:has([data-tab='5']:checked)_[data-panel='5']]:flex",
942
+ "[&:has([data-tab='6']:checked)_[data-panel='6']]:flex",
943
+ "[&:has([data-tab='7']:checked)_[data-panel='7']]:flex",
944
+ "[&:has([data-tab='8']:checked)_[data-panel='8']]:flex",
945
+ "[&:has([data-tab='9']:checked)_[data-panel='9']]:flex",
946
+ "[&:has([data-tab='10']:checked)_[data-panel='10']]:flex",
947
+ "[&:has([data-tab='11']:checked)_[data-panel='11']]:flex",
948
+ "[&:has([data-tab='12']:checked)_[data-panel='12']]:flex",
949
+ "[&:has([data-tab='13']:checked)_[data-panel='13']]:flex",
950
+ "[&:has([data-tab='14']:checked)_[data-panel='14']]:flex",
951
+ "[&:has([data-tab='15']:checked)_[data-panel='15']]:flex",
952
+ "[&:has([data-tab='16']:checked)_[data-panel='16']]:flex",
953
+ "[&:has([data-tab='17']:checked)_[data-panel='17']]:flex",
954
+ "[&:has([data-tab='18']:checked)_[data-panel='18']]:flex",
955
+ "[&:has([data-tab='19']:checked)_[data-panel='19']]:flex"
956
+ ];
957
+ /**
958
+ * Notion tabs, with no JavaScript: each tab is a radio button drawn as a pill,
959
+ * in a row that scrolls sideways when the tabs don't fit, and CSS shows the
960
+ * panel of the checked one. The first tab starts selected. Radios give
961
+ * keyboard support for free (arrow keys switch tabs), and screen readers
962
+ * announce "pnpm, radio button, 2 of 3". Each tab is a paragraph (its title
963
+ * and icon) whose children are the tab's content.
964
+ */
965
+ function Tab({ block, context }) {
966
+ const tabs = (block.children ?? []).filter((child) => child.type === "paragraph").slice(0, SHOW_PANEL.length);
967
+ if (tabs.length === 0) return null;
968
+ const name = `notionic-tabs-${block.id}`;
969
+ return /* @__PURE__ */ jsxs("div", {
970
+ className: cx("group/tabs rounded-xl border border-border p-2", ...SHOW_PANEL.slice(0, tabs.length)),
971
+ children: [/* @__PURE__ */ jsx("div", {
972
+ className: "flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
973
+ children: tabs.map((tab, index) => {
974
+ if (tab.type !== "paragraph") return null;
975
+ const { rich_text, icon } = tab.paragraph;
976
+ const id = `${name}-${index}`;
977
+ return /* @__PURE__ */ jsxs("span", {
978
+ className: "relative flex shrink-0",
979
+ children: [/* @__PURE__ */ jsx("input", {
980
+ type: "radio",
981
+ name,
982
+ id,
983
+ "data-tab": index,
984
+ defaultChecked: index === 0,
985
+ className: "pointer-events-none absolute inset-0 appearance-none opacity-0"
986
+ }), /* @__PURE__ */ jsxs("label", {
987
+ htmlFor: id,
988
+ className: "flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 font-medium whitespace-nowrap text-muted-foreground transition-[color,background-color] duration-150 hover:text-foreground motion-reduce:transition-none [:checked+&]:bg-muted [:checked+&]:text-foreground [:focus-visible+&]:outline-2 [:focus-visible+&]:-outline-offset-2 [:focus-visible+&]:outline-ring",
989
+ children: [icon?.type === "emoji" ? /* @__PURE__ */ jsx("span", {
990
+ "aria-hidden": "true",
991
+ children: icon.emoji
992
+ }) : null, /* @__PURE__ */ jsx(RichText, {
993
+ text: rich_text,
994
+ context
995
+ })]
996
+ })]
997
+ }, tab.id);
998
+ })
999
+ }), tabs.map((tab, index) => /* @__PURE__ */ jsx("div", {
1000
+ "data-panel": index,
1001
+ className: "hidden flex-col gap-4 px-3 pt-3 pb-2 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-focus-within/tabs:starting:opacity-0",
1002
+ children: /* @__PURE__ */ jsx(BlockList, {
1003
+ blocks: tab.children,
1004
+ context
1005
+ })
1006
+ }, tab.id))]
1007
+ });
1008
+ }
1009
+ //#endregion
1010
+ //#region src/blocks/text.tsx
1011
+ function Paragraph({ block, context, children }) {
1012
+ const { rich_text, color } = block.paragraph;
1013
+ const empty = rich_text.length === 0;
1014
+ if (empty && !children) return null;
1015
+ return /* @__PURE__ */ jsxs(Fragment, { children: [empty ? null : /* @__PURE__ */ jsx("p", {
1016
+ className: blockColorClass(color),
1017
+ children: /* @__PURE__ */ jsx(RichText, {
1018
+ text: rich_text,
1019
+ context
1020
+ })
1021
+ }), children ? /* @__PURE__ */ jsx("div", {
1022
+ className: "flex flex-col gap-4 ps-6",
1023
+ children
1024
+ }) : null] });
1025
+ }
1026
+ function Quote({ block, context, children }) {
1027
+ const { rich_text, color } = block.quote;
1028
+ return /* @__PURE__ */ jsxs("blockquote", {
1029
+ className: cx("border-s-2 border-border ps-4 italic [&_em]:not-italic", colorClass(color), isBackgroundColor(color) && "rounded-e-md py-1 pe-3"),
1030
+ children: [/* @__PURE__ */ jsx(RichText, {
1031
+ text: rich_text,
1032
+ context
1033
+ }), children ? /* @__PURE__ */ jsx("div", {
1034
+ className: "mt-4 flex flex-col gap-4",
1035
+ children
1036
+ }) : null]
1037
+ });
1038
+ }
1039
+ function Callout({ block, context, children }) {
1040
+ const { icon, rich_text, color } = block.callout;
1041
+ return /* @__PURE__ */ jsxs("div", {
1042
+ className: cx("flex gap-3 rounded-lg p-4", isBackgroundColor(color) ? cx("border border-transparent", colorClass(color)) : cx("border border-border bg-muted/50", colorClass(color))),
1043
+ children: [/* @__PURE__ */ jsx(IconSlot, {
1044
+ icon,
1045
+ blockId: block.id,
1046
+ context
1047
+ }), /* @__PURE__ */ jsxs("div", {
1048
+ className: "flex min-w-0 flex-1 flex-col gap-4",
1049
+ children: [rich_text.length > 0 ? /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(RichText, {
1050
+ text: rich_text,
1051
+ context
1052
+ }) }) : null, children]
1053
+ })]
1054
+ });
1055
+ }
1056
+ function Divider(_) {
1057
+ return /* @__PURE__ */ jsx("hr", { className: "my-4 border-border" });
1058
+ }
1059
+ /** An equation's LaTeX source. For formatted math, use `notion-equation` from the shadcn registry. */
1060
+ function Equation({ block }) {
1061
+ return /* @__PURE__ */ jsx("figure", {
1062
+ "aria-label": "Equation, LaTeX source",
1063
+ className: "rounded-lg border border-border bg-muted px-4 py-3 font-mono text-[0.8125rem] leading-relaxed",
1064
+ children: /* @__PURE__ */ jsx("code", { children: block.equation.expression })
1065
+ });
1066
+ }
1067
+ /** An equation inside text, as its LaTeX source. */
1068
+ function InlineEquation({ expression }) {
1069
+ return /* @__PURE__ */ jsx("code", {
1070
+ className: CODE_CLASSES,
1071
+ children: expression
1072
+ });
1073
+ }
1074
+ //#endregion
1075
+ //#region src/slug.ts
1076
+ /** Lowercase, accent-free, hyphenated anchor id for a heading's text. */
1077
+ function slugify(text) {
1078
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "section";
1079
+ }
1080
+ /** Slugs for one page: the first use is plain, repeats get -2, -3… */
1081
+ function createSlugger() {
1082
+ const seen = /* @__PURE__ */ new Map();
1083
+ return (text) => {
1084
+ const slug = slugify(text);
1085
+ const count = (seen.get(slug) ?? 0) + 1;
1086
+ seen.set(slug, count);
1087
+ return count === 1 ? slug : `${slug}-${count}`;
1088
+ };
1089
+ }
1090
+ //#endregion
1091
+ //#region src/headings.ts
1092
+ const LEVELS = {
1093
+ heading_1: 1,
1094
+ heading_2: 2,
1095
+ heading_3: 3,
1096
+ heading_4: 4
1097
+ };
1098
+ /**
1099
+ * Every non-blank heading on the page, in reading order (headings nested in
1100
+ * toggles, columns and callouts included), with unique anchor ids. Use it for
1101
+ * a table of contents or a sidebar outline.
1102
+ */
1103
+ function collectHeadings(blocks) {
1104
+ const slug = createSlugger();
1105
+ const headings = [];
1106
+ const walk = (list) => {
1107
+ for (const block of list) {
1108
+ if (block.type === "heading_1" || block.type === "heading_2" || block.type === "heading_3" || block.type === "heading_4") {
1109
+ const data = block.type === "heading_1" ? block.heading_1 : block.type === "heading_2" ? block.heading_2 : block.type === "heading_3" ? block.heading_3 : block.heading_4;
1110
+ const text = plainText(data.rich_text).trim();
1111
+ if (text) headings.push({
1112
+ blockId: block.id,
1113
+ id: slug(text),
1114
+ text,
1115
+ level: LEVELS[block.type]
1116
+ });
1117
+ }
1118
+ if (block.children) walk(block.children);
1119
+ }
1120
+ };
1121
+ walk(blocks);
1122
+ return headings;
1123
+ }
1124
+ //#endregion
1125
+ //#region src/renderer.tsx
1126
+ const defaultComponents = {
1127
+ paragraph: Paragraph,
1128
+ heading_1: Heading,
1129
+ heading_2: Heading,
1130
+ heading_3: Heading,
1131
+ heading_4: Heading,
1132
+ bulleted_list_item: BulletedListItem,
1133
+ numbered_list_item: NumberedListItem,
1134
+ to_do: ToDo,
1135
+ quote: Quote,
1136
+ callout: Callout,
1137
+ toggle: Toggle,
1138
+ divider: Divider,
1139
+ equation: Equation,
1140
+ code: Code,
1141
+ table: Table,
1142
+ column_list: ColumnList,
1143
+ column: Column,
1144
+ synced_block: SyncedBlock,
1145
+ image: Image,
1146
+ video: Video,
1147
+ audio: Audio,
1148
+ file: File,
1149
+ pdf: Pdf,
1150
+ bookmark: Bookmark,
1151
+ embed: Embed,
1152
+ link_preview: LinkPreview,
1153
+ child_page: ChildPage,
1154
+ link_to_page: LinkToPage,
1155
+ table_of_contents: TableOfContents,
1156
+ tab: Tab,
1157
+ inline_equation: InlineEquation
1158
+ };
1159
+ const LIST_KINDS = {
1160
+ bulleted_list_item: "bulleted",
1161
+ numbered_list_item: "numbered",
1162
+ to_do: "todo"
1163
+ };
1164
+ /**
1165
+ * Merges consecutive list items of one kind into a group, since the API
1166
+ * returns each item as its own block. Other blocks stand alone.
1167
+ */
1168
+ function groupBlocks(blocks) {
1169
+ const groups = [];
1170
+ for (const block of blocks) {
1171
+ const kind = LIST_KINDS[block.type];
1172
+ const last = groups[groups.length - 1];
1173
+ if (!kind) groups.push({
1174
+ kind: "block",
1175
+ block
1176
+ });
1177
+ else if (last && last.kind === kind) last.items.push(block);
1178
+ else groups.push({
1179
+ kind,
1180
+ items: [block]
1181
+ });
1182
+ }
1183
+ return groups;
1184
+ }
1185
+ const NUMBER_FORMATS = {
1186
+ numbers: "list-decimal",
1187
+ letters: "list-[lower-alpha]",
1188
+ roman: "list-[lower-roman]"
1189
+ };
1190
+ const NESTED_NUMBERS = "[&_[data-list=numbers]]:list-[lower-alpha] [&_[data-list=numbers]_[data-list=numbers]]:list-[lower-roman]";
1191
+ const NESTED_BULLETS = "[&_[data-list=bulleted]]:list-[circle] [&_[data-list=bulleted]_[data-list=bulleted]]:list-[square]";
1192
+ function List({ kind, items, context }) {
1193
+ const rendered = items.map((item) => /* @__PURE__ */ jsx(Block, {
1194
+ block: item,
1195
+ context
1196
+ }, item.id));
1197
+ if (kind === "numbered") {
1198
+ const first = items[0].type === "numbered_list_item" ? items[0].numbered_list_item : void 0;
1199
+ const format = first?.list_format ?? "numbers";
1200
+ return /* @__PURE__ */ jsx("ol", {
1201
+ start: first?.list_start_index,
1202
+ "data-list": format,
1203
+ className: cx("flex flex-col gap-2 ps-6", NUMBER_FORMATS[format], NESTED_NUMBERS),
1204
+ children: rendered
1205
+ });
1206
+ }
1207
+ return /* @__PURE__ */ jsx("ul", {
1208
+ "data-list": kind,
1209
+ className: cx("flex flex-col gap-2", kind === "bulleted" ? cx("list-disc ps-6 marker:text-muted-foreground", NESTED_BULLETS) : "list-none"),
1210
+ children: rendered
1211
+ });
1212
+ }
1213
+ /** Blocks in order, with list items grouped into lists. */
1214
+ function BlockList({ blocks, context }) {
1215
+ if (!blocks?.length) return null;
1216
+ return groupBlocks(blocks).map((group) => group.kind === "block" ? /* @__PURE__ */ jsx(Block, {
1217
+ block: group.block,
1218
+ context
1219
+ }, group.block.id) : /* @__PURE__ */ jsx(List, {
1220
+ kind: group.kind,
1221
+ items: group.items,
1222
+ context
1223
+ }, group.items[0].id));
1224
+ }
1225
+ /** One block, rendered by its component, with its nested blocks as children. */
1226
+ function Block({ block, context }) {
1227
+ const Component = context.components[block.type];
1228
+ if (!Component) {
1229
+ warnOnce(`Notion block type "${block.type}" is not rendered`);
1230
+ return null;
1231
+ }
1232
+ const children = block.children?.length ? /* @__PURE__ */ jsx(BlockList, {
1233
+ blocks: block.children,
1234
+ context
1235
+ }) : void 0;
1236
+ return /* @__PURE__ */ jsx(Component, {
1237
+ block,
1238
+ context,
1239
+ children
1240
+ });
1241
+ }
1242
+ /**
1243
+ * Renders Notion blocks. Works as a Server Component; it ships no client
1244
+ * JavaScript of its own (demos ship theirs).
1245
+ */
1246
+ function NotionRenderer({ blocks, components, pageHref = defaultPageHref, transformFileUrl, demos, className }) {
1247
+ const context = {
1248
+ pageHref,
1249
+ transformFileUrl,
1250
+ demos,
1251
+ headings: collectHeadings(blocks),
1252
+ components: {
1253
+ ...defaultComponents,
1254
+ ...components
1255
+ }
1256
+ };
1257
+ return /* @__PURE__ */ jsx("div", {
1258
+ className: cx("notionic flex flex-col gap-4 leading-[1.6] wrap-break-word text-foreground", className),
1259
+ children: /* @__PURE__ */ jsx(BlockList, {
1260
+ blocks,
1261
+ context
1262
+ })
1263
+ });
1264
+ }
1265
+ //#endregion
1266
+ export { Block, BlockList, DemoFrame, NotionLink, NotionRenderer, RichText, blockColorClass, collectHeadings, colorClass, defaultComponents, defaultPageHref, groupBlocks, languageName, normalizePageId, pageTitle, plainText, resolveFileUrl, resolveLink, resolvePageLink };