@carlonicora/nextjs-jsonapi 6.14.0 → 6.15.0

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.
Files changed (42) hide show
  1. package/dist/{BlockNoteEditor-QIVV2OB4.mjs → BlockNoteEditor-I3J6YYXK.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-CUNH4LSQ.js → BlockNoteEditor-UIAX72XW.js} +9 -9
  3. package/dist/{BlockNoteEditor-CUNH4LSQ.js.map → BlockNoteEditor-UIAX72XW.js.map} +1 -1
  4. package/dist/billing/index.js +378 -378
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-GSRTVIIK.js → chunk-RT7XFGIN.js} +222 -218
  7. package/dist/chunk-RT7XFGIN.js.map +1 -0
  8. package/dist/{chunk-YKNULEHB.mjs → chunk-RYY7USSW.mjs} +9 -5
  9. package/dist/chunk-RYY7USSW.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/editors/server.d.mts +7 -1
  13. package/dist/components/editors/server.d.ts +7 -1
  14. package/dist/components/editors/server.js +17 -4
  15. package/dist/components/editors/server.js.map +1 -1
  16. package/dist/components/editors/server.mjs +17 -4
  17. package/dist/components/editors/server.mjs.map +1 -1
  18. package/dist/components/index.js +2 -2
  19. package/dist/components/index.mjs +1 -1
  20. package/dist/contexts/index.js +2 -2
  21. package/dist/contexts/index.mjs +1 -1
  22. package/dist/email-automations/index.js +58 -58
  23. package/dist/email-automations/index.mjs +1 -1
  24. package/dist/features/analytics/index.js +96 -96
  25. package/dist/features/analytics/index.mjs +1 -1
  26. package/dist/features/blog/index.js +93 -93
  27. package/dist/features/blog/index.mjs +1 -1
  28. package/dist/features/help/index.js +31 -31
  29. package/dist/features/help/index.mjs +1 -1
  30. package/dist/features/tokenusage/index.js +102 -102
  31. package/dist/features/tokenusage/index.mjs +1 -1
  32. package/package.json +1 -1
  33. package/src/components/editors/server/__tests__/renderBlockNoteMarkdown.spec.ts +45 -0
  34. package/src/components/editors/server/renderBlockNoteHTML.ts +2 -18
  35. package/src/components/editors/server/renderBlockNoteMarkdown.ts +10 -0
  36. package/src/components/editors/server/serverEditor.ts +25 -0
  37. package/src/components/editors/server-entry.ts +1 -0
  38. package/src/hooks/__tests__/usePageTracker.spec.tsx +61 -0
  39. package/src/hooks/usePageTracker.ts +17 -7
  40. package/dist/chunk-GSRTVIIK.js.map +0 -1
  41. package/dist/chunk-YKNULEHB.mjs.map +0 -1
  42. /package/dist/{BlockNoteEditor-QIVV2OB4.mjs.map → BlockNoteEditor-I3J6YYXK.mjs.map} +0 -0
@@ -0,0 +1,45 @@
1
+ // @vitest-environment node
2
+ // The server render runs in Node, where there is no window or document of our own:
3
+ // BlockNote's ServerBlockNoteEditor brings its JSDOM.
4
+ import type { PartialBlock } from "@blocknote/core";
5
+ import { describe, expect, it } from "vitest";
6
+ import { renderBlockNoteHTML } from "../renderBlockNoteHTML";
7
+ import { renderBlockNoteMarkdown } from "../renderBlockNoteMarkdown";
8
+
9
+ const blocks: PartialBlock[] = [
10
+ { type: "heading", props: { level: 2 }, content: "Think of the Campaign as the lid on the box" },
11
+ { type: "paragraph", content: "One Campaign is one story arc." },
12
+ { type: "bulletListItem", content: "Characters" },
13
+ {
14
+ type: "paragraph",
15
+ content: [{ type: "link", href: "https://wyrd.li/help", content: "help" }],
16
+ },
17
+ { type: "image", props: { url: "https://wyrd.li/marketing/og.png", caption: "Wyrdli" } },
18
+ { type: "image", props: { url: "https://wyrd.li/marketing/plain.png" } },
19
+ ];
20
+
21
+ describe("renderBlockNoteMarkdown", () => {
22
+ it("renders headings, paragraphs, bullet lists and links", async () => {
23
+ const markdown = await renderBlockNoteMarkdown(blocks);
24
+
25
+ expect(markdown).toContain("## Think of the Campaign as the lid on the box");
26
+ expect(markdown).toContain("One Campaign is one story arc.");
27
+ expect(markdown).toMatch(/^\s*[-*+] Characters\s*$/m);
28
+ expect(markdown).toContain("[help](https://wyrd.li/help)");
29
+ // blocksToMarkdownLossy keeps a captioned image as an HTML <figure> (valid
30
+ // CommonMark HTML block); an image with no caption becomes ![](url).
31
+ expect(markdown).toMatch(/<img[^>]*src="https:\/\/wyrd\.li\/marketing\/og\.png"/);
32
+ expect(markdown).toContain("<figcaption>Wyrdli</figcaption>");
33
+ expect(markdown).toMatch(/!\[[^\]]*\]\(https:\/\/wyrd\.li\/marketing\/plain\.png\)/);
34
+ });
35
+
36
+ it("two overlapping calls both resolve and leave no JSDOM window on globalThis", async () => {
37
+ const before = typeof (globalThis as { window?: unknown }).window;
38
+
39
+ const [markdown, html] = await Promise.all([renderBlockNoteMarkdown(blocks), renderBlockNoteHTML(blocks)]);
40
+
41
+ expect(markdown).toContain("One Campaign is one story arc.");
42
+ expect(html).toContain("One Campaign is one story arc.");
43
+ expect(typeof (globalThis as { window?: unknown }).window).toBe(before);
44
+ });
45
+ });
@@ -1,26 +1,10 @@
1
1
  import type { PartialBlock } from "@blocknote/core";
