-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds markdown rendering of the getting started page.
Refs #16. This got a little involved, but I learned a lot in the process here. The general pipeline is: 1. Perform an `async` read of the `getting_started.md` file. 2. Parse the markdown with `gray-matter` to separate the frontmatter and markdown body. 3. Parse the markdown body with `marked` and transform it into HTML. 4. Validate the frontmatter with a `zod` schema. 5. Render the page in Preact. This relies on a "fetch-then-render" approach, where all the data is loaded up front asynchronously _and then_ rendered afterwards. I initially tried a "fetch-as-you-render" approach with `<Suspense />` but found it to be a bit lacking. Preact's `<Suspense />` implementation is still experimental, and currently intended for lazy loading components. Data loading with `<Suspense />` is not really a well-trodden path and does not have defined primitives, either in Preact or React. Most React frameworks seem to define their own data loading primitives which I was initially hoping to avoid, letting users structure their own rendering process. For now, the easiest option is to fetch data up front and render afterwards. This isn't great performance and I'm not totally satisfied with it, but it's good enough for now. In the future, hopefully we can revisit async Preact and come away with a better path forward. [See discussion in `preact-ssr-prepass`.](preactjs/preact-ssr-prepass#55)
- Loading branch information
Showing
19 changed files
with
520 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,6 +18,7 @@ | |
"effectfully", | ||
"execroot", | ||
"expando", | ||
"frontmatter", | ||
"genfiles", | ||
"hydroactive", | ||
"inlines", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
# Documentation Site | ||
|
||
This is the documentation site for `@rules_prerender`. | ||
|
||
Hosted at https://rules-prerender.dwac.dev/. | ||
|
||
The site itself is generated with `@rules_prerender` as an alpha tester of new | ||
features. | ||
|
||
## Markdown Conventions | ||
|
||
Most pages are authored in markdown and processed through the same content | ||
pipeline. Markdown pages support frontmatter and must adhere to a specific | ||
schema documented below. This schema is implemented in | ||
[`markdown_page.mts`](/docs/markdown/markdown_page.mts) and should give solid | ||
error messages when not followed correctly. | ||
|
||
| Option | Semantics | | ||
| ------- | --------- | | ||
| `title` | Defines the title of the generated page. This is used by the `<title>` tag as well as rendered in the page body. Markdown pages should _not_ use a `# h1` header. | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
load("//:index.bzl", "css_library", "prerender_component") | ||
load("//tools/typescript:defs.bzl", "ts_project") | ||
load("//tools/jasmine:defs.bzl", "jasmine_node_test") | ||
|
||
prerender_component( | ||
name = "markdown", | ||
prerender = ":prerender", | ||
styles = ":styles", | ||
visibility = ["//docs:__subpackages__"], | ||
) | ||
|
||
ts_project( | ||
name = "prerender", | ||
srcs = ["markdown.tsx"], | ||
deps = [ | ||
"//docs/components/layout:layout_prerender", | ||
"//docs/markdown:markdown_page", | ||
"//:node_modules/@rules_prerender/preact", | ||
"//:node_modules/preact", | ||
"//:prerender_components/@rules_prerender/declarative_shadow_dom_prerender", | ||
], | ||
) | ||
|
||
ts_project( | ||
name = "prerender_test_lib", | ||
srcs = ["markdown_test.tsx"], | ||
testonly = True, | ||
deps = [ | ||
":markdown_prerender", | ||
"//docs/markdown:markdown_page_mock", | ||
"//:node_modules/preact", | ||
"//:node_modules/preact-render-to-string", | ||
"//:node_modules/rules_prerender", | ||
"//:node_modules/@types/jasmine", | ||
], | ||
) | ||
|
||
jasmine_node_test( | ||
name = "prerender_test", | ||
deps = [":prerender_test_lib"], | ||
) | ||
|
||
css_library( | ||
name = "styles", | ||
srcs = ["markdown.css"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
:host { | ||
height: 100%; | ||
} | ||
|
||
#md { | ||
height: calc(100% - (2 * 1em)); | ||
padding: 1rem 0; | ||
} | ||
|
||
#md > :first-child { | ||
margin-block-start: 0; | ||
} | ||
|
||
#md > :last-child { | ||
margin-block-end: 0; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { Template } from '@rules_prerender/declarative_shadow_dom/preact.mjs'; | ||
import { inlineStyle } from '@rules_prerender/preact'; | ||
import { type VNode } from 'preact'; | ||
import { Layout } from '../layout/layout.js'; | ||
import { Route } from '../../route.mjs'; | ||
import { MarkdownPage } from '../../markdown/markdown_page.mjs'; | ||
|
||
/** | ||
* Renders a docs page based on the given runfiles path to the markdown file. | ||
* | ||
* @param page The runfiles-relative path to the markdown file to render. | ||
* @param routes Routes to render page navigation with. | ||
*/ | ||
export function Markdown({ page, routes }: { | ||
page: MarkdownPage, | ||
routes?: readonly Route[], | ||
}): VNode { | ||
return <Layout | ||
pageTitle={page.metadata.title} | ||
headerTitle={page.metadata.title} | ||
routes={routes} | ||
> | ||
<div> | ||
<Template shadowrootmode="open"> | ||
{inlineStyle('./markdown.css', import.meta)} | ||
|
||
<div id="md" dangerouslySetInnerHTML={{ | ||
__html: page.html.getHtmlAsString(), | ||
}}></div> | ||
</Template> | ||
</div> | ||
</Layout>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import { render } from 'preact-render-to-string'; | ||
import { safe } from 'rules_prerender'; | ||
import { Markdown } from './markdown.js'; | ||
import { mockMarkdownPage } from '../../markdown/markdown_page_mock.mjs'; | ||
|
||
describe('markdown', () => { | ||
describe('Markdown', () => { | ||
it('renders the given markdown page', () => { | ||
const md = mockMarkdownPage({ | ||
metadata: { title: 'My title' }, | ||
html: safe`<div>Hello, World!</div>`, | ||
}); | ||
const page = <Markdown page={md} routes={[]} />; | ||
|
||
const html = render(page); | ||
expect(html).toContain('My title'); | ||
expect(html).toContain('<div>Hello, World!</div>'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
load("//tools/jasmine:defs.bzl", "jasmine_node_test") | ||
load("//tools/typescript:defs.bzl", "ts_project") | ||
|
||
ts_project( | ||
name = "markdown_loader", | ||
srcs = ["markdown_loader.mts"], | ||
visibility = ["//docs:__subpackages__"], | ||
deps = [ | ||
"//:node_modules/@types/marked", | ||
"//:node_modules/@types/node", | ||
"//:node_modules/gray-matter", | ||
"//:node_modules/marked", | ||
"//:node_modules/rules_prerender", | ||
], | ||
) | ||
|
||
ts_project( | ||
name = "markdown_loader_test_lib", | ||
srcs = ["markdown_loader_test.mts"], | ||
data = ["markdown_testdata.md"], | ||
testonly = True, | ||
deps = [ | ||
":markdown_loader", | ||
"//:node_modules/@types/jasmine", | ||
], | ||
) | ||
|
||
jasmine_node_test( | ||
name = "markdown_loader_test", | ||
deps = [":markdown_loader_test_lib"], | ||
) | ||
|
||
ts_project( | ||
name = "markdown_page", | ||
srcs = ["markdown_page.mts"], | ||
visibility = ["//docs:__subpackages__"], | ||
deps = [ | ||
"//:node_modules/rules_prerender", | ||
"//:node_modules/zod", | ||
], | ||
) | ||
|
||
ts_project( | ||
name = "markdown_page_test_lib", | ||
srcs = ["markdown_page_test.mts"], | ||
testonly = True, | ||
deps = [ | ||
":markdown_page", | ||
"//:node_modules/@types/jasmine", | ||
], | ||
) | ||
|
||
jasmine_node_test( | ||
name = "markdown_page_test", | ||
deps = [":markdown_page_test_lib"], | ||
) | ||
|
||
ts_project( | ||
name = "markdown_page_mock", | ||
srcs = ["markdown_page_mock.mts"], | ||
visibility = ["//docs:__subpackages__"], | ||
testonly = True, | ||
deps = [":markdown_page"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { promises as fs } from 'fs'; | ||
import { marked } from 'marked'; | ||
import * as path from 'path'; | ||
import grayMatter from 'gray-matter'; | ||
import { SafeHtml, unsafeTreatStringAsSafeHtml } from 'rules_prerender'; | ||
|
||
/** | ||
* Represents a markdown file which has been parsed into HTML. Includes | ||
* frontmatter content without any schema or assumptions applied. | ||
*/ | ||
export interface ParsedMarkdown { | ||
/** Frontmatter from the markdown file. */ | ||
frontmatter: Record<string, unknown>; | ||
|
||
/** HTML content of the markdown file. */ | ||
html: SafeHtml; | ||
} | ||
|
||
/** | ||
* Reads the page given as a runfiles path and parses it as markdown, returning | ||
* the HTML and frontmatter. | ||
* | ||
* @param page A runfiles-relative path to the markdown file to render. | ||
* @returns The parsed markdown frontmatter and HTML content. | ||
*/ | ||
export async function renderMarkdown(page: string): Promise<ParsedMarkdown> { | ||
const runfiles = process.env['RUNFILES']; | ||
if (!runfiles) throw new Error('`${RUNFILES}` not set.'); | ||
|
||
// Constrain this functionality to `*.md` files to reduce risk of misuse or | ||
// insecure usage. | ||
if (!page.endsWith('.md')) { | ||
throw new Error(`Markdown files *must* use the \`.md\` file extension.`); | ||
} | ||
|
||
// Read markdown from runfiles. | ||
let md: string; | ||
try { | ||
md = await fs.readFile(path.join(runfiles, page), 'utf8'); | ||
} catch (err: any) { | ||
if (err.code === 'ENOENT') { | ||
throw new Error(`Failed to read markdown file. Was it included as a \`data\` dependency?\n${err.message}`); | ||
} else { | ||
throw err; | ||
} | ||
} | ||
|
||
// Extract frontmatter from markdown files. | ||
const { content, data } = grayMatter(md); | ||
|
||
// Convert markdown to HTML. The HTML content comes directly from markdown | ||
// file in runfiles, so we can be fairly confident this is a source file or | ||
// built from source with no user input. | ||
const html = unsafeTreatStringAsSafeHtml(await marked(content, { | ||
async: true, | ||
})); | ||
|
||
return { frontmatter: data, html }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import { renderMarkdown } from './markdown_loader.mjs'; | ||
|
||
describe('markdown_loader', () => { | ||
describe('renderMarkdown()', () => { | ||
it('renders a markdown file from runfiles', async () => { | ||
const { frontmatter: metadata, html } = await renderMarkdown( | ||
'rules_prerender/docs/markdown/markdown_testdata.md'); | ||
|
||
expect(metadata).toEqual({ | ||
key: 'value', | ||
array: [ 1, 2, 3 ], | ||
nested: { | ||
foo: 'bar', | ||
}, | ||
}); | ||
|
||
expect(html.getHtmlAsString()).toContain('<h1>Hello, World!</h1>'); | ||
}); | ||
|
||
it('throws an error when given a file without an `*.md` extension', async () => { | ||
await expectAsync(renderMarkdown('rules_prerender/non/md/file.txt')) | ||
.toBeRejectedWithError(/use the `.md` file extension/); | ||
}); | ||
|
||
it('throws an error when the file is not found', async () => { | ||
await expectAsync(renderMarkdown('rules_prerender/does/not/exist.md')) | ||
.toBeRejectedWithError(/Was it included as a `data` dependency\?/); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import { SafeHtml } from 'rules_prerender'; | ||
import { z } from 'zod'; | ||
|
||
/** | ||
* Represents a parsed markdown page including frontmatter metadata and raw | ||
* HTML content. | ||
*/ | ||
export interface MarkdownPage { | ||
metadata: MarkdownMetadata; | ||
html: SafeHtml; | ||
} | ||
|
||
/** | ||
* Represents the metadata of a markdown page. Most of this comes from | ||
* frontmatter, but some may come from processing of the markdown contents. | ||
*/ | ||
export type MarkdownMetadata = z.infer<typeof pageMetadataSchema>; | ||
|
||
// Validates markdown page frontmatter. | ||
const pageMetadataSchema = z.strictObject({ | ||
title: z.string(), | ||
}); | ||
|
||
/** | ||
* Validates the given frontmatter and asserts that it matches page metadata | ||
* schema. | ||
*/ | ||
export function parsePageMetadata(page: string, frontmatter: unknown): MarkdownMetadata { | ||
const result = pageMetadataSchema.safeParse(frontmatter); | ||
if (result.success) return result.data; | ||
|
||
// `formErrors` are errors on the root object (ex. parsing `null` directly). | ||
// `fieldErrors` are errors for fields of the object and sub-objects. | ||
const { formErrors, fieldErrors } = result.error.flatten(); | ||
const formErrorsMessage = formErrors.join('\n'); | ||
const fieldErrorsMessage = Object.entries(fieldErrors) | ||
.map(([ field, message ]) => `Property \`${field}\`: ${message}`) | ||
.join('\n'); | ||
const errorMessage = formErrorsMessage !== '' | ||
? `${formErrorsMessage}\n${fieldErrorsMessage}` | ||
: fieldErrorsMessage; | ||
|
||
throw new Error(`Error processing markdown frontmatter for page \`${ | ||
page}\`:\n${errorMessage}`); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
/** | ||
* @fileoverview Mocks file with functions for creating mock objects for | ||
* markdown data structures. | ||
*/ | ||
|
||
import { SafeHtml, safe } from 'rules_prerender'; | ||
import { MarkdownMetadata, MarkdownPage } from './markdown_page.mjs'; | ||
|
||
/** Mocks a {@link MarkdownPage} object with defaults. */ | ||
export function mockMarkdownPage({ metadata, html }: { | ||
metadata?: MarkdownMetadata, | ||
html?: SafeHtml, | ||
} = {}): MarkdownPage { | ||
return { | ||
metadata: mockMarkdownMetadata(metadata), | ||
html: html ?? safe`<button>Mocked HTML.</button>`, | ||
}; | ||
} | ||
|
||
/** Mocks a {@link MarkdownMetadata} object with defaults. */ | ||
export function mockMarkdownMetadata({ title }: { title?: string } = {}): | ||
MarkdownMetadata { | ||
return { | ||
title: title ?? 'An interesting post', | ||
}; | ||
} |
Oops, something went wrong.