Skip to content

Commit e1be685

Browse files
authored
Merge branch 'main' into fil/rollup-css-minify
2 parents b7d8113 + 8243833 commit e1be685

File tree

9 files changed

+281
-51
lines changed

9 files changed

+281
-51
lines changed

.github/workflows/deploy.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ jobs:
2323
yarn.lock
2424
'examples/*/yarn.lock'
2525
- run: yarn --frozen-lockfile
26+
- id: date
27+
run: echo "date=$(TZ=America/Los_Angeles date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
28+
- id: cache-data
29+
uses: actions/cache@v4
30+
with:
31+
path: |
32+
docs/.observablehq/cache
33+
examples/*/docs/.observablehq/cache
34+
key: data-${{ hashFiles('docs/data/*', 'examples/*/docs/data/*') }}-${{ steps.date.outputs.date }}
35+
- if: steps.cache-data.outputs.cache-hit == 'true'
36+
run: find docs/.observablehq/cache examples/*/docs/.observablehq/cache -type f -exec touch {} +
2637
- run: yarn build
2738
- name: Build example "api"
2839
run: yarn --frozen-lockfile && yarn build
@@ -64,7 +75,7 @@ jobs:
6475
with:
6576
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
6677
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
67-
projectName: framework-pages
78+
projectName: framework
6879
directory: dist
6980
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
7081
# TODO: This doesn't include the examples. How can we fix that?

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
node-version: ${{ matrix.version }}
2020
cache: yarn
2121
- run: yarn --frozen-lockfile
22-
- run: yarn c8 --check-coverage -x src/**/*.d.ts -x src/preview.ts -x src/observableApiConfig.ts -x src/client --lines 80 --per-file yarn test:mocha
22+
- run: yarn c8 --check-coverage -x src/**/*.d.ts -x src/preview.ts -x src/observableApiConfig.ts -x src/client -x src/convert.ts --lines 80 --per-file yarn test:mocha
2323
- run: yarn test:tsc
2424
- run: |
2525
echo ::add-matcher::.github/eslint.json

