-
Notifications
You must be signed in to change notification settings - Fork 139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
full-page templates #253
full-page templates #253
Changes from all commits
6f61cfa
0492775
93938da
c9004d3
57250a3
bc4c106
48d8900
005b8e8
5b00817
16c3d77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import {parseHTML} from "linkedom"; | ||
import {type Config, type Page, type Section, mergeToc} from "./config.js"; | ||
import {type Html, html} from "./html.js"; | ||
import {findLink} from "./pager.js"; | ||
import {relativeUrl} from "./url.js"; | ||
|
||
export type Template = ( | ||
elements: { | ||
/** | ||
* The page’s path. | ||
*/ | ||
path: string; | ||
/** | ||
* User-provided front-matter. | ||
*/ | ||
data?: any; | ||
/** | ||
* A title element. Typically inserted in the head element. | ||
*/ | ||
title?: string; | ||
/** | ||
* A series of link[rel=modulepreload] elements, to insert in the head. | ||
*/ | ||
preloads: string; | ||
/** | ||
* The main module, a script[type=module] tag containing all the client-side | ||
* JavaScript code for the page. Typically inserted at the top of the main | ||
* element. | ||
*/ | ||
module: string; | ||
/** | ||
* The main HTML contents. Typically inserted in a main or an article | ||
* element. | ||
*/ | ||
main: string; | ||
/** | ||
* A base tag for the project root (only made available for the 404 page, | ||
* since it might be served from any sub-directory path). | ||
*/ | ||
base?: string; | ||
/** | ||
* Relative path to the root of the project; "./" for top-level pages, "../" | ||
* for pages in a subdirectory, etc. | ||
*/ | ||
root?: string; | ||
}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if we instead passed a declarative representation of the page state — rather than partially rendered HTML fragments — so that the template has complete flexibility in turning the declarative representation of the page into HTML (or anything else, e.g., TeX)? Though that requires us to design an intermediate representation as part of the public API. Worth the effort? 🤔 I also think we should encourage people to use the I suspect we will also need to have an API for the CLI, declare a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My approach here is very bare-bones, "stdout"-like. It treats observable markdown as a function that returns HTML fragments that you can just paste together and send as one big string to the browser for a severely unstyled but functional experience:
All these fragments are made of safe HTML (unless I made a mistake). Users can assemble them in their own "page templates" (or placeholders) with zero difficulty. Pareto is happy. A (crazy, or "pure") idea would be to return all of this as a single html fragment, and let the default template collect the elements with a querySelector (it already does it to build the TOC, so it wouldn't be much more costly). But probably too crazy. Some elements are not HTML (path, root and the front-matter data); maybe we should put them in a separate argument? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mbostock could you explain your declarative representation of the page state idea? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes… I need more time to think about this one and then come up with a more actionable proposal. |
||
options: Omit<Config, "template"> | ||
) => string; | ||
|
||
export const page: Template = ({path, data, preloads, module, main, title, base, root}, options) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks like we want to eventually add other parameters for overriding other HTML fragments - like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep, no problem! |
||
const {pages, title: projectTitle} = options; | ||
const toc = mergeToc(data?.toc, options.toc); | ||
const headers = toc.show ? findHeaders(main) : []; | ||
return `<!DOCTYPE html> | ||
<meta charset="utf-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||
${title}${base}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap"> | ||
<link rel="stylesheet" type="text/css" href="${root}_observablehq/style.css">${ | ||
data?.template ? `\n<link rel="stylesheet" type="text/css" href="${root}_observablehq/${data.template}.css">` : "" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. data in this case comes directly from the front matter but what if the user wants to apply the same styling across all pages? maybe I am missing something... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree we'll want to allow customization of the global CSS (adding a stylesheet, or modifying the base stylesheet), but it's not in this PR's scope. |
||
}${preloads} | ||
${module}${pages.length > 0 ? renderSidebar(projectTitle, pages, path) : ""} | ||
${headers.length > 0 ? renderToc(headers, toc.label) : ""}<div id="observablehq-center"> | ||
<main id="observablehq-main" class="observablehq"> | ||
${main}</main> | ||
<footer id="observablehq-footer">${renderPager(path, options)} | ||
<div>© ${new Date().getUTCFullYear()} Observable, Inc.</div> | ||
</footer> | ||
</div> | ||
`; | ||
}; | ||
|
||
function renderSidebar(title = "Home", pages: (Page | Section)[], path: string): Html { | ||
return html`\n<input id="observablehq-sidebar-toggle" type="checkbox"> | ||
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label> | ||
<nav id="observablehq-sidebar"> | ||
<ol> | ||
<li class="observablehq-link${path === "/index" ? " observablehq-link-active" : ""}"><a href="${relativeUrl( | ||
path, | ||
"/" | ||
)}">${title}</a></li> | ||
</ol> | ||
<ol>${pages.map((p, i) => | ||
"pages" in p | ||
? html`${i > 0 && "path" in pages[i - 1] ? html`</ol>` : ""} | ||
<details${p.open ? " open" : ""}> | ||
<summary>${p.name}</summary> | ||
<ol>${p.pages.map((p) => renderListItem(p, path))} | ||
</ol> | ||
</details>` | ||
: "path" in p | ||
? html`${i > 0 && "pages" in pages[i - 1] ? html`\n </ol>\n <ol>` : ""}${renderListItem(p, path)}` | ||
: "" | ||
)} | ||
</ol> | ||
</nav> | ||
<script>{ | ||
const toggle = document.querySelector("#observablehq-sidebar-toggle"); | ||
const initialState = localStorage.getItem("observablehq-sidebar"); | ||
if (initialState) toggle.checked = initialState === "true"; | ||
else toggle.indeterminate = true; | ||
}</script>`; | ||
} | ||
|
||
interface Header { | ||
label: string; | ||
href: string; | ||
} | ||
|
||
function findHeaders(html: string): Header[] { | ||
return Array.from(parseHTML(html).document.querySelectorAll("h2")) | ||
.map((node) => ({label: node.textContent, href: node.firstElementChild?.getAttribute("href")})) | ||
.filter((d): d is Header => !!d.label && !!d.href); | ||
} | ||
|
||
function renderToc(headers: Header[], label = "Contents"): Html { | ||
return html`<aside id="observablehq-toc"> | ||
<nav> | ||
<div>${label}</div> | ||
<ol>${headers.map( | ||
({label, href}) => html`\n<li class="observablehq-secondary-link"><a href="${href}">${label}</a></li>` | ||
)} | ||
</ol> | ||
</nav> | ||
</aside> | ||
`; | ||
} | ||
|
||
function renderListItem(p: Page, path: string): Html { | ||
return html`\n <li class="observablehq-link${ | ||
p.path === path ? " observablehq-link-active" : "" | ||
}"><a href="${relativeUrl(path, prettyPath(p.path))}">${p.name}</a></li>`; | ||
} | ||
|
||
function prettyPath(path: string): string { | ||
return path.replace(/\/index$/, "/") || "/"; | ||
} | ||
|
||
function renderPager(path: string, options: Pick<Config, "pages" | "pager" | "title">): Html | "" { | ||
const link = options.pager && findLink(path, options); | ||
return link | ||
? html`\n<nav>${link.prev ? renderRel(path, link.prev, "prev") : ""}${ | ||
link.next ? renderRel(path, link.next, "next") : "" | ||
}</nav>` | ||
: ""; | ||
} | ||
|
||
function renderRel(path: string, page: Page, rel: "prev" | "next"): Html { | ||
return html`<a rel="${rel}" href="${relativeUrl(path, prettyPath(page.path))}"><span>${page.name}</span></a>`; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export default { | ||
pages: [{path: "/index", name: "Test page"}], | ||
template: (elements, options) => JSON.stringify({elements, options}, null, 2) | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Hello | ||
|
||
```js | ||
d3 | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A bit of churn is due to moving the default rendering function and its utilities into a separate page.ts. I was (pleasantly) surprised that parseHtml is only needed for the toc.