Skip to content
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

embedded api #1637

Merged
merged 45 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
040628e
embedded api
mbostock Sep 3, 2024
738f462
TODO
mbostock Sep 3, 2024
44fbdaa
inline file registration
mbostock Sep 3, 2024
58887fa
TODO
mbostock Sep 3, 2024
e7bf5e5
unexport registerFiles
mbostock Sep 3, 2024
da765ba
minimize churn
mbostock Sep 3, 2024
8dc4fa1
canonical FileAttachment
mbostock Sep 3, 2024
f547f98
safer registerFile
mbostock Sep 4, 2024
e8b05da
inline file registration for build
mbostock Sep 4, 2024
032c127
resolve transitive static imports
mbostock Sep 4, 2024
dcb4aeb
fix lastModified in inlined FileAttachment calls
mbostock Sep 4, 2024
527b5ec
fix getModuleStaticImports test?
mbostock Sep 4, 2024
3b1d315
chart.js example
mbostock Sep 4, 2024
845077c
skip shadowed page chart.js.md in the side bar
Fil Sep 4, 2024
c11a7a3
add a non-working test
Fil Sep 4, 2024
e6d24ca
Merge branch 'main' into mbostock/embedded
mbostock Sep 8, 2024
69d13b4
use findPage
mbostock Sep 8, 2024
607da48
file
mbostock Sep 8, 2024
8cb93eb
pass LoadOptions to load, not find
mbostock Sep 8, 2024
60bae2f
more .js precedence
mbostock Sep 8, 2024
e41893e
Merge remote-tracking branch 'origin/fil/embedded' into mbostock/embe…
mbostock Sep 8, 2024
8a3be6c
smaller test files
mbostock Sep 8, 2024
15d6041
add test output
mbostock Sep 8, 2024
aae83bd
extract renderModule
mbostock Sep 9, 2024
4bcea11
embedPaths
mbostock Sep 9, 2024
9b0a3c2
getModuleResolvers
mbostock Sep 9, 2024
195d4a4
DRY
mbostock Sep 9, 2024
362407f
fold embedPaths into dynamicPaths
mbostock Sep 9, 2024
518e67b
cleaner types
mbostock Sep 9, 2024
a7d962a
tree of modules
mbostock Sep 9, 2024
811df03
shorten
mbostock Sep 9, 2024
a4cc2fc
restricted cors
mbostock Sep 10, 2024
86b77b8
--cors
mbostock Sep 10, 2024
773a45a
docs
mbostock Sep 10, 2024
b57cccc
more docs
mbostock Sep 10, 2024
e3ad0ba
remove /chart page
mbostock Sep 10, 2024
3451619
more docs
mbostock Sep 10, 2024
6fba2ae
more docs
mbostock Sep 10, 2024
afc00a8
more docs
mbostock Sep 10, 2024
9a966f6
update test
Fil Sep 10, 2024
90e3e2c
Apply suggestions from code review
mbostock Sep 10, 2024
6e24c25
Update docs/embeds.md
Fil Sep 10, 2024
0cc7a02
Merge branch 'main' into mbostock/embedded
mbostock Sep 10, 2024
99508da
reorder sections
mbostock Sep 10, 2024
9f98801
cors
mbostock Sep 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/chart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {FileAttachment} from "npm:@observablehq/stdlib";
import * as Plot from "npm:@observablehq/plot";

export async function Chart() {
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true});
return Plot.plot({
y: {grid: true},
color: {scheme: "burd"},
marks: [Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), Plot.ruleY([0])]
});
}
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Whether to show the previous & next links in the footer; defaults to true. The p

## dynamicPaths <a href="https://github.com/observablehq/framework/releases/tag/v1.11.0" class="observablehq-version-badge" data-version="^1.11.0" title="Added in 1.11.0"></a>

The list of [parameterized pages](./params) and [dynamic pages](./page-loaders) to generate, either as a (synchronous) iterable of strings, or a function that returns an async iterable of strings if you wish to load the list of dynamic pages asynchronously.
The list of [parameterized pages](./params), [dynamic pages](./page-loaders), and [embedded modules](./embeds) to generate, either as a (synchronous) iterable of strings, or a function that returns an async iterable of strings if you wish to load the list of dynamic pages asynchronously.

