From 3203b0c96f697fd76a34e68be4bd175c749b0a87 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 09:58:54 -0700 Subject: [PATCH 1/8] First cut and data loader support --- src/dataloader.ts | 50 +++++++++++++++++++++++++++++++ src/files.ts | 10 +++++++ src/javascript.ts | 5 +--- src/preview.ts | 76 ++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 src/dataloader.ts diff --git a/src/dataloader.ts b/src/dataloader.ts new file mode 100644 index 000000000..775814e04 --- /dev/null +++ b/src/dataloader.ts @@ -0,0 +1,50 @@ +import {open, rename, unlink} from "node:fs/promises"; +import {spawn} from "node:child_process"; +import {join} from "node:path"; +import {getStats} from "./files.js"; + +const runningCommands = new Map>(); + +export async function runCommand(commandPath: string, outputPath: string) { + if (runningCommands.has(commandPath)) return runningCommands.get(commandPath); + const command = new Promise((resolve, reject) => { + const cacheTempPath = outputPath + ".tmp"; + open(cacheTempPath, "w").then((cacheFd) => { + const cacheFileStream = cacheFd.createWriteStream({highWaterMark: 1024 * 1024}); + try { + const subprocess = spawn(commandPath, [], { + argv0: commandPath, + //cwd: dirname(commandPath), // TODO: Need to change commandPath to be relative this? + windowsHide: true, + stdio: ["ignore", "pipe", "inherit"] + // timeout: // time in ms + // signal: // abort signal + }); + subprocess.stdout.on("data", (data) => cacheFileStream.write(data)); + subprocess.on("error", (error) => console.error(`${commandPath}: ${error.message}`)); + subprocess.on("close", (code) => { + cacheFd.close(); + if (code === 0) { + rename(cacheTempPath, outputPath); + } else { + unlink(cacheTempPath); + } + resolve(); + }); + } catch (error) { + reject(error); + } finally { + runningCommands.delete(commandPath); + } + }); + }); + runningCommands.set(commandPath, command); + return command; +} + +export async function findLoader(root: string, name: string) { + // TODO: Look in the directory for any file with a known extension? + const path = join(root, name) + ".js"; + const stats = await getStats(path); + return {path, stats}; +} diff --git a/src/files.ts b/src/files.ts index f87874d66..b6bdc5f04 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,3 +1,4 @@ +import type {Stats} from "node:fs"; import {accessSync, constants, statSync} from "node:fs"; import {readdir, stat} from "node:fs/promises"; import {extname, join, normalize, relative} from "node:path"; @@ -36,3 +37,12 @@ export async function* visitFiles(root: string): AsyncGenerator { } } } + +export async function getStats(path: string): Promise { + try { + return await stat(path); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error; + } + return; +} diff --git a/src/javascript.ts b/src/javascript.ts index aaa17f5fa..0c713a249 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -1,7 +1,5 @@ import {Parser, tokTypes, type Options} from "acorn"; import mime from "mime"; -import {join} from "node:path"; -import {canReadSync} from "./files.js"; import {findAwaits} from "./javascript/awaits.js"; import {findDeclarations} from "./javascript/declarations.js"; import {findFeatures} from "./javascript/features.js"; @@ -44,13 +42,12 @@ export interface ParseOptions { } export function transpileJavaScript(input: string, options: ParseOptions): Transpile { - const {root, id} = options; + const {id} = options; try { const node = parseJavaScript(input, options); const databases = node.features.filter((f) => f.type === "DatabaseClient").map((f) => ({name: f.name})); const files = node.features .filter((f) => f.type === "FileAttachment") - .filter((f) => canReadSync(join(root, f.name))) .map((f) => ({name: f.name, mimeType: mime.getType(f.name)})); const inputs = Array.from(new Set(node.references.map((r) => r.name))); const output = new Sourcemap(input); diff --git a/src/preview.ts b/src/preview.ts index 0c0079ff3..c6fc6ab47 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -1,5 +1,5 @@ import type {WatchListener} from "node:fs"; -import {watch, type FSWatcher} from "node:fs"; +import {watch, type FSWatcher, watchFile, unwatchFile} from "node:fs"; import {access, constants, readFile, stat} from "node:fs/promises"; import {createServer, type IncomingMessage, type RequestListener} from "node:http"; import {basename, dirname, extname, join, normalize} from "node:path"; @@ -14,6 +14,8 @@ import {readPages} from "./navigation.js"; import {renderPreview} from "./render.js"; import type {CellResolver} from "./resolver.js"; import {makeCLIResolver} from "./resolver.js"; +import {findLoader, runCommand} from "./dataloader.js"; +import {getStats} from "./files.js"; const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); @@ -23,12 +25,14 @@ class Server { readonly port: number; readonly hostname: string; readonly root: string; + readonly cacheRoot: string; private _resolver: CellResolver | undefined; constructor({port, hostname, root}: CommandContext) { this.port = port; this.hostname = hostname; this.root = root; + this.cacheRoot = join(this.root, "../_cache"); this._server = createServer(); this._server.on("request", this._handleRequest); this._socketServer = new WebSocketServer({server: this._server}); @@ -52,7 +56,34 @@ class Server { } else if (pathname.startsWith("/_observablehq/")) { send(req, pathname.slice("/_observablehq".length), {root: publicRoot}).pipe(res); } else if (pathname.startsWith("/_file/")) { - send(req, pathname.slice("/_file".length), {root: this.root}).pipe(res); + const path = pathname.slice("/_file".length); + const filepath = join(this.root, path); + try { + await access(filepath, constants.R_OK); + send(req, pathname.slice("/_file".length), {root: this.root}).pipe(res); + } catch (error) { + if (isNodeError(error) && error.code !== "ENOENT") { + throw error; + } + } + + // Look for a data loader for this file. + const {path: loaderPath, stats: loaderStat} = await findLoader(this.root, path); + if (loaderStat) { + const cachePath = join(this.cacheRoot, filepath); + const cacheStat = await getStats(cachePath); + if (cacheStat && cacheStat.mtimeMs > loaderStat.mtimeMs) { + send(req, filepath, {root: this.cacheRoot}).pipe(res); + return; + } + if (!(loaderStat.mode & constants.S_IXUSR)) { + throw new HttpError("Data loader is not executable", 404); + } + await runCommand(loaderPath, cachePath); + send(req, filepath, {root: this.cacheRoot}).pipe(res); + return; + } + throw new HttpError("Not found", 404); } else { if (normalize(pathname).startsWith("..")) throw new Error("Invalid path: " + pathname); let path = join(this.root, pathname); @@ -122,16 +153,38 @@ class Server { } class FileWatchers { - watchers: FSWatcher[]; + watchedFiles: string[] = []; + + constructor( + readonly root: string, + readonly files: {name: string}[], + readonly cb: (name: string) => void + ) {} + + async start() { + if (this.watchedFiles.length) throw new Error("already watching"); + const fileset = [...new Set(this.files.map(({name}) => name))]; + for (const name of fileset) { + const file = await this.watchPath(this.root, name); + this.watchedFiles.push(file); + watchFile(file, async (previous, current) => { + if (current.mtimeMs === previous.mtimeMs) return; + this.cb(name); + }); + } + } - constructor(root: string, files: {name: string}[], cb: (name: string) => void) { - const fileset = [...new Set(files.map(({name}) => name))]; - this.watchers = fileset.map((name) => watch(join(root, name), async () => cb(name))); + async watchPath(root: string, name: string) { + const path = join(root, name); + const stats = await getStats(path); + if (stats?.isFile()) return path; + const {path: loaderPath, stats: loaderStat} = await findLoader(root, name); + return loaderStat?.isFile() ? loaderPath : path; } - close() { - this.watchers.forEach((w) => w.close()); - this.watchers = []; + end() { + for (const file of this.watchedFiles) unwatchFile(file); + this.watchedFiles = []; } } @@ -165,6 +218,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe async function refreshMarkdown(path: string): Promise> { let current = await readMarkdown(path, root); attachmentWatcher = new FileWatchers(root, current.parse.files, refreshAttachment(current.parse)); + await attachmentWatcher.start(); return async function watcher(event) { switch (event) { case "rename": { @@ -187,7 +241,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe if (current.hash === updated.hash) break; const diff = resolveDiffs(diffMarkdown(current, updated), resolver); send({type: "update", diff, previousHash: current.hash, updatedHash: updated.hash}); - attachmentWatcher?.close(); + attachmentWatcher?.end(); attachmentWatcher = new FileWatchers(root, updated.parse.files, refreshAttachment(updated.parse)); current = updated; break; @@ -225,7 +279,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe socket.on("close", () => { if (attachmentWatcher) { - attachmentWatcher.close(); + attachmentWatcher.end(); attachmentWatcher = null; } if (markdownWatcher) { From 72749e8fe1f9536eda33182df420126e9dd60780 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 11:22:25 -0700 Subject: [PATCH 2/8] Respect async on close() --- src/dataloader.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/dataloader.ts b/src/dataloader.ts index 775814e04..1fea6395b 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -1,7 +1,8 @@ -import {open, rename, unlink} from "node:fs/promises"; +import {open} from "node:fs/promises"; import {spawn} from "node:child_process"; import {join} from "node:path"; import {getStats} from "./files.js"; +import {renameSync, unlinkSync} from "node:fs"; const runningCommands = new Map>(); @@ -23,13 +24,14 @@ export async function runCommand(commandPath: string, outputPath: string) { subprocess.stdout.on("data", (data) => cacheFileStream.write(data)); subprocess.on("error", (error) => console.error(`${commandPath}: ${error.message}`)); subprocess.on("close", (code) => { - cacheFd.close(); - if (code === 0) { - rename(cacheTempPath, outputPath); - } else { - unlink(cacheTempPath); - } - resolve(); + cacheFd.close().then(() => { + if (code === 0) { + renameSync(cacheTempPath, outputPath); + } else { + unlinkSync(cacheTempPath); + } + resolve(); + }, reject); }); } catch (error) { reject(error); From a02ffa8d7bc81a3da2e99019ba4c8a128c21c319 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 16:20:13 -0700 Subject: [PATCH 3/8] Switch back to watch instead of watchFile --- src/dataloader.ts | 11 +++++++---- src/preview.ts | 38 +++++++++++++++++++++----------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/dataloader.ts b/src/dataloader.ts index 1fea6395b..fb2735a7d 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -45,8 +45,11 @@ export async function runCommand(commandPath: string, outputPath: string) { } export async function findLoader(root: string, name: string) { - // TODO: Look in the directory for any file with a known extension? - const path = join(root, name) + ".js"; - const stats = await getStats(path); - return {path, stats}; + // TODO: It may be more efficient use fs.readdir + for (const ext of [".js", ".ts"]) { + const path = join(root, name) + ext; + const stats = await getStats(path); + if (stats) return {path, stats}; + } + return; } diff --git a/src/preview.ts b/src/preview.ts index c6fc6ab47..b6025286f 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -1,5 +1,5 @@ import type {WatchListener} from "node:fs"; -import {watch, type FSWatcher, watchFile, unwatchFile} from "node:fs"; +import {watch, type FSWatcher} from "node:fs"; import {access, constants, readFile, stat} from "node:fs/promises"; import {createServer, type IncomingMessage, type RequestListener} from "node:http"; import {basename, dirname, extname, join, normalize} from "node:path"; @@ -153,7 +153,7 @@ class Server { } class FileWatchers { - watchedFiles: string[] = []; + watchers: FSWatcher[] = []; constructor( readonly root: string, @@ -161,20 +161,24 @@ class FileWatchers { readonly cb: (name: string) => void ) {} - async start() { - if (this.watchedFiles.length) throw new Error("already watching"); + async watchAll() { const fileset = [...new Set(this.files.map(({name}) => name))]; for (const name of fileset) { - const file = await this.watchPath(this.root, name); - this.watchedFiles.push(file); - watchFile(file, async (previous, current) => { - if (current.mtimeMs === previous.mtimeMs) return; - this.cb(name); - }); + const watchPath = await FileWatchers.getWatchPath(this.root, name); + let prevState = await getStats(watchPath); + this.watchers.push( + watch(watchPath, async () => { + const newState = await getStats(watchPath); + // Ignore if the file was truncated or not modified. + if (prevState?.mtimeMs === newState?.mtimeMs || newState?.size === 0) return; + prevState = newState; + this.cb(name); + }) + ); } } - async watchPath(root: string, name: string) { + static async getWatchPath(root: string, name: string) { const path = join(root, name); const stats = await getStats(path); if (stats?.isFile()) return path; @@ -182,9 +186,9 @@ class FileWatchers { return loaderStat?.isFile() ? loaderPath : path; } - end() { - for (const file of this.watchedFiles) unwatchFile(file); - this.watchedFiles = []; + close() { + this.watchers.forEach((w) => w.close()); + this.watchers = []; } } @@ -218,7 +222,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe async function refreshMarkdown(path: string): Promise> { let current = await readMarkdown(path, root); attachmentWatcher = new FileWatchers(root, current.parse.files, refreshAttachment(current.parse)); - await attachmentWatcher.start(); + await attachmentWatcher.watchAll(); return async function watcher(event) { switch (event) { case "rename": { @@ -241,7 +245,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe if (current.hash === updated.hash) break; const diff = resolveDiffs(diffMarkdown(current, updated), resolver); send({type: "update", diff, previousHash: current.hash, updatedHash: updated.hash}); - attachmentWatcher?.end(); + attachmentWatcher?.close(); attachmentWatcher = new FileWatchers(root, updated.parse.files, refreshAttachment(updated.parse)); current = updated; break; @@ -279,7 +283,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe socket.on("close", () => { if (attachmentWatcher) { - attachmentWatcher.end(); + attachmentWatcher.close(); attachmentWatcher = null; } if (markdownWatcher) { From f5bb91b217ce723f30af69b466f3ac93ea2fc22d Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 16:22:13 -0700 Subject: [PATCH 4/8] Fix type error --- src/dataloader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataloader.ts b/src/dataloader.ts index fb2735a7d..ed369c44e 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -51,5 +51,5 @@ export async function findLoader(root: string, name: string) { const stats = await getStats(path); if (stats) return {path, stats}; } - return; + return {}; } From c7268dd3f8e2b975932a8a104f10ee54679047ed Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 16:54:20 -0700 Subject: [PATCH 5/8] Update tests --- test/output/dynamic-import.js | 2 +- test/output/static-import.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/output/dynamic-import.js b/test/output/dynamic-import.js index 2c6b55646..c07312367 100644 --- a/test/output/dynamic-import.js +++ b/test/output/dynamic-import.js @@ -1,4 +1,4 @@ -define({id: "0", outputs: ["foo"], body: async () => { +define({id: "0", outputs: ["foo"], files: [{"name":"./bar.js","mimeType":"application/javascript"}], body: async () => { const foo = await import("/_file/bar.js"); return {foo}; }}); diff --git a/test/output/static-import.js b/test/output/static-import.js index 8acdc20a5..1d4a8ae34 100644 --- a/test/output/static-import.js +++ b/test/output/static-import.js @@ -1,4 +1,4 @@ -define({id: "0", inputs: ["display"], outputs: ["foo"], body: async (display) => { +define({id: "0", inputs: ["display"], outputs: ["foo"], files: [{"name":"./bar.js","mimeType":"application/javascript"}], body: async (display) => { const {foo} = await import("/_file/bar.js"); display(foo); From 24a9e48e45cd99748786e7846ecc004e26c70f79 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 2 Nov 2023 17:13:12 -0700 Subject: [PATCH 6/8] Add build support for data loaders --- src/build.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/build.ts b/src/build.ts index 12093b15d..51170bd84 100644 --- a/src/build.ts +++ b/src/build.ts @@ -3,10 +3,11 @@ import {basename, dirname, join, normalize, relative} from "node:path"; import {cwd} from "node:process"; import {fileURLToPath} from "node:url"; import {parseArgs} from "node:util"; -import {visitFiles, visitMarkdownFiles} from "./files.js"; +import {getStats, visitFiles, visitMarkdownFiles} from "./files.js"; import {readPages} from "./navigation.js"; import {renderServerless} from "./render.js"; import {makeCLIResolver} from "./resolver.js"; +import {findLoader, runCommand} from "./dataloader.js"; const EXTRA_FILES = new Map([["node_modules/@observablehq/runtime/dist/runtime.js", "_observablehq/runtime.js"]]); @@ -52,6 +53,18 @@ async function build(context: CommandContext) { for (const file of files) { const sourcePath = join(sourceRoot, file); const outputPath = join(outputRoot, "_file", file); + const stats = await getStats(sourcePath); + if (!stats) { + const {path} = await findLoader("", sourcePath); + if (!path) { + console.error("missing referenced file", sourcePath); + continue; + } + console.log("generate", path, "→", outputPath); + await prepareOutput(outputPath); + await runCommand(path, outputPath); + continue; + } console.log("copy", sourcePath, "→", outputPath); await prepareOutput(outputPath); await copyFile(sourcePath, outputPath); From 5d4fa2f3eeac97b27d2c310ad109528785951c97 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Fri, 3 Nov 2023 10:45:47 -0700 Subject: [PATCH 7/8] Move data loader cache to .observablehq/cache --- .gitignore | 1 + src/build.ts | 11 ++------ src/dataloader.ts | 64 ++++++++++++++++++++++++----------------------- src/files.ts | 10 ++++++-- src/preview.ts | 9 ++++--- 5 files changed, 50 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 3cf42cc4c..8899b691b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.observablehq dist/ node_modules/ test/output/*-changed.* diff --git a/src/build.ts b/src/build.ts index 51170bd84..ba2dcf095 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,9 +1,9 @@ -import {access, constants, copyFile, mkdir, readFile, writeFile} from "node:fs/promises"; +import {access, constants, copyFile, readFile, writeFile} from "node:fs/promises"; import {basename, dirname, join, normalize, relative} from "node:path"; import {cwd} from "node:process"; import {fileURLToPath} from "node:url"; import {parseArgs} from "node:util"; -import {getStats, visitFiles, visitMarkdownFiles} from "./files.js"; +import {getStats, prepareOutput, visitFiles, visitMarkdownFiles} from "./files.js"; import {readPages} from "./navigation.js"; import {renderServerless} from "./render.js"; import {makeCLIResolver} from "./resolver.js"; @@ -61,7 +61,6 @@ async function build(context: CommandContext) { continue; } console.log("generate", path, "→", outputPath); - await prepareOutput(outputPath); await runCommand(path, outputPath); continue; } @@ -80,12 +79,6 @@ async function build(context: CommandContext) { } } -async function prepareOutput(outputPath: string): Promise { - const outputDir = dirname(outputPath); - if (outputDir === ".") return; - await mkdir(outputDir, {recursive: true}); -} - const USAGE = `Usage: observable build [--root dir] [--output dir]`; interface CommandContext { diff --git a/src/dataloader.ts b/src/dataloader.ts index ed369c44e..913c0a2a3 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -1,7 +1,7 @@ import {open} from "node:fs/promises"; import {spawn} from "node:child_process"; import {join} from "node:path"; -import {getStats} from "./files.js"; +import {getStats, prepareOutput} from "./files.js"; import {renameSync, unlinkSync} from "node:fs"; const runningCommands = new Map>(); @@ -9,36 +9,38 @@ const runningCommands = new Map>(); export async function runCommand(commandPath: string, outputPath: string) { if (runningCommands.has(commandPath)) return runningCommands.get(commandPath); const command = new Promise((resolve, reject) => { - const cacheTempPath = outputPath + ".tmp"; - open(cacheTempPath, "w").then((cacheFd) => { - const cacheFileStream = cacheFd.createWriteStream({highWaterMark: 1024 * 1024}); - try { - const subprocess = spawn(commandPath, [], { - argv0: commandPath, - //cwd: dirname(commandPath), // TODO: Need to change commandPath to be relative this? - windowsHide: true, - stdio: ["ignore", "pipe", "inherit"] - // timeout: // time in ms - // signal: // abort signal - }); - subprocess.stdout.on("data", (data) => cacheFileStream.write(data)); - subprocess.on("error", (error) => console.error(`${commandPath}: ${error.message}`)); - subprocess.on("close", (code) => { - cacheFd.close().then(() => { - if (code === 0) { - renameSync(cacheTempPath, outputPath); - } else { - unlinkSync(cacheTempPath); - } - resolve(); - }, reject); - }); - } catch (error) { - reject(error); - } finally { - runningCommands.delete(commandPath); - } - }); + const outputTempPath = outputPath + ".tmp"; + prepareOutput(outputTempPath).then(() => + open(outputTempPath, "w").then((cacheFd) => { + const cacheFileStream = cacheFd.createWriteStream({highWaterMark: 1024 * 1024}); + try { + const subprocess = spawn(commandPath, [], { + argv0: commandPath, + //cwd: dirname(commandPath), // TODO: Need to change commandPath to be relative this? + windowsHide: true, + stdio: ["ignore", "pipe", "inherit"] + // timeout: // time in ms + // signal: // abort signal + }); + subprocess.stdout.on("data", (data) => cacheFileStream.write(data)); + subprocess.on("error", (error) => console.error(`${commandPath}: ${error.message}`)); + subprocess.on("close", (code) => { + cacheFd.close().then(() => { + if (code === 0) { + renameSync(outputTempPath, outputPath); + } else { + unlinkSync(outputTempPath); + } + resolve(); + }, reject); + }); + } catch (error) { + reject(error); + } finally { + runningCommands.delete(commandPath); + } + }) + ); }); runningCommands.set(commandPath, command); return command; diff --git a/src/files.ts b/src/files.ts index afdb70c27..4ba8074f5 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,7 +1,7 @@ import type {Stats} from "node:fs"; import {accessSync, constants, statSync} from "node:fs"; -import {readdir, stat} from "node:fs/promises"; -import {extname, join, normalize, relative} from "node:path"; +import {mkdir, readdir, stat} from "node:fs/promises"; +import {dirname, extname, join, normalize, relative} from "node:path"; import {isNodeError} from "./error.js"; // A file is local if it exists in the root folder or a subfolder. @@ -60,3 +60,9 @@ export async function getStats(path: string): Promise { } return; } + +export async function prepareOutput(outputPath: string): Promise { + const outputDir = dirname(outputPath); + if (outputDir === ".") return; + await mkdir(outputDir, {recursive: true}); +} diff --git a/src/preview.ts b/src/preview.ts index b6025286f..df0611d9c 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -18,6 +18,7 @@ import {findLoader, runCommand} from "./dataloader.js"; import {getStats} from "./files.js"; const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); +const cacheRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".observablehq", "cache"); class Server { private _server: ReturnType; @@ -28,11 +29,11 @@ class Server { readonly cacheRoot: string; private _resolver: CellResolver | undefined; - constructor({port, hostname, root}: CommandContext) { + constructor({port, hostname, root, cacheRoot}: CommandContext) { this.port = port; this.hostname = hostname; this.root = root; - this.cacheRoot = join(this.root, "../_cache"); + this.cacheRoot = cacheRoot; this._server = createServer(); this._server.on("request", this._handleRequest); this._socketServer = new WebSocketServer({server: this._server}); @@ -305,6 +306,7 @@ interface CommandContext { root: string; hostname: string; port: number; + cacheRoot: string; } function makeCommandContext(): CommandContext { @@ -332,7 +334,8 @@ function makeCommandContext(): CommandContext { return { root: normalize(values.root).replace(/\/$/, ""), hostname: values.hostname ?? process.env.HOSTNAME ?? "127.0.0.1", - port: values.port ? +values.port : process.env.PORT ? +process.env.PORT : 3000 + port: values.port ? +values.port : process.env.PORT ? +process.env.PORT : 3000, + cacheRoot }; } From 78fd3b25a408dcaf335331f357d0b1fafb9e3a36 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Fri, 3 Nov 2023 10:55:58 -0700 Subject: [PATCH 8/8] Add .sh extension --- src/dataloader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataloader.ts b/src/dataloader.ts index 913c0a2a3..756be8a09 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -48,7 +48,7 @@ export async function runCommand(commandPath: string, outputPath: string) { export async function findLoader(root: string, name: string) { // TODO: It may be more efficient use fs.readdir - for (const ext of [".js", ".ts"]) { + for (const ext of [".js", ".ts", ".sh"]) { const path = join(root, name) + ext; const stats = await getStats(path); if (stats) return {path, stats};