2
- import { ServerBlockNoteEditor } from "@blocknote/server-util";
3
-
4
- // Default schema only, the same one BlockNoteViewer uses: the editor's mention
5
- // and diff inline specs are React specs from "use client" modules (next/link,
6
- // hover cards), so they cannot run here.
7
- let editor: ServerBlockNoteEditor | undefined;
8
-
9
- // ServerBlockNoteEditor swaps globalThis.window/document for its JSDOM around
10
- // each call and restores them after an await. Two overlapping calls would
11
- // restore each other's values and could leave the JSDOM window on the server's
12
- // globals, so calls run one at a time.
13
- let queue: Promise<unknown> = Promise.resolve();
2
+ import { runWithServerEditor } from "./serverEditor";
14
3
 
15
4
  /**
16
5
  * Renders a BlockNote blocks array to the HTML the editor itself would render,
17
6
  * through BlockNote's own server editor (`blocksToFullHTML`). Server only.
18
7
  */
19
8
  export function renderBlockNoteHTML(blocks: PartialBlock[]): Promise<string> {
20
- const run = queue.then(() => {
21
- editor ??= ServerBlockNoteEditor.create();
22
- return editor.blocksToFullHTML(blocks);
23
- });
24
- queue = run.catch(() => undefined);
25
- return run;
9
+ return runWithServerEditor((editor) => editor.blocksToFullHTML(blocks));
26
10
  }
@@ -0,0 +1,10 @@
1
+ import type { PartialBlock } from "@blocknote/core";
2
+ import { runWithServerEditor } from "./serverEditor";
3
+
4
+ /**
5
+ * Renders a BlockNote blocks array to Markdown through BlockNote's own server
6
+ * editor (`blocksToMarkdownLossy`). Server only.
7
+ */
8
+ export function renderBlockNoteMarkdown(blocks: PartialBlock[]): Promise<string> {
9
+ return runWithServerEditor((editor) => editor.blocksToMarkdownLossy(blocks));
10
+ }
@@ -0,0 +1,25 @@
1
+ import { ServerBlockNoteEditor } from "@blocknote/server-util";
2
+
3
+ // Default schema only, the same one BlockNoteViewer uses: the editor's mention
4
+ // and diff inline specs are React specs from "use client" modules (next/link,
5
+ // hover cards), so they cannot run here.
6
+ let editor: ServerBlockNoteEditor | undefined;
7
+
8
+ // ServerBlockNoteEditor swaps globalThis.window/document for its JSDOM around
9
+ // each call and restores them after an await. Two overlapping calls would
10
+ // restore each other's values and could leave the JSDOM window on the server's
11
+ // globals, so calls run one at a time.
12
+ let queue: Promise<unknown> = Promise.resolve();
13
+
14
+ /**
15
+ * Runs `fn` with the shared server editor, one call at a time. Server only.
16
+ * Internal to the library: not exported from the barrel.
17
+ */
18
+ export function runWithServerEditor<T>(fn: (editor: ServerBlockNoteEditor) => T | Promise<T>): Promise<T> {
19
+ const run = queue.then(() => {
20
+ editor ??= ServerBlockNoteEditor.create();
21
+ return fn(editor);
22
+ });
23
+ queue = run.catch(() => undefined);
24
+ return run;
25
+ }
@@ -5,3 +5,4 @@
5
5
  // `serverExternalPackages` so Node loads it instead of the bundler.