## head

Expand Down
4 changes: 2 additions & 2 deletions docs/data-loaders.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
keywords: server-side rendering, ssr
keywords: server-side rendering, ssr, polyglot
---

# Data loaders
Expand All @@ -11,7 +11,7 @@ Why static snapshots? Performance is critical for dashboards: users don’t like
<div class="tip">Data loaders are optional. You can use <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch">fetch</a></code> or <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket">WebSocket</a></code> if you prefer to load data at runtime, or you can store data in static files.</div>
<div class="tip">You can use <a href="./deploying">continuous deployment</a> to rebuild data as often as you like, ensuring that data is always up-to-date.</div>

Data loaders can be written in any programming language. They can even invoke binary executables such as ffmpeg or DuckDB. For convenience, Framework has built-in support for common languages: JavaScript, TypeScript, Python, and R. Naturally you can use any third-party library or SDK for these languages, too.
Data loaders are polyglot: they can be written in any programming language. They can even invoke binary executables such as ffmpeg or DuckDB. For convenience, Framework has built-in support for common languages: JavaScript, TypeScript, Python, and R. Naturally you can use any third-party library or SDK for these languages, too.

A data loader can be as simple as a shell script that invokes [curl](https://curl.se/) to fetch recent earthquakes from the [USGS](https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php):

Expand Down
120 changes: 120 additions & 0 deletions docs/embeds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Embedded analytics <a href="https://github.com/observablehq/framework/pull/1637" class="observablehq-version-badge" data-version="prerelease" title="Added in #1637"></a>

In addition to full-page apps, Framework can generate modules to embed analytics in external applications. Embedded modules can take full advantage of Framework’s polyglot, baked data architecture for instant page loads.
mbostock marked this conversation as resolved.
Show resolved Hide resolved

To allow a [JavaScript module](./imports#local-imports) to be embedded in an external application, declare the module’s path in your [config file](./config) using the [**dynamicPaths** option](./config#dynamic-paths). For example, to embed a single component named `chart.js`:

```js run=false
export default {
dynamicPaths: [
"/chart.js"
]
};
```

Or for [parameterized routes](./params), name the component `product-[id]/chart.js`, then load a list of product identifiers from a database with a SQL query:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit too allusive. It might be worth developing an example of how to use observable.params.id within chart.js so that it corresponds to the requested product and displays data from the corresponding (parametrized) data loader. The example can live as a stand-alone example app, and we'd just add a link here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I didn’t want to duplicate the documentation of parameterized routes here, but I also wanted to allude to it, since it feels like a natural question (how would I show multiple views?) with perhaps a surprising answer (the views bake-in their data rather than expecting it to be passed in as a prop from the host app).


```js run=false
import postgres from "postgres";

const sql = postgres(); // Note: uses psql environment variables
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const sql = postgres(); // Note: uses psql environment variables
const sql = postgres(); // See https://github.com/porsager/postgres to set up the environment variables

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment exists in multiple places. (I’ve been copying it every time I use Postgres.js.) I’m not sure we need it. The link from the text is probably enough, but I did think it was useful to mention that it depends on environment variables.


export default {
async *dynamicPaths() {
for await (const {id} of sql`SELECT id FROM products`.cursor()) {
yield `/product-${id}/chart.js`;
}
}
};
```

Embedded modules are vanilla JavaScript, and will behave identically when embedded in an external application as on a Framework page. As always, you can load data from a [data loader](./data-loaders) using [`FileAttachment`](./files), and you can [import](./imports) [self-hosted](./imports#self-hosting-of-npm-imports) local modules and libraries from npm; file and import resolutions are baked into the generated code at build time so that imported module “just works”.
mbostock marked this conversation as resolved.
Show resolved Hide resolved

Embedded modules are often written as functions that return DOM elements. These functions can take options (or “props”), and typically load their own data via `FileAttachment`. For example, below is a simple `chart.js` module that exports a `Chart` function that renders a scatterplot.
Fil marked this conversation as resolved.
Show resolved Hide resolved

```js run=false
import {FileAttachment} from "npm:@observablehq/stdlib";
import * as Plot from "npm:@observablehq/plot";

export async function Chart() {
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true});
return Plot.plot({
y: {grid: true},
color: {scheme: "burd"},
marks: [
Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}),
Plot.ruleY([0])
]
});
}
```

When Framework builds your app, any transitive static imports needed are preloaded automatically when the module is imported. This can significantly improve performance by avoiding long request chains.
mbostock marked this conversation as resolved.
Show resolved Hide resolved

## Embedding modules

An embedded component can be imported into a vanilla web application like so:

```html run=false
<script type="module">

import {Chart} from "https://my-workspace.observablehq.cloud/my-app/chart.js";

document.body.append(await Chart());

</script>
```

<div class="note">

The code above assumes the Framework app is called “my-app” and that it’s deployed to Observable Cloud in the workspace named “my-workspace”.

</div>

In React, you can do something similar using [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) and [`useEffect`](https://react.dev/reference/react/useEffect) and [`useRef`](https://react.dev/reference/react/useRef) hooks:

```jsx run=false
import {useEffect, useRef} from "react";

export function EmbedChart() {
const ref = useRef(null);

useEffect(() => {
let parent = ref.current, child;
import("https://my-workspace.observablehq.cloud/my-app/chart.js")
.then(({Chart}) => Chart())
.then((chart) => parent?.append((child = chart)));
return () => ((parent = null), child?.remove());
}, []);

return <div ref={ref} />;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to test this and log every line of it! it works :)

```

<div class="tip">

Since both dynamic import and the imported component are async, the code above is careful to clean up the effect and avoid race conditions.

</div>

<div class="tip">

You can alternatively embed Framework pages using [iframe embeds](https://observablehq.observablehq.cloud/framework-example-responsive-iframe/).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking of which, an example of a responsive chart (that reruns to adapt to the container's width) might be welcome here, or separately in a full-blown example.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That should just be the resize helper, as normal.


</div>

## Developing modules

To develop your component, you can import it into a Framework page like normal, giving you instant reactivity as you make changes to the component or its data.

```js echo
import {Chart} from "./chart.js";
```

To instantiate the imported component, simply call the function:

```js echo
Chart()
```

A Framework page can serve as live and documentation for your component: you can describe and demonstrate all the states and options for your component, and review the behavior visually.
mbostock marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions observablehq.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default {
{name: "Themes", path: "/themes"},
{name: "Page loaders", path: "/page-loaders"},
{name: "Parameterized routes", path: "/params"},
{name: "Embedded analytics", path: "/embeds"},
{name: "Configuration", path: "/config"},
{name: "Examples", path: "https://github.com/observablehq/framework/tree/main/examples"},
{
Expand Down Expand Up @@ -89,6 +90,7 @@ export default {
{name: "Contributing", path: "/contributing", pager: false}
],
dynamicPaths: [
"/chart.js",
"/theme/dark",
"/theme/dark-alt",
"/theme/dashboard",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"observable": "dist/bin/observable.js"
},
"scripts": {
"dev": "tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open",
"dev": "tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open --cors",
"docs:build": "tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build",
"docs:deploy": "tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy",
"build": "rimraf dist && node build.js --outdir=dist --outbase=src \"src/**/*.{ts,js,css}\" --ignore \"**/*.d.ts\"",
Expand Down
21 changes: 17 additions & 4 deletions src/bin/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,28 +155,41 @@ try {
...CONFIG_OPTION,
host: {
type: "string",
default: "127.0.0.1"
default: "127.0.0.1",
description: "the server host; use 0.0.0.0 to accept external connections"
},
port: {
type: "string"
type: "string",
description: "the server port; defaults to 3000 (or higher if unavailable)"
},
cors: {
type: "boolean",
description: "allow cross-origin requests on all origins (*)"
},
"allow-origin": {
type: "string",
multiple: true,
description: "allow cross-origin requests on a specific origin"
},
open: {
type: "boolean",
default: true
default: true,
description: "open browser"
},
"no-open": {
type: "boolean"
}
}
});
const {config, root, host, port, open} = values;
const {config, root, host, port, open, cors, ["allow-origin"]: origins} = values;
await readConfig(config, root); // Ensure the config is valid.
await import("../preview.js").then(async (preview) =>
preview.preview({
config,
root,
hostname: host!,
port: port === undefined ? undefined : +port,
origins: cors ? ["*"] : origins,
open
})
);
Expand Down
88 changes: 61 additions & 27 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type {Logger, Writer} from "./logger.js";
import type {MarkdownPage} from "./markdown.js";
import {populateNpmCache, resolveNpmImport, rewriteNpmImports} from "./npm.js";
import {isAssetPath, isPathImport, relativePath, resolvePath, within} from "./path.js";
import {renderPage} from "./render.js";
import {renderModule, renderPage} from "./render.js";
import type {Resolvers} from "./resolvers.js";
import {getModuleResolver, getResolvers} from "./resolvers.js";
import {getModuleResolver, getModuleResolvers, getResolvers} from "./resolvers.js";
import {resolveImportPath, resolveStylesheetPath} from "./resolvers.js";
import {bundleStyles, rollupClient} from "./rollup.js";
import {searchIndex} from "./search.js";
Expand Down Expand Up @@ -58,36 +58,56 @@ export async function build(
// Prepare for build (such as by emptying the existing output root).
await effects.prepare();

// Parse .md files, building a list of additional assets as we go.
const pages = new Map<string, {page: MarkdownPage; resolvers: Resolvers}>();
// Accumulate outputs.
const outputs = new Map<string, ({type: "page"; page: MarkdownPage} | {type: "module"}) & {resolvers: Resolvers}>();
const files = new Set<string>(); // e.g., "/assets/foo.png"
const localImports = new Set<string>(); // e.g., "/components/foo.js"
const globalImports = new Set<string>(); // e.g., "/_observablehq/search.js"
const stylesheets = new Set<string>(); // e.g., "/style.css"
const addFile = (path: string, f: string) => files.add(resolvePath(path, f));
const addLocalImport = (path: string, i: string) => localImports.add(resolvePath(path, i));
const addGlobalImport = (path: string, i: string) => isPathImport(i) && globalImports.add(resolvePath(path, i));
const addStylesheet = (path: string, s: string) => stylesheets.add(/^\w+:/.test(s) ? s : resolvePath(path, s));

// Load pages, building a list of additional assets as we go.
for await (const path of config.paths()) {
effects.output.write(`${faint("parse")} ${path} `);
effects.output.write(`${faint("load")} ${path} `);
const start = performance.now();
const options = {path, ...config};
if (path.endsWith(".js")) {
const module = findModule(root, path);
if (module) {
const resolvers = await getModuleResolvers(path, config);
const elapsed = Math.floor(performance.now() - start);
for (const f of resolvers.files) addFile(path, f);
for (const i of resolvers.localImports) addLocalImport(path, i);
for (const i of resolvers.globalImports) addGlobalImport(path, resolvers.resolveImport(i));
for (const s of resolvers.stylesheets) addStylesheet(path, s);
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
outputs.set(path, {type: "module", resolvers});
continue;
}
}
const page = await loaders.loadPage(path, options, effects);
if (page.data.draft) {
effects.logger.log(faint("(skipped)"));
continue;
}
const resolvers = await getResolvers(page, options);
const elapsed = Math.floor(performance.now() - start);
for (const f of resolvers.assets) files.add(resolvePath(path, f));
for (const f of resolvers.files) files.add(resolvePath(path, f));
for (const i of resolvers.localImports) localImports.add(resolvePath(path, i));
for (let i of resolvers.globalImports) if (isPathImport((i = resolvers.resolveImport(i)))) globalImports.add(resolvePath(path, i)); // prettier-ignore
for (const s of resolvers.stylesheets) stylesheets.add(/^\w+:/.test(s) ? s : resolvePath(path, s));
for (const f of resolvers.assets) addFile(path, f);
for (const f of resolvers.files) addFile(path, f);
for (const i of resolvers.localImports) addLocalImport(path, i);
for (const i of resolvers.globalImports) addGlobalImport(path, resolvers.resolveImport(i));
for (const s of resolvers.stylesheets) addStylesheet(path, s);
effects.output.write(`${faint("in")} ${(elapsed >= 100 ? yellow : faint)(`${elapsed}ms`)}\n`);
pages.set(path, {page, resolvers});
outputs.set(path, {type: "page", page, resolvers});
}

// Check that there’s at least one page.
const pageCount = pages.size;
if (!pageCount) throw new CliError(`Nothing to build: no page files found in your ${root} directory.`);
effects.logger.log(`${faint("built")} ${pageCount} ${faint(`page${pageCount === 1 ? "" : "s"} in`)} ${root}`);
// Check that there’s at least one output.
const outputCount = outputs.size;
if (!outputCount) throw new CliError(`Nothing to build: no pages found in your ${root} directory.`);
effects.logger.log(`${faint("built")} ${outputCount} ${faint(`page${outputCount === 1 ? "" : "s"} in`)} ${root}`);

// For cache-breaking we rename most assets to include content hashes.
const aliases = new Map<string, string>();
Expand Down Expand Up @@ -242,6 +262,13 @@ export async function build(
root,
path,
params: module.params,
resolveFile(name) {
const resolution = loaders.resolveFilePath(resolvePath(path, name));
return aliases.get(resolution) ?? resolution;
},
resolveFileInfo(name) {
return loaders.getOutputInfo(resolvePath(path, name));
},
async resolveImport(specifier) {
let resolution: string;
if (isPathImport(specifier)) {
Expand All @@ -262,10 +289,10 @@ export async function build(
}

// Wrap the resolvers to apply content-hashed file names.
for (const [path, page] of pages) {
const {resolvers} = page;
pages.set(path, {
...page,
for (const [path, output] of outputs) {
const {resolvers} = output;
outputs.set(path, {
...output,
resolvers: {
...resolvers,
resolveFile(specifier) {
Expand Down Expand Up @@ -294,29 +321,36 @@ export async function build(

// Render pages!
const buildManifest: BuildManifest = {pages: []};
for (const [path, {page, resolvers}] of pages) {
for (const [path, output] of outputs) {
effects.output.write(`${faint("render")} ${path} ${faint("→")} `);
const html = await renderPage(page, {...config, path, resolvers});
await effects.writeFile(`${path}.html`, html);
buildManifest.pages.push({path: config.normalizePath(path), title: page.title});
if (output.type === "page") {
const {page, resolvers} = output;
const html = await renderPage(page, {...config, path, resolvers});
await effects.writeFile(`${path}.html`, html);
buildManifest.pages.push({path: config.normalizePath(path), title: page.title});
} else {
const {resolvers} = output;
const source = await renderModule(root, path, resolvers);
await effects.writeFile(path, source);
}
}

// Write the build manifest.
await effects.writeBuildManifest(buildManifest);
// Log page sizes.
const columnWidth = 12;
effects.logger.log("");
for (const [indent, name, description, node] of tree(pages)) {
for (const [indent, name, description, node] of tree(outputs)) {
if (node.children) {
effects.logger.log(
`${faint(indent)}${name}${faint(description)} ${
node.depth ? "" : ["Page", "Imports", "Files"].map((name) => name.padStart(columnWidth)).join(" ")
}`
);
} else {
const [path, {resolvers}] = node.data!;
const [path, {type, resolvers}] = node.data!;
const resolveOutput = (name: string) => join(config.output, resolvePath(path, name));
const pageSize = (await stat(join(config.output, `${path}.html`))).size;
const pageSize = (await stat(join(config.output, type === "page" ? `${path}.html` : path))).size;
const importSize = await accumulateSize(resolvers.staticImports, resolvers.resolveImport, resolveOutput);
const fileSize =
(await accumulateSize(resolvers.files, resolvers.resolveFile, resolveOutput)) +
Expand All @@ -331,7 +365,7 @@ export async function build(
}
effects.logger.log("");

Telemetry.record({event: "build", step: "finish", pageCount});
Telemetry.record({event: "build", step: "finish", pageCount: outputCount});
}

function applyHash(path: string, hash: string): string {
Expand Down
Loading