thibaud frere commited on
Commit
a17a3bf
·
1 Parent(s): 1ee6ce7

add notion to mdx converter

Browse files
Files changed (47) hide show
  1. .gitignore +2 -0
  2. app/scripts/notion-to-mdx/.cursorignore +1 -0
  3. app/scripts/notion-to-mdx/.notion-to-md/media/27877f1c-9c9d-804d-9c82-f7b3905578ff_media.json +3 -0
  4. app/scripts/notion-to-mdx/README.md +261 -0
  5. app/scripts/notion-to-mdx/custom-code-renderer.mjs +33 -0
  6. app/scripts/notion-to-mdx/debug-properties.mjs +87 -0
  7. app/scripts/notion-to-mdx/env.example +72 -0
  8. app/scripts/notion-to-mdx/index.mjs +252 -0
  9. app/scripts/notion-to-mdx/input/pages.json +3 -0
  10. app/scripts/notion-to-mdx/mdx-converter.mjs +551 -0
  11. app/scripts/notion-to-mdx/notion-converter.mjs +259 -0
  12. app/scripts/notion-to-mdx/notion-metadata-extractor.mjs +303 -0
  13. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8013-b668-f14bd1ac0ec0.png +3 -0
  14. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8014-834f-d700b623256b.png +3 -0
  15. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-801d-841a-e35011491566.png +3 -0
  16. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8031-ac8d-c5678af1bdd5.png +3 -0
  17. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8048-9b7e-db4fa7485915.png +3 -0
  18. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-804d-bd0a-e0b1c15e504f.png +3 -0
  19. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8075-ae2e-dc24fe9296ca.png +3 -0
  20. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8078-b6da-c7a4c67c8f35.png +3 -0
  21. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808d-9c6d-fae817ac8868.png +3 -0
  22. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808f-b712-c7c608da3fc6.png +3 -0
  23. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80a9-b4d0-f2129716632d.png +3 -0
  24. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80aa-b968-c54c9fe7e5d7.png +3 -0
  25. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b6-be07-e8646502f82a.png +3 -0
  26. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b9-8cfb-f0a6aaaa8760.png +3 -0
  27. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e7-a500-fb79cebde7e3.png +3 -0
  28. app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e9-b729-dbd328930bed.png +3 -0
  29. app/scripts/notion-to-mdx/output/smol-training-guide.md +0 -0
  30. app/scripts/notion-to-mdx/output/smol-training-guide.mdx +0 -0
  31. app/scripts/notion-to-mdx/package-lock.json +0 -0
  32. app/scripts/notion-to-mdx/package.json +0 -0
  33. app/scripts/notion-to-mdx/post-processor.mjs +369 -0
  34. app/scripts/notion-to-mdx/test-access.mjs +39 -0
  35. app/scripts/notion-to-mdx/yarn.lock +1118 -0
  36. app/src/components/Hero.astro +147 -73
  37. app/src/components/HtmlEmbed.astro +8 -3
  38. app/src/components/Sidenote.astro +36 -15
  39. app/src/styles/_layout.css +15 -6
  40. app/src/styles/_variables.css +1 -2
  41. app/src/styles/components/_form.css +9 -4
  42. app/src/styles/components/_table.css +123 -95
  43. tools/duplicated-spaces/README.md +0 -32
  44. tools/duplicated-spaces/duplicated_spaces/__init__.py +0 -5
  45. tools/duplicated-spaces/duplicated_spaces/cli.py +0 -69
  46. tools/duplicated-spaces/duplicated_spaces/finder.py +0 -99
  47. tools/duplicated-spaces/pyproject.toml +0 -23
.gitignore CHANGED
@@ -11,6 +11,8 @@ build/
11
  *.egg
12
  .idea/
13
  .vscode/
 
 
14
  *.swp
15
  .DS_Store
16
  # Node
 
11
  *.egg
12
  .idea/
13
  .vscode/
14
+ .astro/
15
+ .claude/
16
  *.swp
17
  .DS_Store
18
  # Node
