notro-loader 0.0.1 → 0.0.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/image-service.ts CHANGED
@@ -1,45 +1,45 @@
1
- import sharpImageService from "astro/assets/services/sharp";
2
- import { normalizeNotionPresignedUrl } from "./src/utils/notion-url.ts";
3
-
4
- /**
5
- * Astro image service config for astro.config.mjs.
6
- *
7
- * Notion images are served from pre-signed S3 URLs whose query parameters
8
- * (X-Amz-*) expire after ~1 hour. Astro derives the image cache key from
9
- * the full URL, so a fresh URL on each build causes a cache miss and forces
10
- * re-optimization every time. This service strips those expiring parameters
11
- * before the cache key is computed, so the optimized output is reused across
12
- * builds as long as the underlying file is unchanged.
13
- *
14
- * @example
15
- * // astro.config.mjs
16
- * import { notionImageService } from "notro-loader/image-service";
17
- * export default defineConfig({
18
- * image: { service: notionImageService },
19
- * });
20
- */
21
- export const notionImageService = {
22
- entrypoint: "notro-loader/image-service",
23
- config: {},
24
- };
25
-
26
- // Runtime image service — called by Astro during the build.
27
- // Must be the default export of the module named in `entrypoint` above.
28
- export default {
29
- ...sharpImageService,
30
- getURL(
31
- options: Parameters<typeof sharpImageService.getURL>[0],
32
- serviceConfig: Parameters<typeof sharpImageService.getURL>[1],
33
- ): ReturnType<typeof sharpImageService.getURL> {
34
- return sharpImageService.getURL(
35
- {
36
- ...options,
37
- src:
38
- typeof options.src === "string"
39
- ? normalizeNotionPresignedUrl(options.src)
40
- : options.src,
41
- },
42
- serviceConfig,
43
- );
44
- },
45
- };
1
+ import sharpImageService from "astro/assets/services/sharp";
2
+ import { normalizeNotionPresignedUrl } from "./src/utils/notion-url.ts";
3
+
4
+ /**
5
+ * Astro image service config for astro.config.mjs.
6
+ *
7
+ * Notion images are served from pre-signed S3 URLs whose query parameters
8
+ * (X-Amz-*) expire after ~1 hour. Astro derives the image cache key from
9
+ * the full URL, so a fresh URL on each build causes a cache miss and forces
10
+ * re-optimization every time. This service strips those expiring parameters
11
+ * before the cache key is computed, so the optimized output is reused across
12
+ * builds as long as the underlying file is unchanged.
13
+ *
14
+ * @example
15
+ * // astro.config.mjs
16
+ * import { notionImageService } from "notro-loader/image-service";
17
+ * export default defineConfig({
18
+ * image: { service: notionImageService },
19
+ * });
20
+ */
21
+ export const notionImageService = {
22
+ entrypoint: "notro-loader/image-service",
23
+ config: {},
24
+ };
25
+
26
+ // Runtime image service — called by Astro during the build.
27
+ // Must be the default export of the module named in `entrypoint` above.
28
+ export default {
29
+ ...sharpImageService,
30
+ getURL(
31
+ options: Parameters<typeof sharpImageService.getURL>[0],
32
+ serviceConfig: Parameters<typeof sharpImageService.getURL>[1],
33
+ ): ReturnType<typeof sharpImageService.getURL> {
34
+ return sharpImageService.getURL(
35
+ {
36
+ ...options,
37
+ src:
38
+ typeof options.src === "string"
39
+ ? normalizeNotionPresignedUrl(options.src)
40
+ : options.src,
41
+ },
42
+ serviceConfig,
43
+ );
44
+ },
45
+ };
package/index.ts CHANGED
@@ -1,21 +1,21 @@
1
- export { default as NotroContent } from "./src/components/NotroContent.astro";
2
- export type { LinkToPages } from "./src/types.ts";
3
-
4
- export * from "./src/utils/notion";
5
- export { normalizeNotionPresignedUrl, markdownHasPresignedUrls } from "./src/utils/notion-url.ts";
6
-
7
- // Low-level MDX compile API — for use in custom .astro renderers (e.g. notro-ui).
8
- export { compileMdxCached } from "./src/utils/compile-mdx.ts";
9
-
10
- // Astro JSX component factory — wrap any HTML tag with optional default classes.
11
- export { makeHtmlElement } from "./src/utils/HtmlElements.ts";
12
-
13
- // Default headless component map (all Notion block types → semantic HTML, no Tailwind).
14
- export { defaultComponents } from "./src/utils/default-components.ts";
15
-
16
- // notro() integration options type — mirrors @astrojs/mdx interface.
17
- export type { NotroOptions } from "./src/integration.ts";
18
-
19
- export * from "./src/loader/loader";
20
- export * from "./src/loader/live-loader";
21
- export * from "./src/loader/schema";
1
+ export { default as NotroContent } from "./src/components/NotroContent.astro";
2
+ export type { LinkToPages } from "./src/types.ts";
3
+
4
+ export * from "./src/utils/notion";
5
+ export { normalizeNotionPresignedUrl, markdownHasPresignedUrls } from "./src/utils/notion-url.ts";
6
+
7
+ // Low-level MDX compile API — for use in custom .astro renderers (e.g. notro-ui).
8
+ export { compileMdxCached } from "./src/utils/compile-mdx.ts";
9
+
10
+ // Astro JSX component factory — wrap any HTML tag with optional default classes.
11
+ export { makeHtmlElement } from "./src/utils/HtmlElements.ts";
12
+
13
+ // Default headless component map (all Notion block types → semantic HTML, no Tailwind).
14
+ export { defaultComponents } from "./src/utils/default-components.ts";
15
+
16
+ // notro() integration options type — mirrors @astrojs/mdx interface.
17
+ export type { NotroOptions } from "./src/integration.ts";
18
+
19
+ export * from "./src/loader/loader";
20
+ export * from "./src/loader/live-loader";
21
+ export * from "./src/loader/schema";
package/integration.ts CHANGED
@@ -1,13 +1,13 @@
1
- /**
2
- * Astro integration entry point for notro.
3
- *
4
- * Safe to import in `astro.config.mjs` — no Astro component dependencies.
5
- *
6
- * @example
7
- * ```js
8
- * // astro.config.mjs
9
- * import { notro } from 'notro/integration';
10
- * export default defineConfig({ integrations: [notro()] });
11
- * ```
12
- */
13
- export { notro } from './src/integration.ts';
1
+ /**
2
+ * Astro integration entry point for notro.
3
+ *
4
+ * Safe to import in `astro.config.mjs` — no Astro component dependencies.
5
+ *
6
+ * @example
7
+ * ```js
8
+ * // astro.config.mjs
9
+ * import { notro } from 'notro/integration';
10
+ * export default defineConfig({ integrations: [notro()] });
11
+ * ```
12
+ */
13
+ export { notro } from './src/integration.ts';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "notro-loader",
3
3
  "description": "A Content loader for Astro that uses the Notion Public API",
