@observablehq/notebook-kit 1.2.0 → 1.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/observablehq/notebook-kit.git"
7
7
  },
8
- "version": "1.2.0",
8
+ "version": "1.3.0-rc.1",
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "test": "vitest",
@@ -2,7 +2,7 @@ import { createReadStream } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { json } from "node:stream/consumers";
4
4
  import { isEnoent } from "../lib/error.js";
5
- import { hash as getQueryHash, nameHash as getNameHash } from "../lib/hash.js";
5
+ import { hash, nameHash } from "../lib/hash.js";
6
6
  export async function getDatabaseConfig(sourcePath, databaseName) {
7
7
  const sourceDir = dirname(sourcePath);
8
8
  const configPath = join(sourceDir, ".observable", "databases.json");
@@ -60,6 +60,6 @@ export async function getReplacer(config) {
60
60
  }
61
61
  export async function getQueryCachePath(sourcePath, databaseName, strings, ...params) {
62
62
  const sourceDir = dirname(sourcePath);
63
- const cacheName = `${await getNameHash(databaseName)}-${await getQueryHash(strings, ...params)}.json`;
63
+ const cacheName = `${await nameHash(databaseName)}-${await hash(strings, ...params)}.json`;
64
64
  return join(sourceDir, ".observable", "cache", cacheName);
65
65
  }
@@ -0,0 +1,2 @@
1
+ import type { Cell } from "../lib/notebook.js";
2
+ export declare function getInterpreterCachePath(sourcePath: string, interpreter: string, format: Cell["format"], input: string): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import { dirname, join } from "node:path";
2
+ import { nameHash, stringHash } from "../lib/hash.js";
3
+ import { getInterpreterExtension } from "../lib/interpreters.js";
4
+ export async function getInterpreterCachePath(sourcePath, interpreter, format, input) {
5
+ const sourceDir = dirname(sourcePath);
6
+ const cacheName = `${await nameHash(interpreter)}-${await stringHash(input)}${getInterpreterExtension(format)}`; // TODO avoid conflict with database cache?
7
+ return join(sourceDir, ".observable", "cache", cacheName);
8
+ }
@@ -1,4 +1,5 @@
1
1
  import { TokContext, tokTypes as tt, Parser } from "acorn";
2
+ import { getInterpreterMethod, isInterpreter } from "../lib/interpreters.js";
2
3
  import { acornOptions } from "./parse.js";
3
4
  import { Sourcemap } from "./sourcemap.js";
4
5
  const CODE_DOLLAR = 36;
@@ -74,21 +75,40 @@ export function transpileTemplate(input, tag = "", raw = false) {
74
75
  if (typeof input !== "string") {
75
76
  cell = input;
76
77
  input = cell.value;
77
- tag = cell.mode === "tex" ? "tex.block" : cell.mode === "sql" ? getSqlTag(cell) : cell.mode;
78
- raw = cell.mode !== "md";
78
+ tag = getTag(cell);
79
+ raw = getRaw(cell);
79
80
  }
80
81
  if (!input)
81
82
  return input;
82
83
  const source = new Sourcemap(input);
83
- const node = parseTemplate(input);
84
- (raw ? escapeRawTemplateElements : escapeTemplateElements)(source, node);
84
+ let node;
85
+ if (cell && isInterpreter(cell)) {
86
+ node = { type: "Literal", start: 0, end: input.length };
87
+ escapeBacktick(source, node);
88
+ escapeBackslash(source, node);
89
+ escapeDollarCurly(source, node);
90
+ }
91
+ else {
92
+ const template = parseTemplate(input);
93
+ (raw ? escapeRawTemplateElements : escapeTemplateElements)(source, template);
94
+ node = template;
95
+ }
85
96
  source.insertLeft(node.start, "`");
86
97
  source.insertRight(node.end, "`");
87
98
  source.insertLeft(node.start, tag);
88
- let output = String(source);
89
- if (cell?.mode === "sql" && !cell.hidden)
90
- output += ".then(Inputs.table)";
91
- return output;
99
+ return String(source) + (cell ? getSuffix(cell) : "");
100
+ }
101
+ function getRaw(cell) {
102
+ return cell.mode !== "md";
103
+ }
104
+ function getTag(cell) {
105
+ return cell.mode === "tex"
106
+ ? "tex.block"
107
+ : cell.mode === "sql"
108
+ ? getSqlTag(cell)
109
+ : isInterpreter(cell)
110
+ ? getInterpreterTag(cell)
111
+ : cell.mode;
92
112
  }
93
113
  function getSqlTag(cell) {
94
114
  const { id, database = "var:db", since } = cell;
@@ -96,10 +116,25 @@ function getSqlTag(cell) {
96
116
  ? `${database.slice("var:".length)}.sql`
97
117
  : `DatabaseClient(${JSON.stringify(database)}, {id: ${id}${since === undefined ? "" : `, since: ${JSON.stringify(since)}`}}).sql`;
98
118
  }
119
+ function getInterpreterTag(cell) {
120
+ const { id, mode, format, since } = cell;
121
+ return `Interpreter(${JSON.stringify(mode)}, {id: ${id}${format === undefined ? "" : `, format: ${JSON.stringify(format)}`}${since === undefined ? "" : `, since: ${JSON.stringify(since)}`}}).run(`;
122
+ }
123
+ function getSuffix(cell) {
124
+ return cell.mode === "sql" && !cell.hidden
125
+ ? ".then(Inputs.table)"
126
+ : isInterpreter(cell)
127
+ ? getInterpreterSuffix(cell)
128
+ : "";
129
+ }
130
+ function getInterpreterSuffix(cell) {
131
+ const method = getInterpreterMethod(cell.format);
132
+ return method ? `).then((file) => file${method})` : "";
133
+ }
99
134
  function escapeTemplateElements(source, node) {
100
135
  for (const quasi of node.quasis) {
101
136
  escapeBacktick(source, quasi);
102
- escapeBackslash(source, quasi);
137
+ escapeLiteralBackslash(source, quasi);
103
138
  }
104
139
  }
105
140
  function escapeRawTemplateElements(source, node) {
@@ -108,6 +143,7 @@ function escapeRawTemplateElements(source, node) {
108
143
  }
109
144
  interpolateTerminalBackslash(source);
110
145
  }
146
+ /** Escapes any backtick. */
111
147
  function escapeBacktick(source, { start, end }) {
112
148
  const { input } = source;
113
149
  for (let i = start; i < end; ++i) {
@@ -116,7 +152,17 @@ function escapeBacktick(source, { start, end }) {
116
152
  }
117
153
  }
118
154
  }
155
+ /** Escapes any backslash. */
119
156
  function escapeBackslash(source, { start, end }) {
157
+ const { input } = source;
158
+ for (let i = start; i < end; ++i) {
159
+ if (input.charCodeAt(i) === CODE_BACKSLASH) {
160
+ source.insertRight(i, "\\");
161
+ }
162
+ }
163
+ }
164
+ /** Escapes a backslash, unless it is used to escape a dollar-curly such as "$\{" or "\${". */
165
+ function escapeLiteralBackslash(source, { start, end }) {
120
166
  const { input } = source;
121
167
  let afterDollar = false;
122
168
  let oddBackslashes = false;
@@ -155,3 +201,12 @@ function interpolateTerminalBackslash(source) {
155
201
  if (oddBackslashes)
156
202
  source.replaceRight(input.length - 1, input.length, "${'\\\\'}");
157
203
  }
204
+ /** Escapes a dollar curly, from "${…}" to "$\{…}". */
205
+ function escapeDollarCurly(source, { start, end }) {
206
+ const { input } = source;
207
+ for (let i = start; i < end; ++i) {
208
+ if (input.charCodeAt(i) !== CODE_BRACEL || input.charCodeAt(i - 1) !== CODE_DOLLAR)
209
+ continue;
210
+ source.insertRight(i, "\\");
211
+ }
212
+ }
@@ -1,5 +1,15 @@
1
1
  import { expect, it } from "vitest";
2
2
  import { parseTemplate, transpileTemplate } from "./template.js";
3
+ import { toCell } from "../lib/notebook.js";
4
+ function md(value) {
5
+ return transpileTemplate(toCell({ id: 1, mode: "md", value }));
6
+ }
7
+ function html(value) {
8
+ return transpileTemplate(toCell({ id: 1, mode: "html", value }));
9
+ }
10
+ function node(value) {
11
+ return transpileTemplate(toCell({ id: 1, mode: "node", value }));
12
+ }
3
13
  it("parses a simple template", () => {
4
14
  expect(parseTemplate(`Hello, world!`)).toMatchSnapshot();
5
15
  });
@@ -15,18 +25,36 @@ it("parses a template with backquotes", () => {
15
25
  it("parses a template with backslashes", () => {
16
26
  expect(parseTemplate(`Hello, \\world\\!`)).toMatchSnapshot();
17
27
  });
18
- it("transpiles a simple template", () => {
19
- expect(transpileTemplate(`Hello, world!`)).toMatchSnapshot();
28
+ it("transpiles a simple markdown template", () => {
29
+ expect(md(`Hello, world!`)).toMatchSnapshot();
20
30
  });
21
- it("transpiles an empty template", () => {
22
- expect(transpileTemplate(``)).toMatchSnapshot();
31
+ it("transpiles an empty markdown template", () => {
32
+ expect(md(``)).toMatchSnapshot();
23
33
  });
24
- it("transpiles a template with an interpolated expression", () => {
25
- expect(transpileTemplate(`Hello, $\{"world"}!`)).toMatchSnapshot();
34
+ it("transpiles a markdown template with an interpolated expression", () => {
35
+ expect(md(`Hello, $\{"world"}!`)).toMatchSnapshot();
26
36
  });
27
- it("transpiles a template with backquotes", () => {
28
- expect(transpileTemplate(`Hello, \`world\`!`)).toMatchSnapshot();
37
+ it("transpiles a markdown template with backquotes", () => {
38
+ expect(md(`Hello, \`world\`!`)).toMatchSnapshot();
29
39
  });
30
- it("transpiles a template with backslashes", () => {
31
- expect(transpileTemplate(`Hello, \\world\\!`)).toMatchSnapshot();
40
+ it("transpiles a markdown template with backslashes", () => {
41
+ expect(md(`Hello, \\world\\!`)).toMatchSnapshot();
42
+ });
43
+ it("transpiles a simple html template", () => {
44
+ expect(html(`Hello, world!`)).toMatchSnapshot();
45
+ });
46
+ it("transpiles an empty html template", () => {
47
+ expect(html(``)).toMatchSnapshot();
48
+ });
49
+ it("transpiles a html template with an interpolated expression", () => {
50
+ expect(html(`Hello, $\{"world"}!`)).toMatchSnapshot();
51
+ });
52
+ it("transpiles a html template with backquotes", () => {
53
+ expect(html(`Hello, \`world\`!`)).toMatchSnapshot();
54
+ });
55
+ it("transpiles a html template with backslashes", () => {
56
+ expect(html(`Hello, \\world\\!`)).toMatchSnapshot();
57
+ });
58
+ it("transpiles a node template with backslashes", () => {
59
+ expect(node(`Hello, \\world\\!`)).toMatchSnapshot();
32
60
  });
@@ -43,3 +43,10 @@ it("transpiles import.meta.resolve", () => {
43
43
  expect(transpile('import.meta.resolve("./test")', "js", { resolveLocalImports: true })).toMatchSnapshot();
44
44
  expect(transpile('import.meta.resolve("./test")', "js", { resolveLocalImports: false })).toMatchSnapshot();
45
45
  });
46
+ it("transpiles node cells", () => {
47
+ expect(transpile("process.stdout.write(`Node ${process.version}`);", "node")).toMatchSnapshot();
48
+ expect(transpile("process.stdout.write(`Node \\${process.version}`);", "node")).toMatchSnapshot();
49
+ expect(transpile("process.stdout.write(`Node \\\\${process.version}`);", "node")).toMatchSnapshot();
50
+ expect(transpile("process.stdout.write(`Node $\\{process.version}`);", "node")).toMatchSnapshot();
51
+ expect(transpile("process.stdout.write(`Node \\$\\{process.version}`);", "node")).toMatchSnapshot();
52
+ });
@@ -1,2 +1,3 @@
1
1
  export declare function hash(strings: readonly string[], ...params: unknown[]): Promise<string>;
2
+ export declare function stringHash(string: string): Promise<string>;
2
3
  export declare function nameHash(name: string): Promise<string>;
@@ -10,6 +10,9 @@ function base36(int, length) {
10
10
  export async function hash(strings, ...params) {
11
11
  return base36(await sha256(JSON.stringify([strings, ...params])), 16);
12
12
  }
13
+ export async function stringHash(string) {
14
+ return base36(await sha256(string), 16);
15
+ }
13
16
  export async function nameHash(name) {
14
17
  return /^[\w-]+$/.test(name)
15
18
  ? name
@@ -0,0 +1,4 @@
1
+ import type { Cell } from "./notebook.js";
2
+ export declare function isInterpreter(cell: Cell): boolean;
3
+ export declare function getInterpreterExtension(format: Cell["format"]): string;
4
+ export declare function getInterpreterMethod(format: Cell["format"]): string;
@@ -0,0 +1,47 @@
1
+ export function isInterpreter(cell) {
2
+ return cell.mode === "node";
3
+ }
4
+ export function getInterpreterExtension(format) {
5
+ switch (format) {
6
+ case "html":
7
+ case "text":
8
+ return ".txt";
9
+ case "jpeg":
10
+ return ".jpg";
11
+ case "json":
12
+ case "arrow":
13
+ case "parquet":
14
+ case "csv":
15
+ case "tsv":
16
+ case "png":
17
+ case "gif":
18
+ case "webp":
19
+ return `.${format}`;
20
+ default:
21
+ return ".bin";
22
+ }
23
+ }
24
+ export function getInterpreterMethod(format) {
25
+ switch (format) {
26
+ case "arrow":
27
+ case "parquet":
28
+ case "json":
29
+ case "blob":
30
+ case "text":
31
+ return `.${format}()`;
32
+ case "html":
33
+ return `.text().then((text) => html({raw: [text]}))`;
34
+ case "buffer":
35
+ return ".arrayBuffer()";
36
+ case "jpeg":
37
+ case "png":
38
+ case "gif":
39
+ case "webp":
40
+ return ".image()";
41
+ case "csv":
42
+ case "tsv":
43
+ return `.${format}({typed: true})`;
44
+ default:
45
+ return "";
46
+ }
47
+ }
@@ -21,13 +21,15 @@ export interface CellSpec {
21
21
  /** the committed cell value; defaults to empty */
22
22
  value?: string;
23
23
  /** the mode; affects how the value is evaluated; defaults to js */
24
- mode?: "js" | "ojs" | "md" | "html" | "tex" | "dot" | "sql";
24
+ mode?: "js" | "ojs" | "md" | "html" | "tex" | "dot" | "sql" | "node";
25
25
  /** if true, the editor will stay open when not focused; defaults to false */
26
26
  pinned?: boolean;
27
27
  /** if true, implicit display will be suppressed; defaults to false */
28
28
  hidden?: boolean;
29
29
  /** if present, exposes the cell’s value to the rest of the notebook */
30
30
  output?: string;
31
+ /** for data loader cells, how the data is represented */
32
+ format?: "text" | "blob" | "buffer" | "json" | "csv" | "tsv" | "jpeg" | "gif" | "webp" | "png" | "arrow" | "parquet" | "html";
31
33
  /** for SQL cells, the database to query; use var:<name> to refer to a variable */
32
34
  database?: string;
33
35
  /** for SQL cells, the oldest allowable age of the cached query result */
@@ -41,5 +43,5 @@ export interface Cell extends CellSpec {
41
43
  since?: Date;
42
44
  }
43
45
  export declare function toNotebook({ cells, title, theme, readOnly }: NotebookSpec): Notebook;
44
- export declare function toCell({ id, value, mode, pinned, hidden, output, database, since }: CellSpec): Cell;
46
+ export declare function toCell({ id, value, mode, pinned, hidden, output, format, database, since }: CellSpec): Cell;
45
47
  export declare function defaultPinned(mode: Cell["mode"]): boolean;
@@ -6,7 +6,7 @@ export function toNotebook({ cells = [], title = "Untitled", theme = "air", read
6
6
  readOnly
7
7
  };
8
8
  }
9
- export function toCell({ id, value = "", mode = "js", pinned = defaultPinned(mode), hidden = false, output, database = mode === "sql" ? "var:db" : undefined, since }) {
9
+ export function toCell({ id, value = "", mode = "js", pinned = defaultPinned(mode), hidden = false, output, format = mode === "node" ? "buffer" : undefined, database = mode === "sql" ? "var:db" : undefined, since }) {
10
10
  return {
11
11
  id,
12
12
  value,
@@ -14,6 +14,7 @@ export function toCell({ id, value = "", mode = "js", pinned = defaultPinned(mod
14
14
  pinned,
15
15
  hidden,
16
16
  output,
17
+ format: mode === "node" ? format : undefined,
17
18
  database: mode === "sql" ? database : undefined,
18
19
  since: since !== undefined ? asDate(since) : undefined
19
20
  };
@@ -22,5 +23,5 @@ function asDate(date) {
22
23
  return date instanceof Date ? date : new Date(date);
23
24
  }
24
25
  export function defaultPinned(mode) {
25
- return mode === "js" || mode === "sql" || mode === "ojs";
26
+ return mode === "js" || mode === "sql" || mode === "node" || mode === "ojs";
26
27
  }
@@ -15,6 +15,7 @@ test("converts a cell spec to a cell", () => {
15
15
  mode: "js",
16
16
  pinned: true,
17
17
  hidden: false,
18
+ format: undefined,
18
19
  output: undefined,
19
20
  database: undefined,
20
21
  since: undefined
@@ -27,6 +28,7 @@ test("computes an appropriate default pinned based on the cell mode", () => {
27
28
  mode: "md",
28
29
  pinned: false,
29
30
  hidden: false,
31
+ format: undefined,
30
32
  output: undefined,
31
33
  database: undefined,
32
34
  since: undefined
@@ -23,6 +23,8 @@ export function serialize(notebook, { document = globalThis.document } = {}) {
23
23
  _cell.setAttribute("database", cell.database);
24
24
  if (cell.output)
25
25
  _cell.setAttribute("output", cell.output);
26
+ if (cell.format)
27
+ _cell.setAttribute("format", cell.format);
26
28
  _notebook.appendChild(_cell);
27
29
  }
28
30
  _notebook.appendChild(document.createTextNode("\n"));
@@ -47,9 +49,10 @@ export function deserialize(data, { parser = new DOMParser() } = {}) {
47
49
  const value = dedent(cell.textContent?.replace(/<\\(?=\\*\/script(\s|>))/gi, "<") ?? "");
48
50
  const pinned = cell.hasAttribute("pinned");
49
51
  const hidden = cell.hasAttribute("hidden");
52
+ const format = deserializeFormat(cell.getAttribute("format"));
50
53
  const output = cell.getAttribute("output") ?? undefined;
51
54
  const database = cell.getAttribute("database") ?? undefined;
52
- return { id, mode, value, pinned, hidden, output, database };
55
+ return { id, mode, value, pinned, hidden, format, output, database };
53
56
  });
54
57
  return toNotebook({ title, theme, readOnly, cells });
55
58
  }
@@ -65,6 +68,8 @@ function serializeMode(mode) {
65
68
  return "application/sql";
66
69
  case "dot":
67
70
  return "text/vnd.graphviz";
71
+ case "node":
72
+ return "application/vnd.node.javascript";
68
73
  case "ojs":
69
74
  return "application/vnd.observable.javascript";
70
75
  default:
@@ -83,12 +88,32 @@ function deserializeMode(mode) {
83
88
  return "sql";
84
89
  case "text/vnd.graphviz":
85
90
  return "dot";
91
+ case "application/vnd.node.javascript":
92
+ return "node";
86
93
  case "application/vnd.observable.javascript":
87
94
  return "ojs";
88
95
  default:
89
96
  return "js";
90
97
  }
91
98
  }
99
+ function deserializeFormat(format) {
100
+ switch (format) {
101
+ case "text":
102
+ case "blob":
103
+ case "buffer":
104
+ case "json":
105
+ case "csv":
106
+ case "tsv":
107
+ case "jpeg":
108
+ case "png":
109
+ case "webp":
110
+ case "gif":
111
+ case "arrow":
112
+ case "parquet":
113
+ case "html":
114
+ return format;
115
+ }
116
+ }
92
117
  function deserializeTheme(theme) {
93
118
  return theme ?? "air";
94
119
  }
@@ -11,6 +11,8 @@ export type * from "./stdlib/databaseClient.js";
11
11
  export { DatabaseClient } from "./stdlib/databaseClient.js";
12
12
  export type * from "./stdlib/fileAttachment.js";
13
13
  export { FileAttachment, registerFile } from "./stdlib/fileAttachment.js";
14
+ export type * from "./stdlib/interpreter.js";
15
+ export { Interpreter } from "./stdlib/interpreter.js";
14
16
  export declare class NotebookRuntime {
15
17
  readonly runtime: Runtime & {
16
18
  fileAttachments: typeof fileAttachments;
@@ -96,6 +98,15 @@ export declare class NotebookRuntime {
96
98
  };
97
99
  };
98
100
  Generators: () => typeof import("./stdlib/generators/index.js");
101
+ Interpreter: () => {
102
+ (name: string, options?: import("./stdlib/interpreter.js").InterpreterOptionsSpec): import("./stdlib/interpreter.js").Interpreter;
103
+ prototype: {
104
+ readonly name: string;
105
+ readonly options: import("./stdlib/interpreter.js").InterpreterOptions;
106
+ run(input: string): Promise<import("./stdlib/fileAttachment.js").FileAttachment>;
107
+ cachePath(input: string): Promise<string>;
108
+ };
109
+ };
99
110
  Mutable: () => typeof import("./stdlib/mutable.js").Mutable;
100
111
  DOM: () => typeof import("./stdlib/dom/index.js");
101
112
  require: () => typeof import("./stdlib/require.js").require;
@@ -7,6 +7,7 @@ export * from "./inspect.js";
7
7
  export * from "./stdlib/index.js";
8
8
  export { DatabaseClient } from "./stdlib/databaseClient.js";
9
9
  export { FileAttachment, registerFile } from "./stdlib/fileAttachment.js";
10
+ export { Interpreter } from "./stdlib/interpreter.js";
10
11
  export class NotebookRuntime {
11
12
  constructor(builtins = library) {
12
13
  Object.defineProperty(this, "runtime", {
@@ -18,7 +18,7 @@ export interface ColumnSchema {
18
18
  export interface QueryOptionsSpec {
19
19
  /** if present, the id of the cell that owns this database client */
20
20
  id?: number;
21
- /** if present, query results are at least as fresh as the specified date */
21
+ /** if present, results are at least as fresh as the specified date */
22
22
  since?: Date | string | number;
23
23
  }
24
24
  export interface QueryOptions extends QueryOptionsSpec {
@@ -51,6 +51,7 @@ async function getParser(language) {
51
51
  case "js":
52
52
  case "ts":
53
53
  case "jsx":
54
+ case "node":
54
55
  return (await import("@lezer/javascript")).parser.configure({ dialect: language });
55
56
  case "html":
56
57
  return (await import("@lezer/html")).parser;
@@ -2,6 +2,7 @@ import { DatabaseClient } from "./databaseClient.js";
2
2
  import * as DOM from "./dom/index.js";
3
3
  import { FileAttachment } from "./fileAttachment.js";
4
4
  import * as Generators from "./generators/index.js";
5
+ import { Interpreter } from "./interpreter.js";
5
6
  import { Mutable } from "./mutable.js";
6
7
  import { Observer } from "./observer.js";
7
8
  import { require } from "./require.js";
@@ -86,6 +87,15 @@ export declare const library: {
86
87
  };
87
88
  };
88
89
  Generators: () => typeof Generators;
90
+ Interpreter: () => {
91
+ (name: string, options?: import("./interpreter.js").InterpreterOptionsSpec): Interpreter;
92
+ prototype: {
93
+ readonly name: string;
94
+ readonly options: import("./interpreter.js").InterpreterOptions;
95
+ run(input: string): Promise<FileAttachment>;
96
+ cachePath(input: string): Promise<string>;
97
+ };
98
+ };
89
99
  Mutable: () => typeof Mutable;
90
100
  DOM: () => typeof DOM;
91
101
  require: () => typeof require;
@@ -2,6 +2,7 @@ import { DatabaseClient } from "./databaseClient.js";
2
2
  import * as DOM from "./dom/index.js";
3
3
  import { FileAttachment } from "./fileAttachment.js";
4
4
  import * as Generators from "./generators/index.js";
5
+ import { Interpreter } from "./interpreter.js";
5
6
  import { Mutable } from "./mutable.js";
6
7
  import { Observer } from "./observer.js";
7
8
  import * as recommendedLibraries from "./recommendedLibraries.js";
@@ -14,6 +15,7 @@ export const library = {
14
15
  DatabaseClient: () => DatabaseClient,
15
16
  FileAttachment: () => FileAttachment,
16
17
  Generators: () => Generators,
18
+ Interpreter: () => Interpreter,
17
19
  Mutable: () => Mutable,
18
20
  DOM: () => DOM, // deprecated!
19
21
  require: () => require, // deprecated!
@@ -0,0 +1,31 @@
1
+ import type { Cell } from "../../lib/notebook.js";
2
+ import { FileAttachment } from "./fileAttachment.js";
3
+ /** A serializable value that can be interpolated into a query. */
4
+ export type InterpreterParam = any;
5
+ export interface InterpreterOptionsSpec {
6
+ /** the interpreter format; defaults to "buffer" */
7
+ format?: Cell["format"];
8
+ /** if present, the id of the cell that owns this interpreter */
9
+ id?: number;
10
+ /** if present, results are at least as fresh as the specified date */
11
+ since?: Date | string | number;
12
+ }
13
+ export interface InterpreterOptions extends InterpreterOptionsSpec {
14
+ since?: Date;
15
+ }
16
+ export interface Interpreter {
17
+ readonly options: InterpreterOptions;
18
+ run(input: string): Promise<FileAttachment>;
19
+ }
20
+ export declare const Interpreter: {
21
+ (name: string, options?: InterpreterOptionsSpec): Interpreter;
22
+ prototype: InterpreterImpl;
23
+ };
24
+ declare class InterpreterImpl implements Interpreter {
25
+ readonly name: string;
26
+ readonly options: InterpreterOptions;
27
+ constructor(name: string, options: InterpreterOptions);
28
+ run(input: string): Promise<FileAttachment>;
29
+ cachePath(input: string): Promise<string>;
30
+ }
31
+ export {};
@@ -0,0 +1,43 @@
1
+ import { nameHash, stringHash } from "../../lib/hash.js";
2
+ import { getInterpreterExtension } from "../../lib/interpreters.js";
3
+ import { FileAttachment } from "./fileAttachment.js";
4
+ export const Interpreter = (name, options) => {
5
+ return new InterpreterImpl(name, normalizeOptions(options));
6
+ };
7
+ function normalizeOptions({ format = "buffer", id, since } = {}) {
8
+ const options = { format };
9
+ if (id !== undefined)
10
+ options.id = id;
11
+ if (since !== undefined)
12
+ options.since = new Date(since);
13
+ return options;
14
+ }
15
+ class InterpreterImpl {
16
+ constructor(name, options) {
17
+ Object.defineProperty(this, "name", {
18
+ enumerable: true,
19
+ configurable: true,
20
+ writable: true,
21
+ value: void 0
22
+ });
23
+ Object.defineProperty(this, "options", {
24
+ enumerable: true,
25
+ configurable: true,
26
+ writable: true,
27
+ value: void 0
28
+ });
29
+ Object.defineProperties(this, {
30
+ name: { value: name, enumerable: true },
31
+ options: { value: options, enumerable: true }
32
+ });
33
+ }
34
+ async run(input) {
35
+ return FileAttachment(await this.cachePath(input));
36
+ }
37
+ async cachePath(input) {
38
+ const { format } = this.options;
39
+ return `.observable/cache/${await nameHash(this.name)}-${await stringHash(input)}${getInterpreterExtension(format)}`; // TODO avoid conflict with database cache?
40
+ }
41
+ }
42
+ Interpreter.prototype = InterpreterImpl.prototype; // instanceof
43
+ Object.defineProperty(InterpreterImpl, "name", { value: "Interpreter" }); // prevent mangling
@@ -85,9 +85,14 @@ pre[data-language] {
85
85
 
86
86
  pre[data-language]::before {
87
87
  content: attr(data-language);
88
- float: right;
89
- margin: -4px -8px -4px -4px;
90
- padding: 4px;
88
+ position: sticky;
89
+ top: 0;
90
+ left: 0;
91
+ height: 0;
92
+ display: block;
93
+ text-align: right;
94
+ margin-right: -4px;
95
+ pointer-events: none;
91
96
  font-size: 12px;
92
97
  line-height: 21px;
93
98
  color: var(--theme-foreground-muted);
@@ -157,12 +162,16 @@ pre {
157
162
  background-color: var(--theme-background-alt);
158
163
  border-radius: 4px;
159
164
  margin: 1rem -1rem;
160
- /* max-width: 960px; */
161
165
  min-height: 1.5em;
162
166
  padding: 4px 1rem;
163
167
  overflow-x: auto;
168
+ overscroll-behavior-x: none;
164
169
  box-sizing: border-box;
165
- white-space: pre-wrap;
170
+ }
171
+
172
+ pre > code:only-child {
173
+ display: block;
174
+ width: fit-content;
166
175
  }
167
176
 
168
177
  input:not([type]),
@@ -1,11 +1,13 @@
1
- import { fork } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
1
+ import { fork, spawn } from "node:child_process";
2
+ import { createWriteStream, existsSync } from "node:fs";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { relative } from "node:path/posix";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { JSDOM } from "jsdom";
8
8
  import { getQueryCachePath } from "../databases/index.js";
9
+ import { getInterpreterCachePath } from "../interpreters/index.js";
10
+ import { getInterpreterMethod } from "../lib/interpreters.js";
9
11
  import { deserialize } from "../lib/serialize.js";
10
12
  import { Sourcemap } from "../javascript/sourcemap.js";
11
13
  import { transpile } from "../javascript/transpile.js";
@@ -27,6 +29,8 @@ export function observable({ window = new JSDOM().window, parser = new window.DO
27
29
  transformIndexHtml: {
28
30
  order: "pre",
29
31
  async handler(input, context) {
32
+ if (context.path.startsWith("/.observable/"))
33
+ return input;
30
34
  const notebook = await transformNotebook(deserialize(input, { parser }), context);
31
35
  const templateHtml = await transformTemplate(await readFile(template, "utf-8"), context);
32
36
  const document = parser.parseFromString(templateHtml, "text/html");
@@ -44,7 +48,7 @@ export function observable({ window = new JSDOM().window, parser = new window.DO
44
48
  let cells = document.querySelector("main");
45
49
  cells ?? (cells = document.body.appendChild(document.createElement("main")));
46
50
  for (const cell of notebook.cells) {
47
- const { id, mode, pinned, hidden, value } = cell;
51
+ const { id, mode, pinned, hidden, format, value } = cell;
48
52
  const contents = document.createDocumentFragment();
49
53
  const div = contents.appendChild(document.createElement("div"));
50
54
  div.id = `cell-${id}`;
@@ -74,13 +78,37 @@ export function observable({ window = new JSDOM().window, parser = new window.DO
74
78
  const child = fork(fileURLToPath(import.meta.resolve("../../bin/query.js")), args);
75
79
  await new Promise((resolve, reject) => {
76
80
  child.on("error", reject);
77
- child.on("exit", resolve);
81
+ child.on("exit", resolve); // TODO check exit code
78
82
  });
79
83
  }
80
84
  cell.mode = "js";
81
85
  cell.value = `FileAttachment(${JSON.stringify(relative(dir, cachePath))}).json().then(DatabaseClient.revive)${hidden ? "" : `.then(Inputs.table)${cell.output ? ".then(view)" : ""}`}`;
82
86
  }
83
87
  }
88
+ else if (mode === "node") {
89
+ const { filename: sourcePath } = context;
90
+ const sourceDir = dirname(sourcePath);
91
+ const cachePath = await getInterpreterCachePath(sourcePath, mode, format, value);
92
+ if (!existsSync(cachePath)) {
93
+ await mkdir(dirname(cachePath), { recursive: true });
94
+ const args = ["--input-type=module", "--permission", "--allow-fs-read=."];
95
+ const child = spawn("node", args, { cwd: sourceDir });
96
+ child.stdin.end(value);
97
+ child.stderr.pipe(process.stderr);
98
+ child.stdout.pipe(createWriteStream(cachePath));
99
+ await new Promise((resolve, reject) => {
100
+ child.on("error", reject);
101
+ child.on("exit", resolve); // TODO check exit code
102
+ });
103
+ }
104
+ if (format === "html" && !hidden) {
105
+ div.innerHTML = await readFile(cachePath, "utf-8");
106
+ if (!cell.output)
107
+ statics.add(cell);
108
+ }
109
+ cell.mode = "js";
110
+ cell.value = `FileAttachment(${JSON.stringify(relative(sourceDir, cachePath))})${getInterpreterMethod(format)}`;
111
+ }
84
112
  collectAssets(assets, div);
85
113
  if (pinned) {
86
114
  const pre = contents.appendChild(document.createElement("pre"));
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/observablehq/notebook-kit.git"
7
7
  },
8
- "version": "1.2.0",
8
+ "version": "1.3.0-rc.1",
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "test": "vitest",