bin/observable.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ else if (values.help) {
6262

6363
/** Commands that use Clack formatting. When handling CliErrors, clack.outro()
6464
* will be used for these commands. */
65-
const CLACKIFIED_COMMANDS = ["create", "deploy", "login"];
65+
const CLACKIFIED_COMMANDS = ["create", "deploy", "login", "convert"];
6666

6767
try {
6868
switch (command) {
@@ -78,6 +78,7 @@ try {
7878
logout sign-out of Observable
7979
deploy deploy a project to Observable
8080
whoami check authentication status
81+
convert convert an Observable notebook to Markdown
8182
help print usage information
8283
version print the version`
8384
);
@@ -177,6 +178,17 @@ try {
177178
await import("../src/observableApiAuth.js").then((auth) => auth.whoami());
178179
break;
179180
}
181+
case "convert": {
182+
const {
183+
positionals,
184+
values: {output, force}
185+
} = helpArgs(command, {
186+
options: {output: {type: "string", default: "."}, force: {type: "boolean", short: "f"}},
187+
allowPositionals: true
188+
});
189+
await import("../src/convert.js").then((convert) => convert.convert(positionals, {output: output!, force}));
190+
break;
191+
}
180192
default: {
181193
console.error(`observable: unknown command '${command}'. See 'observable help'.`);
182194
process.exit(1);
@@ -223,6 +235,7 @@ try {
223235
}
224236
}
225237
}
238+
process.exit(1);
226239
}
227240

228241
// A wrapper for parseArgs that adds --help functionality with automatic usage.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@observablehq/framework",
33
"license": "ISC",
4-
"version": "1.0.0-rc.8",
4+
"version": "1.0.0",
55
"type": "module",
66
"publishConfig": {
77
"access": "public"

src/client/deploy.js

Lines changed: 0 additions & 44 deletions
This file was deleted.

src/convert.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {existsSync} from "node:fs";
2+
import {utimes, writeFile} from "node:fs/promises";
3+
import {join} from "node:path";
4+
import * as clack from "@clack/prompts";
5+
import wrapAnsi from "wrap-ansi";
6+
import type {ClackEffects} from "./clack.js";
7+
import {CliError} from "./error.js";
8+
import {prepareOutput} from "./files.js";
9+
import {getObservableUiOrigin} from "./observableApiClient.js";
10+
import {type TtyEffects, bold, cyan, faint, inverse, link, reset, defaultEffects as ttyEffects} from "./tty.js";
11+
12+
export interface ConvertEffects extends TtyEffects {
13+
clack: ClackEffects;
14+
prepareOutput(outputPath: string): Promise<void>;
15+
existsSync(outputPath: string): boolean;
16+
writeFile(outputPath: string, contents: Buffer | string): Promise<void>;
17+
touch(outputPath: string, date: Date | string | number): Promise<void>;
18+
}
19+
20+
const defaultEffects: ConvertEffects = {
21+
...ttyEffects,
22+
clack,
23+
async prepareOutput(outputPath: string): Promise<void> {
24+
await prepareOutput(outputPath);
25+
},
26+
existsSync(outputPath: string): boolean {
27+
return existsSync(outputPath);
28+
},
29+
async writeFile(outputPath: string, contents: Buffer | string): Promise<void> {
30+
await writeFile(outputPath, contents);
31+
},
32+
async touch(outputPath: string, date: Date | string | number): Promise<void> {
33+
await utimes(outputPath, (date = new Date(date)), date);
34+
}
35+
};
36+
37+
export async function convert(
38+
inputs: string[],
39+
{output, force = false, files: includeFiles = true}: {output: string; force?: boolean; files?: boolean},
40+
effects: ConvertEffects = defaultEffects
41+
): Promise<void> {
42+
const {clack} = effects;
43+
clack.intro(`${inverse(" observable convert ")}`);
44+
let n = 0;
45+
for (const input of inputs) {
46+
let start = Date.now();
47+
let s = clack.spinner();
48+
const url = resolveInput(input);
49+
const name = inferFileName(url);
50+
const path = join(output, name);
51+
if (await maybeFetch(path, force, effects)) {
52+
s.start(`Downloading ${bold(path)}`);
53+
const response = await fetch(url);
54+
if (!response.ok) throw new Error(`error fetching ${url}: ${response.status}`);
55+
const {nodes, files, update_time} = await response.json();
56+
s.stop(`Downloaded ${bold(path)} ${faint(`in ${(Date.now() - start).toLocaleString("en-US")}ms`)}`);
57+
await effects.prepareOutput(path);
58+
await effects.writeFile(path, convertNodes(nodes));
59+
await effects.touch(path, update_time);
60+
n++;
61+
if (includeFiles) {
62+
for (const file of files) {
63+
const path = join(output, file.name);
64+
if (await maybeFetch(path, force, effects)) {
65+
start = Date.now();
66+
s = clack.spinner();
67+
s.start(`Downloading ${bold(file.name)}`);
68+
const response = await fetch(file.download_url);
69+
if (!response.ok) throw new Error(`error fetching ${file.download_url}: ${response.status}`);
70+
const buffer = Buffer.from(await response.arrayBuffer());
71+
s.stop(`Downloaded ${bold(file.name)} ${faint(`in ${(Date.now() - start).toLocaleString("en-US")}ms`)}`);
72+
await effects.prepareOutput(path);
73+
await effects.writeFile(path, buffer);
74+
await effects.touch(path, file.create_time);
75+
n++;
76+
}
77+
}
78+
}
79+
}
80+
}
81+
clack.note(
82+
wrapAnsi(
83+
"Due to syntax differences between Observable notebooks and " +
84+
"Observable Framework, converted notebooks may require further " +
85+
"changes to function correctly. To learn more about JavaScript " +
86+
"in Framework, please read:\n\n" +
87+
reset(cyan(link("https://observablehq.com/framework/javascript"))),
88+
Math.min(64, effects.outputColumns)
89+
),
90+
"Note"
91+
);
92+
clack.outro(
93+
`${inputs.length} notebook${inputs.length === 1 ? "" : "s"} converted; ${n} file${n === 1 ? "" : "s"} written`
94+
);
95+
}
96+
97+
async function maybeFetch(path: string, force: boolean, effects: ConvertEffects): Promise<boolean> {
98+
const {clack} = effects;
99+
if (effects.existsSync(path) && !force) {
100+
const choice = await clack.confirm({message: `${bold(path)} already exists; replace?`, initialValue: false});
101+
if (!choice) return false;
102+
if (clack.isCancel(choice)) throw new CliError("Stopped convert", {print: false});
103+
}
104+
return true;
105+
}
106+
107+
export function convertNodes(nodes): string {
108+
let string = "";
109+
let first = true;
110+
for (const node of nodes) {
111+
if (first) first = false;
112+
else string += "\n";
113+
string += convertNode(node);
114+
}
115+
return string;
116+
}
117+
118+
export function convertNode(node): string {
119+
let string = "";
120+
if (node.mode !== "md") string += `\`\`\`${node.mode}${node.pinned ? " echo" : ""}\n`;
121+
string += `${node.value}\n`;
122+
if (node.mode !== "md") string += "```\n";
123+
return string;
124+
}
125+
126+
export function inferFileName(input: string): string {
127+
return new URL(input).pathname.replace(/^\/document(\/@[^/]+)?\//, "").replace(/\//g, ",") + ".md";
128+
}
129+
130+
export function resolveInput(input: string): string {
131+
let url: URL;
132+
if (isIdSpecifier(input)) url = new URL(`/d/${input}`, getObservableUiOrigin());
133+
else if (isSlugSpecifier(input)) url = new URL(`/${input}`, getObservableUiOrigin());
134+
else url = new URL(input);
135+
url.host = `api.${url.host}`;
136+
url.pathname = `/document${url.pathname.replace(/^\/d\//, "/")}`;
137+
return String(url);
138+
}
139+
140+
function isIdSpecifier(string: string) {
141+
return /^([0-9a-f]{16})(?:@(\d+)|~(\d+)|@(\w+))?$/.test(string);
142+
}
143+
144+
function isSlugSpecifier(string: string) {
145+
return /^(?:@([0-9a-z_-]+))\/([0-9a-z_-]+(?:\/[0-9]+)?)(?:@(\d+)|~(\d+)|@(\w+))?$/.test(string);
146+
}

src/deploy.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export async function deploy(
296296
}
297297

298298
// Build the project
299-
await build({config, clientEntry: "./src/client/deploy.js"}, new DeployBuildEffects(apiClient, deployId, effects));
299+
await build({config}, new DeployBuildEffects(apiClient, deployId, effects));
300300

301301
// Mark the deploy as uploaded
302302
await apiClient.postDeployUploaded(deployId);
@@ -353,6 +353,9 @@ class DeployBuildEffects implements BuildEffects {
353353
try {
354354
await this.apiClient.postDeployFile(this.deployId, sourcePath, outputPath);
355355
} catch (error) {
356+
if (isApiError(error) && error.details.errors.some((e) => e.code === "FILE_QUOTA_EXCEEDED")) {
357+
throw new CliError("You have reached the total file size limit.", {cause: error});
358+
}
356359
// 413 is "Payload Too Large", however sometimes Cloudflare returns a
357360
// custom Cloudflare error, 520. Sometimes we also see 502. Handle them all
358361
if (isHttpError(error) && (error.statusCode === 413 || error.statusCode === 503 || error.statusCode === 520)) {
@@ -363,7 +366,14 @@ class DeployBuildEffects implements BuildEffects {
363366
}
364367
async writeFile(outputPath: string, content: Buffer | string) {
365368
this.logger.log(outputPath);
366-
await this.apiClient.postDeployFileContents(this.deployId, content, outputPath);
369+
try {
370+
await this.apiClient.postDeployFileContents(this.deployId, content, outputPath);
371+
} catch (error) {
372+
if (isApiError(error) && error.details.errors.some((e) => e.code === "FILE_QUOTA_EXCEEDED")) {
373+
throw new CliError("You have reached the total file size limit.", {cause: error});
374+
}
375+
throw error;
376+
}
367377
}
368378
}
369379

src/style/layout.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@
490490
#observablehq-search-results {
491491
--relevance-width: 32px;
492492
position: absolute;
493-
overflow-y: scroll;
493+
overflow-y: auto;
494494
top: 6.5rem;
495495
left: 0;
496496
right: 0.5rem;

0 commit comments

Comments
 (0)