diff --git a/.github/workflows/check.tsx b/.github/workflows/check.tsx new file mode 100644 index 0000000..d45e659 --- /dev/null +++ b/.github/workflows/check.tsx @@ -0,0 +1,61 @@ +import { stringify } from "@std/yaml"; +import { CheckoutStep, SetupDenoStep } from "./shared.tsx"; + +/** + * CheckWorkflow is a GitHub workflow for the jsonx project. + */ +export function CheckWorkflow() { + return { + name: "Check", + on: { + push: { + branches: ["main"], + }, + pull_request: { + branches: ["main"], + }, + }, + jobs: { + check: { + "runs-on": "ubuntu-latest", + steps: [ + , + , + , + , + { + name: "Test", + run: "deno task test", + }, + ], + }, + }, + }; +} + +/** + * DenoFormatStep is a step that formats the code. + */ +export function DenoFormatStep() { + return { + name: "Format", + run: "deno fmt && git diff-index --quiet HEAD", + }; +} + +/** + * DenoLintStep is a step that lints the code. + */ +export function DenoLintStep() { + return { + name: "Lint", + run: "deno lint && git diff-index --quiet HEAD", + }; +} + +if (import.meta.main) { + Deno.writeTextFileSync( + Deno.args[0], + stringify(, { lineWidth: 80 }), + ); +} diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..a5ad76e --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,20 @@ +name: Check +'on': + push: + branches: + - main + pull_request: + branches: + - main +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v1 + - name: Format + run: deno fmt && git diff-index --quiet HEAD + - name: Lint + run: deno lint && git diff-index --quiet HEAD + - name: Test + run: deno task test diff --git a/.github/workflows/shared.tsx b/.github/workflows/shared.tsx new file mode 100644 index 0000000..f238fda --- /dev/null +++ b/.github/workflows/shared.tsx @@ -0,0 +1,17 @@ +/** + * CheckoutStep is a step that checks out the repository. + */ +export function CheckoutStep() { + return { + uses: "actions/checkout@v4", + }; +} + +/** + * SetupDenoStep is a step that sets up the Deno runtime. + */ +export function SetupDenoStep() { + return { + uses: "denoland/setup-deno@v1", + }; +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cca6f16 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "deno.enable": true, + "deno.unstable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[markdown]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[jsonc]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "files.eol": "\n" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..456c488 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..294edc0 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# jsonx_docs + +Documentation site of [FartLabs/jsonx](https://github.com/FartLabs/jsonx), a JSX +runtime for composing JSON data. + +## Contribute + +### Style + +Run `deno fmt` to format the code. + +Run `deno lint` to lint the code. + +Run `deno task generate` to generate code. + +Run `deno task start` to locally serve the jsonx documentation site. + +### Testing + +Run `deno task test` to run the unit tests. + +--- + +Developed with ❤️ [**@FartLabs**](https://github.com/FartLabs) diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..eb7ebd0 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@fartlabs/jsonx" + }, + "imports": { + "@fartlabs/jsonx": "jsr:@fartlabs/jsonx@^0.0.9", + "@std/cli": "jsr:@std/cli@^0.220.1", + "@std/http": "jsr:@std/http@^0.220.1", + "@std/semver": "jsr:@std/semver@^0.220.1", + "@std/yaml": "jsr:@std/yaml@^0.220.1" + }, + "tasks": { + "start": "deno run -A --unstable-kv main.ts", + "generate": "deno run -Ar https://deno.land/x/generate/cli/main.ts gen.ts" + }, + "exclude": ["./static"] +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..fe5bd46 --- /dev/null +++ b/deno.lock @@ -0,0 +1,83 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@fartlabs/jsonx@^0.0.9": "jsr:@fartlabs/jsonx@0.0.9", + "jsr:@std/assert@^0.220.1": "jsr:@std/assert@0.220.1", + "jsr:@std/async@^0.220.1": "jsr:@std/async@0.220.1", + "jsr:@std/cli@^0.220.1": "jsr:@std/cli@0.220.1", + "jsr:@std/collections@^0.219.1": "jsr:@std/collections@0.219.1", + "jsr:@std/encoding@^0.220.1": "jsr:@std/encoding@0.220.1", + "jsr:@std/fmt@^0.220.1": "jsr:@std/fmt@0.220.1", + "jsr:@std/http@^0.220.1": "jsr:@std/http@0.220.1", + "jsr:@std/media-types@^0.220.1": "jsr:@std/media-types@0.220.1", + "jsr:@std/path@^0.220.1": "jsr:@std/path@0.220.1", + "jsr:@std/semver@^0.220.1": "jsr:@std/semver@0.220.1", + "jsr:@std/streams@^0.220.1": "jsr:@std/streams@0.220.1" + }, + "jsr": { + "@fartlabs/jsonx@0.0.9": { + "integrity": "ec269a8e436e3052c5ea7a60b3ac525639a1c39b847e3fe14b9396070384a72f", + "dependencies": [ + "jsr:@std/collections@^0.219.1" + ] + }, + "@std/assert@0.220.1": { + "integrity": "88710d54f3afdd7a5761e7805abba1f56cd14e4b212feffeb3e73a9f77482425" + }, + "@std/async@0.220.1": { + "integrity": "61f0e9c53bf81f516aa7f54843f307eb5f05c5cf9902575a636ead162b5dfafe" + }, + "@std/cli@0.220.1": { + "integrity": "6c38388efc899c9d98811d38a46effe01e0d91138068483f1e6ae834caad5e82", + "dependencies": [ + "jsr:@std/assert@^0.220.1" + ] + }, + "@std/collections@0.219.1": { + "integrity": "ed0adf354efaf90069a1e9af263b9941f6a370d16e8a98f2fc67d12ed75d825a" + }, + "@std/encoding@0.220.1": { + "integrity": "8dc38dd72e36cd68857a5837e24eb09a64bb296b96c295239c75eec17d45d23f" + }, + "@std/fmt@0.220.1": { + "integrity": "3b1a698477a26b1dacbbee26db1a65030a005c6d71db3b3a0e59f8a638f04d7a" + }, + "@std/http@0.220.1": { + "integrity": "1672a8d31f3e0ccf1c55221907daba4a22f3760cf2d079afb26def2dcfd0513b", + "dependencies": [ + "jsr:@std/assert@^0.220.1", + "jsr:@std/async@^0.220.1", + "jsr:@std/cli@^0.220.1", + "jsr:@std/encoding@^0.220.1", + "jsr:@std/fmt@^0.220.1", + "jsr:@std/media-types@^0.220.1", + "jsr:@std/path@^0.220.1", + "jsr:@std/streams@^0.220.1" + ] + }, + "@std/media-types@0.220.1": { + "integrity": "9dea30f7e0c87426ad8736678137ad277f1450f858efa1ef2c3aad6d9029b682" + }, + "@std/path@0.220.1": { + "integrity": "cc63c1b5e5192e2f718dc2f365a3514fffb26cc0380959bab0f8fb5988a52f0c" + }, + "@std/semver@0.220.1": { + "integrity": "f3df4abc715ad723680011f19d2934cfd436b4c41c317e82bfc14ac586b3b724" + }, + "@std/streams@0.220.1": { + "integrity": "f26c7cb98c471af89de8309a0246cccb4c9bb6f3c639bd84aee5488127dc8545" + } + } + }, + "remote": {}, + "workspace": { + "dependencies": [ + "jsr:@fartlabs/jsonx@^0.0.9", + "jsr:@std/cli@^0.220.1", + "jsr:@std/http@^0.220.1", + "jsr:@std/semver@^0.220.1", + "jsr:@std/yaml@^0.220.1" + ] + } +} diff --git a/docs.ts b/docs.ts new file mode 100644 index 0000000..03d3a9e --- /dev/null +++ b/docs.ts @@ -0,0 +1,110 @@ +import { compare, greaterThan, parse } from "@std/semver"; +import { serveDir } from "@std/http"; + +/** + * Playground is a jsonx playground. + */ +export interface Playground { + id: string; + version: string; + code: string; +} + +/** + * Meta is the module metadata. + */ +export interface Meta { + latest: string; + versions: string[]; +} + +/** + * Docs is a class for serving the documentation site including + * jsonx playgrounds. + */ +export class Docs { + public constructor(private fsRoot: string, private kv: Deno.Kv) {} + + /** + * fetch handles incoming requests. + */ + public async fetch(request: Request): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/meta") { + return Response.json(await this.meta); + } + + if (request.method === "GET" && url.pathname.startsWith("/playgrounds/")) { + const id = url.pathname.split("/")[2]; + const playground = await this.getPlayground(id); + if (playground === null) { + return new Response(null, { status: 404 }); + } + + return Response.json(playground); + } + + if (request.method === "POST" && url.pathname === "/playgrounds") { + const playground = await request.json(); + await this.setPlayground(playground); + return Response.json(playground); + } + + return await serveDir(request, { fsRoot: this.fsRoot }); + } + + /** + * getPlayground gets a playground by ID. + */ + private async getPlayground(id: string): Promise { + const result = await this.kv.get(playgroundKey(id)); + return result.value; + } + + /** + * setPlayground sets a playground by ID. + */ + private async setPlayground(playground: Playground): Promise { + const result = await this.kv.set(playgroundKey(playground.id), playground); + if (!result) { + throw new Error("Failed to set playground!"); + } + } + + /** + * meta gets the latest module metadata. + */ + private get meta(): Promise { + return fetch("https://jsr.io/@fartlabs/jsonx/meta.json") + .then((response) => response.json()) + .then((meta) => playgroundMeta(meta)); + } +} + +/** + * createDocs creates a new Docs instance. + */ +export function createDocs({ fsRoot, kv }: { + fsRoot: string; + kv: Deno.Kv; +}): Docs { + return new Docs(fsRoot, kv); +} + +function playgroundMeta({ latest, versions }: { + latest: string; + versions: Record; +}): Meta { + // https://github.com/FartLabs/jsonx/issues/13 + const minCompatible = parse("0.0.8"); + return { + latest: latest, + versions: Object.keys(versions) + .filter((versionTag) => greaterThan(parse(versionTag), minCompatible)) + .sort((a, b) => compare(parse(b), parse(a))), + }; +} + +function playgroundKey(id: string): Deno.KvKey { + return ["playgrounds", id]; +} diff --git a/gen.ts b/gen.ts new file mode 100644 index 0000000..50ad92d --- /dev/null +++ b/gen.ts @@ -0,0 +1 @@ +//deno:generate deno run --allow-write .github/workflows/check.tsx .github/workflows/check.yaml diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..557bcd2 --- /dev/null +++ b/main.ts @@ -0,0 +1,20 @@ +import { parseArgs } from "@std/cli"; +import { createDocs } from "./docs.ts"; + +if (import.meta.main) { + const flags = parseArgs(Deno.args, { + string: ["port", "root"], + default: { + port: "8000", + root: "static", + }, + }); + const docs = createDocs({ + fsRoot: flags.root, + kv: await Deno.openKv(), + }); + Deno.serve( + { port: parseInt(flags.port) }, + docs.fetch.bind(docs), + ); +} diff --git a/static/examples/animals.tsx b/static/examples/animals.tsx new file mode 100644 index 0000000..e0ab6f8 --- /dev/null +++ b/static/examples/animals.tsx @@ -0,0 +1,16 @@ +function Cat() { + return { animals: ["🐈"] }; +} + +function Dog() { + return { animals: ["🐕"] }; +} + +const data = ( + <> + + + +); + +console.log(data); diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..5f48a9e Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..d12cc9f --- /dev/null +++ b/static/index.html @@ -0,0 +1,88 @@ + + + + + + jsonx + + + + + + +
+
+ + TSX + + + + + + + +
+
+ +
+ + Console + + +
    +
    + + +
    + + Build logs + + +
      +
      + + +
      + + Result + + + + + +
      +
      + + + + diff --git a/static/lib/playground/build.js b/static/lib/playground/build.js new file mode 100644 index 0000000..d714fd8 --- /dev/null +++ b/static/lib/playground/build.js @@ -0,0 +1,21 @@ +import * as esbuild from "https://esm.sh/esbuild-wasm@0.20.1"; + +export async function transform(options) { + const transformation = await esbuild.transform(options.code, { + loader: "tsx", + tsconfigRaw: { + compilerOptions: makeCompilerOptions(options.version), + }, + }); + + return transformation; +} + +export function makeCompilerOptions(version) { + return { + jsx: "react-jsx", + jsxFactory: "h", + jsxFragmentFactory: "Fragment", + jsxImportSource: `https://esm.sh/jsr/@fartlabs/jsonx@${version}`, + }; +} diff --git a/static/lib/playground/editor.js b/static/lib/playground/editor.js new file mode 100644 index 0000000..15bd2f9 --- /dev/null +++ b/static/lib/playground/editor.js @@ -0,0 +1,16 @@ +import { + EditorView, + keymap, + lineNumbers, +} from "https://esm.sh/@codemirror/view@6.0.1"; +import { defaultKeymap } from "https://esm.sh/@codemirror/commands@6.0.1"; + +export let cmEditor; + +export function createEditor(options) { + cmEditor = new EditorView({ + doc: options.code, + parent: options.target, + extensions: [keymap.of(defaultKeymap), lineNumbers()], + }); +} diff --git a/static/lib/playground/index.js b/static/lib/playground/index.js new file mode 100644 index 0000000..5b45af5 --- /dev/null +++ b/static/lib/playground/index.js @@ -0,0 +1 @@ +export * from "./playground.js"; diff --git a/static/lib/playground/output.js b/static/lib/playground/output.js new file mode 100644 index 0000000..fc05213 --- /dev/null +++ b/static/lib/playground/output.js @@ -0,0 +1,32 @@ +export const COLOR = { + log: "dodgerblue", + warn: "yellow", + error: "red", + info: "lime", + table: "lavender", +}; + +export function renderPrefix(type) { + const timestamp = new Date().toISOString(); + return `${timestamp} [${type.toUpperCase()}] `; +} + +export function appendBuildOutput(type, message) { + const li = document.createElement("li"); + li.innerHTML = `${renderPrefix(type)}${message}`; + buildOutput.append(li); + if (!buildDetails.open) { + buildDetails.open = true; + } +} + +export function appendConsoleOutput(type, message) { + const li = document.createElement("li"); + li.innerHTML = `${renderPrefix(type)}${message}`; + consoleOutput.append(li); + if (!consoleDetails.open) { + consoleDetails.open = true; + } +} diff --git a/static/lib/playground/playground.css b/static/lib/playground/playground.css new file mode 100644 index 0000000..cfc3ff1 --- /dev/null +++ b/static/lib/playground/playground.css @@ -0,0 +1,132 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +} + +body { + background-color: #bbb; + overflow-y: scroll; + overflow-x: hidden; + margin: 0 -5px 0 0; +} + +nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em 1em 0 1em; +} + +.badges { + display: flex; + gap: 0.5em; +} + +.badges a { + display: flex; + align-items: center; + justify-content: center; + outline: 2px solid #333; + color: #333; + border-radius: 0.5em; + transition: outline 0.2s; + padding: 0.5em; + text-decoration: none; + font-weight: bold; +} + +.badges a:hover { + outline: 5px solid #333; +} + +@media (max-width: 400px) { + .badges a { + outline: none; + } + + .badges a:hover { + outline: none; + } + + .badges a span { + display: none; + } +} + +.badges img { + height: 1.5em; + align-self: center; +} + +main { + padding: 0 0.7em; +} + +details { + color: #bbb; + outline: 2px solid #333; + border-radius: 0.5em; + margin: 1em 0; +} + +details summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em; + border-radius: 0.5em; + background-color: #333; +} + +details[open] summary { + border-bottom: 2px solid #333; + border-radius: 0.5em 0.5em 0 0; +} + +details:not([open]) summary span:first-child::before { + content: "▶"; + margin-right: 0.5em; +} + +details[open] summary span:first-child::before { + content: "▼"; + margin-right: 0.5em; +} + +#editor, +iframe { + width: 100%; + height: 34vh; + background-color: #fff; + padding: 0.5em; + resize: vertical; + margin-bottom: -5px; + border-radius: 0 0 0.5em 0.5em; +} + +#editor { + height: initial; + color: black; +} + +#buildOutput, +#consoleOutput { + margin: 0; + padding: 0.5em; + list-style: none; + white-space: pre-wrap; + word-wrap: break-word; + background-color: #000; + font-family: monospace; +} + +#buildOutput:empty::before, +#consoleOutput:empty::before { + content: "No output"; + color: #bbb; + display: block; + padding: 0.5em; +} diff --git a/static/lib/playground/playground.js b/static/lib/playground/playground.js new file mode 100644 index 0000000..81b0cdc --- /dev/null +++ b/static/lib/playground/playground.js @@ -0,0 +1,100 @@ +import { transform } from "./build.js"; +import { createEditor, cmEditor } from "./editor.js"; +import { appendBuildOutput, appendConsoleOutput } from "./output.js"; + +/** + * createPlayground create a playground. + */ +export async function createPlayground(options) { + // Fetch the module meta. + await fetch("./meta") + .then((response) => response.json()) + .then((json) => { + // Set up version input element. + version.value = json.latest; + json.versions.forEach((versionTag) => { + const option = document.createElement("option"); + option.value = versionTag; + option.textContent = `Version: ${versionTag}`; + version.append(option); + }); + }); + + // Set up default values. + if (options.version) { + version.value = options.version; + } + + await createEditor({ + target: editor, + code: options.code, + version: version.value, + }); + + // Set up event listeners. + play.addEventListener("click", () => handlePlay()); + clearBuildOutput.addEventListener( + "click", + () => (buildOutput.innerHTML = "") + ); + clearConsoleOutput.addEventListener( + "click", + () => (consoleOutput.innerHTML = "") + ); + window.addEventListener("message", (event) => { + if (event.data.type === "console") { + appendConsoleOutput( + event.data.method, + event.data.arguments + .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg)) + .join(" ") + ); + } + }); + + // Enable button interactions. + play.disabled = false; + share.disabled = false; + version.disabled = false; + + // Play the code if autoplay is enabled. + if (options.autoplay) { + handlePlay(); + } +} + +async function handlePlay() { + try { + const code = cmEditor?.state?.doc?.toString(); + if (!code) { + appendBuildOutput("error", "No code to build."); + return; + } + + const transformation = await transform({ + code: cmEditor.state.doc.toString(), + version: version.value, + }); + transformation.warnings.forEach((warning) => { + appendBuildOutput("warning", warning.text); + }); + + const html = ``; + result.srcdoc = html; + } catch (error) { + appendBuildOutput("error", error.message); + } +} + +const CONSOLE_INTERCEPT = `const _console = { ...console }; +for (const key in _console) { + if (typeof _console[key] === "function") { + console[key] = function () { + _console[key](...arguments); + parent.postMessage({ type: "console", method: key, arguments: [...arguments] }); + }; + } +} +window.onerror = function (message, source, lineno, colno, error) { + parent.postMessage({ type: "console", method: "error", arguments: [error ? error.stack : message] }); +}`; diff --git a/static/play.js b/static/play.js new file mode 100644 index 0000000..0149a29 --- /dev/null +++ b/static/play.js @@ -0,0 +1,37 @@ +import * as esbuild from "https://esm.sh/esbuild-wasm@0.20.1"; +import { createPlayground } from "./lib/playground/index.js"; + +main(); + +async function main() { + // Create the playground. + const url = new URL(location.href); + const id = url.searchParams.get("id"); + + // Initialize esbuild. + await esbuild.initialize({ + wasmURL: "https://esm.sh/esbuild-wasm@0.20.1/esbuild.wasm", + }); + + // Fetch the playground. + let code; + let version; + try { + if (id) { + const playground = await fetch(`./playgrounds/${id}`).then((response) => + response.json() + ); + code = playground.code; + version = playground.version; + } + + // Fetch default code if unset. + if (!code) { + code = await fetch("./examples/animals.tsx").then((response) => + response.text() + ); + } + } finally { + await createPlayground({ code, version, autoplay: true }); + } +}