Skip to content

Commit 864c335

Browse files
feat: render links as relative paths
We previously rendered all page links as absolute paths e.g. `/page.md`. This created some problems - some page generators that use base path prefixes like `/docs/page.md` and thus need to rewrite these links, which is brittle - text editors like VSCode cannot follow the markdown links properly because they think the links are absolute to the root `/` of the fs - tools like textlint-rule-no-dead-link expect relative links to resolve them correctly Now rendering them as relative paths. As part of these changes it was also necessary to switch properties parsing to render properties only as plaintext, not markdown. This is fine because we consider frontmatter to be data (YML), not markup. Most site generators cannot deal with markdown in frontmatter anyway. This change was necessary because we need to parse page properties before we can determine the destination path of the markdown file where the page will be rendered to, and that would now require resolving relative links.
1 parent 9e162cb commit 864c335

16 files changed

+210
-129
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The following [Notion API page property types](https://developers.notion.com/ref
7777

7878
| Propety type | Supported | Notes |
7979
| ---------------- | --------- | ----------------------------- |
80-
| Rich text | ✅ Yes | rendered as markdown string |
80+
| Rich text | ✅ Yes | rendered as plaintext string |
8181
| Number | ✅ Yes | |
8282
| Select | ✅ Yes | rendered as name |
8383
| Multi Select | ✅ Yes | rendered as array of names |

src/AssetWriter.ts

+11-13
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,25 @@
1-
import { promises as fs } from 'fs';
2-
import got from 'got';
3-
import { KeyvFile } from 'keyv-file';
4-
import * as mime from 'mime-types';
5-
6-
import { RenderingLoggingContext } from './logger';
1+
import { promises as fs } from "fs";
2+
import got from "got";
3+
import { KeyvFile } from "keyv-file";
4+
import * as mime from "mime-types";
5+
import { RenderingContextLogger } from "./RenderingContextLogger";
76

87
const cache = new KeyvFile({
98
filename: ".cache/keyv.json",
109
});
1110

1211
export class AssetWriter {
13-
constructor(readonly dir: string) {}
12+
constructor(
13+
private readonly dir: string,
14+
private readonly logger: RenderingContextLogger
15+
) {}
1416

1517
async store(name: string, buffer: Buffer) {
1618
await fs.mkdir(this.dir, { recursive: true });
1719
await fs.writeFile(`${this.dir}/${name}`, buffer);
1820
}
1921

20-
async download(
21-
url: string,
22-
fileName: string,
23-
context: RenderingLoggingContext
24-
) {
22+
async download(url: string, fileName: string) {
2523
// the got http lib promises to do proper user-agent compliant http caching
2624
// see https://github.com/sindresorhus/got/blob/main/documentation/cache.md
2725

@@ -35,7 +33,7 @@ export class AssetWriter {
3533
const imageFile = fileName + "." + ext;
3634

3735
const cacheInfo = response.isFromCache ? " (from cache)" : "";
38-
context.info(`downloading ${imageFile}` + cacheInfo);
36+
this.logger.info(`downloading ${imageFile}` + cacheInfo);
3937
await this.store(imageFile, response.rawBody);
4038

4139
return imageFile;

src/BlockRenderer.ts

+24-19
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { RichText } from '@notionhq/client/build/src/api-types';
1+
import { RichText } from "@notionhq/client/build/src/api-types";
22

3-
import { AssetWriter } from './AssetWriter';
3+
import { AssetWriter } from "./AssetWriter";
44
import {
5-
Block, Emoji, ExternalFile, ExternalFileWithCaption, File, FileWithCaption, ImageBlock
6-
} from './Blocks';
7-
import { DeferredRenderer } from './DeferredRenderer';
8-
import { RenderingLoggingContext } from './logger';
9-
import { RichTextRenderer } from './RichTextRenderer';
5+
Block,
6+
Emoji,
7+
ExternalFile,
8+
ExternalFileWithCaption,
9+
File,
10+
FileWithCaption,
11+
ImageBlock,
12+
} from "./Blocks";
13+
import { DeferredRenderer } from "./DeferredRenderer";
14+
import { RenderingContextLogger } from "./RenderingContextLogger";
15+
import { RichTextRenderer } from "./RichTextRenderer";
16+
import { RenderingContext } from "./RenderingContext";
1017

1118
const debug = require("debug")("blocks");
1219

@@ -22,8 +29,7 @@ export class BlockRenderer {
2229

2330
async renderBlock(
2431
block: Block,
25-
assets: AssetWriter,
26-
context: RenderingLoggingContext
32+
context: RenderingContext
2733
): Promise<BlockRenderResult | null> {
2834
const renderMarkdown = async (text: RichText[]) => {
2935
return await this.richText.renderMarkdown(text, context);
@@ -63,7 +69,7 @@ export class BlockRenderer {
6369
};
6470
case "image":
6571
return {
66-
lines: await this.renderImage(block, assets, context),
72+
lines: await this.renderImage(block, context.assetWriter),
6773
};
6874
case "quote": {
6975
// it's legal for a notion block to be cmoposed of multiple lines
@@ -85,7 +91,7 @@ export class BlockRenderer {
8591
case "callout": {
8692
// render emoji as bold, this enables css to target it as `blockquote > strong:first-child`
8793
const content =
88-
`**${this.renderIcon(block.callout.icon, context)}** ` +
94+
`**${this.renderIcon(block.callout.icon, context.logger)}** ` +
8995
(await renderMarkdown(block.callout.text));
9096

9197
return {
@@ -96,7 +102,7 @@ export class BlockRenderer {
96102
return { lines: "---" };
97103
case "child_database":
98104
const msg = `<!-- included database ${block.id} -->\n`;
99-
const db = await this.deferredRenderer.renderChildDatabase(block.id);
105+
const db = await this.deferredRenderer.renderChildDatabase(block.id, context.linkResolver);
100106
return { lines: msg + db.markdown };
101107
case "synced_block":
102108
// nothing to render, only the contents of the synced block are relevant
@@ -116,15 +122,15 @@ export class BlockRenderer {
116122
lines: this.renderUnsupported(
117123
`unsupported block type: ${block.type}`,
118124
block,
119-
context
125+
context.logger
120126
),
121127
};
122128
}
123129
}
124130

125131
private renderIcon(
126132
icon: File | ExternalFile | Emoji,
127-
context: RenderingLoggingContext
133+
logger: RenderingContextLogger
128134
): string {
129135
switch (icon.type) {
130136
case "emoji":
@@ -134,19 +140,18 @@ export class BlockRenderer {
134140
return this.renderUnsupported(
135141
`unsupported icon type: ${icon.type}`,
136142
icon,
137-
context
143+
logger
138144
);
139145
}
140146
}
141147

142148
async renderImage(
143149
block: ImageBlock,
144-
assets: AssetWriter,
145-
context: RenderingLoggingContext
150+
assets: AssetWriter
146151
): Promise<string> {
147152
const url = this.parseUrl(block.image);
148153

149-
const imageFile = await assets.download(url, block.id, context);
154+
const imageFile = await assets.download(url, block.id);
150155

151156
// todo: caption support
152157
const markdown = `![image-${block.id}](./${imageFile})`;
@@ -172,7 +177,7 @@ export class BlockRenderer {
172177
private renderUnsupported(
173178
msg: string,
174179
obj: any,
175-
context: RenderingLoggingContext
180+
context: RenderingContextLogger
176181
): string {
177182
context.warn(msg);
178183
debug(msg + "\n%O", obj);

src/ChildDatabaseRenderer.ts

+35-18
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import { Page } from '@notionhq/client/build/src/api-types';
2-
3-
import { SyncConfig } from './';
4-
import { lookupDatabaseConfig } from './config';
5-
import { Database } from './Database';
6-
import { DatabaseViewRenderer } from './DatabaseViewRenderer';
7-
import { DeferredRenderer } from './DeferredRenderer';
8-
import { NotionApiFacade } from './NotionApiFacade';
9-
import { RenderDatabasePageTask } from './RenderDatabasePageTask';
10-
import { DatabaseConfig, DatabaseConfigRenderPages, DatabaseConfigRenderTable } from './SyncConfig';
1+
import { Page } from "@notionhq/client/build/src/api-types";
2+
3+
import { SyncConfig } from "./";
4+
import { lookupDatabaseConfig } from "./config";
5+
import { Database } from "./Database";
6+
import { DatabaseViewRenderer } from "./DatabaseViewRenderer";
7+
import { DeferredRenderer } from "./DeferredRenderer";
8+
import { NotionApiFacade } from "./NotionApiFacade";
9+
import { PageLinkResolver } from "./PageLinkResolver";
10+
import { RenderDatabasePageTask } from "./RenderDatabasePageTask";
11+
import {
12+
DatabaseConfig,
13+
DatabaseConfigRenderPages,
14+
DatabaseConfigRenderTable,
15+
} from "./SyncConfig";
1116

1217
const debug = require("debug")("child-database");
1318

@@ -17,22 +22,31 @@ export class ChildDatabaseRenderer {
1722
private readonly publicApi: NotionApiFacade,
1823
private readonly deferredRenderer: DeferredRenderer,
1924
private readonly viewRenderer: DatabaseViewRenderer
20-
) { }
25+
) {}
2126

22-
async renderChildDatabase(databaseId: string): Promise<Database> {
27+
async renderChildDatabase(
28+
databaseId: string,
29+
linkResolver: PageLinkResolver
30+
): Promise<Database> {
2331
const dbConfig = lookupDatabaseConfig(this.config, databaseId);
2432

2533
// no view was defined for this database, render as a plain inline table
2634
const allPages = await this.fetchPages(databaseId, dbConfig);
2735

28-
const renderPages = dbConfig.renderAs === "pages+views"
36+
const renderPages = dbConfig.renderAs === "pages+views";
2937

30-
debug("rendering child database " + databaseId + " as " + dbConfig.renderAs);
38+
debug(
39+
"rendering child database " + databaseId + " as " + dbConfig.renderAs
40+
);
3141

3242
if (renderPages) {
3343
const pageConfig = dbConfig as DatabaseConfigRenderPages;
3444
const entries = await this.queuePageRendering(allPages, pageConfig);
35-
const markdown = await this.viewRenderer.renderViews(entries, dbConfig as DatabaseConfigRenderPages);
45+
const markdown = await this.viewRenderer.renderViews(
46+
entries,
47+
dbConfig as DatabaseConfigRenderPages,
48+
linkResolver
49+
);
3650

3751
return {
3852
config: dbConfig,
@@ -42,15 +56,18 @@ export class ChildDatabaseRenderer {
4256
} else {
4357
// render table
4458
const entries = await this.queueEntryRendering(allPages, dbConfig);
45-
const markdown = this.viewRenderer.renderViews(entries, dbConfig);
46-
59+
const markdown = this.viewRenderer.renderViews(
60+
entries,
61+
dbConfig,
62+
linkResolver
63+
);
64+
4765
return {
4866
config: dbConfig,
4967
entries,
5068
markdown,
5169
};
5270
}
53-
5471
}
5572

5673
private async queueEntryRendering(

src/DatabasePageRenderer.ts

+7-10
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ import * as fsc from "fs";
22

33
import { Page } from "@notionhq/client/build/src/api-types";
44

5-
import { AssetWriter } from "./AssetWriter";
65
import { FrontmatterRenderer } from "./FrontmatterRenderer";
7-
import { RenderingLoggingContext } from "./logger";
86
import { PropertiesParser } from "./PropertiesParser";
97
import { RecursiveBodyRenderer } from "./RecursiveBodyRenderer";
108
import { RenderDatabasePageTask as RenderDatabasePageTask } from "./RenderDatabasePageTask";
119
import { DatabaseConfigRenderPages } from "./SyncConfig";
1210
import { slugify } from "./slugify";
11+
import { RenderingContext } from "./RenderingContext";
1312

1413
const fs = fsc.promises;
1514

@@ -41,35 +40,33 @@ export class DatabasePageRenderer {
4140
frontmatter: frontmatterProperties,
4241
properties: props,
4342
render: async () => {
44-
const context = new RenderingLoggingContext(page.url, file);
45-
43+
44+
const context = new RenderingContext(page.url, file)
45+
4646
if (page.archived) {
4747
// have to skip rendering archived pages as attempting to retrieve the block will result in a HTTP 404
48-
context.warn(`page is archived - skipping`);
48+
context.logger.warn(`page is archived - skipping`);
4949

5050
return;
5151
}
5252

5353
try {
54-
const assetWriter = new AssetWriter(destDir);
55-
5654
const frontmatter = this.frontmatterRenderer.renderFrontmatter(frontmatterProperties);
5755
const body = await this.bodyRenderer.renderBody(
5856
page,
59-
assetWriter,
6057
context
6158
);
6259

6360
await fs.mkdir(destDir, { recursive: true });
6461
await fs.writeFile(file, frontmatter + body);
6562

66-
context.complete();
63+
context.logger.complete();
6764
} catch (error) {
6865
// While catch-log-throw is usually an antipattern, it is the renderes job to orchestrate the rendering
6966
// job with concerns like logging and writing to the outside world. Hence this place is appropriate.
7067
// We need to throw the error here so that the rendering process can crash with a proper error message, since
7168
// an error at this point here is unrecoverable.
72-
context.error(error);
69+
context.logger.error(error);
7370
throw error;
7471
}
7572
},

src/DatabaseViewRenderer.ts

+11-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DatabaseConfigRenderTable } from ".";
22
import { LinkRenderer } from "./LinkRenderer";
33
import * as markdownTable from "./markdown-table";
4+
import { PageLinkResolver } from "./PageLinkResolver";
45
import { RenderDatabaseEntryTask } from "./RenderDatabaseEntryTask";
56
import { RenderDatabasePageTask } from "./RenderDatabasePageTask";
67
import { DatabaseConfigRenderPages, DatabaseView } from "./SyncConfig";
@@ -11,15 +12,16 @@ export class DatabaseViewRenderer {
1112

1213
public renderViews(
1314
entries: (RenderDatabasePageTask | RenderDatabaseEntryTask)[],
14-
config: DatabaseConfigRenderPages | DatabaseConfigRenderTable
15+
config: DatabaseConfigRenderPages | DatabaseConfigRenderTable,
16+
linkResolver: PageLinkResolver
1517
): string {
1618
const configuredViews = config.views || [{}];
1719

1820
const views = configuredViews?.map((view) => {
1921
const groupByProperty = view?.properties?.groupBy;
2022

2123
if (!groupByProperty) {
22-
return this.renderView(entries, null, view);
24+
return this.renderView(entries, null, view, linkResolver);
2325
} else {
2426
const grouped = new Array(
2527
...groupBy(entries, (p) =>
@@ -28,7 +30,7 @@ export class DatabaseViewRenderer {
2830
);
2931

3032
return grouped
31-
.map(([key, pages]) => this.renderView(pages, key, view))
33+
.map(([key, pages]) => this.renderView(pages, key, view, linkResolver))
3234
.join("\n\n");
3335
}
3436
});
@@ -39,7 +41,8 @@ export class DatabaseViewRenderer {
3941
private renderView(
4042
pages: (RenderDatabasePageTask | RenderDatabaseEntryTask)[],
4143
titleAppendix: string | null,
42-
view: DatabaseView
44+
view: DatabaseView,
45+
linkResolver: PageLinkResolver
4346
): string {
4447
if (!pages[0]) {
4548
return "<!-- no pages inside this database -->";
@@ -60,15 +63,17 @@ export class DatabaseViewRenderer {
6063
cols.map((c, i) => {
6164
const content = escapeTableCell(r.properties.properties.get(c));
6265
return i == 0 && isRenderPageTask(r)
63-
? this.linkRenderer.renderPageLink(content, r) // make the first cell a relative link to the page
66+
? this.linkRenderer.renderPageLink(content, r, linkResolver) // make the first cell a relative link to the page
6467
: content;
6568
})
6669
)
6770
);
6871

6972
const tableMd = markdownTable.markdownTable(table);
7073
if (view.title) {
71-
const formattedTitle = [view.title, titleAppendix].filter(x => !!x).join(" - ");
74+
const formattedTitle = [view.title, titleAppendix]
75+
.filter((x) => !!x)
76+
.join(" - ");
7277
return `## ${formattedTitle}\n\n` + tableMd;
7378
} else {
7479
return tableMd;

0 commit comments

Comments
 (0)