notro-loader 0.0.1 → 0.0.2

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.
@@ -1,49 +1,49 @@
1
- /**
2
- * Utilities for handling Notion pre-signed S3 URLs.
3
- *
4
- * Notion images use time-limited pre-signed S3 URLs with expiring query
5
- * parameters (X-Amz-*). These utilities centralise detection and
6
- * normalisation of such URLs so the knowledge is not duplicated between
7
- * the loader and the app's image service.
8
- */
9
-
10
- const AMZN_PRESIGNED_PARAM = 'X-Amz-Algorithm';
11
-
12
- /**
13
- * Returns true if the text contains Notion pre-signed S3 URLs.
14
- *
15
- * Detection strategy:
16
- * - X-Amz-Algorithm: must appear as a URL query parameter
17
- * (i.e. preceded by "?" or "&" within an https:// URL context) to avoid
18
- * false positives when the literal string appears in body text or code blocks.
19
- * - prod-files-secure.s3: matched as a hostname within an https:// URL, which
20
- * is an unambiguous indicator of a Notion S3 URL regardless of query params.
21
- */
22
- export function markdownHasPresignedUrls(text: string): boolean {
23
- // Match X-Amz-Algorithm only when it appears as a URL query parameter
24
- if (/https?:\/\/[^\s)"']*[?&]X-Amz-Algorithm=/.test(text)) {
25
- return true;
26
- }
27
- // Match Notion's secure S3 hostname as a URL
28
- if (/https?:\/\/prod-files-secure\.s3/.test(text)) {
29
- return true;
30
- }
31
- return false;
32
- }
33
-
34
- /**
35
- * Strips expiring query parameters from a Notion pre-signed S3 URL,
36
- * yielding a stable cache key for Astro's image service.
37
- * Non-Notion URLs and invalid URLs are returned unchanged.
38
- */
39
- export function normalizeNotionPresignedUrl(src: string): string {
40
- try {
41
- const url = new URL(src);
42
- if (url.searchParams.has(AMZN_PRESIGNED_PARAM)) {
43
- return `${url.protocol}//${url.hostname}${url.pathname}`;
44
- }
45
- } catch {
46
- // Not a valid URL, return as-is
47
- }
48
- return src;
49
- }
1
+ /**
2
+ * Utilities for handling Notion pre-signed S3 URLs.
3
+ *
4
+ * Notion images use time-limited pre-signed S3 URLs with expiring query
5
+ * parameters (X-Amz-*). These utilities centralise detection and
6
+ * normalisation of such URLs so the knowledge is not duplicated between
7
+ * the loader and the app's image service.
8
+ */
9
+
10
+ const AMZN_PRESIGNED_PARAM = 'X-Amz-Algorithm';
11
+
12
+ /**
13
+ * Returns true if the text contains Notion pre-signed S3 URLs.
14
+ *
15
+ * Detection strategy:
16
+ * - X-Amz-Algorithm: must appear as a URL query parameter
17
+ * (i.e. preceded by "?" or "&" within an https:// URL context) to avoid
18
+ * false positives when the literal string appears in body text or code blocks.
19
+ * - prod-files-secure.s3: matched as a hostname within an https:// URL, which
20
+ * is an unambiguous indicator of a Notion S3 URL regardless of query params.
21
+ */
22
+ export function markdownHasPresignedUrls(text: string): boolean {
23
+ // Match X-Amz-Algorithm only when it appears as a URL query parameter
24
+ if (/https?:\/\/[^\s)"']*[?&]X-Amz-Algorithm=/.test(text)) {
25
+ return true;
26
+ }
27
+ // Match Notion's secure S3 hostname as a URL
28
+ if (/https?:\/\/prod-files-secure\.s3/.test(text)) {
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Strips expiring query parameters from a Notion pre-signed S3 URL,
36
+ * yielding a stable cache key for Astro's image service.
37
+ * Non-Notion URLs and invalid URLs are returned unchanged.
38
+ */
39
+ export function normalizeNotionPresignedUrl(src: string): string {
40
+ try {
41
+ const url = new URL(src);
42
+ if (url.searchParams.has(AMZN_PRESIGNED_PARAM)) {
43
+ return `${url.protocol}//${url.hostname}${url.pathname}`;
44
+ }
45
+ } catch {
46
+ // Not a valid URL, return as-is
47
+ }
48
+ return src;
49
+ }
@@ -1,127 +1,127 @@
1
- import type { PropertyPageObjectResponseType } from "../loader/schema.ts";
2
- import type { LinkToPages } from "../types.ts";
3
-
4
- export const getPlainText = (
5
- property: PropertyPageObjectResponseType,
6
- ): string | undefined => {
7
- if (property?.type === "rich_text" && property.rich_text.length > 0) {
8
- // rich_text arrays represent adjacent text spans; direct concatenation (no separator) is correct per Notion spec.
9
- return property.rich_text.map((t) => t.plain_text).join("");
10
- }
11
- if (property?.type === "title" && property.title.length > 0) {
12
- // title arrays also represent adjacent text spans; direct concatenation is correct.
13
- return property.title.map((t) => t.plain_text).join("");
14
- }
15
- if (property?.type === "select" && property.select?.name !== undefined) {
16
- return property.select.name;
17
- }
18
- if (
19
- property?.type === "multi_select" &&
20
- property.multi_select !== undefined
21
- ) {
22
- // Use ", " separator so that multi-select values are human-readable (e.g. "A, B, C").
23
- return property.multi_select.map((option) => option.name).join(", ");
24
- }
25
- if (property?.type === "number" && property.number !== null) {
26
- return String(property.number);
27
- }
28
- if (property?.type === "url") {
29
- return property.url ?? undefined;
30
- }
31
- if (property?.type === "email") {
32
- return property.email ?? undefined;
33
- }
34
- if (property?.type === "phone_number") {
35
- return property.phone_number ?? undefined;
36
- }
37
- if (property?.type === "date" && property.date !== null) {
38
- return property.date.start;
39
- }
40
- if (property?.type === "unique_id" && property.unique_id.number !== null) {
41
- return property.unique_id.prefix
42
- ? `${property.unique_id.prefix}-${property.unique_id.number}`
43
- : String(property.unique_id.number);
44
- }
45
- return undefined;
46
- };
47
-
48
- /**
49
- * Returns the multi-select options array from a multi_select property,
50
- * or an empty array if the property is not a multi_select or is undefined.
51
- *
52
- * @example
53
- * ```ts
54
- * const tags = getMultiSelect(entry.data.properties.Tags);
55
- * // Array<{ id: string; name: string; color: string }>
56
- * tags.forEach(t => console.log(t.name));
57
- * ```
58
- */
59
- export const getMultiSelect = (
60
- property: PropertyPageObjectResponseType | undefined,
61
- ): { id: string; name: string; color: string }[] => {
62
- if (property?.type === "multi_select") {
63
- return property.multi_select;
64
- }
65
- return [];
66
- };
67
-
68
- /**
69
- * Returns true if a multi_select property contains a tag with the given name.
70
- * Returns false if the property is not a multi_select or is undefined.
71
- *
72
- * @example
73
- * ```ts
74
- * if (hasTag(entry.data.properties.Tags, "pinned")) {
75
- * // show pinned badge
76
- * }
77
- * ```
78
- */
79
- export const hasTag = (
80
- property: PropertyPageObjectResponseType | undefined,
81
- tagName: string,
82
- ): boolean => {
83
- if (property?.type !== "multi_select") return false;
84
- return property.multi_select.some((t) => t.name === tagName);
85
- };
86
-
87
- /**
88
- * Builds a `linkToPages` map from a collection of entries so that
89
- * `NotionMarkdownRenderer` can resolve inter-page Notion links.
90
- *
91
- * @param entries - Array of content collection entries (from `getCollection`)
92
- * @param options - Accessor functions that return the URL and title for each entry
93
- *
94
- * @example
95
- * ```ts
96
- * import { getCollection } from "astro:content";
97
- * import { buildLinkToPages, getPlainText } from "notro";
98
- *
99
- * const posts = await getCollection("posts");
100
- * const linkToPages = buildLinkToPages(posts, {
101
- * url: (e) => `blog/${getPlainText(e.data.properties.Slug) || e.id}/`,
102
- * title: (e) => getPlainText(e.data.properties.Name) ?? e.id,
103
- * });
104
- * ```
105
- */
106
- export function buildLinkToPages<T extends { id: string; data: Record<string, unknown> }>(
107
- entries: T[],
108
- options: {
109
- url: (entry: T) => string;
110
- title: (entry: T) => string;
111
- },
112
- ): LinkToPages {
113
- const result: LinkToPages = {};
114
- for (const entry of entries) {
115
- if (entry.id in result) {
116
- // Warn when two entries share the same Notion page ID; the later entry wins.
117
- console.warn(
118
- `[notro] buildLinkToPages: duplicate entry id "${entry.id}" — the later entry will overwrite the earlier one.`,
119
- );
120
- }
121
- result[entry.id] = {
122
- url: options.url(entry),
123
- title: options.title(entry),
124
- };
125
- }
126
- return result;
127
- }
1
+ import type { PropertyPageObjectResponseType } from "../loader/schema.ts";
2
+ import type { LinkToPages } from "../types.ts";
3
+
4
+ export const getPlainText = (
5
+ property: PropertyPageObjectResponseType,
6
+ ): string | undefined => {
7
+ if (property?.type === "rich_text" && property.rich_text.length > 0) {
8
+ // rich_text arrays represent adjacent text spans; direct concatenation (no separator) is correct per Notion spec.
9
+ return property.rich_text.map((t) => t.plain_text).join("");
10
+ }
11
+ if (property?.type === "title" && property.title.length > 0) {
12
+ // title arrays also represent adjacent text spans; direct concatenation is correct.
13
+ return property.title.map((t) => t.plain_text).join("");
14
+ }
15
+ if (property?.type === "select" && property.select?.name !== undefined) {
16
+ return property.select.name;
17
+ }
18
+ if (
19
+ property?.type === "multi_select" &&
20
+ property.multi_select !== undefined
21
+ ) {
22
+ // Use ", " separator so that multi-select values are human-readable (e.g. "A, B, C").
23
+ return property.multi_select.map((option) => option.name).join(", ");
24
+ }
25
+ if (property?.type === "number" && property.number !== null) {
26
+ return String(property.number);
27
+ }
28
+ if (property?.type === "url") {
29
+ return property.url ?? undefined;
30
+ }
31
+ if (property?.type === "email") {
32
+ return property.email ?? undefined;
33
+ }
34
+ if (property?.type === "phone_number") {
35
+ return property.phone_number ?? undefined;
36
+ }
37
+ if (property?.type === "date" && property.date !== null) {
38
+ return property.date.start;
39
+ }
40
+ if (property?.type === "unique_id" && property.unique_id.number !== null) {
41
+ return property.unique_id.prefix
42
+ ? `${property.unique_id.prefix}-${property.unique_id.number}`
43
+ : String(property.unique_id.number);
44
+ }
45
+ return undefined;
46
+ };
47
+
48
+ /**
49
+ * Returns the multi-select options array from a multi_select property,
50
+ * or an empty array if the property is not a multi_select or is undefined.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const tags = getMultiSelect(entry.data.properties.Tags);
55
+ * // Array<{ id: string; name: string; color: string }>
56
+ * tags.forEach(t => console.log(t.name));
57
+ * ```
58
+ */
59
+ export const getMultiSelect = (
60
+ property: PropertyPageObjectResponseType | undefined,
61
+ ): { id: string; name: string; color: string }[] => {
62
+ if (property?.type === "multi_select") {
63
+ return property.multi_select;
64
+ }
65
+ return [];
66
+ };
67
+
68
+ /**
69
+ * Returns true if a multi_select property contains a tag with the given name.
70
+ * Returns false if the property is not a multi_select or is undefined.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * if (hasTag(entry.data.properties.Tags, "pinned")) {
75
+ * // show pinned badge
76
+ * }
77
+ * ```
78
+ */
79
+ export const hasTag = (
80
+ property: PropertyPageObjectResponseType | undefined,
81
+ tagName: string,
82
+ ): boolean => {
83
+ if (property?.type !== "multi_select") return false;
84
+ return property.multi_select.some((t) => t.name === tagName);
85
+ };
86
+
87
+ /**
88
+ * Builds a `linkToPages` map from a collection of entries so that
89
+ * `NotionMarkdownRenderer` can resolve inter-page Notion links.
90
+ *
91
+ * @param entries - Array of content collection entries (from `getCollection`)
92
+ * @param options - Accessor functions that return the URL and title for each entry
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { getCollection } from "astro:content";
97
+ * import { buildLinkToPages, getPlainText } from "notro";
98
+ *
99
+ * const posts = await getCollection("posts");
100
+ * const linkToPages = buildLinkToPages(posts, {
101
+ * url: (e) => `blog/${getPlainText(e.data.properties.Slug) || e.id}/`,
102
+ * title: (e) => getPlainText(e.data.properties.Name) ?? e.id,
103
+ * });
104
+ * ```
105
+ */
106
+ export function buildLinkToPages<T extends { id: string; data: Record<string, unknown> }>(
107
+ entries: T[],
108
+ options: {
109
+ url: (entry: T) => string;
110
+ title: (entry: T) => string;
111
+ },
112
+ ): LinkToPages {
113
+ const result: LinkToPages = {};
114
+ for (const entry of entries) {
115
+ if (entry.id in result) {
116
+ // Warn when two entries share the same Notion page ID; the later entry wins.
117
+ console.warn(
118
+ `[notro] buildLinkToPages: duplicate entry id "${entry.id}" — the later entry will overwrite the earlier one.`,
119
+ );
120
+ }
121
+ result[entry.id] = {
122
+ url: options.url(entry),
123
+ title: options.title(entry),
124
+ };
125
+ }
126
+ return result;
127
+ }
@@ -1,35 +1,35 @@
1
- /**
2
- * Module-level configuration store for notro's MDX plugin pipeline.
3
- *
4
- * The notro() Astro integration stores the user-provided remark/rehype plugins
5
- * here during astro:config:setup. buildMdxPlugins() reads them at render time
6
- * so that both the runtime Notion path (compileMdxCached) and the static .mdx
7
- * path (@astrojs/mdx) use the same plugin configuration.
8
- *
9
- * NOTE: We use globalThis instead of module-level variables so the state
10
- * persists across Vite module instances. Astro's integration hooks run in a
11
- * plain Node.js module context; at build/prerender time, Vite creates new
12
- * module instances for the same files. globalThis is the same object in both
13
- * contexts within the same Node.js process, so storing plugins there bridges
14
- * the two contexts without requiring a virtual module or serialisation.
15
- */
16
- import type { PluggableList } from 'unified';
17
-
18
- declare global {
19
- // eslint-disable-next-line no-var
20
- var __notro_remarkPlugins: PluggableList | undefined;
21
- // eslint-disable-next-line no-var
22
- var __notro_rehypePlugins: PluggableList | undefined;
23
- }
24
-
25
- export function setNotroPlugins(remarkPlugins: PluggableList, rehypePlugins: PluggableList): void {
26
- globalThis.__notro_remarkPlugins = remarkPlugins;
27
- globalThis.__notro_rehypePlugins = rehypePlugins;
28
- }
29
-
30
- export function getNotroPlugins(): { remarkPlugins: PluggableList; rehypePlugins: PluggableList } {
31
- return {
32
- remarkPlugins: globalThis.__notro_remarkPlugins ?? [],
33
- rehypePlugins: globalThis.__notro_rehypePlugins ?? [],
34
- };
35
- }
1
+ /**
2
+ * Module-level configuration store for notro's MDX plugin pipeline.
3
+ *
4
+ * The notro() Astro integration stores the user-provided remark/rehype plugins
5
+ * here during astro:config:setup. buildMdxPlugins() reads them at render time
6
+ * so that both the runtime Notion path (compileMdxCached) and the static .mdx
7
+ * path (@astrojs/mdx) use the same plugin configuration.
8
+ *
9
+ * NOTE: We use globalThis instead of module-level variables so the state
10
+ * persists across Vite module instances. Astro's integration hooks run in a
11
+ * plain Node.js module context; at build/prerender time, Vite creates new
12
+ * module instances for the same files. globalThis is the same object in both
13
+ * contexts within the same Node.js process, so storing plugins there bridges
14
+ * the two contexts without requiring a virtual module or serialisation.
15
+ */
16
+ import type { PluggableList } from 'unified';
17
+
18
+ declare global {
19
+ // eslint-disable-next-line no-var
20
+ var __notro_remarkPlugins: PluggableList | undefined;
21
+ // eslint-disable-next-line no-var
22
+ var __notro_rehypePlugins: PluggableList | undefined;
23
+ }
24
+
25
+ export function setNotroPlugins(remarkPlugins: PluggableList, rehypePlugins: PluggableList): void {
26
+ globalThis.__notro_remarkPlugins = remarkPlugins;
27
+ globalThis.__notro_rehypePlugins = rehypePlugins;
28
+ }
29
+
30
+ export function getNotroPlugins(): { remarkPlugins: PluggableList; rehypePlugins: PluggableList } {
31
+ return {
32
+ remarkPlugins: globalThis.__notro_remarkPlugins ?? [],
33
+ rehypePlugins: globalThis.__notro_rehypePlugins ?? [],
34
+ };
35
+ }
package/utils.ts CHANGED
@@ -1,11 +1,11 @@
1
- /**
2
- * Pure TypeScript utilities — safe to import anywhere, including astro.config.mjs.
3
- *
4
- * Use this entry point (`notro/utils`) when you need notro helpers in contexts
5
- * where Astro components cannot be loaded (config files, Node scripts, etc.).
6
- *
7
- * For Astro components and the Content Loader, use the main `notro` entry instead.
8
- */
9
- export { normalizeNotionPresignedUrl, markdownHasPresignedUrls } from './src/utils/notion-url.ts';
10
- export { getPlainText, getMultiSelect, hasTag, buildLinkToPages } from './src/utils/notion.ts';
11
- export type { LinkToPages } from './src/types.ts';
1
+ /**
2
+ * Pure TypeScript utilities — safe to import anywhere, including astro.config.mjs.
3
+ *
4
+ * Use this entry point (`notro/utils`) when you need notro helpers in contexts
5
+ * where Astro components cannot be loaded (config files, Node scripts, etc.).
6
+ *
7
+ * For Astro components and the Content Loader, use the main `notro` entry instead.
8
+ */
9
+ export { normalizeNotionPresignedUrl, markdownHasPresignedUrls } from './src/utils/notion-url.ts';
10
+ export { getPlainText, getMultiSelect, hasTag, buildLinkToPages } from './src/utils/notion.ts';
11
+ export type { LinkToPages } from './src/types.ts';