4
- "version": "0.0.1",
4
+ "version": "0.0.3",
5
5
  "type": "module",
6
6
  "author": "mosugi <mosugeek@gmail.com>",
7
7
  "license": "MIT",
@@ -42,7 +42,7 @@
42
42
  "src/utils"
43
43
  ],
44
44
  "engines": {
45
- "node": ">=22.0.0"
45
+ "node": ">=24.0.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "astro": ">=6.0.0",
@@ -54,7 +54,8 @@
54
54
  "vitest": "^4.1.0"
55
55
  },
56
56
  "publishConfig": {
57
- "access": "public"
57
+ "access": "public",
58
+ "provenance": true
58
59
  },
59
60
  "dependencies": {
60
61
  "@astrojs/mdx": "^5.0.0",
@@ -64,7 +65,7 @@
64
65
  "rehype-raw": "^7.0.0",
65
66
  "rehype-slug": "^6.0.0",
66
67
  "unist-util-visit": "^5.0.0",
67
- "remark-nfm": "0.0.1"
68
+ "remark-notro": "0.0.3"
68
69
  },
69
70
  "optionalDependencies": {
70
71
  "@shikijs/rehype": "^4.0.0"
@@ -1,37 +1,37 @@
1
- ---
2
- /**
3
- * Headless Notion markdown renderer.
4
- *
5
- * Works standalone — no notro-ui required. Outputs plain semantic HTML
6
- * with no Tailwind classes or .astro component dependencies.
7
- *
8
- * Usage (headless, no styling):
9
- * <NotroContent markdown={markdown} />
10
- *
11
- * Usage (with notro-ui styled components):
12
- * import { notroComponents } from '../components/notro';
13
- * <NotroContent markdown={markdown} components={notroComponents} />
14
- *
15
- * Override individual components:
16
- * <NotroContent markdown={markdown} components={{ ...notroComponents, callout: MyCallout }} />
17
- */
18
- import { compileMdxCached } from "../utils/compile-mdx.ts";
19
- import { defaultComponents } from "../utils/default-components.ts";
20
- import type { LinkToPages } from "../types.ts";
21
-
22
- interface Props {
23
- markdown: string;
24
- linkToPages?: LinkToPages;
25
- components?: Record<string, unknown>;
26
- class?: string;
27
- }
28
-
29
- const { markdown, linkToPages, components: overrides, class: className } = Astro.props;
30
-
31
- const Content = await compileMdxCached(markdown, { linkToPages });
32
- const components = { ...defaultComponents, ...overrides };
33
- ---
34
-
35
- <div class:list={[className]}>
36
- <Content components={components} />
37
- </div>
1
+ ---
2
+ /**
3
+ * Headless Notion markdown renderer.
4
+ *
5
+ * Works standalone — no notro-ui required. Outputs plain semantic HTML
6
+ * with no Tailwind classes or .astro component dependencies.
7
+ *
8
+ * Usage (headless, no styling):
9
+ * <NotroContent markdown={markdown} />
10
+ *
11
+ * Usage (with notro-ui styled components):
12
+ * import { notroComponents } from '../components/notro';
13
+ * <NotroContent markdown={markdown} components={notroComponents} />
14
+ *
15
+ * Override individual components:
16
+ * <NotroContent markdown={markdown} components={{ ...notroComponents, callout: MyCallout }} />
17
+ */
18
+ import { compileMdxCached } from "../utils/compile-mdx.ts";
19
+ import { defaultComponents } from "../utils/default-components.ts";
20
+ import type { LinkToPages } from "../types.ts";
21
+
22
+ interface Props {
23
+ markdown: string;
24
+ linkToPages?: LinkToPages;
25
+ components?: Record<string, unknown>;
26
+ class?: string;
27
+ }
28
+
29
+ const { markdown, linkToPages, components: overrides, class: className } = Astro.props;
30
+
31
+ const Content = await compileMdxCached(markdown, { linkToPages });
32
+ const components = { ...defaultComponents, ...overrides };
33
+ ---
34
+
35
+ <div class:list={[className]}>
36
+ <Content components={components} />
37
+ </div>
@@ -1,181 +1,181 @@
1
- import type { LiveLoader } from "astro/loaders";
2
- import {
3
- Client,
4
- isFullPage,
5
- iteratePaginatedAPI,
6
- APIErrorCode,
7
- APIResponseError,
8
- } from "@notionhq/client";
9
- import type { QueryDataSourceParameters } from "@notionhq/client";
10
- import { type PageWithMarkdownType } from "./schema.ts";
11
-
12
- type LiveLoaderOptions = {
13
- queryParameters: QueryDataSourceParameters;
14
- // Derive from Client constructor to avoid importing from internal paths
15
- clientOptions: ConstructorParameters<typeof Client>[0];
16
- };
17
-
18
- // Error codes that are safe to retry (rate limit, server errors).
19
- const RETRYABLE_API_ERROR_CODES: ReadonlySet<string> = new Set([
20
- APIErrorCode.RateLimited,
21
- APIErrorCode.InternalServerError,
22
- APIErrorCode.ServiceUnavailable,
23
- ]);
24
-
25
- // Retry delays in milliseconds for each attempt (exponential backoff: 1s, 2s, 4s).
26
- const RETRY_DELAYS_MS = [1000, 2000, 4000];
27
-
28
- /**
29
- * Generic retry wrapper for Notion API calls.
30
- * Retries on 429/500/503 up to 3 times with exponential backoff.
31
- * Other errors are re-thrown immediately.
32
- */
33
- async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
34
- let lastError: unknown;
35
-
36
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
37
- try {
38
- return await fn();
39
- } catch (error) {
40
- lastError = error;
41
-
42
- const isRetryable =
43
- error instanceof APIResponseError &&
44
- RETRYABLE_API_ERROR_CODES.has(error.code);
45
-
46
- if (!isRetryable || attempt === RETRY_DELAYS_MS.length) {
47
- throw error;
48
- }
49
-
50
- const delayMs = RETRY_DELAYS_MS[attempt];
51
- await new Promise((resolve) => setTimeout(resolve, delayMs));
52
- }
53
- }
54
-
55
- throw lastError;
56
- }
57
-
58
- /**
59
- * Fetches the markdown for a page and maps it onto the page object.
60
- * Returns null if the page could not be retrieved.
61
- */
62
- async function fetchPageWithMarkdown(
63
- client: Client,
64
- page: Awaited<ReturnType<typeof client.pages.retrieve>>,
65
- ): Promise<PageWithMarkdownType | null> {
66
- if (!isFullPage(page)) {
67
- return null;
68
- }
69
-
70
- let markdownResponse: Awaited<ReturnType<typeof client.pages.retrieveMarkdown>>;
71
- try {
72
- markdownResponse = await withRetry(() =>
73
- client.pages.retrieveMarkdown({ page_id: page.id }),
74
- );
75
- } catch {
76
- return null;
77
- }
78
-
79
- return {
80
- parent: page.parent,
81
- properties: page.properties,
82
- icon: page.icon,
83
- cover: page.cover,
84
- created_by: page.created_by,
85
- last_edited_by: page.last_edited_by,
86
- object: page.object,
87
- id: page.id,
88
- created_time: page.created_time,
89
- last_edited_time: page.last_edited_time,
90
- archived: page.archived ?? false,
91
- in_trash: page.in_trash ?? false,
92
- url: page.url,
93
- public_url: page.public_url,
94
- markdown: markdownResponse.markdown,
95
- truncated: markdownResponse.truncated,
96
- };
97
- }
98
-
99
- /**
100
- * Live content loader for Notion pages.
101
- *
102
- * Unlike the build-time `loader`, this loader fetches content on every request.
103
- * Use it with `defineLiveCollection()` in `src/live.config.ts` for SSR pages
104
- * that need up-to-date Notion content without rebuilding.
105
- *
106
- * @example
107
- * ```ts
108
- * // src/live.config.ts
109
- * import { defineLiveCollection } from "astro:content";
110
- * import { liveLoader } from "notro";
111
- *
112
- * export const posts = defineLiveCollection({
113
- * loader: liveLoader({
114
- * queryParameters: { data_source_id: import.meta.env.NOTION_DATASOURCE_ID },
115
- * clientOptions: { auth: import.meta.env.NOTION_TOKEN },
116
- * }),
117
- * });
118
- * ```
119
- */
120
- export function liveLoader({
121
- queryParameters,
122
- clientOptions,
123
- }: LiveLoaderOptions): LiveLoader<PageWithMarkdownType> {
124
- const client = new Client({ notionVersion: "2026-03-11", ...clientOptions });
125
-
126
- return {
127
- name: "notro-live-loader",
128
-
129
- async loadCollection() {
130
- let pages: Awaited<ReturnType<typeof client.pages.retrieve>>[];
131
- try {
132
- const results = await withRetry(() =>
133
- Array.fromAsync(
134
- iteratePaginatedAPI(client.dataSources.query, queryParameters),
135
- ),
136
- );
137
- pages = results.filter((p) => isFullPage(p));
138
- } catch (error) {
139
- return { error: error instanceof Error ? error : new Error(String(error)) };
140
- }
141
-
142
- const entries = (
143
- await Promise.all(pages.map((page) => fetchPageWithMarkdown(client, page)))
144
- ).filter((entry): entry is PageWithMarkdownType => entry !== null);
145
-
146
- return {
147
- entries: entries.map((data) => ({
148
- id: data.id,
149
- data,
150
- cacheHint: {
151
- lastModified: new Date(data.last_edited_time),
152
- tags: [data.id],
153
- },
154
- })),
155
- };
156
- },
157
-
158
- async loadEntry({ filter: { id } }) {
159
- let page: Awaited<ReturnType<typeof client.pages.retrieve>>;
160
- try {
161
- page = await withRetry(() => client.pages.retrieve({ page_id: id }));
162
- } catch (error) {
163
- return { error: error instanceof Error ? error : new Error(String(error)) };
164
- }
165
-
166
- const data = await fetchPageWithMarkdown(client, page);
167
- if (!data) {
168
- return undefined;
169
- }
170
-
171
- return {
172
- id: data.id,
173
- data,
174
- cacheHint: {
175
- lastModified: new Date(data.last_edited_time),
176
- tags: [data.id],
177
- },
178
- };
179
- },
180
- };
181
- }
1
+ import type { LiveLoader } from "astro/loaders";
2
+ import {
3
+ Client,
4
+ isFullPage,
5
+ iteratePaginatedAPI,
6
+ APIErrorCode,
7
+ APIResponseError,
8
+ } from "@notionhq/client";
9
+ import type { QueryDataSourceParameters } from "@notionhq/client";
10
+ import { type PageWithMarkdownType } from "./schema.ts";
11
+
12
+ type LiveLoaderOptions = {
13
+ queryParameters: QueryDataSourceParameters;
14
+ // Derive from Client constructor to avoid importing from internal paths
15
+ clientOptions: ConstructorParameters<typeof Client>[0];
16
+ };
17
+
18
+ // Error codes that are safe to retry (rate limit, server errors).
19
+ const RETRYABLE_API_ERROR_CODES: ReadonlySet<string> = new Set([
20
+ APIErrorCode.RateLimited,
21
+ APIErrorCode.InternalServerError,
22
+ APIErrorCode.ServiceUnavailable,
23
+ ]);
24
+
25
+ // Retry delays in milliseconds for each attempt (exponential backoff: 1s, 2s, 4s).
26
+ const RETRY_DELAYS_MS = [1000, 2000, 4000];
27
+
28
+ /**
29
+ * Generic retry wrapper for Notion API calls.
30
+ * Retries on 429/500/503 up to 3 times with exponential backoff.
31
+ * Other errors are re-thrown immediately.
32
+ */
33
+ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
34
+ let lastError: unknown;
35
+
36
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
37
+ try {
38
+ return await fn();
39
+ } catch (error) {
40
+ lastError = error;
41
+
42
+ const isRetryable =
43
+ error instanceof APIResponseError &&
44
+ RETRYABLE_API_ERROR_CODES.has(error.code);
45
+
46
+ if (!isRetryable || attempt === RETRY_DELAYS_MS.length) {
47
+ throw error;
48
+ }
49
+
50
+ const delayMs = RETRY_DELAYS_MS[attempt];
51
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
52
+ }
53
+ }
54
+
55
+ throw lastError;
56
+ }
57
+
58
+ /**
59
+ * Fetches the markdown for a page and maps it onto the page object.
60
+ * Returns null if the page could not be retrieved.
61
+ */
62
+ async function fetchPageWithMarkdown(
63
+ client: Client,
64
+ page: Awaited<ReturnType<typeof client.pages.retrieve>>,
65
+ ): Promise<PageWithMarkdownType | null> {
66
+ if (!isFullPage(page)) {
67
+ return null;
68
+ }
69
+
70
+ let markdownResponse: Awaited<ReturnType<typeof client.pages.retrieveMarkdown>>;
71
+ try {
72
+ markdownResponse = await withRetry(() =>
73
+ client.pages.retrieveMarkdown({ page_id: page.id }),
74
+ );
75
+ } catch {
76
+ return null;
77
+ }
78
+
79
+ return {
80
+ parent: page.parent,
81
+ properties: page.properties,
82
+ icon: page.icon,
83
+ cover: page.cover,
84
+ created_by: page.created_by,
85
+ last_edited_by: page.last_edited_by,
86
+ object: page.object,
87
+ id: page.id,
88
+ created_time: page.created_time,
89
+ last_edited_time: page.last_edited_time,
90
+ archived: page.archived ?? false,
91
+ in_trash: page.in_trash ?? false,
92
+ url: page.url,
93
+ public_url: page.public_url,
94
+ markdown: markdownResponse.markdown,
95
+ truncated: markdownResponse.truncated,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Live content loader for Notion pages.
101
+ *
102
+ * Unlike the build-time `loader`, this loader fetches content on every request.
103
+ * Use it with `defineLiveCollection()` in `src/live.config.ts` for SSR pages
104
+ * that need up-to-date Notion content without rebuilding.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * // src/live.config.ts
109
+ * import { defineLiveCollection } from "astro:content";
110
+ * import { liveLoader } from "notro";
111
+ *
112
+ * export const posts = defineLiveCollection({
113
+ * loader: liveLoader({
114
+ * queryParameters: { data_source_id: import.meta.env.NOTION_DATASOURCE_ID },
115
+ * clientOptions: { auth: import.meta.env.NOTION_TOKEN },
116
+ * }),
117
+ * });
118
+ * ```
119
+ */
120
+ export function liveLoader({
121
+ queryParameters,
122
+ clientOptions,
123
+ }: LiveLoaderOptions): LiveLoader<PageWithMarkdownType> {
124
+ const client = new Client({ notionVersion: "2026-03-11", ...clientOptions });
125
+
126
+ return {
127
+ name: "notro-live-loader",
128
+
129
+ async loadCollection() {
130
+ let pages: Awaited<ReturnType<typeof client.pages.retrieve>>[];
131
+ try {
132
+ const results = await withRetry(() =>
133
+ Array.fromAsync(
134
+ iteratePaginatedAPI(client.dataSources.query, queryParameters),
135
+ ),
136
+ );
137
+ pages = results.filter((p) => isFullPage(p));
138
+ } catch (error) {
139
+ return { error: error instanceof Error ? error : new Error(String(error)) };
140
+ }
141
+
142
+ const entries = (
143
+ await Promise.all(pages.map((page) => fetchPageWithMarkdown(client, page)))
144
+ ).filter((entry): entry is PageWithMarkdownType => entry !== null);
145
+
146
+ return {
147
+ entries: entries.map((data) => ({
148
+ id: data.id,
149
+ data,
150
+ cacheHint: {
151
+ lastModified: new Date(data.last_edited_time),
152
+ tags: [data.id],
153
+ },
154
+ })),
155
+ };
156
+ },
157
+
158
+ async loadEntry({ filter: { id } }) {
159
+ let page: Awaited<ReturnType<typeof client.pages.retrieve>>;
160
+ try {
161
+ page = await withRetry(() => client.pages.retrieve({ page_id: id }));
162
+ } catch (error) {
163
+ return { error: error instanceof Error ? error : new Error(String(error)) };
164
+ }
165
+
166
+ const data = await fetchPageWithMarkdown(client, page);
167
+ if (!data) {
168
+ return undefined;
169
+ }
170
+
171
+ return {
172
+ id: data.id,
173
+ data,
174
+ cacheHint: {
175
+ lastModified: new Date(data.last_edited_time),
176
+ tags: [data.id],
177
+ },
178
+ };
179
+ },
180
+ };
181
+ }