Skip to content

asg017/unofficial-observablehq-compiler

Repository files navigation

@alex.garcia/unofficial-observablehq-compiler CircleCI

An unoffical compiler for Observable notebooks (glue between the Observable parser and runtime)

⚠️This library is currently under construction!⚠️

The README below is for v0.5.0, but v0.6.0 is currently in alpha and has several improvements to this library + API that you probably care about. Check out #29 to stay in the loop, and check out that version's README for docs on the soon-to-be-released v0.6.0.


This compiler will compile "observable syntax" into "javascript syntax". For example -

import compiler from "@alex.garcia/unofficial-observablehq-compiler";
import { Inspector, Runtime } from "@observablehq/runtime";

const compile = new compiler.Compiler();

compile.module(`
import {text} from '@jashkenas/inputs'

viewof name = text({
  title: "what's your name?",
  value: ''
})

md\`Hello **\${name}**, it's nice to meet you!\`

`).then(define => {
  const runtime = new Runtime();

  const module = runtime.module(define, Inpsector.into(document.body));
});

For more live examples and functionality, take a look at the announcement notebook and this test page.

API Reference

Compiler

# new Compiler(resolve = defaultResolver, fileAttachmentsResolve = name => name, resolvePath = defaultResolvePath) <>

Returns a new compiler. resolve is an optional function that, given a path string, will resolve a new define function for a new module. This is used when the compiler comes across an import statement - for example:

import {chart} from "@d3/bar-chart"

In this case, resolve gets called with path="@d3/bar-chart". The defaultResolver function will lookup the given path on observablehq.com and return the define function to define that notebook.

For example, if you have your own set of notebooks on some other server, you could use something like:

const resolve = path =>
  import(`other.server.com/notebooks/${path}.js`).then(
    module => module.default
  );

const compile = new Compiler(resolve);

fileAttachmentsResolve is an optional function from strings to URLs which is used as a resolve function in the standard library's FileAttachments function. For example, if you wanted to reference example.com/my_file.png in a cell which reads:

await FileAttachment("my_file.png").url();

Then you could compile this cell with:

const fileAttachmentsResolve = name => `example.com/${name}`;

const compile = new Compiler(, fileAttachmentsResolve);

By default, fileAtachmentsResolve simply returns the same string, so you would have to use valid absolute or relative URLs in your FileAttachments.

resolvePath is an optional function from strings to URLs which is used to turn the strings in import cells to URLs in compile.moduleToESModule and compile.notebookToESModule. For instance, if those functions encounter this cell:

import {chart} from "@d3/bar-chart"

then resolvePath is called with path="@d3/bar-chart" and the resulting URL is included in the static import statements at the beginning of the generated ES module source.

#compile.module(contents)

Returns a define function. contents is a string that defines a "module", which is a list of "cells" (both defintions from @observablehq/parser). It must be compatible with parseModule. This fetches all imports so it is asynchronous.

For example:

const define = await compile.module(`a = 1
b = 2
c = a + b`);

You can now use define with the Observable runtime:

const runtime = new Runtime();
const main = runtime.module(define, Inspector.into(document.body));

#compile.notebook(object)

Returns a define function. object is a "notebook JSON object" as used by the ObservableHQ notebook app to display notebooks. Such JSON files are served by the API endpoint at https://api.observablehq.com/document/:slug (see the observable-client for a convenient way to authenticate and make requests).

compile.notebook requires that object has a field named "nodes" consisting of an array of cell objects. Each of the cell objects must have a field "value" consisting of a string with the source code for that cell.

The notebook JSON objects also ordinarily contain some other metadata fields, e.g. "id", "slug", "owner", etc. which are currently ignored by the compiler. Similarly, the cell objects in "nodes" ordinarily contain "id" and "pinned" fields which are also unused here.

This fetches all imports so it is asynchronous.

For example:

const define = await compile.notebook({
  nodes: [{ value: "a = 1" }, { value: "b = 2" }, { value: "c = a + b" }]
});

You can now use define with the Observable runtime:

const runtime = new Runtime();
const main = runtime.module(define, Inspector.into(document.body));

#compile.cell(contents)

Returns an object that has define and redefine functions that would define or redefine variables in the given cell to a specified module. contents is input for the parseCell function. If the cell is not an ImportDeclaration, then the redefine functions can be used to redefine previously existing variables in a module. This is an asynchronous function because if the cell is an import, the imported notebook is fetched.

let define, redefine;

define = await compile.module(`a = 1;
b = 2;

c = a + b`);

const runtime = new Runtime();
const main = runtime.module(define, Inspector.into(document.body));

await main.value("a") // 1

{define, redefine} = await compile.cell(`a = 20`);

redefine(main);

await main.value("a"); // 20
await main.value("c"); // 22

define(main); // would throw an error, since a is already defined in main

{define} = await compile.cell(`x = 2`);
define(main);
{define} = await compile.cell(`y = x * 4`);
define(main);

await main.value("y") // 8

Keep in mind, if you want to use define from compile.cell, you'll have to provide an observer function, which will most likely be the same observer that was used when defining the module. For example:

let define, redefine;

define = await compile.module(`a = 1;
b = 2;`);

const runtime = new Runtime();
const observer = Inspector.into(document.body);
const main = runtime.module(define, observer);

{define} = await compile.cell(`c = a + b`);

define(main, observer);

Since redefine is done on a module level, an observer is not required.

#compile.moduleToESModule(contents)

Returns a string containing the source code of an ES module. This ES module is compiled from the Observable runtime module in the string contents.

For example:

const src = compile.moduleToESModule(`a = 1
b = 2
c = a + b`);

Now src contains the following:

export default function define(runtime, observer) {
  const main = runtime.module();

  main.variable(observer("a")).define("a", function(){return(
1
)});
  main.variable(observer("b")).define("b", function(){return(
2
)});
  main.variable(observer("c")).define("c", ["a","b"], function(a,b){return(
a + b
)});
  return main;
}

#compile.notebookToESModule(object)

Returns a string containing the source code of an ES module. This ES module is compiled from the Observable runtime module in the notebok object object. (See compile.notebook).

For example:

const src = compile.notebookToESModule({
  nodes: [{ value: "a = 1" }, { value: "b = 2" }, { value: "c = a + b" }]
});

Now src contains the following:

export default function define(runtime, observer) {
  const main = runtime.module();

  main.variable(observer("a")).define("a", function(){return(
1
)});
  main.variable(observer("b")).define("b", function(){return(
2
)});
  main.variable(observer("c")).define("c", ["a","b"], function(a,b){return(
a + b
)});
  return main;
}

License

This library is MIT, but it relies and gets heavy inspiration from the following libraries licensed under ISC:

Contributing

Feel free to send in PR's as you wish! Take a look at the issues to find something to work on. Just please follow the Contributor Covenant in all your interactions 😄