6
6
 
7
7
  export { BlockNoteServerContent } from "./server/BlockNoteServerContent";
8
+ export { renderBlockNoteMarkdown } from "./server/renderBlockNoteMarkdown";
@@ -299,4 +299,65 @@ describe("usePageTracker", () => {
299
299
 
300
300
  await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
301
301
  });
302
+
303
+ describe("most-specific-first titles (`Name · Type · Context | App`)", () => {
304
+ it("records the entity name on a hard load", async () => {
305
+ pathnameRef.current = "/npcs/npc-1";
306
+ document.title = "Charlie · NPC · Venezia Obscura | App";
307
+ const store = createStore();
308
+ store.set(recentPagesAtom, []);
309
+
310
+ mount(store);
311
+
312
+ await waitFor(() => expect(pages(store)).toHaveLength(1));
313
+ expect(pages(store)[0]).toMatchObject({ url: "/npcs/npc-1", title: "Charlie", moduleType: "npcs" });
314
+ });
315
+
316
+ it("records a single-part title such as a campaign's", async () => {
317
+ pathnameRef.current = "/campaigns/camp-1";
318
+ document.title = "Venezia Obscura | App";
319
+ const store = createStore();
320
+ store.set(recentPagesAtom, []);
321
+
322
+ mount(store);
323
+
324
+ await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
325
+ });
326
+
327
+ it("never stamps an entity with the previous page's name, then upgrades when the title lands", async () => {
328
+ pathnameRef.current = "/campaigns/camp-1";
329
+ document.title = "Venezia Obscura | App";
330
+ const store = createStore();
331
+ store.set(recentPagesAtom, []);
332
+ const view = mount(store);
333
+ await waitFor(() => expect(pages(store)[0]?.title).toBe("Venezia Obscura"));
334
+
335
+ pathnameRef.current = "/npcs/npc-1";
336
+ view.rerender(
337
+ <Provider store={store}>
338
+ <Harness />
339
+ </Provider>,
340
+ );
341
+
342
+ await waitFor(() => expect(pages(store)[0]?.url).toBe("/npcs/npc-1"));
343
+ expect(pages(store)[0].title).toBe("npcs");
344
+
345
+ document.title = "Charlie · NPC · Venezia Obscura | App";
346
+
347
+ await waitFor(() => expect(pages(store)[0]?.title).toBe("Charlie"));
348
+ expect(pages(store).find((page) => page.url === "/campaigns/camp-1")?.title).toBe("Venezia Obscura");
349
+ });
350
+
351
+ it("keeps the module name when the title is only the app name", async () => {
352
+ pathnameRef.current = "/npcs/npc-1";
353
+ document.title = "App";
354
+ const store = createStore();
355
+ store.set(recentPagesAtom, []);
356
+
357
+ mount(store);
358
+
359
+ await waitFor(() => expect(pages(store)).toHaveLength(1));
360
+ expect(pages(store)[0].title).toBe("npcs");
361
+ });
362
+ });
302
363
  });
@@ -10,18 +10,28 @@ import { getTrackablePages } from "../client/config";
10
10
  const EXCLUDED_ROUTES = ["/", "/login", "/register", "/forgot-password", "/reset-password", "/activate"];
11
11
 
12
12
  /**
13
- * Detail pages title themselves `[Entity type] Entity name | App name`. Returns
14
- * the entity name, or null when the document title is not (yet) a detail-page
15
- * title — the normal state for the first tick after a client-side navigation,
16
- * before the route's streamed metadata replaces the <title>.
13
+ * Detail pages title themselves in one of two shapes:
14
+ *
15
+ * `[Entity type] Entity name | App name` (bracketed)
16
+ * `Entity name · Entity type · Context | App name` (most-specific-first)
17
+ *
18
+ * Returns the entity name, or null when the document title carries no page
19
+ * name — just the bare app name. Whether the title belongs to THIS route (and
20
+ * not the page we left) is decided by the caller, not here.
17
21
  */
18
22
  function readEntityTitle(): string | null {
19
23
  if (typeof document === "undefined") return null;
20
24
 
21
- const afterEntityType = document.title.split("]")[1];
22
- if (!afterEntityType) return null;
25
+ const title = document.title;
23
26
 
24
- return afterEntityType.split("|")[0]?.trim() || null;
27
+ if (title.includes("]")) {
28
+ const afterEntityType = title.split("]")[1];
29
+ return afterEntityType?.split("|")[0]?.trim() || null;
30
+ }
31
+
32
+ if (!title.includes("|")) return null;
33
+
34
+ return title.split("|")[0].split("·")[0]?.trim() || null;
25
35
  }
26
36
 
27
37
  export function usePageTracker() {