app/scripts/notion-to-mdx/.cursorignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
app/scripts/notion-to-mdx/.notion-to-md/media/27877f1c-9c9d-804d-9c82-f7b3905578ff_media.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c282e7bcb40ee2caafda422b3614d996d6023dbb4bbe10f96521348ee151aeb0
3
+ size 36969
app/scripts/notion-to-mdx/README.md ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notion to MDX Toolkit
2
+
3
+ Complete Notion to MDX (Markdown + JSX) conversion optimized for Astro with advanced media handling, interactive components, and seamless integration.
4
+
5
+ ## 🚀 Quick Start
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ npm install
10
+
11
+ # Setup environment variables
12
+ cp env.example .env
13
+ # Edit .env with your Notion token
14
+
15
+ # Complete Notion → MDX conversion with all features
16
+ node index.mjs
17
+
18
+ # For step-by-step debugging
19
+ node notion-converter.mjs # Notion → Markdown
20
+ node mdx-converter.mjs # Markdown → MDX
21
+ ```
22
+
23
+ ## 📁 Structure
24
+
25
+ ```
26
+ notion-to-mdx/
27
+ ├── index.mjs # Complete Notion → MDX pipeline
28
+ ├── notion-converter.mjs # Notion → Markdown with notion-to-md v4
29
+ ├── mdx-converter.mjs # Markdown → MDX with Astro components
30
+ ├── post-processor.mjs # Markdown post-processing
31
+ ├── package.json # Dependencies and scripts
32
+ ├── env.example # Environment variables template
33
+ ├── input/ # Configuration
34
+ │ └── pages.json # Notion pages to convert
35
+ └── output/ # Results
36
+ ├── *.md # Intermediate Markdown
37
+ ├── *.mdx # Final MDX for Astro
38
+ └── media/ # Downloaded media files
39
+ ```
40
+
41
+ ## ✨ Key Features
42
+
43
+ ### 🎯 **Advanced Media Handling**
44
+ - **Local download**: Automatic download of all Notion media (images, files, PDFs)
45
+ - **Path transformation**: Smart path conversion for web accessibility
46
+ - **Figure components**: Automatic conversion to Astro `Figure` components with zoom/download
47
+ - **Media organization**: Structured media storage by page ID
48
+
49
+ ### 🧮 **Interactive Components**
50
+ - **Callouts → Notes**: Notion callouts converted to Astro `Note` components
51
+ - **Enhanced tables**: Tables wrapped in styled containers
52
+ - **Code blocks**: Enhanced with copy functionality
53
+ - **Automatic imports**: Smart component and image import generation
54
+
55
+ ### 🎨 **Smart Formatting**
56
+ - **Link fixing**: Notion internal links converted to relative links
57
+ - **Artifact cleanup**: Removal of Notion-specific formatting artifacts
58
+ - **Frontmatter generation**: Automatic YAML frontmatter from Notion properties
59
+ - **Astro compatibility**: Full compatibility with Astro MDX processing
60
+
61
+ ### 🔧 **Robust Pipeline**
62
+ - **Notion preprocessing**: Advanced page configuration and media strategy
63
+ - **Post-processing**: Markdown cleanup and optimization
64
+ - **MDX conversion**: Final transformation with Astro components
65
+ - **Auto-copy**: Automatic copying to Astro content directory
66
+
67
+ ## 📊 Example Workflow
68
+
69
+ ```bash
70
+ # 1. Configure your Notion pages
71
+ # Edit input/pages.json with your page IDs
72
+
73
+ # 2. Complete automatic conversion
74
+ NOTION_TOKEN=your_token node index.mjs --clean
75
+
76
+ # 3. Generated results
77
+ ls output/
78
+ # → getting-started.md (Intermediate Markdown)
79
+ # → getting-started.mdx (Final MDX for Astro)
80
+ # → media/ (downloaded images and files)
81
+ ```
82
+
83
+ ### 📋 Conversion Result
84
+
85
+ The pipeline generates MDX files optimized for Astro with:
86
+
87
+ ```mdx
88
+ ---
89
+ title: "Getting Started with Notion"
90
+ published: "2024-01-15"
91
+ tableOfContentsAutoCollapse: true
92
+ ---
93
+
94
+ import Figure from '../components/Figure.astro';
95
+ import Note from '../components/Note.astro';
96
+ import gettingStartedImage from './media/getting-started/image1.png';
97
+
98
+ ## Introduction
99
+
100
+ Here is some content with a callout:
101
+
102
+ <Note type="info" title="Important">
103
+ This is a converted Notion callout.
104
+ </Note>
105
+
106
+ And an image:
107
+
108
+ <Figure
109
+ src={gettingStartedImage}
110
+ alt="Getting started screenshot"
111
+ zoomable
112
+ downloadable
113
+ layout="fixed"
114
+ />
115
+ ```
116
+
117
+ ## ⚙️ Required Astro Configuration
118
+
119
+ To use the generated MDX files, ensure your Astro project has the required components:
120
+
121
+ ```astro
122
+ // src/components/Figure.astro
123
+ ---
124
+ export interface Props {
125
+ src: any;
126
+ alt?: string;
127
+ caption?: string;
128
+ zoomable?: boolean;
129
+ downloadable?: boolean;
130
+ layout?: string;
131
+ id?: string;
132
+ }
133
+
134
+ const { src, alt, caption, zoomable, downloadable, layout, id } = Astro.props;
135
+ ---
136
+
137
+ <figure {id} class="figure">
138
+ <img src={src} alt={alt} />
139
+ {caption && <figcaption>{caption}</figcaption>}
140
+ </figure>
141
+ ```
142
+
143
+ ## 🛠️ Prerequisites
144
+
145
+ - **Node.js** with ESM support
146
+ - **Notion Integration**: Set up an integration in your Notion workspace
147
+ - **Notion Token**: Copy the "Internal Integration Token"
148
+ - **Shared Pages**: Share the specific Notion page(s) with your integration
149
+ - **Astro** to use the generated MDX
150
+
151
+ ## 🎯 Technical Architecture
152
+
153
+ ### 4-Stage Pipeline
154
+
155
+ 1. **Notion Preprocessing** (`notion-converter.mjs`)
156
+ - Configuration loading from `pages.json`
157
+ - Notion API client initialization
158
+ - Media download strategy configuration
159
+
160
+ 2. **Notion-to-Markdown** (notion-to-md v4)
161
+ - Page conversion with `NotionConverter`
162
+ - Media downloading with `downloadMediaTo()`
163
+ - File export with `DefaultExporter`
164
+
165
+ 3. **Markdown Post-processing** (`post-processor.mjs`)
166
+ - Notion artifact cleanup
167
+ - Link fixing and optimization
168
+ - Table and code block enhancement
169
+
170
+ 4. **MDX Conversion** (`mdx-converter.mjs`)
171
+ - Component transformation (Figure, Note)
172
+ - Automatic import generation
173
+ - Frontmatter enhancement
174
+ - Astro compatibility optimization
175
+
176
+ ## 📊 Configuration Options
177
+
178
+ ### Pages Configuration (`input/pages.json`)
179
+
180
+ ```json
181
+ {
182
+ "pages": [
183
+ {
184
+ "id": "your-notion-page-id",
185
+ "title": "Page Title",
186
+ "slug": "page-slug"
187
+ }
188
+ ]
189
+ }
190
+ ```
191
+
192
+ ### Environment Variables
193
+
194
+ Copy `env.example` to `.env` and configure:
195
+
196
+ ```bash
197
+ cp env.example .env
198
+ # Edit .env with your actual Notion token
199
+ ```
200
+
201
+ Required variables:
202
+ ```bash
203
+ NOTION_TOKEN=secret_your_notion_integration_token_here
204
+ ```
205
+
206
+ ### Command Line Options
207
+
208
+ ```bash
209
+ # Full workflow
210
+ node index.mjs --clean --token=your_token
211
+
212
+ # Notion to Markdown only
213
+ node index.mjs --notion-only
214
+
215
+ # Markdown to MDX only
216
+ node index.mjs --mdx-only
217
+
218
+ # Custom paths
219
+ node index.mjs --input=my-pages.json --output=converted/
220
+ ```
221
+
222
+ ## 📊 Conversion Statistics
223
+
224
+ For a typical Notion page:
225
+ - **Media files** automatically downloaded and organized
226
+ - **Callouts** converted to interactive Note components
227
+ - **Images** transformed to Figure components with zoom/download
228
+ - **Tables** enhanced with proper styling containers
229
+ - **Code blocks** enhanced with copy functionality
230
+ - **Links** fixed for proper internal navigation
231
+
232
+ ## ✅ Project Status
233
+
234
+ ### 🎉 **Complete Features**
235
+ - ✅ **Notion → MDX Pipeline**: Full end-to-end functional conversion
236
+ - ✅ **Media Management**: Automatic download and path transformation
237
+ - ✅ **Component Integration**: Seamless Astro component integration
238
+ - ✅ **Smart Formatting**: Intelligent cleanup and optimization
239
+ - ✅ **Robustness**: Error handling and graceful degradation
240
+ - ✅ **Flexibility**: Modular pipeline with step-by-step options
241
+
242
+ ### 🚀 **Production Ready**
243
+ The toolkit is now **100% operational** for converting Notion pages to MDX/Astro with all advanced features (media handling, component integration, smart formatting).
244
+
245
+ ## 🔗 Integration with notion-to-md v4
246
+
247
+ This toolkit leverages the powerful [notion-to-md v4](https://notionconvert.com/docs/v4/guides/) library with:
248
+
249
+ - **Advanced Media Strategies**: Download, upload, and direct media handling
250
+ - **Custom Renderers**: Block transformers and annotation transformers
251
+ - **Exporter Plugins**: File, buffer, and stdout output options
252
+ - **Database Support**: Full database property and frontmatter transformation
253
+ - **Page References**: Smart internal link handling
254
+
255
+ ## 📚 Additional Resources
256
+
257
+ - [notion-to-md v4 Documentation](https://notionconvert.com/docs/v4/guides/)
258
+ - [Notion API Documentation](https://developers.notion.com/)
259
+ - [Astro MDX Documentation](https://docs.astro.build/en/guides/integrations-guide/mdx/)
260
+ - [Media Handling Strategies](https://notionconvert.com/blog/mastering-media-handling-in-notion-to-md-v4-download-upload-and-direct-strategies/)
261
+ - [Frontmatter Transformation](https://notionconvert.com/blog/how-to-convert-notion-properties-to-frontmatter-with-notion-to-md-v4/)
app/scripts/notion-to-mdx/custom-code-renderer.mjs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Custom Code Block Renderer for notion-to-md
5
+ * Fixes the issue where code blocks end with "text" instead of proper closing
6
+ */
7
+
8
+ export function createCustomCodeRenderer() {
9
+ return {
10
+ name: 'custom-code-renderer',
11
+ type: 'renderer',
12
+
13
+ /**
14
+ * Custom renderer for code blocks
15
+ * @param {Object} block - Notion code block
16
+ * @returns {string} - Properly formatted markdown code block
17
+ */
18
+ code: (block) => {
19
+ const { language, rich_text } = block.code;
20
+
21
+ // Extract the actual code content from rich_text
22
+ const codeContent = rich_text
23
+ .map(text => text.plain_text)
24
+ .join('');
25
+
26
+ // Determine the language (default to empty string if not specified)
27
+ const lang = language || '';
28
+
29
+ // Return properly formatted markdown code block
30
+ return `\`\`\`${lang}\n${codeContent}\n\`\`\``;
31
+ }
32
+ };
33
+ }
app/scripts/notion-to-mdx/debug-properties.mjs ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv';
4
+ import { Client } from '@notionhq/client';
5
+
6
+ // Load environment variables from .env file
7
+ config();
8
+
9
+ const notion = new Client({
10
+ auth: process.env.NOTION_TOKEN,
11
+ });
12
+
13
+ async function debugPageProperties() {
14
+ const pageId = '27877f1c9c9d804d9c82f7b3905578ff';
15
+
16
+ try {
17
+ console.log('🔍 Debugging page properties...');
18
+ console.log(`📄 Page ID: ${pageId}`);
19
+
20
+ const page = await notion.pages.retrieve({ page_id: pageId });
21
+
22
+ console.log('\n📋 Available properties:');
23
+ console.log('========================');
24
+
25
+ for (const [key, value] of Object.entries(page.properties)) {
26
+ console.log(`\n🔹 ${key}:`);
27
+ console.log(` Type: ${value.type}`);
28
+
29
+ switch (value.type) {
30
+ case 'title':
31
+ console.log(` Value: "${value.title.map(t => t.plain_text).join('')}"`);
32
+ break;
33
+ case 'rich_text':
34
+ console.log(` Value: "${value.rich_text.map(t => t.plain_text).join('')}"`);
35
+ break;
36
+ case 'people':
37
+ console.log(` People: ${value.people.map(p => p.name || p.id).join(', ')}`);
38
+ break;
39
+ case 'select':
40
+ console.log(` Value: ${value.select?.name || 'null'}`);
41
+ break;
42
+ case 'multi_select':
43
+ console.log(` Values: [${value.multi_select.map(s => s.name).join(', ')}]`);
44
+ break;
45
+ case 'date':
46
+ console.log(` Value: ${value.date?.start || 'null'}`);
47
+ break;
48
+ case 'checkbox':
49
+ console.log(` Value: ${value.checkbox}`);
50
+ break;
51
+ case 'url':
52
+ console.log(` Value: ${value.url || 'null'}`);
53
+ break;
54
+ case 'email':
55
+ console.log(` Value: ${value.email || 'null'}`);
56
+ break;
57
+ case 'phone_number':
58
+ console.log(` Value: ${value.phone_number || 'null'}`);
59
+ break;
60
+ case 'number':
61
+ console.log(` Value: ${value.number || 'null'}`);
62
+ break;
63
+ case 'created_time':
64
+ console.log(` Value: ${value.created_time}`);
65
+ break;
66
+ case 'created_by':
67
+ console.log(` Value: ${value.created_by?.id || 'null'}`);
68
+ break;
69
+ case 'last_edited_time':
70
+ console.log(` Value: ${value.last_edited_time}`);
71
+ break;
72
+ case 'last_edited_by':
73
+ console.log(` Value: ${value.last_edited_by?.id || 'null'}`);
74
+ break;
75
+ default:
76
+ console.log(` Value: ${JSON.stringify(value, null, 2)}`);
77
+ }
78
+ }
79
+
80
+ console.log('\n✅ Properties debug completed!');
81
+
82
+ } catch (error) {
83
+ console.error('❌ Error:', error.message);
84
+ }
85
+ }
86
+
87
+ debugPageProperties();
app/scripts/notion-to-mdx/env.example ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notion to MDX Toolkit - Environment Variables
2
+ # Copy this file to .env and fill in your actual values
3
+
4
+ # ===========================================
5
+ # NOTION API CONFIGURATION
6
+ # ===========================================
7
+
8
+ # Your Notion Integration Token
9
+ # Get this from: https://www.notion.so/my-integrations
10
+ # Format: secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
11
+ NOTION_TOKEN=secret_your_notion_integration_token_here
12
+
13
+ # ===========================================
14
+ # OPTIONAL CONFIGURATION
15
+ # ===========================================
16
+
17
+ # Custom output directory (optional)
18
+ # Default: ./output
19
+ # OUTPUT_DIR=./my-custom-output
20
+
21
+ # Custom input configuration file (optional)
22
+ # Default: ./input/pages.json
23
+ # INPUT_CONFIG=./my-pages.json
24
+
25
+ # ===========================================
26
+ # USAGE EXAMPLES
27
+ # ===========================================
28
+
29
+ # 1. Basic usage:
30
+ # NOTION_TOKEN=secret_xxx node index.mjs
31
+
32
+ # 2. With custom paths:
33
+ # NOTION_TOKEN=secret_xxx OUTPUT_DIR=./converted node index.mjs
34
+
35
+ # 3. Test access to a page:
36
+ # NOTION_TOKEN=secret_xxx node test-access.mjs
37
+
38
+ # ===========================================
39
+ # SETUP INSTRUCTIONS
40
+ # ===========================================
41
+
42
+ # 1. Create a Notion integration:
43
+ # - Go to https://www.notion.so/my-integrations
44
+ # - Click "New integration"
45
+ # - Give it a name (e.g., "MDX Converter")
46
+ # - Select your workspace
47
+ # - Click "Submit"
48
+ # - Copy the "Internal Integration Token"
49
+
50
+ # 2. Share your Notion pages with the integration:
51
+ # - Open your Notion page
52
+ # - Click "Share" (top right)
53
+ # - Click "Invite"
54
+ # - Search for your integration name
55
+ # - Select it and give "Can read content" permission
56
+ # - Click "Invite"
57
+
58
+ # 3. Configure your pages in input/pages.json:
59
+ # {
60
+ # "pages": [
61
+ # {
62
+ # "id": "your-notion-page-id",
63
+ # "title": "Page Title",
64
+ # "slug": "page-slug"
65
+ # }
66
+ # ]
67
+ # }
68
+
69
+ # 4. Run the conversion:
70
+ # cp env.example .env
71
+ # # Edit .env with your actual token
72
+ # node index.mjs --clean
app/scripts/notion-to-mdx/index.mjs ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv';
4
+ import { join, dirname, basename } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
7
+ import { convertNotionToMarkdown } from './notion-converter.mjs';
8
+ import { convertToMdx } from './mdx-converter.mjs';
9
+
10
+ // Load environment variables from .env file
11
+ config();
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+
16
+ // Default configuration
17
+ const DEFAULT_INPUT = join(__dirname, 'input', 'pages.json');
18
+ const DEFAULT_OUTPUT = join(__dirname, 'output');
19
+ const ASTRO_CONTENT_PATH = join(__dirname, '..', '..', 'src', 'content', 'article.mdx');
20
+ const ASTRO_ASSETS_PATH = join(__dirname, '..', '..', 'src', 'content', 'assets', 'image');
21
+ const ASTRO_BIB_PATH = join(__dirname, '..', '..', 'src', 'content', 'bibliography.bib');
22
+
23
+ function parseArgs() {
24
+ const args = process.argv.slice(2);
25
+ const config = {
26
+ input: DEFAULT_INPUT,
27
+ output: DEFAULT_OUTPUT,
28
+ clean: false,
29
+ notionOnly: false,
30
+ mdxOnly: false,
31
+ token: process.env.NOTION_TOKEN
32
+ };
33
+
34
+ for (const arg of args) {
35
+ if (arg.startsWith('--input=')) {
36
+ config.input = arg.split('=')[1];
37
+ } else if (arg.startsWith('--output=')) {
38
+ config.output = arg.split('=')[1];
39
+ } else if (arg.startsWith('--token=')) {
40
+ config.token = arg.split('=')[1];
41
+ } else if (arg === '--clean') {
42
+ config.clean = true;
43
+ } else if (arg === '--notion-only') {
44
+ config.notionOnly = true;
45
+ } else if (arg === '--mdx-only') {
46
+ config.mdxOnly = true;
47
+ }
48
+ }
49
+
50
+ return config;
51
+ }
52
+
53
+ function showHelp() {
54
+ console.log(`
55
+ 🚀 Notion to MDX Toolkit
56
+
57
+ Usage:
58
+ node index.mjs [options]
59
+
60
+ Options:
61
+ --input=PATH Input pages configuration file (default: input/pages.json)
62
+ --output=PATH Output directory (default: output/)
63
+ --token=TOKEN Notion API token (or set NOTION_TOKEN env var)
64
+ --clean Clean output directory before processing
65
+ --notion-only Only convert Notion to Markdown (skip MDX conversion)
66
+ --mdx-only Only convert existing Markdown to MDX
67
+ --help, -h Show this help
68
+
69
+ Environment Variables:
70
+ NOTION_TOKEN Your Notion integration token
71
+
72
+ Examples:
73
+ # Full conversion workflow
74
+ NOTION_TOKEN=your_token node index.mjs --clean
75
+
76
+ # Only convert Notion pages to Markdown
77
+ node index.mjs --notion-only --token=your_token
78
+
79
+ # Only convert existing Markdown to MDX
80
+ node index.mjs --mdx-only
81
+
82
+ # Custom paths
83
+ node index.mjs --input=my-pages.json --output=converted/ --token=your_token
84
+
85
+ Configuration File Format (pages.json):
86
+ {
87
+ "pages": [
88
+ {
89
+ "id": "your-notion-page-id",
90
+ "title": "Page Title",
91
+ "slug": "page-slug"
92
+ }
93
+ ]
94
+ }
95
+
96
+ Workflow:
97
+ 1. Notion → Markdown (with media download)
98
+ 2. Markdown → MDX (with Astro components)
99
+ 3. Copy to Astro content directory
100
+ `);
101
+ }
102
+
103
+ function ensureDirectory(dir) {
104
+ if (!existsSync(dir)) {
105
+ mkdirSync(dir, { recursive: true });
106
+ }
107
+ }
108
+
109
+ async function cleanDirectory(dir) {
110
+ if (existsSync(dir)) {
111
+ const { execSync } = await import('child_process');
112
+ execSync(`rm -rf "${dir}"/*`, { stdio: 'inherit' });
113
+ }
114
+ }
115
+
116
+ function readPagesConfig(inputFile) {
117
+ try {
118
+ const content = readFileSync(inputFile, 'utf8');
119
+ return JSON.parse(content);
120
+ } catch (error) {
121
+ console.error(`❌ Error reading pages config: ${error.message}`);
122
+ return { pages: [] };
123
+ }
124
+ }
125
+
126
+ function copyToAstroContent(outputDir) {
127
+ console.log('📋 Copying MDX files to Astro content directory...');
128
+
129
+ try {
130
+ // Ensure Astro directories exist
131
+ mkdirSync(dirname(ASTRO_CONTENT_PATH), { recursive: true });
132
+ mkdirSync(ASTRO_ASSETS_PATH, { recursive: true });
133
+
134
+ // Copy MDX file
135
+ const files = readdirSync(outputDir);
136
+ const mdxFiles = files.filter(file => file.endsWith('.mdx'));
137
+ if (mdxFiles.length > 0) {
138
+ const mdxFile = join(outputDir, mdxFiles[0]); // Take the first MDX file
139
+ copyFileSync(mdxFile, ASTRO_CONTENT_PATH);
140
+ console.log(` ✅ Copied MDX to ${ASTRO_CONTENT_PATH}`);
141
+ }
142
+
143
+ // Copy images
144
+ const mediaDir = join(outputDir, 'media');
145
+ if (existsSync(mediaDir)) {
146
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg'];
147
+ let imageCount = 0;
148
+
149
+ function copyImagesRecursively(dir) {
150
+ const files = readdirSync(dir);
151
+ for (const file of files) {
152
+ const filePath = join(dir, file);
153
+ const stat = statSync(filePath);
154
+
155
+ if (stat.isDirectory()) {
156
+ copyImagesRecursively(filePath);
157
+ } else if (imageExtensions.some(ext => file.toLowerCase().endsWith(ext))) {
158
+ const filename = basename(filePath);
159
+ const destPath = join(ASTRO_ASSETS_PATH, filename);
160
+ copyFileSync(filePath, destPath);
161
+ imageCount++;
162
+ }
163
+ }
164
+ }
165
+
166
+ copyImagesRecursively(mediaDir);
167
+ console.log(` ✅ Copied ${imageCount} image(s) to ${ASTRO_ASSETS_PATH}`);
168
+
169
+ // Update image paths in MDX file
170
+ const mdxContent = readFileSync(ASTRO_CONTENT_PATH, 'utf8');
171
+ let updatedContent = mdxContent.replace(/\.\/media\//g, './assets/image/');
172
+ // Remove the subdirectory from image paths since we copy images directly to assets/image/
173
+ updatedContent = updatedContent.replace(/\.\/assets\/image\/[^\/]+\//g, './assets/image/');
174
+ writeFileSync(ASTRO_CONTENT_PATH, updatedContent);
175
+ console.log(` ✅ Updated image paths in MDX file`);
176
+ }
177
+
178
+ // Create empty bibliography.bib
179
+ writeFileSync(ASTRO_BIB_PATH, '');
180
+ console.log(` ✅ Created empty bibliography at ${ASTRO_BIB_PATH}`);
181
+
182
+ } catch (error) {
183
+ console.warn(` ⚠️ Failed to copy to Astro: ${error.message}`);
184
+ }
185
+ }
186
+
187
+
188
+ async function main() {
189
+ const args = process.argv.slice(2);
190
+
191
+ if (args.includes('--help') || args.includes('-h')) {
192
+ showHelp();
193
+ process.exit(0);
194
+ }
195
+
196
+ const config = parseArgs();
197
+
198
+ console.log('🚀 Notion to MDX Toolkit');
199
+ console.log('========================');
200
+
201
+ try {
202
+ if (config.clean) {
203
+ console.log('🧹 Cleaning output directory...');
204
+ await cleanDirectory(config.output);
205
+ }
206
+
207
+ if (config.mdxOnly) {
208
+ // Only convert existing Markdown to MDX
209
+ console.log('📝 MDX conversion only mode');
210
+ await convertToMdx(config.output, config.output);
211
+ copyToAstroContent(config.output);
212
+
213
+ } else if (config.notionOnly) {
214
+ // Only convert Notion to Markdown
215
+ console.log('📄 Notion conversion only mode');
216
+ await convertNotionToMarkdown(config.input, config.output, config.token);
217
+
218
+ } else {
219
+ // Full workflow
220
+ console.log('🔄 Full conversion workflow');
221
+
222
+ // Step 1: Convert Notion to Markdown
223
+ console.log('\n📄 Step 1: Converting Notion pages to Markdown...');
224
+ await convertNotionToMarkdown(config.input, config.output, config.token);
225
+
226
+ // Step 2: Convert Markdown to MDX with Notion metadata
227
+ console.log('\n📝 Step 2: Converting Markdown to MDX...');
228
+ const pagesConfig = readPagesConfig(config.input);
229
+ const firstPage = pagesConfig.pages && pagesConfig.pages.length > 0 ? pagesConfig.pages[0] : null;
230
+ const pageId = firstPage ? firstPage.id : null;
231
+ await convertToMdx(config.output, config.output, pageId, config.token);
232
+
233
+ // Step 3: Copy to Astro content directory
234
+ console.log('\n📋 Step 3: Copying to Astro content directory...');
235
+ copyToAstroContent(config.output);
236
+ }
237
+
238
+ console.log('\n🎉 Conversion completed successfully!');
239
+
240
+ } catch (error) {
241
+ console.error('❌ Error:', error.message);
242
+ process.exit(1);
243
+ }
244
+ }
245
+
246
+ // Export functions for use as module
247
+ export { convertNotionToMarkdown, convertToMdx };
248
+
249
+ // Run CLI if called directly
250
+ if (import.meta.url === `file://${process.argv[1]}`) {
251
+ main();
252
+ }
app/scripts/notion-to-mdx/input/pages.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d51fba4ce9b05562f5df611a150e3cd702b487d2e608441318336556e0f248a
3
+ size 188
app/scripts/notion-to-mdx/mdx-converter.mjs ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
4
+ import { join, dirname, basename, extname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import matter from 'gray-matter';
7
+ import { extractAndGenerateNotionFrontmatter } from './notion-metadata-extractor.mjs';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ // Configuration
13
+ const DEFAULT_INPUT = join(__dirname, 'output');
14
+ const DEFAULT_OUTPUT = join(__dirname, 'output');
15
+
16
+ function parseArgs() {
17
+ const args = process.argv.slice(2);
18
+ const config = {
19
+ input: DEFAULT_INPUT,
20
+ output: DEFAULT_OUTPUT,
21
+ };
22
+
23
+ for (const arg of args) {
24
+ if (arg.startsWith('--input=')) {
25
+ config.input = arg.substring('--input='.length);
26
+ } else if (arg.startsWith('--output=')) {
27
+ config.output = arg.substring('--output='.length);
28
+ } else if (arg === '--help' || arg === '-h') {
29
+ console.log(`
30
+ 📝 Notion Markdown to MDX Converter
31
+
32
+ Usage:
33
+ node mdx-converter.mjs [options]
34
+
35
+ Options:
36
+ --input=PATH Input directory or file (default: ${DEFAULT_INPUT})
37
+ --output=PATH Output directory (default: ${DEFAULT_OUTPUT})
38
+ --help, -h Show this help
39
+
40
+ Examples:
41
+ # Convert all markdown files in output directory
42
+ node mdx-converter.mjs
43
+
44
+ # Convert specific file
45
+ node mdx-converter.mjs --input=article.md --output=converted/
46
+
47
+ # Convert directory
48
+ node mdx-converter.mjs --input=markdown-files/ --output=mdx-files/
49
+ `);
50
+ process.exit(0);
51
+ } else if (!config.input) {
52
+ config.input = arg;
53
+ } else if (!config.output) {
54
+ config.output = arg;
55
+ }
56
+ }
57
+ return config;
58
+ }
59
+
60
+ /**
61
+ * Track which Astro components are used during transformations
62
+ */
63
+ const usedComponents = new Set();
64
+
65
+ /**
66
+ * Track individual image imports needed
67
+ */
68
+ const imageImports = new Map(); // src -> varName
69
+
70
+ /**
71
+ * Generate a variable name from image path
72
+ * @param {string} src - Image source path
73
+ * @returns {string} - Valid variable name
74
+ */
75
+ function generateImageVarName(src) {
76
+ // Extract filename without extension and make it a valid JS variable
77
+ const filename = src.split('/').pop().replace(/\.[^.]+$/, '');
78
+ return filename.replace(/[^a-zA-Z0-9]/g, '_').replace(/^[0-9]/, 'img_$&');
79
+ }
80
+
81
+ /**
82
+ * Add required component imports to the frontmatter
83
+ * @param {string} content - MDX content
84
+ * @returns {string} - Content with component imports
85
+ */
86
+ function addComponentImports(content) {
87
+ console.log(' 📦 Adding component and image imports...');
88
+
89
+ let imports = [];
90
+
91
+ // Add component imports
92
+ if (usedComponents.size > 0) {
93
+ const componentImports = Array.from(usedComponents)
94
+ .map(component => `import ${component} from '../components/${component}.astro';`);
95
+ imports.push(...componentImports);
96
+ console.log(` ✅ Importing components: ${Array.from(usedComponents).join(', ')}`);
97
+ }
98
+
99
+ // Add image imports
100
+ if (imageImports.size > 0) {
101
+ const imageImportStatements = Array.from(imageImports.entries())
102
+ .map(([src, varName]) => `import ${varName} from '${src}';`);
103
+ imports.push(...imageImportStatements);
104
+ console.log(` ✅ Importing ${imageImports.size} image(s)`);
105
+ }
106
+
107
+ if (imports.length === 0) {
108
+ console.log(' ℹ️ No imports needed');
109
+ return content;
110
+ }
111
+
112
+ const importBlock = imports.join('\n');
113
+
114
+ // Insert imports after frontmatter
115
+ const frontmatterEnd = content.indexOf('---', 3) + 3;
116
+ if (frontmatterEnd > 2) {
117
+ return content.slice(0, frontmatterEnd) + '\n\n' + importBlock + '\n' + content.slice(frontmatterEnd);
118
+ } else {
119
+ // No frontmatter, add at beginning
120
+ return importBlock + '\n\n' + content;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Transform Notion images to Figure components
126
+ * @param {string} content - MDX content
127
+ * @returns {string} - Content with Figure components
128
+ */
129
+ function transformImages(content) {
130
+ console.log(' 🖼️ Transforming images to Figure components...');
131
+
132
+ let hasImages = false;
133
+
134
+ // Helper function to clean source paths
135
+ const cleanSrcPath = (src) => {
136
+ // Convert Notion media paths to relative paths
137
+ return src.replace(/^\/media\//, './media/')
138
+ .replace(/^\.\/media\//, './media/');
139
+ };
140
+
141
+ // Helper to clean caption text
142
+ const cleanCaption = (caption) => {
143
+ return caption
144
+ .replace(/<[^>]*>/g, '') // Remove HTML tags
145
+ .replace(/\n/g, ' ') // Replace newlines with spaces
146
+ .replace(/\r/g, ' ') // Replace carriage returns with spaces
147
+ .replace(/\s+/g, ' ') // Replace multiple spaces with single space
148
+ .replace(/'/g, "\\'") // Escape quotes
149
+ .trim(); // Trim whitespace
150
+ };
151
+
152
+ // Helper to clean alt text
153
+ const cleanAltText = (alt, maxLength = 100) => {
154
+ const cleaned = alt
155
+ .replace(/<[^>]*>/g, '') // Remove HTML tags
156
+ .replace(/\n/g, ' ') // Replace newlines with spaces
157
+ .replace(/\r/g, ' ') // Replace carriage returns with spaces
158
+ .replace(/\s+/g, ' ') // Replace multiple spaces with single space
159
+ .trim(); // Trim whitespace
160
+
161
+ return cleaned.length > maxLength
162
+ ? cleaned.substring(0, maxLength) + '...'
163
+ : cleaned;
164
+ };
165
+
166
+ // Create Figure component with import
167
+ const createFigureComponent = (src, alt = '', caption = '') => {
168
+ const cleanSrc = cleanSrcPath(src);
169
+
170
+ // Skip PDF URLs and external URLs - they should remain as links only
171
+ if (cleanSrc.includes('.pdf') || cleanSrc.includes('arxiv.org/pdf') ||
172
+ (cleanSrc.startsWith('http') && !cleanSrc.includes('/media/'))) {
173
+ console.log(` ⚠️ Skipping external/PDF URL: ${cleanSrc}`);
174
+ // Return the original markdown image syntax for external URLs
175
+ return `![${alt}](${src})`;
176
+ }
177
+
178
+ const varName = generateImageVarName(cleanSrc);
179
+ imageImports.set(cleanSrc, varName);
180
+ usedComponents.add('Figure');
181
+
182
+ const props = [];
183
+ props.push(`src={${varName}}`);
184
+ props.push('zoomable');
185
+ props.push('downloadable');
186
+ props.push('layout="fixed"');
187
+ if (alt) props.push(`alt="${alt}"`);
188
+ if (caption) props.push(`caption={'${caption}'}`);
189
+
190
+ return `<Figure\n ${props.join('\n ')}\n/>`;
191
+ };
192
+
193
+ // Transform markdown images: ![alt](src)
194
+ content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
195
+ const cleanSrc = cleanSrcPath(src);
196
+ const cleanAlt = cleanAltText(alt || 'Figure');
197
+ hasImages = true;
198
+
199
+ return createFigureComponent(cleanSrc, cleanAlt);
200
+ });
201
+
202
+ // Transform images with captions (Notion sometimes adds captions as separate text)
203
+ content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)\s*\n\s*([^\n]+)/g, (match, alt, src, caption) => {
204
+ const cleanSrc = cleanSrcPath(src);
205
+ const cleanAlt = cleanAltText(alt || 'Figure');
206
+ const cleanCap = cleanCaption(caption);
207
+ hasImages = true;
208
+
209
+ return createFigureComponent(cleanSrc, cleanAlt, cleanCap);
210
+ });
211
+
212
+ if (hasImages) {
213
+ console.log(' ✅ Figure components with imports will be created');
214
+ }
215
+
216
+ return content;
217
+ }
218
+
219
+ /**
220
+ * Transform Notion callouts to Note components
221
+ * @param {string} content - MDX content
222
+ * @returns {string} - Content with Note components
223
+ */
224
+ function transformCallouts(content) {
225
+ console.log(' 📝 Transforming callouts to Note components...');
226
+
227
+ let transformedCount = 0;
228
+
229
+ // Transform blockquotes that look like Notion callouts
230
+ content = content.replace(/^> \*\*([^*]+)\*\*\s*\n> (.+?)(?=\n> \*\*|\n\n|\n$)/gms, (match, title, content) => {
231
+ transformedCount++;
232
+ usedComponents.add('Note');
233
+
234
+ const cleanContent = content
235
+ .replace(/^> /gm, '') // Remove blockquote markers
236
+ .replace(/\n+/g, '\n') // Normalize newlines
237
+ .trim();
238
+
239
+ return `<Note type="${title.toLowerCase()}" title="${title}">\n${cleanContent}\n</Note>\n\n`;
240
+ });
241
+
242
+ if (transformedCount > 0) {
243
+ console.log(` ✅ Transformed ${transformedCount} callout(s) to Note components`);
244
+ }
245
+
246
+ return content;
247
+ }
248
+
249
+ /**
250
+ * Transform Notion databases/tables to enhanced table components
251
+ * @param {string} content - MDX content
252
+ * @returns {string} - Content with enhanced tables
253
+ */
254
+ function transformTables(content) {
255
+ console.log(' 📊 Enhancing tables...');
256
+
257
+ let enhancedCount = 0;
258
+
259
+ // Wrap tables in a container for better styling
260
+ content = content.replace(/^(\|[^|\n]+\|[\s\S]*?)(?=\n\n|\n$)/gm, (match) => {
261
+ if (match.includes('|') && match.split('\n').length > 2) {
262
+ enhancedCount++;
263
+ return `<div class="table-container">\n\n${match}\n\n</div>`;
264
+ }
265
+ return match;
266
+ });
267
+
268
+ if (enhancedCount > 0) {
269
+ console.log(` ✅ Enhanced ${enhancedCount} table(s)`);
270
+ }
271
+
272
+ return content;
273
+ }
274
+
275
+ /**
276
+ * Transform Notion code blocks to enhanced code components
277
+ * @param {string} content - MDX content
278
+ * @returns {string} - Content with enhanced code blocks
279
+ */
280
+ function transformCodeBlocks(content) {
281
+ console.log(' 💻 Enhancing code blocks...');
282
+
283
+ let enhancedCount = 0;
284
+
285
+ // Add copy functionality to code blocks
286
+ content = content.replace(/^```(\w+)\n([\s\S]*?)\n```$/gm, (match, lang, code) => {
287
+ enhancedCount++;
288
+ return `\`\`\`${lang} copy\n${code}\n\`\`\``;
289
+ });
290
+
291
+ if (enhancedCount > 0) {
292
+ console.log(` ✅ Enhanced ${enhancedCount} code block(s)`);
293
+ }
294
+
295
+ return content;
296
+ }
297
+
298
+ /**
299
+ * Fix Notion-specific formatting issues
300
+ * @param {string} content - MDX content
301
+ * @returns {string} - Content with fixed formatting
302
+ */
303
+ function fixNotionFormatting(content) {
304
+ console.log(' 🔧 Fixing Notion formatting issues...');
305
+
306
+ let fixedCount = 0;
307
+
308
+ // Fix Notion's toggle lists that don't convert well
309
+ content = content.replace(/^(\s*)•\s*(.+)$/gm, (match, indent, text) => {
310
+ fixedCount++;
311
+ return `${indent}- ${text}`;
312
+ });
313
+
314
+ // Fix Notion's numbered lists that might have issues
315
+ content = content.replace(/^(\s*)\d+\.\s*(.+)$/gm, (match, indent, text) => {
316
+ // Only fix if it's not already properly formatted
317
+ if (!text.includes('\n') || text.split('\n').length === 1) {
318
+ return match; // Keep as is
319
+ }
320
+ fixedCount++;
321
+ return `${indent}1. ${text}`;
322
+ });
323
+
324
+ // Fix Notion's bold/italic combinations
325
+ content = content.replace(/\*\*([^*]+)\*\*([^*]+)\*\*([^*]+)\*\*/g, (match, part1, part2, part3) => {
326
+ fixedCount++;
327
+ return `**${part1}${part2}${part3}**`;
328
+ });
329
+
330
+ if (fixedCount > 0) {
331
+ console.log(` ✅ Fixed ${fixedCount} formatting issue(s)`);
332
+ }
333
+
334
+ return content;
335
+ }
336
+
337
+ /**
338
+ * Ensure proper frontmatter for MDX with Notion metadata
339
+ * @param {string} content - MDX content
340
+ * @param {string} pageId - Notion page ID (optional)
341
+ * @param {string} notionToken - Notion API token (optional)
342
+ * @returns {string} - Content with proper frontmatter
343
+ */
344
+ async function ensureFrontmatter(content, pageId = null, notionToken = null) {
345
+ console.log(' 📄 Ensuring proper frontmatter...');
346
+
347
+ if (!content.startsWith('---')) {
348
+ let frontmatter;
349
+
350
+ if (pageId && notionToken) {
351
+ try {
352
+ console.log(' 🔍 Extracting Notion metadata...');
353
+ frontmatter = await extractAndGenerateNotionFrontmatter(pageId, notionToken);
354
+ console.log(' ✅ Generated rich frontmatter from Notion');
355
+ } catch (error) {
356
+ console.log(' ⚠️ Failed to extract Notion metadata, using basic frontmatter');
357
+ frontmatter = generateBasicFrontmatter();
358
+ }
359
+ } else {
360
+ frontmatter = generateBasicFrontmatter();
361
+ console.log(' ✅ Generated basic frontmatter');
362
+ }
363
+
364
+ return frontmatter + content;
365
+ }
366
+
367
+ // Parse existing frontmatter and enhance it
368
+ try {
369
+ const { data, content: body } = matter(content);
370
+
371
+ // If we have Notion metadata available, try to enhance the frontmatter
372
+ if (pageId && notionToken && (!data.notion_id || data.notion_id !== pageId)) {
373
+ try {
374
+ console.log(' 🔍 Enhancing frontmatter with Notion metadata...');
375
+ const notionFrontmatter = await extractAndGenerateNotionFrontmatter(pageId, notionToken);
376
+ const { data: notionData } = matter(notionFrontmatter);
377
+
378
+ // Merge Notion metadata with existing frontmatter
379
+ const enhancedData = { ...data, ...notionData };
380
+ const enhancedContent = matter.stringify(body, enhancedData);
381
+ console.log(' ✅ Enhanced frontmatter with Notion metadata');
382
+ return enhancedContent;
383
+ } catch (error) {
384
+ console.log(' ⚠️ Could not enhance with Notion metadata, keeping existing');
385
+ }
386
+ }
387
+
388
+ // Ensure required fields
389
+ if (!data.title) data.title = 'Notion Article';
390
+ if (!data.published) data.published = new Date().toISOString().split('T')[0];
391
+ if (!data.tableOfContentsAutoCollapse) data.tableOfContentsAutoCollapse = true;
392
+
393
+ const enhancedContent = matter.stringify(body, data);
394
+ console.log(' ✅ Enhanced existing frontmatter');
395
+ return enhancedContent;
396
+ } catch (error) {
397
+ console.log(' ⚠️ Could not parse frontmatter, keeping as is');
398
+ return content;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Generate basic frontmatter
404
+ * @returns {string} - Basic frontmatter
405
+ */
406
+ function generateBasicFrontmatter() {
407
+ const currentDate = new Date().toLocaleDateString('en-US', {
408
+ year: 'numeric',
409
+ month: 'short',
410
+ day: '2-digit'
411
+ });
412
+ return `---
413
+ title: "Notion Article"
414
+ published: "${currentDate}"
415
+ tableOfContentsAutoCollapse: true
416
+ ---
417
+
418
+ `;
419
+ }
420
+
421
+ /**
422
+ * Main MDX processing function that applies all transformations
423
+ * @param {string} content - Raw Markdown content
424
+ * @param {string} pageId - Notion page ID (optional)
425
+ * @param {string} notionToken - Notion API token (optional)
426
+ * @returns {string} - Processed MDX content compatible with Astro
427
+ */
428
+ async function processMdxContent(content, pageId = null, notionToken = null) {
429
+ console.log('🔧 Processing for Astro MDX compatibility...');
430
+
431
+ // Clear previous tracking
432
+ usedComponents.clear();
433
+ imageImports.clear();
434
+
435
+ let processedContent = content;
436
+
437
+ // Apply each transformation step sequentially
438
+ processedContent = await ensureFrontmatter(processedContent, pageId, notionToken);
439
+ processedContent = fixNotionFormatting(processedContent);
440
+ processedContent = transformCallouts(processedContent);
441
+ processedContent = transformImages(processedContent);
442
+ processedContent = transformTables(processedContent);
443
+ processedContent = transformCodeBlocks(processedContent);
444
+
445
+ // Add component imports at the end
446
+ processedContent = addComponentImports(processedContent);
447
+
448
+ return processedContent;
449
+ }
450
+
451
+ /**
452
+ * Convert a single markdown file to MDX
453
+ * @param {string} inputFile - Input markdown file
454
+ * @param {string} outputDir - Output directory
455
+ * @param {string} pageId - Notion page ID (optional)
456
+ * @param {string} notionToken - Notion API token (optional)
457
+ */
458
+ async function convertFileToMdx(inputFile, outputDir, pageId = null, notionToken = null) {
459
+ const filename = basename(inputFile, '.md');
460
+ const outputFile = join(outputDir, `${filename}.mdx`);
461
+
462
+ console.log(`📝 Converting: ${basename(inputFile)} → ${basename(outputFile)}`);
463
+
464
+ try {
465
+ const markdownContent = readFileSync(inputFile, 'utf8');
466
+ const mdxContent = await processMdxContent(markdownContent, pageId, notionToken);
467
+ writeFileSync(outputFile, mdxContent);
468
+
469
+ console.log(` ✅ Converted: ${outputFile}`);
470
+
471
+ // Show file size
472
+ const inputSize = Math.round(markdownContent.length / 1024);
473
+ const outputSize = Math.round(mdxContent.length / 1024);
474
+ console.log(` 📊 Input: ${inputSize}KB → Output: ${outputSize}KB`);
475
+
476
+ } catch (error) {
477
+ console.error(` ❌ Failed to convert ${inputFile}: ${error.message}`);
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Convert all markdown files in a directory to MDX
483
+ * @param {string} inputPath - Input path (file or directory)
484
+ * @param {string} outputDir - Output directory
485
+ * @param {string} pageId - Notion page ID (optional)
486
+ * @param {string} notionToken - Notion API token (optional)
487
+ */
488
+ async function convertToMdx(inputPath, outputDir, pageId = null, notionToken = null) {
489
+ console.log('📝 Notion Markdown to Astro MDX Converter');
490
+ console.log(`📁 Input: ${inputPath}`);
491
+ console.log(`📁 Output: ${outputDir}`);
492
+
493
+ // Check if input exists
494
+ if (!existsSync(inputPath)) {
495
+ console.error(`❌ Input not found: ${inputPath}`);
496
+ process.exit(1);
497
+ }
498
+
499
+ try {
500
+ // Ensure output directory exists
501
+ if (!existsSync(outputDir)) {
502
+ mkdirSync(outputDir, { recursive: true });
503
+ }
504
+
505
+ let filesToConvert = [];
506
+
507
+ if (statSync(inputPath).isDirectory()) {
508
+ // Convert all .md files in directory
509
+ const files = readdirSync(inputPath);
510
+ filesToConvert = files
511
+ .filter(file => file.endsWith('.md'))
512
+ .map(file => join(inputPath, file));
513
+ } else if (inputPath.endsWith('.md')) {
514
+ // Convert single file
515
+ filesToConvert = [inputPath];
516
+ } else {
517
+ console.error('❌ Input must be a .md file or directory containing .md files');
518
+ process.exit(1);
519
+ }
520
+
521
+ if (filesToConvert.length === 0) {
522
+ console.log('ℹ️ No .md files found to convert');
523
+ return;
524
+ }
525
+
526
+ console.log(`🔄 Found ${filesToConvert.length} file(s) to convert`);
527
+
528
+ // Convert each file
529
+ for (const file of filesToConvert) {
530
+ await convertFileToMdx(file, outputDir, pageId, notionToken);
531
+ }
532
+
533
+ console.log(`✅ Conversion completed! ${filesToConvert.length} file(s) processed`);
534
+
535
+ } catch (error) {
536
+ console.error('❌ Conversion failed:', error.message);
537
+ process.exit(1);
538
+ }
539
+ }
540
+
541
+ export { convertToMdx };
542
+
543
+ function main() {
544
+ const config = parseArgs();
545
+ convertToMdx(config.input, config.output);
546
+ console.log('🎉 MDX conversion completed!');
547
+ }
548
+
549
+ if (import.meta.url === `file://${process.argv[1]}`) {
550
+ main();
551
+ }
app/scripts/notion-to-mdx/notion-converter.mjs ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv';
4
+ import { Client } from '@notionhq/client';
5
+ import { NotionConverter } from 'notion-to-md';
6
+ import { DefaultExporter } from 'notion-to-md/plugins/exporter';
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
8
+ import { join, dirname, basename } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { postProcessMarkdown } from './post-processor.mjs';
11
+ import { createCustomCodeRenderer } from './custom-code-renderer.mjs';
12
+
13
+ // Load environment variables from .env file
14
+ config();
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ // Configuration
20
+ const DEFAULT_INPUT = join(__dirname, 'input', 'pages.json');
21
+ const DEFAULT_OUTPUT = join(__dirname, 'output');
22
+
23
+ function parseArgs() {
24
+ const args = process.argv.slice(2);
25
+ const config = {
26
+ input: DEFAULT_INPUT,
27
+ output: DEFAULT_OUTPUT,
28
+ clean: false,
29
+ token: process.env.NOTION_TOKEN
30
+ };
31
+
32
+ for (const arg of args) {
33
+ if (arg.startsWith('--input=')) {
34
+ config.input = arg.split('=')[1];
35
+ } else if (arg.startsWith('--output=')) {
36
+ config.output = arg.split('=')[1];
37
+ } else if (arg.startsWith('--token=')) {
38
+ config.token = arg.split('=')[1];
39
+ } else if (arg === '--clean') {
40
+ config.clean = true;
41
+ }
42
+ }
43
+
44
+ return config;
45
+ }
46
+
47
+ function ensureDirectory(dir) {
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+ }
52
+
53
+ function loadPagesConfig(configFile) {
54
+ if (!existsSync(configFile)) {
55
+ console.error(`❌ Configuration file not found: ${configFile}`);
56
+ console.log('📝 Create a pages.json file with your Notion page IDs:');
57
+ console.log(`
58
+ {
59
+ "pages": [
60
+ {
61
+ "id": "your-notion-page-id-1",
62
+ "title": "Page Title 1",
63
+ "slug": "page-1"
64
+ },
65
+ {
66
+ "id": "your-notion-page-id-2",
67
+ "title": "Page Title 2",
68
+ "slug": "page-2"
69
+ }
70
+ ]
71
+ }
72
+ `);
73
+ process.exit(1);
74
+ }
75
+
76
+ try {
77
+ const config = JSON.parse(readFileSync(configFile, 'utf8'));
78
+ return config.pages || [];
79
+ } catch (error) {
80
+ console.error(`❌ Error reading configuration: ${error.message}`);
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Convert a single Notion page to Markdown with advanced media handling
87
+ * @param {Object} notion - Notion client
88
+ * @param {string} pageId - Notion page ID
89
+ * @param {string} outputDir - Output directory
90
+ * @param {string} pageTitle - Page title for file naming
91
+ * @returns {Promise<string>} - Path to generated markdown file
92
+ */
93
+ async function convertNotionPage(notion, pageId, outputDir, pageTitle) {
94
+ console.log(`📄 Converting Notion page: ${pageTitle} (${pageId})`);
95
+
96
+ try {
97
+ // Create media directory for this page
98
+ const mediaDir = join(outputDir, 'media', pageId);
99
+ ensureDirectory(mediaDir);
100
+
101
+ // Configure the DefaultExporter to save to a file
102
+ const outputFile = join(outputDir, `${pageTitle}.md`);
103
+ const exporter = new DefaultExporter({
104
+ outputType: 'file',
105
+ outputPath: outputFile,
106
+ });
107
+
108
+ // Create the converter with media downloading strategy
109
+ const n2m = new NotionConverter(notion)
110
+ .withExporter(exporter)
111
+ // Download media to local directory with path transformation
112
+ .downloadMediaTo({
113
+ outputDir: mediaDir,
114
+ // Transform paths to be web-accessible
115
+ transformPath: (localPath) => `/media/${pageId}/${basename(localPath)}`,
116
+ });
117
+
118
+ // Convert the page
119
+ const result = await n2m.convert(pageId);
120
+
121
+ console.log(` ✅ Converted to: ${outputFile}`);
122
+ console.log(` 📊 Content length: ${result.content.length} characters`);
123
+ console.log(` 🖼️ Media saved to: ${mediaDir}`);
124
+
125
+ return outputFile;
126
+
127
+ } catch (error) {
128
+ console.error(` ❌ Failed to convert page ${pageId}: ${error.message}`);
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Process Notion pages with advanced configuration
135
+ * @param {string} inputFile - Path to pages configuration
136
+ * @param {string} outputDir - Output directory
137
+ * @param {string} notionToken - Notion API token
138
+ */
139
+ export async function convertNotionToMarkdown(inputFile, outputDir, notionToken) {
140
+ console.log('🚀 Notion to Markdown Converter');
141
+ console.log(`📁 Input: ${inputFile}`);
142
+ console.log(`📁 Output: ${outputDir}`);
143
+
144
+ // Validate Notion token
145
+ if (!notionToken) {
146
+ console.error('❌ NOTION_TOKEN not found. Please set it as environment variable or use --token=YOUR_TOKEN');
147
+ process.exit(1);
148
+ }
149
+
150
+ // Ensure output directory exists
151
+ ensureDirectory(outputDir);
152
+
153
+ try {
154
+ // Initialize Notion client
155
+ const notion = new Client({
156
+ auth: notionToken,
157
+ });
158
+
159
+ // Load pages configuration
160
+ const pages = loadPagesConfig(inputFile);
161
+ console.log(`📋 Found ${pages.length} page(s) to convert`);
162
+
163
+ const convertedFiles = [];
164
+
165
+ // Convert each page
166
+ for (const page of pages) {
167
+ try {
168
+ const outputFile = await convertNotionPage(
169
+ notion,
170
+ page.id,
171
+ outputDir,
172
+ page.slug || page.title?.toLowerCase().replace(/\s+/g, '-') || page.id
173
+ );
174
+ convertedFiles.push(outputFile);
175
+ } catch (error) {
176
+ console.error(`❌ Failed to convert page ${page.id}: ${error.message}`);
177
+ // Continue with other pages
178
+ }
179
+ }
180
+
181
+ // Post-process all converted files
182
+ console.log('🔧 Post-processing converted files...');
183
+ for (const file of convertedFiles) {
184
+ try {
185
+ let content = readFileSync(file, 'utf8');
186
+ content = postProcessMarkdown(content);
187
+ writeFileSync(file, content);
188
+ console.log(` ✅ Post-processed: ${basename(file)}`);
189
+ } catch (error) {
190
+ console.error(` ❌ Failed to post-process ${file}: ${error.message}`);
191
+ }
192
+ }
193
+
194
+ console.log(`✅ Conversion completed! ${convertedFiles.length} file(s) generated`);
195
+
196
+ } catch (error) {
197
+ console.error('❌ Conversion failed:', error.message);
198
+ process.exit(1);
199
+ }
200
+ }
201
+
202
+ function main() {
203
+ const config = parseArgs();
204
+
205
+ if (config.clean) {
206
+ console.log('🧹 Cleaning output directory...');
207
+ // Clean output directory logic would go here
208
+ }
209
+
210
+ convertNotionToMarkdown(config.input, config.output, config.token);
211
+ console.log('🎉 Notion conversion completed!');
212
+ }
213
+
214
+ // Show help if requested
215
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
216
+ console.log(`
217
+ 🚀 Notion to Markdown Converter
218
+
219
+ Usage:
220
+ node notion-converter.mjs [options]
221
+
222
+ Options:
223
+ --input=PATH Input pages configuration file (default: input/pages.json)
224
+ --output=PATH Output directory (default: output/)
225
+ --token=TOKEN Notion API token (or set NOTION_TOKEN env var)
226
+ --clean Clean output directory before conversion
227
+ --help, -h Show this help
228
+
229
+ Environment Variables:
230
+ NOTION_TOKEN Your Notion integration token
231
+
232
+ Examples:
233
+ # Basic conversion with environment token
234
+ NOTION_TOKEN=your_token node notion-converter.mjs
235
+
236
+ # Custom paths and token
237
+ node notion-converter.mjs --input=my-pages.json --output=converted/ --token=your_token
238
+
239
+ # Clean output first
240
+ node notion-converter.mjs --clean
241
+
242
+ Configuration File Format (pages.json):
243
+ {
244
+ "pages": [
245
+ {
246
+ "id": "your-notion-page-id",
247
+ "title": "Page Title",
248
+ "slug": "page-slug"
249
+ }
250
+ ]
251
+ }
252
+ `);
253
+ process.exit(0);
254
+ }
255
+
256
+ // Run CLI if called directly
257
+ if (import.meta.url === `file://${process.argv[1]}`) {
258
+ main();
259
+ }
app/scripts/notion-to-mdx/notion-metadata-extractor.mjs ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { Client } from '@notionhq/client';
4
+
5
+ /**
6
+ * Notion Metadata Extractor
7
+ * Extracts document metadata from Notion pages for frontmatter generation
8
+ */
9
+
10
+ /**
11
+ * Extract metadata from Notion page
12
+ * @param {string} pageId - Notion page ID
13
+ * @param {string} notionToken - Notion API token
14
+ * @returns {object} - Extracted metadata object
15
+ */
16
+ export async function extractNotionMetadata(pageId, notionToken) {
17
+ const notion = new Client({
18
+ auth: notionToken,
19
+ });
20
+
21
+ const metadata = {};
22
+
23
+ try {
24
+ // Get page information
25
+ const page = await notion.pages.retrieve({ page_id: pageId });
26
+
27
+ // Extract title from page properties
28
+ if (page.properties.title && page.properties.title.title && page.properties.title.title.length > 0) {
29
+ metadata.title = page.properties.title.title[0].plain_text;
30
+ }
31
+
32
+ // Extract creation date
33
+ if (page.created_time) {
34
+ metadata.published = new Date(page.created_time).toLocaleDateString('en-US', {
35
+ year: 'numeric',
36
+ month: 'short',
37
+ day: '2-digit'
38
+ });
39
+ metadata.created_time = page.created_time;
40
+ }
41
+
42
+ // Extract last edited date
43
+ if (page.last_edited_time) {
44
+ metadata.last_edited_time = page.last_edited_time;
45
+ }
46
+
47
+ // Extract created by
48
+ if (page.created_by && page.created_by.id) {
49
+ metadata.created_by = page.created_by.id;
50
+ }
51
+
52
+ // Extract last edited by
53
+ if (page.last_edited_by && page.last_edited_by.id) {
54
+ metadata.last_edited_by = page.last_edited_by.id;
55
+ }
56
+
57
+ // Extract page URL
58
+ metadata.notion_url = page.url;
59
+
60
+ // Extract page ID
61
+ metadata.notion_id = page.id;
62
+
63
+ // Extract parent information
64
+ if (page.parent) {
65
+ metadata.parent = {
66
+ type: page.parent.type,
67
+ id: page.parent[page.parent.type]?.id || page.parent[page.parent.type]
68
+ };
69
+ }
70
+
71
+ // Extract cover image if available
72
+ if (page.cover) {
73
+ metadata.cover = {
74
+ type: page.cover.type,
75
+ url: page.cover[page.cover.type]?.url || page.cover[page.cover.type]
76
+ };
77
+ }
78
+
79
+ // Extract icon if available
80
+ if (page.icon) {
81
+ metadata.icon = {
82
+ type: page.icon.type,
83
+ emoji: page.icon.emoji,
84
+ url: page.icon.external?.url || page.icon.file?.url
85
+ };
86
+ }
87
+
88
+ // Extract authors and custom properties
89
+ const customProperties = {};
90
+ for (const [key, value] of Object.entries(page.properties)) {
91
+ if (key !== 'title') { // Skip title as it's handled separately
92
+ const extractedValue = extractPropertyValue(value);
93
+
94
+ // Check for author-related properties
95
+ if (key.toLowerCase().includes('author') ||
96
+ key.toLowerCase().includes('writer') ||
97
+ key.toLowerCase().includes('creator') ||
98
+ value.type === 'people') {
99
+ metadata.authors = extractedValue;
100
+ } else {
101
+ customProperties[key] = extractedValue;
102
+ }
103
+ }
104
+ }
105
+
106
+ // If no authors found in properties, try to get from created_by
107
+ if (!metadata.authors && page.created_by) {
108
+ try {
109
+ const user = await notion.users.retrieve({ user_id: page.created_by.id });
110
+ metadata.authors = [{
111
+ name: user.name || user.id,
112
+ id: user.id
113
+ }];
114
+ } catch (error) {
115
+ console.log(' ⚠️ Could not fetch author from created_by:', error.message);
116
+ // Fallback to basic info
117
+ metadata.authors = [{
118
+ name: page.created_by.name || page.created_by.id,
119
+ id: page.created_by.id
120
+ }];
121
+ }
122
+ }
123
+
124
+ if (Object.keys(customProperties).length > 0) {
125
+ metadata.properties = customProperties;
126
+ }
127
+
128
+ // Try to extract description from page content (first paragraph)
129
+ try {
130
+ const blocks = await notion.blocks.children.list({ block_id: pageId });
131
+ const firstParagraph = blocks.results.find(block =>
132
+ block.type === 'paragraph' &&
133
+ block.paragraph.rich_text &&
134
+ block.paragraph.rich_text.length > 0
135
+ );
136
+
137
+ if (firstParagraph) {
138
+ const description = firstParagraph.paragraph.rich_text
139
+ .map(text => text.plain_text)
140
+ .join('')
141
+ .trim();
142
+
143
+ if (description && description.length > 0) {
144
+ metadata.description = description.substring(0, 200) + (description.length > 200 ? '...' : '');
145
+ }
146
+ }
147
+ } catch (error) {
148
+ console.log(' ⚠️ Could not extract description from page content');
149
+ }
150
+
151
+ // Generate tags from page properties
152
+ const tags = [];
153
+ for (const [key, value] of Object.entries(page.properties)) {
154
+ if (value.type === 'multi_select' && value.multi_select) {
155
+ value.multi_select.forEach(option => {
156
+ tags.push(option.name);
157
+ });
158
+ } else if (value.type === 'select' && value.select) {
159
+ tags.push(value.select.name);
160
+ }
161
+ }
162
+
163
+ if (tags.length > 0) {
164
+ metadata.tags = tags;
165
+ }
166
+
167
+ } catch (error) {
168
+ console.error('Error extracting Notion metadata:', error.message);
169
+ // Return basic metadata if extraction fails
170
+ metadata.title = "Notion Article";
171
+ metadata.published = new Date().toLocaleDateString('en-US', {
172
+ year: 'numeric',
173
+ month: 'short',
174
+ day: '2-digit'
175
+ });
176
+ }
177
+
178
+ return metadata;
179
+ }
180
+
181
+ /**
182
+ * Extract value from Notion property
183
+ * @param {object} property - Notion property object
184
+ * @returns {any} - Extracted value
185
+ */
186
+ function extractPropertyValue(property) {
187
+ switch (property.type) {
188
+ case 'rich_text':
189
+ return property.rich_text.map(text => text.plain_text).join('');
190
+ case 'title':
191
+ return property.title.map(text => text.plain_text).join('');
192
+ case 'number':
193
+ return property.number;
194
+ case 'select':
195
+ return property.select?.name || null;
196
+ case 'multi_select':
197
+ return property.multi_select.map(option => option.name);
198
+ case 'date':
199
+ return property.date?.start || null;
200
+ case 'checkbox':
201
+ return property.checkbox;
202
+ case 'url':
203
+ return property.url;
204
+ case 'email':
205
+ return property.email;
206
+ case 'phone_number':
207
+ return property.phone_number;
208
+ case 'created_time':
209
+ return property.created_time;
210
+ case 'created_by':
211
+ return property.created_by?.id || null;
212
+ case 'last_edited_time':
213
+ return property.last_edited_time;
214
+ case 'last_edited_by':
215
+ return property.last_edited_by?.id || null;
216
+ case 'people':
217
+ return property.people.map(person => ({
218
+ name: person.name || person.id,
219
+ id: person.id
220
+ }));
221
+ default:
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Generate YAML frontmatter from metadata object
228
+ * @param {object} metadata - Metadata object
229
+ * @returns {string} - YAML frontmatter string
230
+ */
231
+ export function generateNotionFrontmatter(metadata) {
232
+ let frontmatter = '---\n';
233
+
234
+ // Title
235
+ if (metadata.title) {
236
+ frontmatter += `title: "${metadata.title}"\n`;
237
+ }
238
+
239
+ // Description
240
+ if (metadata.description) {
241
+ frontmatter += `description: "${metadata.description}"\n`;
242
+ }
243
+
244
+ // Publication date
245
+ if (metadata.published) {
246
+ frontmatter += `published: "${metadata.published}"\n`;
247
+ }
248
+
249
+ // Authors
250
+ if (metadata.authors && metadata.authors.length > 0) {
251
+ frontmatter += 'authors:\n';
252
+ metadata.authors.forEach(author => {
253
+ if (typeof author === 'string') {
254
+ frontmatter += ` - name: "${author}"\n`;
255
+ } else if (author.name) {
256
+ frontmatter += ` - name: "${author.name}"\n`;
257
+ }
258
+ });
259
+ }
260
+
261
+ // Tags
262
+ if (metadata.tags && metadata.tags.length > 0) {
263
+ frontmatter += 'tags:\n';
264
+ metadata.tags.forEach(tag => {
265
+ frontmatter += ` - "${tag}"\n`;
266
+ });
267
+ }
268
+
269
+ // Notion metadata removed - keeping only standard frontmatter fields
270
+
271
+ // Cover image
272
+ if (metadata.cover && metadata.cover.url) {
273
+ frontmatter += `cover: "${metadata.cover.url}"\n`;
274
+ }
275
+
276
+ // Icon
277
+ if (metadata.icon) {
278
+ if (metadata.icon.emoji) {
279
+ frontmatter += `icon: "${metadata.icon.emoji}"\n`;
280
+ } else if (metadata.icon.url) {
281
+ frontmatter += `icon: "${metadata.icon.url}"\n`;
282
+ }
283
+ }
284
+
285
+ // Custom properties removed - keeping frontmatter clean and standard
286
+
287
+ // Default Astro configuration
288
+ frontmatter += 'tableOfContentsAutoCollapse: true\n';
289
+ frontmatter += '---\n\n';
290
+
291
+ return frontmatter;
292
+ }
293
+
294
+ /**
295
+ * Extract and generate frontmatter from Notion page
296
+ * @param {string} pageId - Notion page ID
297
+ * @param {string} notionToken - Notion API token
298
+ * @returns {string} - Complete YAML frontmatter
299
+ */
300
+ export async function extractAndGenerateNotionFrontmatter(pageId, notionToken) {
301
+ const metadata = await extractNotionMetadata(pageId, notionToken);
302
+ return generateNotionFrontmatter(metadata);
303
+ }
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8013-b668-f14bd1ac0ec0.png ADDED

Git LFS Details

  • SHA256: d98d74457cf5234df8ca8adcc934352fd85f5a9e0136b6a29df9fd06085b36af
  • Pointer size: 131 Bytes
  • Size of remote file: 291 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8014-834f-d700b623256b.png ADDED

Git LFS Details

  • SHA256: b461e8e2a5b124ddb1758524277e7ed820aed6d2d2d0a620f4f570e30803b66d
  • Pointer size: 130 Bytes
  • Size of remote file: 74 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-801d-841a-e35011491566.png ADDED

Git LFS Details

  • SHA256: b01c31948030bed4175a5b2f74608a3d961af2a4f707849aa406cd9646e26e5f
  • Pointer size: 131 Bytes
  • Size of remote file: 186 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8031-ac8d-c5678af1bdd5.png ADDED

Git LFS Details

  • SHA256: c99977a98f7bb69f3afc9fd1a31c6e6b422c0becd9f3968ec580a4d8186d3182
  • Pointer size: 131 Bytes
  • Size of remote file: 180 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8048-9b7e-db4fa7485915.png ADDED

Git LFS Details

  • SHA256: d3ea46c46f2270fc88543836183bae85bd457d4c82329fb7e83a1d5227392fd6
  • Pointer size: 131 Bytes
  • Size of remote file: 163 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-804d-bd0a-e0b1c15e504f.png ADDED

Git LFS Details

  • SHA256: 7ee903889e42121d6953f0a41545f59e69431fdb33ea94808abf7d12b2760b0a
  • Pointer size: 131 Bytes
  • Size of remote file: 265 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8075-ae2e-dc24fe9296ca.png ADDED

Git LFS Details

  • SHA256: 3c8e9ec1d8288a94705ad53a789a80b89684718162ecd5194232f84f5961c938
  • Pointer size: 130 Bytes
  • Size of remote file: 80.6 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-8078-b6da-c7a4c67c8f35.png ADDED

Git LFS Details

  • SHA256: f435105ea29cdb02bfd774bf9a6b9a1e83c62c1a15b10da81d3c849f97c917a7
  • Pointer size: 130 Bytes
  • Size of remote file: 55.7 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808d-9c6d-fae817ac8868.png ADDED

Git LFS Details

  • SHA256: 70dd1ad811ef5941d3bc1eb67ec66924782e511c78ebecce7798624716556f15
  • Pointer size: 130 Bytes
  • Size of remote file: 36.1 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-808f-b712-c7c608da3fc6.png ADDED

Git LFS Details

  • SHA256: 286dfd49b4378214990cbc1532272f00a8a2c9cb2cf6305509f5377ee90e25cc
  • Pointer size: 131 Bytes
  • Size of remote file: 187 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80a9-b4d0-f2129716632d.png ADDED

Git LFS Details

  • SHA256: 99cbea898b137e7befa3835241d170034b526d0d3f78f5dce2596fdf144f4882
  • Pointer size: 131 Bytes
  • Size of remote file: 262 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80aa-b968-c54c9fe7e5d7.png ADDED

Git LFS Details

  • SHA256: 2904c36730cd019f75a50e5f6bfe7ea0936fd741a7908c79344635e1c3237739
  • Pointer size: 131 Bytes
  • Size of remote file: 249 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b6-be07-e8646502f82a.png ADDED

Git LFS Details

  • SHA256: 3432dabfcb5f515b65018d73e15c77923dc85208024f47cdab985fa271002a1e
  • Pointer size: 131 Bytes
  • Size of remote file: 213 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80b9-8cfb-f0a6aaaa8760.png ADDED

Git LFS Details

  • SHA256: 138e37882a394c0e4d103ea6eb46e694ece5bf7092c4c7aeef2bf72b7bf2efa9
  • Pointer size: 131 Bytes
  • Size of remote file: 272 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e7-a500-fb79cebde7e3.png ADDED

Git LFS Details

  • SHA256: bb5141925372d43013596fd0a953e4d7b763a8837d23175f771ec78d29756d57
  • Pointer size: 131 Bytes
  • Size of remote file: 283 kB
app/scripts/notion-to-mdx/output/media/27877f1c9c9d804d9c82f7b3905578ff/image_27877f1c-9c9d-80e9-b729-dbd328930bed.png ADDED

Git LFS Details

  • SHA256: cda03fd1680cf1e8e21b689be6e91269a64c96c629995eb20ae4cebf6ea94ec1
  • Pointer size: 131 Bytes
  • Size of remote file: 221 kB
app/scripts/notion-to-mdx/output/smol-training-guide.md ADDED
The diff for this file is too large to render. See raw diff
 
app/scripts/notion-to-mdx/output/smol-training-guide.mdx ADDED
The diff for this file is too large to render. See raw diff
 
app/scripts/notion-to-mdx/package-lock.json ADDED
Binary file (89.3 kB). View file
 
app/scripts/notion-to-mdx/package.json ADDED
Binary file (1.19 kB). View file
 
app/scripts/notion-to-mdx/post-processor.mjs ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { join, dirname, basename } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ /**
11
+ * Post-process Notion-generated Markdown for better MDX compatibility
12
+ * @param {string} content - Raw markdown content from Notion
13
+ * @returns {string} - Processed markdown content
14
+ */
15
+ export function postProcessMarkdown(content) {
16
+ console.log('🔧 Post-processing Notion Markdown for MDX compatibility...');
17
+
18
+ let processedContent = content;
19
+
20
+ // Apply each transformation step
21
+ processedContent = cleanNotionArtifacts(processedContent);
22
+ processedContent = fixNotionLinks(processedContent);
23
+ processedContent = optimizeImages(processedContent);
24
+ processedContent = shiftHeadingLevels(processedContent);
25
+ processedContent = cleanEmptyLines(processedContent);
26
+ processedContent = fixCodeBlocks(processedContent);
27
+ processedContent = fixCodeBlockEndings(processedContent);
28
+ processedContent = optimizeTables(processedContent);
29
+
30
+ return processedContent;
31
+ }
32
+
33
+ /**
34
+ * Clean Notion-specific artifacts and formatting
35
+ * @param {string} content - Markdown content
36
+ * @returns {string} - Cleaned content
37
+ */
38
+ function cleanNotionArtifacts(content) {
39
+ console.log(' 🧹 Cleaning Notion artifacts...');
40
+
41
+ let cleanedCount = 0;
42
+
43
+ // Remove Notion's internal page references that don't convert well
44
+ content = content.replace(/\[([^\]]+)\]\(https:\/\/www\.notion\.so\/[^)]+\)/g, (match, text) => {
45
+ cleanedCount++;
46
+ return text; // Keep just the text, remove the broken link
47
+ });
48
+
49
+ // Clean up Notion's callout blocks that might not render properly
50
+ content = content.replace(/^> \*\*([^*]+)\*\*\s*\n/gm, '> **$1**\n\n');
51
+
52
+ // Remove Notion's page dividers that don't have markdown equivalents
53
+ content = content.replace(/^---+\s*$/gm, '');
54
+
55
+ // Clean up empty blockquotes
56
+ content = content.replace(/^>\s*$/gm, '');
57
+
58
+ if (cleanedCount > 0) {
59
+ console.log(` ✅ Cleaned ${cleanedCount} Notion artifact(s)`);
60
+ }
61
+
62
+ return content;
63
+ }
64
+
65
+ /**
66
+ * Fix Notion internal links to be more MDX-friendly
67
+ * @param {string} content - Markdown content
68
+ * @returns {string} - Content with fixed links
69
+ */
70
+ function fixNotionLinks(content) {
71
+ console.log(' 🔗 Fixing Notion internal links...');
72
+
73
+ let fixedCount = 0;
74
+
75
+ // Convert Notion page links to relative links (assuming they'll be converted to MDX)
76
+ content = content.replace(/\[([^\]]+)\]\(https:\/\/www\.notion\.so\/[^/]+\/([^?#)]+)\)/g, (match, text, pageId) => {
77
+ fixedCount++;
78
+ // Convert to relative link - this will need to be updated based on your routing
79
+ return `[${text}](#${pageId})`;
80
+ });
81
+
82
+ // Fix broken notion.so links that might be malformed
83
+ content = content.replace(/\[([^\]]+)\]\(https:\/\/www\.notion\.so\/[^)]*\)/g, (match, text) => {
84
+ fixedCount++;
85
+ return text; // Remove broken links, keep text
86
+ });
87
+
88
+ if (fixedCount > 0) {
89
+ console.log(` ✅ Fixed ${fixedCount} Notion link(s)`);
90
+ }
91
+
92
+ return content;
93
+ }
94
+
95
+ /**
96
+ * Optimize images for better MDX compatibility
97
+ * @param {string} content - Markdown content
98
+ * @returns {string} - Content with optimized images
99
+ */
100
+ function optimizeImages(content) {
101
+ console.log(' 🖼️ Optimizing images...');
102
+
103
+ let optimizedCount = 0;
104
+
105
+ // Ensure images have proper alt text
106
+ content = content.replace(/!\[\]\(([^)]+)\)/g, (match, src) => {
107
+ optimizedCount++;
108
+ const filename = basename(src);
109
+ return `![${filename}](${src})`;
110
+ });
111
+
112
+ // Clean up image paths that might have query parameters
113
+ content = content.replace(/!\[([^\]]*)\]\(([^)]+)\?[^)]*\)/g, (match, alt, src) => {
114
+ optimizedCount++;
115
+ return `![${alt}](${src})`;
116
+ });
117
+
118
+ if (optimizedCount > 0) {
119
+ console.log(` ✅ Optimized ${optimizedCount} image(s)`);
120
+ }
121
+
122
+ return content;
123
+ }
124
+
125
+ /**
126
+ * Shift all heading levels down by one (H1 → H2, H2 → H3, etc.)
127
+ * @param {string} content - Markdown content
128
+ * @returns {string} - Content with shifted heading levels
129
+ */
130
+ function shiftHeadingLevels(content) {
131
+ console.log(' 📝 Shifting heading levels down by one...');
132
+
133
+ let shiftedCount = 0;
134
+
135
+ // Shift heading levels: H1 → H2, H2 → H3, H3 → H4, H4 → H5, H5 → H6
136
+ // Process from highest to lowest to avoid conflicts
137
+ content = content.replace(/^##### (.*$)/gim, '###### $1');
138
+ content = content.replace(/^#### (.*$)/gim, '##### $1');
139
+ content = content.replace(/^### (.*$)/gim, '#### $1');
140
+ content = content.replace(/^## (.*$)/gim, '### $1');
141
+ content = content.replace(/^# (.*$)/gim, '## $1');
142
+
143
+ // Count the number of headings shifted
144
+ const headingMatches = content.match(/^#{1,6} /gm);
145
+ if (headingMatches) {
146
+ shiftedCount = headingMatches.length;
147
+ }
148
+
149
+ console.log(` ✅ Shifted ${shiftedCount} heading level(s)`);
150
+ return content;
151
+ }
152
+
153
+ /**
154
+ * Fix code block endings that end with "text" instead of proper closing
155
+ * @param {string} content - Markdown content
156
+ * @returns {string} - Content with fixed code block endings
157
+ */
158
+ function fixCodeBlockEndings(content) {
159
+ console.log(' 💻 Fixing code block endings...');
160
+
161
+ let fixedCount = 0;
162
+
163
+ // Fix code blocks that end with ```text instead of ```
164
+ content = content.replace(/```text\n/g, '```\n');
165
+
166
+ // Count the number of fixes
167
+ const textEndingMatches = content.match(/```text\n/g);
168
+ if (textEndingMatches) {
169
+ fixedCount = textEndingMatches.length;
170
+ }
171
+
172
+ if (fixedCount > 0) {
173
+ console.log(` ✅ Fixed ${fixedCount} code block ending(s)`);
174
+ }
175
+
176
+ return content;
177
+ }
178
+
179
+ /**
180
+ * Clean up excessive empty lines
181
+ * @param {string} content - Markdown content
182
+ * @returns {string} - Content with cleaned spacing
183
+ */
184
+ function cleanEmptyLines(content) {
185
+ console.log(' 📝 Cleaning excessive empty lines...');
186
+
187
+ // Replace 3+ consecutive newlines with 2 newlines
188
+ const cleanedContent = content.replace(/\n{3,}/g, '\n\n');
189
+
190
+ const originalLines = content.split('\n').length;
191
+ const cleanedLines = cleanedContent.split('\n').length;
192
+ const removedLines = originalLines - cleanedLines;
193
+
194
+ if (removedLines > 0) {
195
+ console.log(` ✅ Removed ${removedLines} excessive empty line(s)`);
196
+ }
197
+
198
+ return cleanedContent;
199
+ }
200
+
201
+ /**
202
+ * Fix code blocks for better MDX compatibility
203
+ * @param {string} content - Markdown content
204
+ * @returns {string} - Content with fixed code blocks
205
+ */
206
+ function fixCodeBlocks(content) {
207
+ console.log(' 💻 Fixing code blocks...');
208
+
209
+ let fixedCount = 0;
210
+
211
+ // Ensure code blocks have proper language identifiers
212
+ content = content.replace(/^```\s*$/gm, '```text');
213
+
214
+ // Fix code blocks that might have Notion-specific formatting
215
+ content = content.replace(/^```(\w+)\s*\n([\s\S]*?)\n```$/gm, (match, lang, code) => {
216
+ // Clean up any Notion artifacts in code
217
+ const cleanCode = code.replace(/\u00A0/g, ' '); // Replace non-breaking spaces
218
+ return `\`\`\`${lang}\n${cleanCode}\n\`\`\``;
219
+ });
220
+
221
+ if (fixedCount > 0) {
222
+ console.log(` ✅ Fixed ${fixedCount} code block(s)`);
223
+ }
224
+
225
+ return content;
226
+ }
227
+
228
+ /**
229
+ * Optimize tables for better MDX rendering
230
+ * @param {string} content - Markdown content
231
+ * @returns {string} - Content with optimized tables
232
+ */
233
+ function optimizeTables(content) {
234
+ console.log(' 📊 Optimizing tables...');
235
+
236
+ let optimizedCount = 0;
237
+
238
+ // Fix tables that might have inconsistent column counts
239
+ content = content.replace(/^\|(.+)\|\s*$/gm, (match, row) => {
240
+ const cells = row.split('|').map(cell => cell.trim());
241
+ const cleanCells = cells.filter(cell => cell.length > 0);
242
+
243
+ if (cleanCells.length > 0) {
244
+ optimizedCount++;
245
+ return `| ${cleanCells.join(' | ')} |`;
246
+ }
247
+ return match;
248
+ });
249
+
250
+ // Ensure table headers are properly formatted
251
+ content = content.replace(/^\|(.+)\|\s*\n\|([-:\s|]+)\|\s*$/gm, (match, header, separator) => {
252
+ const headerCells = header.split('|').map(cell => cell.trim()).filter(cell => cell.length > 0);
253
+ const separatorCells = separator.split('|').map(cell => cell.trim()).filter(cell => cell.length > 0);
254
+
255
+ if (headerCells.length !== separatorCells.length) {
256
+ optimizedCount++;
257
+ const newSeparator = headerCells.map(() => '---').join(' | ');
258
+ return `| ${headerCells.join(' | ')} |\n| ${newSeparator} |`;
259
+ }
260
+ return match;
261
+ });
262
+
263
+ if (optimizedCount > 0) {
264
+ console.log(` ✅ Optimized ${optimizedCount} table(s)`);
265
+ }
266
+
267
+ return content;
268
+ }
269
+
270
+ /**
271
+ * Extract frontmatter from Notion page properties
272
+ * @param {Object} pageProperties - Notion page properties
273
+ * @returns {string} - YAML frontmatter
274
+ */
275
+ export function generateFrontmatter(pageProperties) {
276
+ console.log(' 📄 Generating frontmatter from Notion properties...');
277
+
278
+ const frontmatter = {
279
+ title: pageProperties.title || 'Untitled',
280
+ published: new Date().toISOString().split('T')[0],
281
+ tableOfContentsAutoCollapse: true
282
+ };
283
+
284
+ // Add other properties if they exist
285
+ if (pageProperties.description) {
286
+ frontmatter.description = pageProperties.description;
287
+ }
288
+ if (pageProperties.tags) {
289
+ frontmatter.tags = pageProperties.tags;
290
+ }
291
+ if (pageProperties.author) {
292
+ frontmatter.author = pageProperties.author;
293
+ }
294
+
295
+ // Convert to YAML string
296
+ const yamlLines = Object.entries(frontmatter)
297
+ .map(([key, value]) => {
298
+ if (Array.isArray(value)) {
299
+ return `${key}:\n${value.map(v => ` - ${v}`).join('\n')}`;
300
+ }
301
+ return `${key}: "${value}"`;
302
+ });
303
+
304
+ return `---\n${yamlLines.join('\n')}\n---\n\n`;
305
+ }
306
+
307
+ function main() {
308
+ const args = process.argv.slice(2);
309
+
310
+ if (args.includes('--help') || args.includes('-h')) {
311
+ console.log(`
312
+ 🔧 Notion Markdown Post-Processor
313
+
314
+ Usage:
315
+ node post-processor.mjs [options] [input-file] [output-file]
316
+
317
+ Options:
318
+ --verbose Show detailed processing information
319
+ --help, -h Show this help
320
+
321
+ Examples:
322
+ # Process a single file
323
+ node post-processor.mjs input.md output.md
324
+
325
+ # Process with verbose output
326
+ node post-processor.mjs --verbose input.md output.md
327
+ `);
328
+ process.exit(0);
329
+ }
330
+
331
+ const verbose = args.includes('--verbose');
332
+ const inputFile = args.find(arg => !arg.startsWith('--') && arg.endsWith('.md'));
333
+ const outputFile = args.find(arg => !arg.startsWith('--') && arg !== inputFile && arg.endsWith('.md'));
334
+
335
+ if (!inputFile) {
336
+ console.error('❌ Please provide an input markdown file');
337
+ process.exit(1);
338
+ }
339
+
340
+ if (!existsSync(inputFile)) {
341
+ console.error(`❌ Input file not found: ${inputFile}`);
342
+ process.exit(1);
343
+ }
344
+
345
+ try {
346
+ console.log(`📖 Reading: ${inputFile}`);
347
+ const content = readFileSync(inputFile, 'utf8');
348
+
349
+ const processedContent = postProcessMarkdown(content);
350
+
351
+ const finalOutputFile = outputFile || inputFile.replace('.md', '.processed.md');
352
+ writeFileSync(finalOutputFile, processedContent);
353
+
354
+ console.log(`✅ Processed: ${finalOutputFile}`);
355
+
356
+ if (verbose) {
357
+ console.log(`📊 Input: ${content.length} chars → Output: ${processedContent.length} chars`);
358
+ }
359
+
360
+ } catch (error) {
361
+ console.error('❌ Processing failed:', error.message);
362
+ process.exit(1);
363
+ }
364
+ }
365
+
366
+ // Run CLI if called directly
367
+ if (import.meta.url === `file://${process.argv[1]}`) {
368
+ main();
369
+ }
app/scripts/notion-to-mdx/test-access.mjs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv';
4
+ import { Client } from '@notionhq/client';
5
+
6
+ // Load environment variables from .env file
7
+ config();
8
+
9
+ const notion = new Client({
10
+ auth: process.env.NOTION_TOKEN,
11
+ });
12
+
13
+ async function testAccess() {
14
+ const pageId = '27877f1c9c9d804d9c82f7b3905578ff';
15
+
16
+ try {
17
+ console.log('🔍 Testing access to Notion page...');
18
+ console.log(`📄 Page ID: ${pageId}`);
19
+
20
+ const response = await notion.pages.retrieve({ page_id: pageId });
21
+
22
+ console.log('✅ Access successful!');
23
+ console.log(`📝 Page title: ${response.properties.title?.title?.[0]?.text?.content || 'No title'}`);
24
+ console.log(`📅 Created: ${response.created_time}`);
25
+ console.log(`👤 Created by: ${response.created_by.id}`);
26
+
27
+ } catch (error) {
28
+ console.error('❌ Access failed:', error.message);
29
+
30
+ if (error.code === 'unauthorized') {
31
+ console.log('\n💡 Solutions:');
32
+ console.log('1. Check that your NOTION_TOKEN is correct');
33
+ console.log('2. Make sure the page is shared with your integration');
34
+ console.log('3. Verify that the integration has the right permissions');
35
+ }
36
+ }
37
+ }
38
+
39
+ testAccess();
app/scripts/notion-to-mdx/yarn.lock ADDED
@@ -0,0 +1,1118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2
+ # yarn lockfile v1
3
+
4
+
5
+ "@cspotcode/source-map-support@^0.8.0":
6
+ version "0.8.1"
7
+ resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz"
8
+ integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
9
+ dependencies:
10
+ "@jridgewell/trace-mapping" "0.3.9"
11
+
12
+ "@jridgewell/resolve-uri@^3.0.3":
13
+ version "3.1.2"
14
+ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
15
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
16
+
17
+ "@jridgewell/sourcemap-codec@^1.4.10":
18
+ version "1.5.5"
19
+ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"
20
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
21
+
22
+ "@jridgewell/trace-mapping@0.3.9":
23
+ version "0.3.9"
24
+ resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz"
25
+ integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
26
+ dependencies:
27
+ "@jridgewell/resolve-uri" "^3.0.3"
28
+ "@jridgewell/sourcemap-codec" "^1.4.10"
29
+
30
+ "@notionhq/client@^2.0.0", "@notionhq/client@^2.2.15":
31
+ version "2.3.0"
32
+ resolved "https://registry.npmjs.org/@notionhq/client/-/client-2.3.0.tgz"
33
+ integrity sha512-l7WqTCpQqC+HibkB9chghONQTYcxNQT0/rOJemBfmuKQRTu2vuV8B3yA395iKaUdDo7HI+0KvQaz9687Xskzkw==
34
+ dependencies:
35
+ "@types/node-fetch" "^2.5.10"
36
+ node-fetch "^2.6.1"
37
+
38
+ "@tsconfig/node10@^1.0.7":
39
+ version "1.0.11"
40
+ resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz"
41
+ integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==
42
+
43
+ "@tsconfig/node12@^1.0.7":
44
+ version "1.0.11"
45
+ resolved "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz"
46
+ integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
47
+
48
+ "@tsconfig/node14@^1.0.0":
49
+ version "1.0.3"
50
+ resolved "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz"
51
+ integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
52
+
53
+ "@tsconfig/node16@^1.0.2":
54
+ version "1.0.4"
55
+ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz"
56
+ integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
57
+
58
+ "@types/debug@^4.0.0":
59
+ version "4.1.12"
60
+ resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz"
61
+ integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
62
+ dependencies:
63
+ "@types/ms" "*"
64
+
65
+ "@types/estree-jsx@^1.0.0":
66
+ version "1.0.5"
67
+ resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
68
+ integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==
69
+ dependencies:
70
+ "@types/estree" "*"
71
+
72
+ "@types/estree@*", "@types/estree@^1.0.0":
73
+ version "1.0.8"
74
+ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
75
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
76
+
77
+ "@types/hast@^3.0.0":
78
+ version "3.0.4"
79
+ resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz"
80
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
81
+ dependencies:
82
+ "@types/unist" "*"
83
+
84
+ "@types/mdast@^4.0.0":
85
+ version "4.0.4"
86
+ resolved "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz"
87
+ integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==
88
+ dependencies:
89
+ "@types/unist" "*"
90
+
91
+ "@types/ms@*":
92
+ version "2.1.0"
93
+ resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"
94
+ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
95
+
96
+ "@types/node-fetch@^2.5.10":
97
+ version "2.6.13"
98
+ resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz"
99
+ integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==
100
+ dependencies:
101
+ "@types/node" "*"
102
+ form-data "^4.0.4"
103
+
104
+ "@types/node@*":
105
+ version "24.5.2"
106
+ resolved "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz"
107
+ integrity sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==
108
+ dependencies:
109
+ undici-types "~7.12.0"
110
+
111
+ "@types/unist@*", "@types/unist@^3.0.0":
112
+ version "3.0.3"
113
+ resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"
114
+ integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
115
+
116
+ "@types/unist@^2.0.0":
117
+ version "2.0.11"
118
+ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz"
119
+ integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
120
+
121
+ acorn-jsx@^5.0.0:
122
+ version "5.3.2"
123
+ resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
124
+ integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
125
+
126
+ acorn-walk@^8.1.1:
127
+ version "8.3.4"
128
+ resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz"
129
+ integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==
130
+ dependencies:
131
+ acorn "^8.11.0"
132
+
133
+ "acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.0.0, acorn@^8.11.0, acorn@^8.4.1:
134
+ version "8.15.0"
135
+ resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
136
+ integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
137
+
138
+ arg@^4.1.0:
139
+ version "4.1.3"
140
+ resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz"
141
+ integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
142
+
143
+ argparse@^1.0.7:
144
+ version "1.0.10"
145
+ resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz"
146
+ integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
147
+ dependencies:
148
+ sprintf-js "~1.0.2"
149
+
150
+ asynckit@^0.4.0:
151
+ version "0.4.0"
152
+ resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
153
+ integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
154
+
155
+ bail@^2.0.0:
156
+ version "2.0.2"
157
+ resolved "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz"
158
+ integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
159
+
160
+ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
161
+ version "1.0.2"
162
+ resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz"
163
+ integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
164
+ dependencies:
165
+ es-errors "^1.3.0"
166
+ function-bind "^1.1.2"
167
+
168
+ ccount@^2.0.0:
169
+ version "2.0.1"
170
+ resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"
171
+ integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
172
+
173
+ character-entities-html4@^2.0.0:
174
+ version "2.1.0"
175
+ resolved "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz"
176
+ integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==
177
+
178
+ character-entities-legacy@^3.0.0:
179
+ version "3.0.0"
180
+ resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz"
181
+ integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
182
+
183
+ character-entities@^2.0.0:
184
+ version "2.0.2"
185
+ resolved "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz"
186
+ integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
187
+
188
+ character-reference-invalid@^2.0.0:
189
+ version "2.0.1"
190
+ resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz"
191
+ integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
192
+
193
+ combined-stream@^1.0.8:
194
+ version "1.0.8"
195
+ resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
196
+ integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
197
+ dependencies:
198
+ delayed-stream "~1.0.0"
199
+
200
+ create-require@^1.1.0:
201
+ version "1.1.1"
202
+ resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz"
203
+ integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
204
+
205
+ data-uri-to-buffer@^4.0.0:
206
+ version "4.0.1"
207
+ resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz"
208
+ integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
209
+
210
+ debug@^4.0.0:
211
+ version "4.4.3"
212
+ resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
213
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
214
+ dependencies:
215
+ ms "^2.1.3"
216
+
217
+ decode-named-character-reference@^1.0.0:
218
+ version "1.2.0"
219
+ resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz"
220
+ integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==
221
+ dependencies:
222
+ character-entities "^2.0.0"
223
+
224
+ delayed-stream@~1.0.0:
225
+ version "1.0.0"
226
+ resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
227
+ integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
228
+
229
+ dequal@^2.0.0:
230
+ version "2.0.3"
231
+ resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz"
232
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
233
+
234
+ devlop@^1.0.0, devlop@^1.1.0:
235
+ version "1.1.0"
236
+ resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz"
237
+ integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
238
+ dependencies:
239
+ dequal "^2.0.0"
240
+
241
+ diff@^4.0.1:
242
+ version "4.0.2"
243
+ resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz"
244
+ integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
245
+
246
+ dotenv@^17.2.2:
247
+ version "17.2.2"
248
+ resolved "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz"
249
+ integrity sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==
250
+
251
+ dunder-proto@^1.0.1:
252
+ version "1.0.1"
253
+ resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
254
+ integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
255
+ dependencies:
256
+ call-bind-apply-helpers "^1.0.1"
257
+ es-errors "^1.3.0"
258
+ gopd "^1.2.0"
259
+
260
+ es-define-property@^1.0.1:
261
+ version "1.0.1"
262
+ resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"
263
+ integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
264
+
265
+ es-errors@^1.3.0:
266
+ version "1.3.0"
267
+ resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz"
268
+ integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
269
+
270
+ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
271
+ version "1.1.1"
272
+ resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz"
273
+ integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
274
+ dependencies:
275
+ es-errors "^1.3.0"
276
+
277
+ es-set-tostringtag@^2.1.0:
278
+ version "2.1.0"
279
+ resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz"
280
+ integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
281
+ dependencies:
282
+ es-errors "^1.3.0"
283
+ get-intrinsic "^1.2.6"
284
+ has-tostringtag "^1.0.2"
285
+ hasown "^2.0.2"
286
+
287
+ esprima@^4.0.0:
288
+ version "4.0.1"
289
+ resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
290
+ integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
291
+
292
+ estree-util-is-identifier-name@^3.0.0:
293
+ version "3.0.0"
294
+ resolved "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz"
295
+ integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
296
+
297
+ estree-util-visit@^2.0.0:
298
+ version "2.0.0"
299
+ resolved "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz"
300
+ integrity sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==
301
+ dependencies:
302
+ "@types/estree-jsx" "^1.0.0"
303
+ "@types/unist" "^3.0.0"
304
+
305
+ extend-shallow@^2.0.1:
306
+ version "2.0.1"
307
+ resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"
308
+ integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==
309
+ dependencies:
310
+ is-extendable "^0.1.0"
311
+
312
+ extend@^3.0.0:
313
+ version "3.0.2"
314
+ resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz"
315
+ integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
316
+
317
+ fetch-blob@^3.1.2, fetch-blob@^3.1.4:
318
+ version "3.2.0"
319
+ resolved "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz"
320
+ integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
321
+ dependencies:
322
+ node-domexception "^1.0.0"
323
+ web-streams-polyfill "^3.0.3"
324
+
325
+ form-data@^4.0.4:
326
+ version "4.0.4"
327
+ resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz"
328
+ integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==
329
+ dependencies:
330
+ asynckit "^0.4.0"
331
+ combined-stream "^1.0.8"
332
+ es-set-tostringtag "^2.1.0"
333
+ hasown "^2.0.2"
334
+ mime-types "^2.1.12"
335
+
336
+ formdata-polyfill@^4.0.10:
337
+ version "4.0.10"
338
+ resolved "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz"
339
+ integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
340
+ dependencies:
341
+ fetch-blob "^3.1.2"
342
+
343
+ function-bind@^1.1.2:
344
+ version "1.1.2"
345
+ resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
346
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
347
+
348
+ get-intrinsic@^1.2.6:
349
+ version "1.3.0"
350
+ resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
351
+ integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
352
+ dependencies:
353
+ call-bind-apply-helpers "^1.0.2"
354
+ es-define-property "^1.0.1"
355
+ es-errors "^1.3.0"
356
+ es-object-atoms "^1.1.1"
357
+ function-bind "^1.1.2"
358
+ get-proto "^1.0.1"
359
+ gopd "^1.2.0"
360
+ has-symbols "^1.1.0"
361
+ hasown "^2.0.2"
362
+ math-intrinsics "^1.1.0"
363
+
364
+ get-proto@^1.0.1:
365
+ version "1.0.1"
366
+ resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"
367
+ integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
368
+ dependencies:
369
+ dunder-proto "^1.0.1"
370
+ es-object-atoms "^1.0.0"
371
+
372
+ gopd@^1.2.0:
373
+ version "1.2.0"
374
+ resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
375
+ integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
376
+
377
+ gray-matter@^4.0.3:
378
+ version "4.0.3"
379
+ resolved "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz"
380
+ integrity sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==
381
+ dependencies:
382
+ js-yaml "^3.13.1"
383
+ kind-of "^6.0.2"
384
+ section-matter "^1.0.0"
385
+ strip-bom-string "^1.0.0"
386
+
387
+ has-symbols@^1.0.3, has-symbols@^1.1.0:
388
+ version "1.1.0"
389
+ resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz"
390
+ integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
391
+
392
+ has-tostringtag@^1.0.2:
393
+ version "1.0.2"
394
+ resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz"
395
+ integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
396
+ dependencies:
397
+ has-symbols "^1.0.3"
398
+
399
+ hasown@^2.0.2:
400
+ version "2.0.2"
401
+ resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz"
402
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
403
+ dependencies:
404
+ function-bind "^1.1.2"
405
+
406
+ is-alphabetical@^2.0.0:
407
+ version "2.0.1"
408
+ resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz"
409
+ integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
410
+
411
+ is-alphanumerical@^2.0.0:
412
+ version "2.0.1"
413
+ resolved "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz"
414
+ integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
415
+ dependencies:
416
+ is-alphabetical "^2.0.0"
417
+ is-decimal "^2.0.0"
418
+
419
+ is-decimal@^2.0.0:
420
+ version "2.0.1"
421
+ resolved "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz"
422
+ integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
423
+
424
+ is-extendable@^0.1.0:
425
+ version "0.1.1"
426
+ resolved "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz"
427
+ integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==
428
+
429
+ is-hexadecimal@^2.0.0:
430
+ version "2.0.1"
431
+ resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz"
432
+ integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==
433
+
434
+ is-plain-obj@^4.0.0:
435
+ version "4.1.0"
436
+ resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz"
437
+ integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
438
+
439
+ js-yaml@^3.13.1:
440
+ version "3.14.1"
441
+ resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz"
442
+ integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
443
+ dependencies:
444
+ argparse "^1.0.7"
445
+ esprima "^4.0.0"
446
+
447
+ kind-of@^6.0.0, kind-of@^6.0.2:
448
+ version "6.0.3"
449
+ resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz"
450
+ integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
451
+
452
+ longest-streak@^3.0.0:
453
+ version "3.1.0"
454
+ resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"
455
+ integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
456
+
457
+ make-error@^1.1.1:
458
+ version "1.3.6"
459
+ resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz"
460
+ integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
461
+
462
+ math-intrinsics@^1.1.0:
463
+ version "1.1.0"
464
+ resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
465
+ integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
466
+
467
+ mdast-util-from-markdown@^2.0.0:
468
+ version "2.0.2"
469
+ resolved "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz"
470
+ integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
471
+ dependencies:
472
+ "@types/mdast" "^4.0.0"
473
+ "@types/unist" "^3.0.0"
474
+ decode-named-character-reference "^1.0.0"
475
+ devlop "^1.0.0"
476
+ mdast-util-to-string "^4.0.0"
477
+ micromark "^4.0.0"
478
+ micromark-util-decode-numeric-character-reference "^2.0.0"
479
+ micromark-util-decode-string "^2.0.0"
480
+ micromark-util-normalize-identifier "^2.0.0"
481
+ micromark-util-symbol "^2.0.0"
482
+ micromark-util-types "^2.0.0"
483
+ unist-util-stringify-position "^4.0.0"
484
+
485
+ mdast-util-mdx-expression@^2.0.0:
486
+ version "2.0.1"
487
+ resolved "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz"
488
+ integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==
489
+ dependencies:
490
+ "@types/estree-jsx" "^1.0.0"
491
+ "@types/hast" "^3.0.0"
492
+ "@types/mdast" "^4.0.0"
493
+ devlop "^1.0.0"
494
+ mdast-util-from-markdown "^2.0.0"
495
+ mdast-util-to-markdown "^2.0.0"
496
+
497
+ mdast-util-mdx-jsx@^3.0.0:
498
+ version "3.2.0"
499
+ resolved "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz"
500
+ integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==
501
+ dependencies:
502
+ "@types/estree-jsx" "^1.0.0"
503
+ "@types/hast" "^3.0.0"
504
+ "@types/mdast" "^4.0.0"
505
+ "@types/unist" "^3.0.0"
506
+ ccount "^2.0.0"
507
+ devlop "^1.1.0"
508
+ mdast-util-from-markdown "^2.0.0"
509
+ mdast-util-to-markdown "^2.0.0"
510
+ parse-entities "^4.0.0"
511
+ stringify-entities "^4.0.0"
512
+ unist-util-stringify-position "^4.0.0"
513
+ vfile-message "^4.0.0"
514
+
515
+ mdast-util-mdx@^3.0.0:
516
+ version "3.0.0"
517
+ resolved "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz"
518
+ integrity sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==
519
+ dependencies:
520
+ mdast-util-from-markdown "^2.0.0"
521
+ mdast-util-mdx-expression "^2.0.0"
522
+ mdast-util-mdx-jsx "^3.0.0"
523
+ mdast-util-mdxjs-esm "^2.0.0"
524
+ mdast-util-to-markdown "^2.0.0"
525
+
526
+ mdast-util-mdxjs-esm@^2.0.0:
527
+ version "2.0.1"
528
+ resolved "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz"
529
+ integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==
530
+ dependencies:
531
+ "@types/estree-jsx" "^1.0.0"
532
+ "@types/hast" "^3.0.0"
533
+ "@types/mdast" "^4.0.0"
534
+ devlop "^1.0.0"
535
+ mdast-util-from-markdown "^2.0.0"
536
+ mdast-util-to-markdown "^2.0.0"
537
+
538
+ mdast-util-phrasing@^4.0.0:
539
+ version "4.1.0"
540
+ resolved "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz"
541
+ integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==
542
+ dependencies:
543
+ "@types/mdast" "^4.0.0"
544
+ unist-util-is "^6.0.0"
545
+
546
+ mdast-util-to-markdown@^2.0.0:
547
+ version "2.1.2"
548
+ resolved "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz"
549
+ integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==
550
+ dependencies:
551
+ "@types/mdast" "^4.0.0"
552
+ "@types/unist" "^3.0.0"
553
+ longest-streak "^3.0.0"
554
+ mdast-util-phrasing "^4.0.0"
555
+ mdast-util-to-string "^4.0.0"
556
+ micromark-util-classify-character "^2.0.0"
557
+ micromark-util-decode-string "^2.0.0"
558
+ unist-util-visit "^5.0.0"
559
+ zwitch "^2.0.0"
560
+
561
+ mdast-util-to-string@^4.0.0:
562
+ version "4.0.0"
563
+ resolved "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz"
564
+ integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==
565
+ dependencies:
566
+ "@types/mdast" "^4.0.0"
567
+
568
+ micromark-core-commonmark@^2.0.0:
569
+ version "2.0.3"
570
+ resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"
571
+ integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==
572
+ dependencies:
573
+ decode-named-character-reference "^1.0.0"
574
+ devlop "^1.0.0"
575
+ micromark-factory-destination "^2.0.0"
576
+ micromark-factory-label "^2.0.0"
577
+ micromark-factory-space "^2.0.0"
578
+ micromark-factory-title "^2.0.0"
579
+ micromark-factory-whitespace "^2.0.0"
580
+ micromark-util-character "^2.0.0"
581
+ micromark-util-chunked "^2.0.0"
582
+ micromark-util-classify-character "^2.0.0"
583
+ micromark-util-html-tag-name "^2.0.0"
584
+ micromark-util-normalize-identifier "^2.0.0"
585
+ micromark-util-resolve-all "^2.0.0"
586
+ micromark-util-subtokenize "^2.0.0"
587
+ micromark-util-symbol "^2.0.0"
588
+ micromark-util-types "^2.0.0"
589
+
590
+ micromark-extension-mdx-expression@^3.0.0:
591
+ version "3.0.1"
592
+ resolved "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz"
593
+ integrity sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==
594
+ dependencies:
595
+ "@types/estree" "^1.0.0"
596
+ devlop "^1.0.0"
597
+ micromark-factory-mdx-expression "^2.0.0"
598
+ micromark-factory-space "^2.0.0"
599
+ micromark-util-character "^2.0.0"
600
+ micromark-util-events-to-acorn "^2.0.0"
601
+ micromark-util-symbol "^2.0.0"
602
+ micromark-util-types "^2.0.0"
603
+
604
+ micromark-extension-mdx-jsx@^3.0.0:
605
+ version "3.0.2"
606
+ resolved "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz"
607
+ integrity sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==
608
+ dependencies:
609
+ "@types/estree" "^1.0.0"
610
+ devlop "^1.0.0"
611
+ estree-util-is-identifier-name "^3.0.0"
612
+ micromark-factory-mdx-expression "^2.0.0"
613
+ micromark-factory-space "^2.0.0"
614
+ micromark-util-character "^2.0.0"
615
+ micromark-util-events-to-acorn "^2.0.0"
616
+ micromark-util-symbol "^2.0.0"
617
+ micromark-util-types "^2.0.0"
618
+ vfile-message "^4.0.0"
619
+
620
+ micromark-extension-mdx-md@^2.0.0:
621
+ version "2.0.0"
622
+ resolved "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz"
623
+ integrity sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==
624
+ dependencies:
625
+ micromark-util-types "^2.0.0"
626
+
627
+ micromark-extension-mdxjs-esm@^3.0.0:
628
+ version "3.0.0"
629
+ resolved "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz"
630
+ integrity sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==
631
+ dependencies:
632
+ "@types/estree" "^1.0.0"
633
+ devlop "^1.0.0"
634
+ micromark-core-commonmark "^2.0.0"
635
+ micromark-util-character "^2.0.0"
636
+ micromark-util-events-to-acorn "^2.0.0"
637
+ micromark-util-symbol "^2.0.0"
638
+ micromark-util-types "^2.0.0"
639
+ unist-util-position-from-estree "^2.0.0"
640
+ vfile-message "^4.0.0"
641
+
642
+ micromark-extension-mdxjs@^3.0.0:
643
+ version "3.0.0"
644
+ resolved "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz"
645
+ integrity sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==
646
+ dependencies:
647
+ acorn "^8.0.0"
648
+ acorn-jsx "^5.0.0"
649
+ micromark-extension-mdx-expression "^3.0.0"
650
+ micromark-extension-mdx-jsx "^3.0.0"
651
+ micromark-extension-mdx-md "^2.0.0"
652
+ micromark-extension-mdxjs-esm "^3.0.0"
653
+ micromark-util-combine-extensions "^2.0.0"
654
+ micromark-util-types "^2.0.0"
655
+
656
+ micromark-factory-destination@^2.0.0:
657
+ version "2.0.1"
658
+ resolved "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz"
659
+ integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
660
+ dependencies:
661
+ micromark-util-character "^2.0.0"
662
+ micromark-util-symbol "^2.0.0"
663
+ micromark-util-types "^2.0.0"
664
+
665
+ micromark-factory-label@^2.0.0:
666
+ version "2.0.1"
667
+ resolved "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz"
668
+ integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
669
+ dependencies:
670
+ devlop "^1.0.0"
671
+ micromark-util-character "^2.0.0"
672
+ micromark-util-symbol "^2.0.0"
673
+ micromark-util-types "^2.0.0"
674
+
675
+ micromark-factory-mdx-expression@^2.0.0:
676
+ version "2.0.3"
677
+ resolved "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz"
678
+ integrity sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==
679
+ dependencies:
680
+ "@types/estree" "^1.0.0"
681
+ devlop "^1.0.0"
682
+ micromark-factory-space "^2.0.0"
683
+ micromark-util-character "^2.0.0"
684
+ micromark-util-events-to-acorn "^2.0.0"
685
+ micromark-util-symbol "^2.0.0"
686
+ micromark-util-types "^2.0.0"
687
+ unist-util-position-from-estree "^2.0.0"
688
+ vfile-message "^4.0.0"
689
+
690
+ micromark-factory-space@^2.0.0:
691
+ version "2.0.1"
692
+ resolved "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz"
693
+ integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
694
+ dependencies:
695
+ micromark-util-character "^2.0.0"
696
+ micromark-util-types "^2.0.0"
697
+
698
+ micromark-factory-title@^2.0.0:
699
+ version "2.0.1"
700
+ resolved "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz"
701
+ integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
702
+ dependencies:
703
+ micromark-factory-space "^2.0.0"
704
+ micromark-util-character "^2.0.0"
705
+ micromark-util-symbol "^2.0.0"
706
+ micromark-util-types "^2.0.0"
707
+
708
+ micromark-factory-whitespace@^2.0.0:
709
+ version "2.0.1"
710
+ resolved "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz"
711
+ integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
712
+ dependencies:
713
+ micromark-factory-space "^2.0.0"
714
+ micromark-util-character "^2.0.0"
715
+ micromark-util-symbol "^2.0.0"
716
+ micromark-util-types "^2.0.0"
717
+
718
+ micromark-util-character@^2.0.0:
719
+ version "2.1.1"
720
+ resolved "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz"
721
+ integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
722
+ dependencies:
723
+ micromark-util-symbol "^2.0.0"
724
+ micromark-util-types "^2.0.0"
725
+
726
+ micromark-util-chunked@^2.0.0:
727
+ version "2.0.1"
728
+ resolved "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz"
729
+ integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
730
+ dependencies:
731
+ micromark-util-symbol "^2.0.0"
732
+
733
+ micromark-util-classify-character@^2.0.0:
734
+ version "2.0.1"
735
+ resolved "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz"
736
+ integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
737
+ dependencies:
738
+ micromark-util-character "^2.0.0"
739
+ micromark-util-symbol "^2.0.0"
740
+ micromark-util-types "^2.0.0"
741
+
742
+ micromark-util-combine-extensions@^2.0.0:
743
+ version "2.0.1"
744
+ resolved "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz"
745
+ integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
746
+ dependencies:
747
+ micromark-util-chunked "^2.0.0"
748
+ micromark-util-types "^2.0.0"
749
+
750
+ micromark-util-decode-numeric-character-reference@^2.0.0:
751
+ version "2.0.2"
752
+ resolved "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz"
753
+ integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
754
+ dependencies:
755
+ micromark-util-symbol "^2.0.0"
756
+
757
+ micromark-util-decode-string@^2.0.0:
758
+ version "2.0.1"
759
+ resolved "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz"
760
+ integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==
761
+ dependencies:
762
+ decode-named-character-reference "^1.0.0"
763
+ micromark-util-character "^2.0.0"
764
+ micromark-util-decode-numeric-character-reference "^2.0.0"
765
+ micromark-util-symbol "^2.0.0"
766
+
767
+ micromark-util-encode@^2.0.0:
768
+ version "2.0.1"
769
+ resolved "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz"
770
+ integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
771
+
772
+ micromark-util-events-to-acorn@^2.0.0:
773
+ version "2.0.3"
774
+ resolved "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz"
775
+ integrity sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==
776
+ dependencies:
777
+ "@types/estree" "^1.0.0"
778
+ "@types/unist" "^3.0.0"
779
+ devlop "^1.0.0"
780
+ estree-util-visit "^2.0.0"
781
+ micromark-util-symbol "^2.0.0"
782
+ micromark-util-types "^2.0.0"
783
+ vfile-message "^4.0.0"
784
+
785
+ micromark-util-html-tag-name@^2.0.0:
786
+ version "2.0.1"
787
+ resolved "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz"
788
+ integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
789
+
790
+ micromark-util-normalize-identifier@^2.0.0:
791
+ version "2.0.1"
792
+ resolved "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz"
793
+ integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
794
+ dependencies:
795
+ micromark-util-symbol "^2.0.0"
796
+
797
+ micromark-util-resolve-all@^2.0.0:
798
+ version "2.0.1"
799
+ resolved "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz"
800
+ integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
801
+ dependencies:
802
+ micromark-util-types "^2.0.0"
803
+
804
+ micromark-util-sanitize-uri@^2.0.0:
805
+ version "2.0.1"
806
+ resolved "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz"
807
+ integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
808
+ dependencies:
809
+ micromark-util-character "^2.0.0"
810
+ micromark-util-encode "^2.0.0"
811
+ micromark-util-symbol "^2.0.0"
812
+
813
+ micromark-util-subtokenize@^2.0.0:
814
+ version "2.1.0"
815
+ resolved "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz"
816
+ integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==
817
+ dependencies:
818
+ devlop "^1.0.0"
819
+ micromark-util-chunked "^2.0.0"
820
+ micromark-util-symbol "^2.0.0"
821
+ micromark-util-types "^2.0.0"
822
+
823
+ micromark-util-symbol@^2.0.0:
824
+ version "2.0.1"
825
+ resolved "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz"
826
+ integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
827
+
828
+ micromark-util-types@^2.0.0:
829
+ version "2.0.2"
830
+ resolved "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz"
831
+ integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
832
+
833
+ micromark@^4.0.0:
834
+ version "4.0.2"
835
+ resolved "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz"
836
+ integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==
837
+ dependencies:
838
+ "@types/debug" "^4.0.0"
839
+ debug "^4.0.0"
840
+ decode-named-character-reference "^1.0.0"
841
+ devlop "^1.0.0"
842
+ micromark-core-commonmark "^2.0.0"
843
+ micromark-factory-space "^2.0.0"
844
+ micromark-util-character "^2.0.0"
845
+ micromark-util-chunked "^2.0.0"
846
+ micromark-util-combine-extensions "^2.0.0"
847
+ micromark-util-decode-numeric-character-reference "^2.0.0"
848
+ micromark-util-encode "^2.0.0"
849
+ micromark-util-normalize-identifier "^2.0.0"
850
+ micromark-util-resolve-all "^2.0.0"
851
+ micromark-util-sanitize-uri "^2.0.0"
852
+ micromark-util-subtokenize "^2.0.0"
853
+ micromark-util-symbol "^2.0.0"
854
+ micromark-util-types "^2.0.0"
855
+
856
+ mime-db@1.52.0:
857
+ version "1.52.0"
858
+ resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
859
+ integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
860
+
861
+ mime-types@^2.1.12, mime-types@^2.1.35:
862
+ version "2.1.35"
863
+ resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
864
+ integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
865
+ dependencies:
866
+ mime-db "1.52.0"
867
+
868
+ mime@^3.0.0:
869
+ version "3.0.0"
870
+ resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz"
871
+ integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
872
+
873
+ ms@^2.1.3:
874
+ version "2.1.3"
875
+ resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
876
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
877
+
878
+ node-domexception@^1.0.0:
879
+ version "1.0.0"
880
+ resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
881
+ integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
882
+
883
+ node-fetch@^2.6.1:
884
+ version "2.7.0"
885
+ resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
886
+ integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
887
+ dependencies:
888
+ whatwg-url "^5.0.0"
889
+
890
+ node-fetch@^2.7.0:
891
+ version "2.7.0"
892
+ resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
893
+ integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
894
+ dependencies:
895
+ whatwg-url "^5.0.0"
896
+
897
+ node-fetch@^3.3.2:
898
+ version "3.3.2"
899
+ resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz"
900
+ integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
901
+ dependencies:
902
+ data-uri-to-buffer "^4.0.0"
903
+ fetch-blob "^3.1.4"
904
+ formdata-polyfill "^4.0.10"
905
+
906
+ notion-to-md@^4.0.0-alpha:
907
+ version "4.0.0-alpha.7"
908
+ resolved "https://registry.npmjs.org/notion-to-md/-/notion-to-md-4.0.0-alpha.7.tgz"
909
+ integrity sha512-3kocKMEVcivy2ccuv2uZDJQFKXdvRmsujbN2GeOwP6yoNqhj/c/fmXroqPkk4XXRqNdJB2jzf5NPhPSWpuZkdA==
910
+ dependencies:
911
+ mime "^3.0.0"
912
+ node-fetch "^2.7.0"
913
+ ts-node "^10.9.2"
914
+
915
+ parse-entities@^4.0.0:
916
+ version "4.0.2"
917
+ resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz"
918
+ integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==
919
+ dependencies:
920
+ "@types/unist" "^2.0.0"
921
+ character-entities-legacy "^3.0.0"
922
+ character-reference-invalid "^2.0.0"
923
+ decode-named-character-reference "^1.0.0"
924
+ is-alphanumerical "^2.0.0"
925
+ is-decimal "^2.0.0"
926
+ is-hexadecimal "^2.0.0"
927
+
928
+ remark-mdx@^3.0.0:
929
+ version "3.1.1"
930
+ resolved "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz"
931
+ integrity sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==
932
+ dependencies:
933
+ mdast-util-mdx "^3.0.0"
934
+ micromark-extension-mdxjs "^3.0.0"
935
+
936
+ remark-parse@^11.0.0:
937
+ version "11.0.0"
938
+ resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"
939
+ integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==
940
+ dependencies:
941
+ "@types/mdast" "^4.0.0"
942
+ mdast-util-from-markdown "^2.0.0"
943
+ micromark-util-types "^2.0.0"
944
+ unified "^11.0.0"
945
+
946
+ remark-stringify@^11.0.0:
947
+ version "11.0.0"
948
+ resolved "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz"
949
+ integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==
950
+ dependencies:
951
+ "@types/mdast" "^4.0.0"
952
+ mdast-util-to-markdown "^2.0.0"
953
+ unified "^11.0.0"
954
+
955
+ section-matter@^1.0.0:
956
+ version "1.0.0"
957
+ resolved "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz"
958
+ integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==
959
+ dependencies:
960
+ extend-shallow "^2.0.1"
961
+ kind-of "^6.0.0"
962
+
963
+ sprintf-js@~1.0.2:
964
+ version "1.0.3"
965
+ resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
966
+ integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
967
+
968
+ stringify-entities@^4.0.0:
969
+ version "4.0.4"
970
+ resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"
971
+ integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==
972
+ dependencies:
973
+ character-entities-html4 "^2.0.0"
974
+ character-entities-legacy "^3.0.0"
975
+
976
+ strip-bom-string@^1.0.0:
977
+ version "1.0.0"
978
+ resolved "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz"
979
+ integrity sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==
980
+
981
+ tr46@~0.0.3:
982
+ version "0.0.3"
983
+ resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz"
984
+ integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
985
+
986
+ trough@^2.0.0:
987
+ version "2.2.0"
988
+ resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz"
989
+ integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
990
+
991
+ ts-node@^10.9.2:
992
+ version "10.9.2"
993
+ resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz"
994
+ integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
995
+ dependencies:
996
+ "@cspotcode/source-map-support" "^0.8.0"
997
+ "@tsconfig/node10" "^1.0.7"
998
+ "@tsconfig/node12" "^1.0.7"
999
+ "@tsconfig/node14" "^1.0.0"
1000
+ "@tsconfig/node16" "^1.0.2"
1001
+ acorn "^8.4.1"
1002
+ acorn-walk "^8.1.1"
1003
+ arg "^4.1.0"
1004
+ create-require "^1.1.0"
1005
+ diff "^4.0.1"
1006
+ make-error "^1.1.1"
1007
+ v8-compile-cache-lib "^3.0.1"
1008
+ yn "3.1.1"
1009
+
1010
+ typescript@>=2.7:
1011
+ version "5.9.2"
1012
+ resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz"
1013
+ integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
1014
+
1015
+ undici-types@~7.12.0:
1016
+ version "7.12.0"
1017
+ resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz"
1018
+ integrity sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==
1019
+
1020
+ unified@^11.0.0, unified@^11.0.4:
1021
+ version "11.0.5"
1022
+ resolved "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz"
1023
+ integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==
1024
+ dependencies:
1025
+ "@types/unist" "^3.0.0"
1026
+ bail "^2.0.0"
1027
+ devlop "^1.0.0"
1028
+ extend "^3.0.0"
1029
+ is-plain-obj "^4.0.0"
1030
+ trough "^2.0.0"
1031
+ vfile "^6.0.0"
1032
+
1033
+ unist-util-is@^6.0.0:
1034
+ version "6.0.0"
1035
+ resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz"
1036
+ integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==
1037
+ dependencies:
1038
+ "@types/unist" "^3.0.0"
1039
+
1040
+ unist-util-position-from-estree@^2.0.0:
1041
+ version "2.0.0"
1042
+ resolved "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz"
1043
+ integrity sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==
1044
+ dependencies:
1045
+ "@types/unist" "^3.0.0"
1046
+
1047
+ unist-util-stringify-position@^4.0.0:
1048
+ version "4.0.0"
1049
+ resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz"
1050
+ integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==
1051
+ dependencies:
1052
+ "@types/unist" "^3.0.0"
1053
+
1054
+ unist-util-visit-parents@^6.0.0:
1055
+ version "6.0.1"
1056
+ resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz"
1057
+ integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==
1058
+ dependencies:
1059
+ "@types/unist" "^3.0.0"
1060
+ unist-util-is "^6.0.0"
1061
+
1062
+ unist-util-visit@^5.0.0:
1063
+ version "5.0.0"
1064
+ resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz"
1065
+ integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==
1066
+ dependencies:
1067
+ "@types/unist" "^3.0.0"
1068
+ unist-util-is "^6.0.0"
1069
+ unist-util-visit-parents "^6.0.0"
1070
+
1071
+ v8-compile-cache-lib@^3.0.1:
1072
+ version "3.0.1"
1073
+ resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"
1074
+ integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
1075
+
1076
+ vfile-message@^4.0.0:
1077
+ version "4.0.3"
1078
+ resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz"
1079
+ integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==
1080
+ dependencies:
1081
+ "@types/unist" "^3.0.0"
1082
+ unist-util-stringify-position "^4.0.0"
1083
+
1084
+ vfile@^6.0.0:
1085
+ version "6.0.3"
1086
+ resolved "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz"
1087
+ integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==
1088
+ dependencies:
1089
+ "@types/unist" "^3.0.0"
1090
+ vfile-message "^4.0.0"
1091
+
1092
+ web-streams-polyfill@^3.0.3:
1093
+ version "3.3.3"
1094
+ resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz"
1095
+ integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
1096
+
1097
+ webidl-conversions@^3.0.0:
1098
+ version "3.0.1"
1099
+ resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
1100
+ integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
1101
+
1102
+ whatwg-url@^5.0.0:
1103
+ version "5.0.0"
1104
+ resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz"
1105
+ integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
1106
+ dependencies:
1107
+ tr46 "~0.0.3"
1108
+ webidl-conversions "^3.0.0"
1109
+
1110
+ yn@3.1.1:
1111
+ version "3.1.1"
1112
+ resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"
1113
+ integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
1114
+
1115
+ zwitch@^2.0.0:
1116
+ version "2.0.4"
1117
+ resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz"
1118
+ integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
app/src/components/Hero.astro CHANGED
@@ -5,26 +5,49 @@ interface Props {
5
  title: string; // may contain HTML (e.g., <br/>)
6
  titleRaw?: string; // plain title for slug/PDF (optional)
7
  description?: string;
8
- authors?: Array<string | { name: string; url?: string; affiliationIndices?: number[] }>;
 
 
9
  affiliations?: Array<{ id: number; name: string; url?: string }>;
10
  affiliation?: string; // legacy single affiliation
11
  published?: string;
12
  doi?: string;
13
  }
14
 
15
- const { title, titleRaw, description, authors = [], affiliations = [], affiliation, published, doi } = Astro.props as Props;
 
 
 
 
 
 
 
 
 
16
 
17
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
18
 
19
- function normalizeAuthors(input: Array<string | { name?: string; url?: string; link?: string; affiliationIndices?: number[] }>): Author[] {
 
 
 
 
 
 
 
 
 
 
20
  return (Array.isArray(input) ? input : [])
21
  .map((a) => {
22
- if (typeof a === 'string') {
23
  return { name: a } as Author;
24
  }
25
- const name = (a?.name ?? '').toString();
26
  const url = (a?.url ?? a?.link) as string | undefined;
27
- const affiliationIndices = Array.isArray((a as any)?.affiliationIndices) ? (a as any).affiliationIndices : undefined;
 
 
28
  return { name, url, affiliationIndices } as Author;
29
  })
30
  .filter((a) => a.name && a.name.trim().length > 0);
@@ -35,35 +58,41 @@ const normalizedAuthors: Author[] = normalizeAuthors(authors as any);
35
  // Determine if affiliation superscripts should be shown (only when there are multiple distinct affiliations referenced by authors)
36
  const authorAffiliationIndexSet = new Set<number>();
37
  for (const author of normalizedAuthors) {
38
- const indices = Array.isArray(author.affiliationIndices) ? author.affiliationIndices : [];
 
 
39
  for (const idx of indices) {
40
- if (typeof idx === 'number') {
41
  authorAffiliationIndexSet.add(idx);
42
  }
43
  }
44
  }
45
  const shouldShowAffiliationSupers = authorAffiliationIndexSet.size > 1;
46
- const hasMultipleAffiliations = Array.isArray(affiliations) && affiliations.length > 1;
 
47
 
48
  function stripHtml(text: string): string {
49
- return String(text || '').replace(/<[^>]*>/g, '');
50
  }
51
 
52
  function slugify(text: string): string {
53
- return String(text || '')
54
- .normalize('NFKD')
55
- .replace(/\p{Diacritic}+/gu, '')
56
- .toLowerCase()
57
- .replace(/[^a-z0-9]+/g, '-')
58
- .replace(/^-+|-+$/g, '')
59
- .slice(0, 120) || 'article';
 
 
60
  }
61
 
62
  const pdfBase = titleRaw ? stripHtml(titleRaw) : stripHtml(title);
63
  const pdfFilename = `${slugify(pdfBase)}.pdf`;
64
  ---
 
65
  <section class="hero">
66
- <h1 class="hero-title" set:html={title}></h1>
67
  <div class="hero-banner">
68
  <HtmlEmbed src="banner.html" frameless />
69
  {description && <p class="hero-desc">{description}</p>}
@@ -72,53 +101,82 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
72
 
73
  <header class="meta" aria-label="Article meta information">
74
  <div class="meta-container">
75
- {normalizedAuthors.length > 0 && (
76
- <div class="meta-container-cell">
77
- <h3>Author{normalizedAuthors.length > 1 ? 's' : ''}</h3>
78
- <ul class="authors">
79
- {normalizedAuthors.map((a, i) => {
80
- const supers = shouldShowAffiliationSupers && Array.isArray(a.affiliationIndices) && a.affiliationIndices.length
81
- ? <sup>{a.affiliationIndices.join(',')}</sup>
82
- : null;
83
- return (
84
- <li>
85
- {a.url ? <a href={a.url}>{a.name}</a> : a.name}{supers}
86
- </li>
87
- );
88
- })}
89
- </ul>
90
- </div>
91
- )}
92
- {(Array.isArray(affiliations) && affiliations.length > 0) && (
93
- <div class="meta-container-cell">
94
- <h3>Affiliation{affiliations.length > 1 ? 's' : ''}</h3>
95
- {hasMultipleAffiliations ? (
96
- <ol class="affiliations">
97
- {affiliations.map((af) => (
98
- <li value={af.id}>{af.url ? <a href={af.url} target="_blank" rel="noopener noreferrer">{af.name}</a> : af.name}</li>
99
- ))}
100
- </ol>
101
- ) : (
102
- <p>
103
- {affiliations[0]?.url
104
- ? <a href={affiliations[0].url} target="_blank" rel="noopener noreferrer">{affiliations[0].name}</a>
105
- : affiliations[0]?.name}
106
- </p>
107
- )}
108
- </div>
109
- )}
110
- {(!affiliations || affiliations.length === 0) && affiliation && (
111
- <div class="meta-container-cell">
112
- <h3>Affiliation</h3>
113
- <p>{affiliation}</p>
114
- </div>
115
- )}
116
- {published && (
117
- <div class="meta-container-cell meta-container-cell--published">
118
- <h3>Published</h3>
119
- <p>{published}</p>
120
- </div>
121
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  <!-- {doi && (
123
  <div class="meta-container-cell">
124
  <h3>DOI</h3>
@@ -128,7 +186,12 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
128
  <div class="meta-container-cell meta-container-cell--pdf">
129
  <h3>PDF</h3>
130
  <p>
131
- <a class="button" href={`/${pdfFilename}`} download={pdfFilename} aria-label={`Download PDF ${pdfFilename}`}>
 
 
 
 
 
132
  Download PDF
133
  </a>
134
  </p>
@@ -136,7 +199,6 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
136
  </div>
137
  </header>
138
 
139
-
140
  <style>
141
  /* Hero (full-width) */
142
  .hero {
@@ -185,7 +247,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
185
  text-underline-offset: 2px;
186
  text-decoration-thickness: 0.06em;
187
  text-decoration-color: var(--link-underline);
188
- transition: text-decoration-color .15s ease-in-out;
189
  }
190
  .meta-container a:hover {
191
  text-decoration-color: var(--link-underline-hover);
@@ -198,6 +260,7 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
198
  display: flex;
199
  flex-direction: column;
200
  gap: 8px;
 
201
  }
202
  .meta-container-cell h3 {
203
  margin: 0;
@@ -205,15 +268,21 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
205
  font-weight: 400;
206
  color: var(--muted-color);
207
  text-transform: uppercase;
208
- letter-spacing: .02em;
209
  }
210
  .meta-container-cell p {
211
  margin: 0;
212
- }
213
  .authors {
214
  margin: 0;
215
  list-style-type: none;
216
  padding-left: 0;
 
 
 
 
 
 
217
  }
218
  .affiliations {
219
  margin: 0;
@@ -227,12 +296,17 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
227
  flex-wrap: wrap;
228
  row-gap: 12px;
229
  }
230
-
 
 
 
 
 
 
 
231
  @media print {
232
  .meta-container-cell--pdf {
233
  display: none !important;
234
  }
235
  }
236
  </style>
237
-
238
-
 
5
  title: string; // may contain HTML (e.g., <br/>)
6
  titleRaw?: string; // plain title for slug/PDF (optional)
7
  description?: string;
8
+ authors?: Array<
9
+ string | { name: string; url?: string; affiliationIndices?: number[] }
10
+ >;
11
  affiliations?: Array<{ id: number; name: string; url?: string }>;
12
  affiliation?: string; // legacy single affiliation
13
  published?: string;
14
  doi?: string;
15
  }
16
 
17
+ const {
18
+ title,
19
+ titleRaw,
20
+ description,
21
+ authors = [],
22
+ affiliations = [],
23
+ affiliation,
24
+ published,
25
+ doi,
26
+ } = Astro.props as Props;
27
 
28
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
29
 
30
+ function normalizeAuthors(
31
+ input: Array<
32
+ | string
33
+ | {
34
+ name?: string;
35
+ url?: string;
36
+ link?: string;
37
+ affiliationIndices?: number[];
38
+ }
39
+ >,
40
+ ): Author[] {
41
  return (Array.isArray(input) ? input : [])
42
  .map((a) => {
43
+ if (typeof a === "string") {
44
  return { name: a } as Author;
45
  }
46
+ const name = (a?.name ?? "").toString();
47
  const url = (a?.url ?? a?.link) as string | undefined;
48
+ const affiliationIndices = Array.isArray((a as any)?.affiliationIndices)
49
+ ? (a as any).affiliationIndices
50
+ : undefined;
51
  return { name, url, affiliationIndices } as Author;
52
  })
53
  .filter((a) => a.name && a.name.trim().length > 0);
 
58
  // Determine if affiliation superscripts should be shown (only when there are multiple distinct affiliations referenced by authors)
59
  const authorAffiliationIndexSet = new Set<number>();
60
  for (const author of normalizedAuthors) {
61
+ const indices = Array.isArray(author.affiliationIndices)
62
+ ? author.affiliationIndices
63
+ : [];
64
  for (const idx of indices) {
65
+ if (typeof idx === "number") {
66
  authorAffiliationIndexSet.add(idx);
67
  }
68
  }
69
  }
70
  const shouldShowAffiliationSupers = authorAffiliationIndexSet.size > 1;
71
+ const hasMultipleAffiliations =
72
+ Array.isArray(affiliations) && affiliations.length > 1;
73
 
74
  function stripHtml(text: string): string {
75
+ return String(text || "").replace(/<[^>]*>/g, "");
76
  }
77
 
78
  function slugify(text: string): string {
79
+ return (
80
+ String(text || "")
81
+ .normalize("NFKD")
82
+ .replace(/\p{Diacritic}+/gu, "")
83
+ .toLowerCase()
84
+ .replace(/[^a-z0-9]+/g, "-")
85
+ .replace(/^-+|-+$/g, "")
86
+ .slice(0, 120) || "article"
87
+ );
88
  }
89
 
90
  const pdfBase = titleRaw ? stripHtml(titleRaw) : stripHtml(title);
91
  const pdfFilename = `${slugify(pdfBase)}.pdf`;
92
  ---
93
+
94
  <section class="hero">
95
+ <h1 class="hero-title" set:html={title} />
96
  <div class="hero-banner">
97
  <HtmlEmbed src="banner.html" frameless />
98
  {description && <p class="hero-desc">{description}</p>}
 
101
 
102
  <header class="meta" aria-label="Article meta information">
103
  <div class="meta-container">
104
+ {
105
+ normalizedAuthors.length > 0 && (
106
+ <div class="meta-container-cell">
107
+ <h3>Author{normalizedAuthors.length > 1 ? "s" : ""}</h3>
108
+ <ul class="authors">
109
+ {normalizedAuthors.map((a, i) => {
110
+ const supers =
111
+ shouldShowAffiliationSupers &&
112
+ Array.isArray(a.affiliationIndices) &&
113
+ a.affiliationIndices.length ? (
114
+ <sup>{a.affiliationIndices.join(",")}</sup>
115
+ ) : null;
116
+ return (
117
+ <li>
118
+ {a.url ? <a href={a.url}>{a.name}</a> : a.name}
119
+ {supers}
120
+ {i < normalizedAuthors.length - 1 && ", "}
121
+ </li>
122
+ );
123
+ })}
124
+ </ul>
125
+ </div>
126
+ )
127
+ }
128
+ {
129
+ Array.isArray(affiliations) && affiliations.length > 0 && (
130
+ <div class="meta-container-cell meta-container-cell--affiliations">
131
+ <h3>Affiliation{affiliations.length > 1 ? "s" : ""}</h3>
132
+ {hasMultipleAffiliations ? (
133
+ <ol class="affiliations">
134
+ {affiliations.map((af) => (
135
+ <li value={af.id}>
136
+ {af.url ? (
137
+ <a href={af.url} target="_blank" rel="noopener noreferrer">
138
+ {af.name}
139
+ </a>
140
+ ) : (
141
+ af.name
142
+ )}
143
+ </li>
144
+ ))}
145
+ </ol>
146
+ ) : (
147
+ <p>
148
+ {affiliations[0]?.url ? (
149
+ <a
150
+ href={affiliations[0].url}
151
+ target="_blank"
152
+ rel="noopener noreferrer"
153
+ >
154
+ {affiliations[0].name}
155
+ </a>
156
+ ) : (
157
+ affiliations[0]?.name
158
+ )}
159
+ </p>
160
+ )}
161
+ </div>
162
+ )
163
+ }
164
+ {
165
+ (!affiliations || affiliations.length === 0) && affiliation && (
166
+ <div class="meta-container-cell meta-container-cell--affiliations">
167
+ <h3>Affiliation</h3>
168
+ <p>{affiliation}</p>
169
+ </div>
170
+ )
171
+ }
172
+ {
173
+ published && (
174
+ <div class="meta-container-cell meta-container-cell--published">
175
+ <h3>Published</h3>
176
+ <p>{published}</p>
177
+ </div>
178
+ )
179
+ }
180
  <!-- {doi && (
181
  <div class="meta-container-cell">
182
  <h3>DOI</h3>
 
186
  <div class="meta-container-cell meta-container-cell--pdf">
187
  <h3>PDF</h3>
188
  <p>
189
+ <a
190
+ class="button"
191
+ href={`/${pdfFilename}`}
192
+ download={pdfFilename}
193
+ aria-label={`Download PDF ${pdfFilename}`}
194
+ >
195
  Download PDF
196
  </a>
197
  </p>
 
199
  </div>
200
  </header>
201
 
 
202
  <style>
203
  /* Hero (full-width) */
204
  .hero {
 
247
  text-underline-offset: 2px;
248
  text-decoration-thickness: 0.06em;
249
  text-decoration-color: var(--link-underline);
250
+ transition: text-decoration-color 0.15s ease-in-out;
251
  }
252
  .meta-container a:hover {
253
  text-decoration-color: var(--link-underline-hover);
 
260
  display: flex;
261
  flex-direction: column;
262
  gap: 8px;
263
+ max-width: 250px;
264
  }
265
  .meta-container-cell h3 {
266
  margin: 0;
 
268
  font-weight: 400;
269
  color: var(--muted-color);
270
  text-transform: uppercase;
271
+ letter-spacing: 0.02em;
272
  }
273
  .meta-container-cell p {
274
  margin: 0;
275
+ }
276
  .authors {
277
  margin: 0;
278
  list-style-type: none;
279
  padding-left: 0;
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ }
283
+ .authors li {
284
+ white-space: nowrap;
285
+ margin-right: 4px;
286
  }
287
  .affiliations {
288
  margin: 0;
 
296
  flex-wrap: wrap;
297
  row-gap: 12px;
298
  }
299
+
300
+ @media (max-width: 768px) {
301
+ .meta-container-cell--affiliations,
302
+ .meta-container-cell--pdf {
303
+ text-align: right;
304
+ }
305
+ }
306
+
307
  @media print {
308
  .meta-container-cell--pdf {
309
  display: none !important;
310
  }
311
  }
312
  </style>
 
 
app/src/components/HtmlEmbed.astro CHANGED
@@ -20,12 +20,15 @@ const html = resolveFragment(src);
20
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
21
  const dataAttr = Array.isArray(data) ? JSON.stringify(data) : (typeof data === 'string' ? data : undefined);
22
  const configAttr = typeof config === 'string' ? config : (config != null ? JSON.stringify(config) : undefined);
 
 
 
23
  ---
24
  { html ? (
25
  <figure class="html-embed" id={id}>
26
  {title && <figcaption class="html-embed__title" style={`text-align:${align}`}>{title}</figcaption>}
27
  <div class={`html-embed__card${frameless ? ' is-frameless' : ''}`}>
28
- <div id={mountId} data-datafiles={dataAttr} data-config={configAttr} set:html={html} />
29
  </div>
30
  {desc && <figcaption class="html-embed__desc" style={`text-align:${align}`} set:html={desc}></figcaption>}
31
  </figure>
@@ -70,7 +73,6 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
70
  .html-embed { margin: 0 0 var(--block-spacing-y);
71
  z-index: var(--z-elevated);
72
  position: relative;
73
-
74
  }
75
  .html-embed__title {
76
  text-align: left;
@@ -83,12 +85,14 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
83
  position: relative;
84
  display: block;
85
  width: 100%;
 
 
86
  }
87
  .html-embed__card {
88
  background: var(--code-bg);
89
  border: 1px solid var(--border-color);
90
  border-radius: 10px;
91
- padding: 8px;
92
  z-index: calc(var(--z-elevated) + 1);
93
  position: relative;
94
  }
@@ -108,6 +112,7 @@ const configAttr = typeof config === 'string' ? config : (config != null ? JSON.
108
  z-index: var(--z-elevated);
109
  display: block;
110
  width: 100%;
 
111
  }
112
  /* Plotly – fragments & controls */
113
  .html-embed__card svg text { fill: var(--text-color); }
 
20
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
21
  const dataAttr = Array.isArray(data) ? JSON.stringify(data) : (typeof data === 'string' ? data : undefined);
22
  const configAttr = typeof config === 'string' ? config : (config != null ? JSON.stringify(config) : undefined);
23
+
24
+ // Apply the ID to the HTML content if provided
25
+ const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div class="$1" id="${id}">`) : html;
26
  ---
27
  { html ? (
28
  <figure class="html-embed" id={id}>
29
  {title && <figcaption class="html-embed__title" style={`text-align:${align}`}>{title}</figcaption>}
30
  <div class={`html-embed__card${frameless ? ' is-frameless' : ''}`}>
31
+ <div id={mountId} data-datafiles={dataAttr} data-config={configAttr} set:html={htmlWithId} />
32
  </div>
33
  {desc && <figcaption class="html-embed__desc" style={`text-align:${align}`} set:html={desc}></figcaption>}
34
  </figure>
 
73
  .html-embed { margin: 0 0 var(--block-spacing-y);
74
  z-index: var(--z-elevated);
75
  position: relative;
 
76
  }
77
  .html-embed__title {
78
  text-align: left;
 
85
  position: relative;
86
  display: block;
87
  width: 100%;
88
+ background: var(--page-bg);
89
+ z-index: var(--z-elevated);
90
  }
91
  .html-embed__card {
92
  background: var(--code-bg);
93
  border: 1px solid var(--border-color);
94
  border-radius: 10px;
95
+ padding: 24px;
96
  z-index: calc(var(--z-elevated) + 1);
97
  position: relative;
98
  }
 
112
  z-index: var(--z-elevated);
113
  display: block;
114
  width: 100%;
115
+ background: var(--page-bg);
116
  }
117
  /* Plotly – fragments & controls */
118
  .html-embed__card svg text { fill: var(--text-color); }
app/src/components/Sidenote.astro CHANGED
@@ -14,28 +14,38 @@
14
 
15
  containers.forEach((container) => {
16
  // Find the previous element (sibling just before)
17
- const previousElement = container.previousElementSibling;
18
 
19
- if (previousElement) {
20
- // Make the sidenote container relative to the previous element
21
- previousElement.style.position = "relative";
 
22
 
23
- // Move the sidenote container as child of the previous element
24
- previousElement.appendChild(container);
25
 
26
- // Style the container so it positions correctly
27
- container.style.position = "absolute";
28
- container.style.top = "0";
29
- container.style.right = "-292px"; // 260px width + 32px gap
30
- container.style.width = "260px";
 
 
 
 
 
 
 
 
 
31
 
32
  // Display the container with a fade-in
33
- container.style.display = "block";
34
- container.style.opacity = "0";
35
 
36
  // Fade-in with transition
37
  setTimeout(() => {
38
- container.style.opacity = "1";
39
  }, 10);
40
  }
41
  });
@@ -43,10 +53,16 @@
43
  </script>
44
 
45
  <style is:global>
 
 
 
 
 
 
46
  .sidenote-container {
47
  /* Caché par défaut, sera affiché par JS */
48
  display: none;
49
- margin: 0 ;
50
  /* Transition for fade-in */
51
  transition: opacity 0.3s ease-in-out;
52
  }
@@ -60,6 +76,11 @@
60
  }
61
 
62
  @media (--bp-content-collapse) {
 
 
 
 
 
63
  .sidenote-container {
64
  position: static !important;
65
  width: auto !important;
 
14
 
15
  containers.forEach((container) => {
16
  // Find the previous element (sibling just before)
17
+ const previousElement = container.previousElementSibling as HTMLElement;
18
 
19
+ if (previousElement && previousElement.parentNode) {
20
+ // Create a wrapper div that will contain both the previous element and the sidenote
21
+ const wrapper = document.createElement("div");
22
+ wrapper.className = "sidenote-wrapper";
23
 
24
+ // Insert the wrapper before the previous element
25
+ previousElement.parentNode.insertBefore(wrapper, previousElement);
26
 
27
+ // Move both the previous element and the sidenote container into the wrapper
28
+ wrapper.appendChild(previousElement);
29
+ wrapper.appendChild(container);
30
+
31
+ // Style the wrapper to create the layout
32
+ wrapper.style.position = "relative";
33
+ wrapper.style.display = "block";
34
+
35
+ // Style the sidenote container so it positions correctly
36
+ const sidenoteContainer = container as HTMLElement;
37
+ sidenoteContainer.style.position = "absolute";
38
+ sidenoteContainer.style.top = "0";
39
+ sidenoteContainer.style.right = "-292px"; // 260px width + 32px gap
40
+ sidenoteContainer.style.width = "260px";
41
 
42
  // Display the container with a fade-in
43
+ sidenoteContainer.style.display = "block";
44
+ sidenoteContainer.style.opacity = "0";
45
 
46
  // Fade-in with transition
47
  setTimeout(() => {
48
+ sidenoteContainer.style.opacity = "1";
49
  }, 10);
50
  }
51
  });
 
53
  </script>
54
 
55
  <style is:global>
56
+ .sidenote-wrapper {
57
+ /* Le wrapper contient l'élément original et le sidenote */
58
+ position: relative;
59
+ display: block;
60
+ }
61
+
62
  .sidenote-container {
63
  /* Caché par défaut, sera affiché par JS */
64
  display: none;
65
+ margin: 0;
66
  /* Transition for fade-in */
67
  transition: opacity 0.3s ease-in-out;
68
  }
 
76
  }
77
 
78
  @media (--bp-content-collapse) {
79
+ .sidenote-wrapper {
80
+ /* Sur mobile, le wrapper n'a pas besoin de position relative */
81
+ position: static !important;
82
+ }
83
+
84
  .sidenote-container {
85
  position: static !important;
86
  width: auto !important;
app/src/styles/_layout.css CHANGED
@@ -13,12 +13,16 @@
13
  align-items: start;
14
  }
15
 
16
- .content-grid > main {
17
  max-width: 100%;
18
  margin: 0;
19
  padding: 0;
20
  }
21
 
 
 
 
 
22
  @media (--bp-content-collapse) {
23
  .content-grid {
24
  overflow: hidden;
@@ -44,7 +48,7 @@
44
  gap: 16px;
45
  }
46
 
47
- .footer-inner > h3 {
48
  grid-column: auto;
49
  margin-top: 16px;
50
  }
@@ -74,6 +78,9 @@
74
  width: min(1100px, 100vw - var(--content-padding-x) * 2);
75
  margin-left: 50%;
76
  transform: translateX(-50%);
 
 
 
77
  }
78
 
79
  .full-width {
@@ -84,11 +91,13 @@
84
  }
85
 
86
  @media (--bp-content-collapse) {
 
87
  .wide,
88
  .full-width {
89
  width: 100%;
90
  margin-left: 0;
91
  margin-right: 0;
 
92
  transform: none;
93
  }
94
  }
@@ -118,6 +127,7 @@
118
  max-width: 100%;
119
  padding: 0 var(--spacing-4);
120
  }
 
121
  header.meta .meta-container .meta-container-cell {
122
  flex: 1 1 calc(50% - 8px);
123
  min-width: 0;
@@ -129,12 +139,14 @@
129
  flex-basis: 100%;
130
  text-align: center;
131
  }
 
132
  /* Center ordered list numbers within meta (e.g., affiliations) */
133
  header.meta .affiliations {
134
  list-style-position: inside;
135
  padding-left: 0;
136
  margin-left: 0;
137
  }
 
138
  header.meta .affiliations li {
139
  text-align: center;
140
  }
@@ -160,7 +172,4 @@
160
  width: 100%;
161
  min-width: 0;
162
  }
163
- }
164
-
165
-
166
-
 
13
  align-items: start;
14
  }
15
 
16
+ .content-grid>main {
17
  max-width: 100%;
18
  margin: 0;
19
  padding: 0;
20
  }
21
 
22
+ .content-grid>main>*:first-child {
23
+ margin-top: 0;
24
+ }
25
+
26
  @media (--bp-content-collapse) {
27
  .content-grid {
28
  overflow: hidden;
 
48
  gap: 16px;
49
  }
50
 
51
+ .footer-inner>h3 {
52
  grid-column: auto;
53
  margin-top: 16px;
54
  }
 
78
  width: min(1100px, 100vw - var(--content-padding-x) * 2);
79
  margin-left: 50%;
80
  transform: translateX(-50%);
81
+ padding: var(--content-padding-x);
82
+ border-radius: var(--button-radius);
83
+ background-color: var(--page-bg);
84
  }
85
 
86
  .full-width {
 
91
  }
92
 
93
  @media (--bp-content-collapse) {
94
+
95
  .wide,
96
  .full-width {
97
  width: 100%;
98
  margin-left: 0;
99
  margin-right: 0;
100
+ padding: 0;
101
  transform: none;
102
  }
103
  }
 
127
  max-width: 100%;
128
  padding: 0 var(--spacing-4);
129
  }
130
+
131
  header.meta .meta-container .meta-container-cell {
132
  flex: 1 1 calc(50% - 8px);
133
  min-width: 0;
 
139
  flex-basis: 100%;
140
  text-align: center;
141
  }
142
+
143
  /* Center ordered list numbers within meta (e.g., affiliations) */
144
  header.meta .affiliations {
145
  list-style-position: inside;
146
  padding-left: 0;
147
  margin-left: 0;
148
  }
149
+
150
  header.meta .affiliations li {
151
  text-align: center;
152
  }
 
172
  width: 100%;
173
  min-width: 0;
174
  }
175
+ }
 
 
 
app/src/styles/_variables.css CHANGED
@@ -109,11 +109,10 @@
109
  --grid-color: rgba(255, 255, 255, .10);
110
 
111
  /* Primary (lower L in dark) */
112
- --primary-color: oklch(from var(--primary-base) calc(l - 0.08) c 100);
113
  --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
114
  --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
115
  --on-primary: #0f1115;
116
 
117
  color-scheme: dark;
118
- background: #0f1115;
119
  }
 
109
  --grid-color: rgba(255, 255, 255, .10);
110
 
111
  /* Primary (lower L in dark) */
 
112
  --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
113
  --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
114
  --on-primary: #0f1115;
115
 
116
  color-scheme: dark;
117
+ background: var(--page-bg);
118
  }
app/src/styles/components/_form.css CHANGED
@@ -5,7 +5,7 @@
5
  /* Select styling with modern chevron */
6
  select {
7
  background-color: var(--page-bg);
8
- border: 1px solid var(--border-color);
9
  border-radius: var(--button-radius);
10
  padding: var(--button-padding-y) var(--button-padding-x) var(--button-padding-y) var(--button-padding-x);
11
  font-family: var(--default-font-family);
@@ -13,13 +13,18 @@ select {
13
  color: var(--text-color);
14
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8.825L1.175 4 2.35 2.825 6 6.475 9.65 2.825 10.825 4z'/%3E%3C/svg%3E");
15
  background-repeat: no-repeat;
16
- background-position: right calc(var(--button-padding-x) + 14px) center;
17
  background-size: 12px;
18
  cursor: pointer;
19
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
 
 
 
20
  }
21
 
22
- select:hover, select:focus, select:active {
 
 
23
  border-color: var(--primary-color);
24
  }
25
 
@@ -238,4 +243,4 @@ div[class*="flex"] label,
238
  .theme-selector label {
239
  margin-bottom: 0 !important;
240
  align-self: center;
241
- }
 
5
  /* Select styling with modern chevron */
6
  select {
7
  background-color: var(--page-bg);
8
+ border: 1px solid color-mix(in srgb, var(--primary-color) 50%, var(--border-color));
9
  border-radius: var(--button-radius);
10
  padding: var(--button-padding-y) var(--button-padding-x) var(--button-padding-y) var(--button-padding-x);
11
  font-family: var(--default-font-family);
 
13
  color: var(--text-color);
14
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8.825L1.175 4 2.35 2.825 6 6.475 9.65 2.825 10.825 4z'/%3E%3C/svg%3E");
15
  background-repeat: no-repeat;
16
+ background-position: right calc(var(--button-padding-x) + 2px) center;
17
  background-size: 12px;
18
  cursor: pointer;
19
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
20
+ -webkit-appearance: none;
21
+ -moz-appearance: none;
22
+ appearance: none;
23
  }
24
 
25
+ select:hover,
26
+ select:focus,
27
+ select:active {
28
  border-color: var(--primary-color);
29
  }
30
 
 
243
  .theme-selector label {
244
  margin-bottom: 0 !important;
245
  align-self: center;
246
+ }
app/src/styles/components/_table.css CHANGED
@@ -1,98 +1,126 @@
1
  .content-grid main table {
2
- border-collapse: collapse;
3
- table-layout: auto;
4
- margin: 0;
5
- }
6
- .content-grid main th, .content-grid main td {
7
- border-bottom: 1px solid var(--border-color);
8
- padding: 6px 8px;
9
- text-align: left;
10
- font-size: 15px;
11
- white-space: nowrap; /* prevent squashing; allow horizontal scroll instead */
12
- }
13
- .content-grid main thead th { border-bottom: 1px solid var(--border-color); }
14
- .content-grid main thead th {
15
- border-bottom: 1px solid var(--border-color);
16
- }
17
- .content-grid main thead th {
18
- background: var(--table-header-bg);
19
- padding-top: 10px;
20
- padding-bottom: 10px;
21
- font-weight: 600;
22
- }
23
-
24
- .content-grid main hr {
25
- border: none;
26
- border-bottom: 1px solid var(--border-color);
27
- margin: var(--spacing-5) 0;
28
- }
29
-
30
- /* Scroll wrapper: keeps table 100% width but enables horizontal scroll when needed */
31
- .content-grid main .table-scroll {
32
- width: 100%;
33
- overflow-x: auto;
34
- -webkit-overflow-scrolling: touch;
35
- border: 1px solid var(--border-color);
36
- border-radius: var(--table-border-radius);
37
- background: var(--surface-bg);
38
- margin: 0 0 var(--block-spacing-y);
39
- }
40
- .content-grid main .table-scroll > table {
41
- width: fit-content;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  min-width: 100%;
43
- max-width: none;
44
- }
45
- /* Vertical dividers between columns (no outer right border) */
46
- .content-grid main .table-scroll > table th,
47
- .content-grid main .table-scroll > table td {
48
- border-right: 1px solid var(--border-color);
49
- }
50
- .content-grid main .table-scroll > table th:last-child,
51
- .content-grid main .table-scroll > table td:last-child {
52
- border-right: none;
53
- }
54
- .content-grid main .table-scroll > table thead th:first-child {
55
- border-top-left-radius: var(--table-border-radius);
56
- }
57
- .content-grid main .table-scroll > table thead th:last-child {
58
- border-top-right-radius: var(--table-border-radius);
59
- }
60
- .content-grid main .table-scroll > table tbody tr:last-child td:first-child {
61
- border-bottom-left-radius: var(--table-border-radius);
62
- }
63
- .content-grid main .table-scroll > table tbody tr:last-child td:last-child {
64
- border-bottom-right-radius: var(--table-border-radius);
65
- }
66
- /* Zebra striping for odd rows */
67
- .content-grid main .table-scroll > table tbody tr:nth-child(odd) td {
68
- background: var(--table-row-odd-bg);
69
- }
70
- /* Remove bottom border on last row */
71
- .content-grid main .table-scroll > table tbody tr:last-child td {
72
- border-bottom: none;
73
- }
74
-
75
- /* Accordion context: remove outer borders/radius and fit content flush */
76
- .accordion .accordion__content .table-scroll {
77
- border: none;
78
- border-radius: 0;
79
- margin: 0;
80
- margin-bottom: 0 !important;
81
- }
82
- /* Ensure no bottom margin even if table isn't wrapped (fallback) */
83
- .accordion .accordion__content table { margin: 0 !important; }
84
- .accordion .accordion__content .table-scroll > table thead th:first-child,
85
- .accordion .accordion__content .table-scroll > table thead th:last-child,
86
- .accordion .accordion__content .table-scroll > table tbody tr:last-child td:first-child,
87
- .accordion .accordion__content .table-scroll > table tbody tr:last-child td:last-child {
88
- border-radius: 0;
89
- }
90
-
91
- /* Fallback for browsers without fit-content support */
92
- @supports not (width: fit-content) {
93
- .content-grid main .table-scroll > table {
94
- width: max-content;
95
- min-width: 100%;
96
- }
97
  }
98
-
 
1
  .content-grid main table {
2
+ border-collapse: collapse;
3
+ table-layout: auto;
4
+ margin: 0;
5
+ }
6
+
7
+ .content-grid main th,
8
+ .content-grid main td {
9
+ border-bottom: 1px solid var(--border-color);
10
+ padding: 6px 8px;
11
+ text-align: left;
12
+ font-size: 15px;
13
+ white-space: nowrap;
14
+ /* prevent squashing; allow horizontal scroll instead */
15
+ word-break: auto-phrase;
16
+ white-space: break-spaces;
17
+ vertical-align: top;
18
+ }
19
+
20
+ .content-grid main th:last-child,
21
+ .content-grid main td:last-child {
22
+ text-align: right;
23
+ }
24
+
25
+ .content-grid main thead th {
26
+ border-bottom: 1px solid var(--border-color);
27
+ }
28
+
29
+ .content-grid main thead th {
30
+ border-bottom: 1px solid var(--border-color);
31
+ }
32
+
33
+ .content-grid main thead th {
34
+ background: var(--table-header-bg);
35
+ padding-top: 10px;
36
+ padding-bottom: 10px;
37
+ font-weight: 600;
38
+ }
39
+
40
+ .content-grid main hr {
41
+ border: none;
42
+ border-bottom: 1px solid var(--border-color);
43
+ margin: var(--spacing-5) 0;
44
+ }
45
+
46
+ /* Scroll wrapper: keeps table 100% width but enables horizontal scroll when needed */
47
+ .content-grid main .table-scroll {
48
+ width: 100%;
49
+ overflow-x: auto;
50
+ -webkit-overflow-scrolling: touch;
51
+ border: 1px solid var(--border-color);
52
+ border-radius: var(--table-border-radius);
53
+ background: var(--surface-bg);
54
+ margin: 0 0 var(--block-spacing-y);
55
+ }
56
+
57
+ .content-grid main .table-scroll>table {
58
+ width: fit-content;
59
+ min-width: 100%;
60
+ max-width: none;
61
+ }
62
+
63
+ /* Vertical dividers between columns (no outer right border) */
64
+ .content-grid main .table-scroll>table th,
65
+ .content-grid main .table-scroll>table td {
66
+ border-right: 1px solid var(--border-color);
67
+ }
68
+
69
+ .content-grid main .table-scroll>table th:last-child,
70
+ .content-grid main .table-scroll>table td:last-child {
71
+ border-right: none;
72
+ }
73
+
74
+ .content-grid main .table-scroll>table thead th:first-child {
75
+ border-top-left-radius: var(--table-border-radius);
76
+ }
77
+
78
+ .content-grid main .table-scroll>table thead th:last-child {
79
+ border-top-right-radius: var(--table-border-radius);
80
+ }
81
+
82
+ .content-grid main .table-scroll>table tbody tr:last-child td:first-child {
83
+ border-bottom-left-radius: var(--table-border-radius);
84
+ }
85
+
86
+ .content-grid main .table-scroll>table tbody tr:last-child td:last-child {
87
+ border-bottom-right-radius: var(--table-border-radius);
88
+ }
89
+
90
+ /* Zebra striping for odd rows */
91
+ .content-grid main .table-scroll>table tbody tr:nth-child(odd) td {
92
+ background: var(--table-row-odd-bg);
93
+ }
94
+
95
+ /* Remove bottom border on last row */
96
+ .content-grid main .table-scroll>table tbody tr:last-child td {
97
+ border-bottom: none;
98
+ }
99
+
100
+ /* Accordion context: remove outer borders/radius and fit content flush */
101
+ .accordion .accordion__content .table-scroll {
102
+ border: none;
103
+ border-radius: 0;
104
+ margin: 0;
105
+ margin-bottom: 0 !important;
106
+ }
107
+
108
+ /* Ensure no bottom margin even if table isn't wrapped (fallback) */
109
+ .accordion .accordion__content table {
110
+ margin: 0 !important;
111
+ }
112
+
113
+ .accordion .accordion__content .table-scroll>table thead th:first-child,
114
+ .accordion .accordion__content .table-scroll>table thead th:last-child,
115
+ .accordion .accordion__content .table-scroll>table tbody tr:last-child td:first-child,
116
+ .accordion .accordion__content .table-scroll>table tbody tr:last-child td:last-child {
117
+ border-radius: 0;
118
+ }
119
+
120
+ /* Fallback for browsers without fit-content support */
121
+ @supports not (width: fit-content) {
122
+ .content-grid main .table-scroll>table {
123
+ width: max-content;
124
  min-width: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
+ }
tools/duplicated-spaces/README.md DELETED
@@ -1,32 +0,0 @@
1
- # duplicated-spaces
2
-
3
- Small Poetry project to list public Spaces created in the last N days that were duplicated from a given source Space.
4
-
5
- ## Setup
6
-
7
- ```bash
8
- cd tools/duplicated-spaces
9
- poetry install --no-root
10
- ```
11
-
12
- Optionally export your token:
13
-
14
- ```bash
15
- export HF_TOKEN=hf_xxx
16
- ```
17
-
18
- ## Usage
19
-
20
- ```bash
21
- poetry run find-duplicated-spaces --source owner/space-name --days 14
22
- ```
23
-
24
- Options:
25
- - `--source`: required. The source Space in the form `owner/space-name`.
26
- - `--days`: optional. Time window in days (default: 14).
27
- - `--token`: optional. Your HF token. Defaults to `HF_TOKEN` env var if set.
28
- - `--no-deep`: optional. Disable README/frontmatter fallback detection.
29
-
30
- The tool checks card metadata and may fallback to README frontmatter parsing for robustness.
31
-
32
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/duplicated-spaces/duplicated_spaces/__init__.py DELETED
@@ -1,5 +0,0 @@
1
- from .finder import find_duplicated_spaces
2
-
3
- __all__ = ["find_duplicated_spaces"]
4
-
5
-
 
 
 
 
 
 
tools/duplicated-spaces/duplicated_spaces/cli.py DELETED
@@ -1,69 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import os
5
- from typing import Optional
6
-
7
- from huggingface_hub import HfApi
8
-
9
- from .finder import find_duplicated_spaces
10
-
11
-
12
- def build_parser() -> argparse.ArgumentParser:
13
- parser = argparse.ArgumentParser(
14
- description="List recent Spaces duplicated from a given Space"
15
- )
16
- parser.add_argument(
17
- "--source",
18
- required=True,
19
- help="Source Space in the form 'owner/space-name'",
20
- )
21
- parser.add_argument(
22
- "--days",
23
- type=int,
24
- default=14,
25
- help="Time window in days (default: 14)",
26
- )
27
- parser.add_argument(
28
- "--token",
29
- default=os.environ.get("HF_TOKEN"),
30
- help="Hugging Face token (optional). Defaults to HF_TOKEN env var if set.",
31
- )
32
- parser.add_argument(
33
- "--no-deep",
34
- action="store_true",
35
- help=(
36
- "Disable deep detection (README/frontmatter fetch) when card metadata is missing."
37
- ),
38
- )
39
- return parser
40
-
41
-
42
- def main(argv: Optional[list[str]] = None) -> None:
43
- parser = build_parser()
44
- args = parser.parse_args(argv)
45
-
46
- api = HfApi(token=args.token)
47
- duplicated = find_duplicated_spaces(
48
- api=api,
49
- source=args.source,
50
- days=args.days,
51
- deep_detection=not args.no_deep,
52
- )
53
-
54
- if duplicated:
55
- print(
56
- f"Found {len(duplicated)} Space(s) duplicated from {args.source} in the last {args.days} days:\n"
57
- )
58
- for sid in duplicated:
59
- print(f"https://huggingface.co/spaces/{sid}")
60
- else:
61
- print(
62
- f"No public Spaces duplicated from {args.source} in the last {args.days} days."
63
- )
64
-
65
-
66
- if __name__ == "__main__":
67
- main()
68
-
69
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/duplicated-spaces/duplicated_spaces/finder.py DELETED
@@ -1,99 +0,0 @@
1
- from __future__ import annotations
2
-
3
- """
4
- Core logic to find Spaces duplicated from a given source within a time window.
5
- Comments are in English (per user preference for code comments).
6
- """
7
-
8
- from datetime import datetime, timedelta, timezone
9
- from typing import Iterable, List, Optional
10
-
11
- import requests
12
- from huggingface_hub import HfApi
13
-
14
-
15
- def iso_to_datetime(value: str) -> datetime:
16
- """Parse ISO 8601 timestamps returned by the Hub to aware datetime in UTC."""
17
- try:
18
- dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
19
- except ValueError:
20
- dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
21
- return dt.replace(tzinfo=timezone.utc)
22
-
23
-
24
- def readme_frontmatter_duplicated_from(space_id: str) -> Optional[str]:
25
- """Fetch README raw and try to extract duplicated_from from YAML frontmatter."""
26
- url = f"https://huggingface.co/spaces/{space_id}/raw/README.md"
27
- try:
28
- resp = requests.get(url, timeout=10)
29
- if resp.status_code != 200:
30
- return None
31
- text = resp.text
32
- except requests.RequestException:
33
- return None
34
-
35
- lines = text.splitlines()
36
- in_frontmatter = False
37
- for line in lines:
38
- if line.strip() == "---":
39
- in_frontmatter = not in_frontmatter
40
- if not in_frontmatter:
41
- break
42
- continue
43
- if in_frontmatter and line.strip().startswith("duplicated_from:"):
44
- value = line.split(":", 1)[1].strip().strip("'\"")
45
- return value or None
46
- return None
47
-
48
-
49
- def get_recent_spaces(api: HfApi, days: int) -> Iterable:
50
- """Yield Spaces created within the last `days` days, iterating newest first if possible."""
51
- cutoff = datetime.now(timezone.utc) - timedelta(days=days)
52
- try:
53
- spaces_iter = api.list_spaces(full=True, sort="created", direction=-1)
54
- except TypeError:
55
- spaces_iter = api.list_spaces(full=True)
56
-
57
- for space in spaces_iter:
58
- created_at_raw = getattr(space, "created_at", None) or getattr(space, "createdAt", None)
59
- if not created_at_raw:
60
- yield space
61
- continue
62
- created_at = (
63
- created_at_raw if isinstance(created_at_raw, datetime) else iso_to_datetime(str(created_at_raw))
64
- )
65
- if created_at >= cutoff:
66
- yield space
67
- else:
68
- # We cannot guarantee sort order when falling back; continue to be safe.
69
- continue
70
-
71
-
72
- def find_duplicated_spaces(api: HfApi, source: str, days: int, deep_detection: bool) -> List[str]:
73
- """Return list of Space IDs that were duplicated from `source` within `days`."""
74
- source = source.strip().strip("/ ")
75
- results: List[str] = []
76
- for space in get_recent_spaces(api, days=days):
77
- space_id = getattr(space, "id", None) or getattr(space, "repo_id", None)
78
- if not space_id:
79
- continue
80
-
81
- card = getattr(space, "cardData", None) or getattr(space, "card_data", None)
82
- duplicated_from_value: Optional[str] = None
83
- if isinstance(card, dict):
84
- for key in ("duplicated_from", "duplicatedFrom", "duplicated-from"):
85
- if key in card and isinstance(card[key], str):
86
- duplicated_from_value = card[key].strip().strip("/ ")
87
- break
88
-
89
- if not duplicated_from_value and deep_detection:
90
- duplicated_from_value = readme_frontmatter_duplicated_from(space_id)
91
- if duplicated_from_value:
92
- duplicated_from_value = duplicated_from_value.strip().strip("/ ")
93
-
94
- if duplicated_from_value and duplicated_from_value.lower() == source.lower():
95
- results.append(space_id)
96
-
97
- return results
98
-
99
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/duplicated-spaces/pyproject.toml DELETED
@@ -1,23 +0,0 @@
1
- [tool.poetry]
2
- name = "duplicated-spaces"
3
- version = "0.1.0"
4
- description = "Find recent Hugging Face Spaces duplicated from a given Space"
5
- authors = ["thibaud frere <>"]
6
- readme = "README.md"
7
- packages = [{ include = "duplicated_spaces" }]
8
-
9
- [tool.poetry.dependencies]
10
- python = ">=3.9,<4.0"
11
- huggingface_hub = "^0.24.0"
12
- requests = "^2.31.0"
13
-
14
- [tool.poetry.group.dev.dependencies]
15
-
16
- [tool.poetry.scripts]
17
- find-duplicated-spaces = "duplicated_spaces.cli:main"
18
-
19
- [build-system]
20
- requires = ["poetry-core>=1.7.0"]
21
- build-backend = "poetry.core.masonry.api"
22
-
23
-