Public: anyone can read this; the crew can change it.
Config files open as a form: YAML and TOML, comments kept #8928
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7337-config-files-open-as-a-form-yaml-and-tom → main 4 commits fork
20 files
changed +14379
−54
docs/features/config-form.md +79 −6
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | # Config files as a form |
| 2 | 2 | |
| 3 | A `.json` file in a repository opens as a form (#7336, part of #7329). | |
| 3 | A `.json`, `.yml`/`.yaml` or `.toml` file in a repository opens as a form (#7336, #7337, part of #7329). | |
| 4 | 4 | |
| 5 | 5 | ## Reading |
| 6 | 6 | |
| 7 | 7 | The blob page's **Rendered** view draws the file's values as read-only |
| 8 | 8 | fields. **Source**, **Raw** and **Download** are where they always are. |
| 9 | A file that is not valid JSON says why, with the line and column, above | |
| 10 | its text. YAML, TOML and other config files keep their source view. | |
| 9 | A file that is not valid says why, with the line and column, above | |
| 10 | its text. Other config files (INI, `.env`) keep their source view. | |
| 11 | 11 | |
| 12 | 12 | ## Editing |
| 13 | 13 | |
| @@ -33,10 +33,72 @@ signed in. A private repository's file is a 404 to anyone who cannot read it. | ||
| 33 | 33 | 1. `$schema` in the file, if it is a path. A path that starts with `/` |
| 34 | 34 | is read from the repository root; any other path is read from the |
| 35 | 35 | file's folder. A URL is ignored. |
| 36 | 2. A sibling `<name>.schema.json`. | |
| 36 | 2. A sibling `<name>.schema.json` (`app.yml` and `app.toml` look for `app.schema.json`). | |
| 37 | 37 | 3. `.forge/schemas.json`, an object that maps globs to schema paths, for |
| 38 | 38 | example `{ "config/**/*.json": "schemas/config.json" }`. |
| 39 | 39 | |
| 40 | ## YAML and TOML | |
| 41 | ||
| 42 | Both read into the same form as JSON and use the same schema lookup. The | |
| 43 | difference is the save: they are patched, not rewritten. | |
| 44 | ||
| 45 | - **YAML** goes through the `yaml` package's Document API. Comments, anchors, | |
| 46 | quoting, key order and every line you did not change are written as they | |
| 47 | were. A file you did not change is returned byte for byte. A file of several | |
| 48 | documents (`---`) is one fieldset per document ("Document 1", …), and only | |
| 49 | a document you changed is rewritten. An alias shows as the value it points | |
| 50 | at; a change that only an alias holds is refused, with the reason, rather | |
| 51 | than guessed. A key that is not text, or an alias that fans out to over | |
| 52 | 2,000 values, sends the file to its source view. | |
| 53 | - **TOML** goes through `@decimalturn/toml-patch`, which keeps comments and | |
| 54 | layout and rewrites only the values that differ. Dates and times show as the | |
| 55 | text they were written as. TOML has no null, so a form that would write one | |
| 56 | is refused. A table is a fieldset and an array of tables is a list of cards. | |
| 57 | - After either writes, the result is read back and must equal what the form | |
| 58 | held; if it does not, nothing is committed. | |
| 59 | - One limit: YAML sequences are indented the way most of the file does it, | |
| 60 | so a file that mixes both styles has its changed document re-indented. | |
| 61 | ||
| 62 | ### The two vendored libraries | |
| 63 | ||
| 64 | Neither is a dependency in `package.json`: each is one bundled file under | |
| 65 | `src/web/render/vendor/`, with its licence beside it, run on the server only | |
| 66 | (no script is sent to the page). | |
| 67 | ||
| 68 | | Bundle | Pinned | Licence | Why | | |
| 69 | | --- | --- | --- | --- | | |
| 70 | | `yaml.mjs` | yaml 2.9.1 (eemeli/yaml) | ISC, `YAML-LICENSE` | Its Document API keeps comments, anchors, quoting and key order, which a parse-and-dump cannot. Writing a YAML parser that does the same is far larger and riskier. | | |
| 71 | | `toml-patch.mjs` | toml-patch 3.3.2 (@decimalturn/toml-patch) | MIT, `TOML-PATCH-LICENSE` | `patch` rewrites only the values that differ, so TOML comments and layout survive. | | |
| 72 | ||
| 73 | To regenerate (or bump) one, in a scratch directory outside the repository | |
| 74 | (this reproduces both files byte for byte; `bun build` writes `// @bun` as | |
| 75 | its first line, which the header replaces): | |
| 76 | ||
| 77 | ||
| 78 | bun add [email protected] @decimalturn/[email protected] | |
| 79 | ( echo "// yaml 2.9.1 (eemeli/yaml, ISC), bundled from node_modules/yaml/browser; licence in YAML-LICENSE" | |
| 80 | bun build node_modules/yaml/browser/index.js --format=esm --target=bun | tail -n +2 ) > yaml.mjs | |
| 81 | ( echo "// @decimalturn/toml-patch 3.3.2 (MIT), bundled from its dist; licence in TOML-PATCH-LICENSE" | |
| 82 | bun build node_modules/@decimalturn/toml-patch/dist/toml-patch.js --format=esm --target=bun | tail -n +2 ) > toml-patch.mjs | |
| 83 | cp node_modules/yaml/LICENSE YAML-LICENSE | |
| 84 | cp node_modules/@decimalturn/toml-patch/LICENSE TOML-PATCH-LICENSE | |
| 85 | ||
| 86 | ||
| 87 | Copy the four files over their namesakes in `vendor/`. Each bundle's SHA-256 | |
| 88 | is pinned here, and `test/config-yaml-toml.test.ts` fails when a bundle, its | |
| 89 | header or this page disagree: | |
| 90 | ||
| 91 | - `yaml.mjs` sha256 `409e0ae0ebbe4017070541566da9a5ae666f0b4990ce9d8aa20c09c92d277454` | |
| 92 | - `toml-patch.mjs` sha256 `b675b1478a5c3e773347425fd476455367b950406f7c2490f2f2c93400b67c3f` | |
| 93 | ||
| 94 | On a bump, change the version in the commands, the headers and the table, | |
| 95 | and the two hashes. | |
| 96 | ||
| 97 | Hostile input is bounded before it is walked: a file over 256 KB is never | |
| 98 | parsed (page and save both check the size first), YAML aliases and merge | |
| 99 | keys share a 2,000-value budget, and nesting deeper than 64 is refused in | |
| 100 | YAML and TOML alike. | |
| 101 | ||
| 40 | 102 | ## Saving |
| 41 | 103 | |
| 42 | 104 | **Review** shows the change as a diff, under a commit message. |
| @@ -48,7 +110,7 @@ signed in. A private repository's file is a 404 to anyone who cannot read it. | ||
| 48 | 110 | branch goes in the repository itself. A reader's goes on their fork; the |
| 49 | 111 | forge makes the fork if they have none, and the usual fork rules apply |
| 50 | 112 | (`actions/propose_edit.ts`). |
| 51 | - The file is written back with its own key order, indent (spaces or tabs), | |
| 113 | - A JSON file is written back with its own key order, indent (spaces or tabs), | |
| 52 | 114 | line endings and final newline. Numbers keep their spelling. |
| 53 | 115 | - A save that changes nothing writes nothing. |
| 54 | 116 | - If the file changed on the branch after you opened it, the save stops |
| @@ -71,7 +133,18 @@ file, then `c`: | ||
| 71 | 133 | opens a pull back. This is the same `proposeEdit` the web uses, with the |
| 72 | 134 | same refusals in the same words. The status line says "proposes from your |
| 73 | 135 | fork". |
| 74 | - Before any commit or proposal, each JSON file in the change is checked | |
| 136 | - Before any commit or proposal, each JSON, YAML or TOML file in the change is checked | |
| 75 | 137 | against its schema (`schemaRefusal`, found the same three ways). A file |
| 76 | 138 | that breaks it is refused in the dialog, naming the field and the schema, |
| 77 | 139 | and nothing is written. |
| 140 | - There is no form screen in the terminal yet, for JSON, YAML or TOML: values | |
| 141 | are edited as text. #8934 adds one. | |
| 142 | ||
| 143 | ## Try it | |
| 144 | ||
| 145 | Every preview's sample repository (`/preview-admin/sample`) holds | |
| 146 | `service.yml` and `service.toml` (`src/spawn/preview-config.ts`, seeded by | |
| 147 | `spawn/preview-seed.ts`, also into a sample seeded before they existed). | |
| 148 | They have comments, an anchor and a merge key (YAML), and a date and an | |
| 149 | array of tables (TOML). Open either on the blob page, press **Edit as | |
| 150 | form**, change one value and **Review**: the diff touches only that line. |
src/spawn/preview-config.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * Config files for a preview's sample repository (#7337): a YAML and a TOML | |
| 3 | * file with comments, an anchor and merge key, a date and an array of | |
| 4 | * tables, so the config form (docs/features/config-form.md) can be tried | |
| 5 | * on any preview without bringing a file of one's own. | |
| 6 | */ | |
| 7 | ||
| 8 | export const PREVIEW_CONFIG_FILES: readonly { readonly path: string; readonly content: string }[] = [ | |
| 9 | { | |
| 10 | path: "service.yml", | |
| 11 | content: `# A service's settings, to try the config form on: press Edit as form. | |
| 12 | name: forge-demo # shown on the home page | |
| 13 | defaults: &defaults | |
| 14 | retries: 3 | |
| 15 | verbose: false | |
| 16 | web: | |
| 17 | <<: *defaults | |
| 18 | port: 8080 | |
| 19 | host: "0.0.0.0" | |
| 20 | tags: | |
| 21 | - alpha | |
| 22 | - beta # second | |
| 23 | `, | |
| 24 | }, | |
| 25 | { | |
| 26 | path: "service.toml", | |
| 27 | content: `# A service's settings, to try the config form on: press Edit as form. | |
| 28 | name = "forge-demo" # shown on the home page | |
| 29 | ||
| 30 | [web] | |
| 31 | port = 8080 | |
| 32 | host = "0.0.0.0" | |
| 33 | started = 2026-11-02T09:30:00Z | |
| 34 | ||
| 35 | [[servers]] | |
| 36 | name = "one" | |
| 37 | weight = 1 | |
| 38 | ||
| 39 | [[servers]] | |
| 40 | name = "two" # the spare | |
| 41 | weight = 0 | |
| 42 | `, | |
| 43 | }, | |
| 44 | ]; |
src/spawn/preview-seed.ts +26 −4
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import type { Issue } from "../core/issues.ts"; |
| 19 | import type { Repo } from "../core/repos.ts"; | |
| 19 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 20 | 20 | import { pinPanel } from "../actions/board-pins.ts"; |
| 21 | 21 | import { writeBoard } from "../actions/boards.ts"; |
| 22 | 22 | import { backBet } from "../actions/bets.ts"; |
| @@ -33,6 +33,7 @@ import { recordUsage } from "../core/usage.ts"; | ||
| 33 | 33 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; |
| 34 | 34 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| 35 | 35 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 36 | import { PREVIEW_CONFIG_FILES } from "./preview-config.ts"; | |
| 36 | 37 | |
| 37 | 38 | /** The preview-only site admin's name. */ |
| 38 | 39 | export const PREVIEW_ADMIN = "preview-admin"; |
| @@ -50,6 +51,7 @@ export const PREVIEW_WAVE_HEADLINE = "The sample says hello"; | ||
| 50 | 51 | /** The board whose panels are pinned to home. */ |
| 51 | 52 | const PREVIEW_BOARD = "preview"; |
| 52 | 53 | const SEEDED = "preview_seeded"; |
| 54 | const CONFIG_SEEDED = "preview_config_seeded"; | |
| 53 | 55 | |
| 54 | 56 | type Env = Record<string, string | undefined>; |
| 55 | 57 | |
| @@ -89,12 +91,32 @@ export async function seedPreview( | ||
| 89 | 91 | } |
| 90 | 92 | if (!admin.isAdmin || admin.kind === "agent") |
| 91 | 93 | return err("the preview admin's name is someone else's"); |
| 92 | if (getUserSetting(app.db, admin.id, SEEDED) !== undefined) | |
| 93 | return ok({ seeded: false }); | |
| 94 | if (getUserSetting(app.db, admin.id, SEEDED) !== undefined) { | |
| 95 | const files = await seedConfigFiles(app, admin); | |
| 96 | return files.ok ? ok({ seeded: false }) : files; | |
| 97 | } | |
| 94 | 98 | const world = await seedWorld(app, admin); |
| 95 | 99 | if (!world.ok) return world; |
| 96 | 100 | setUserSetting(app.db, admin.id, SEEDED, new Date().toISOString()); |
| 97 | return ok({ seeded: true }); | |
| 101 | const files = await seedConfigFiles(app, admin); | |
| 102 | return files.ok ? ok({ seeded: true }) : files; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * The config files the form is tried on, once: a sample seeded before they | |
| 107 | * existed gets them on its next boot, in a commit of their own. | |
| 108 | */ | |
| 109 | async function seedConfigFiles(app: App, admin: User): Promise<Result<void, string>> { | |
| 110 | if (getUserSetting(app.db, admin.id, CONFIG_SEEDED) !== undefined) return ok(undefined); | |
| 111 | const repo = getRepo(app.db, admin.username, PREVIEW_REPO); | |
| 112 | if (repo === undefined) return err("the sample repository is gone"); | |
| 113 | const added = await commitToBranch(app, admin, repo, { | |
| 114 | message: "Config files to try the form on", | |
| 115 | files: [...PREVIEW_CONFIG_FILES], | |
| 116 | }); | |
| 117 | if (!added.ok) return err(`the config files: ${JSON.stringify(added.error)}`); | |
| 118 | setUserSetting(app.db, admin.id, CONFIG_SEEDED, new Date().toISOString()); | |
| 119 | return ok(undefined); | |
| 98 | 120 | } |
| 99 | 121 | |
| 100 | 122 | const panel = (title: string, source: string, groupBy: string) => ({ |
src/web/pages/config.ts +30 −20
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * Editing a JSON config file as a form (#7336). | |
| 2 | * Editing a JSON, YAML or TOML config file as a form (#7336, #7337). | |
| 3 | 3 | * |
| 4 | 4 | * GET /:owner/:name/config/:ref/*path — the file's values as fields, typed |
| 5 | 5 | * by its schema when one is found (`render/json-schema.ts`), else by |
| @@ -12,11 +12,12 @@ | ||
| 12 | 12 | * proposes it: one commit on a new branch and a pull |
| 13 | 13 | * (`actions/propose_edit.ts`). |
| 14 | 14 | * |
| 15 | * The file is written back in its own key order and indent, and a save | |
| 16 | * that changes nothing writes nothing. Everything works as a plain form | |
| 15 | * The file is written back in its own key order and indent (YAML and TOML | |
| 16 | * keep their comments: `render/config-format.ts`), and a save that changes | |
| 17 | * nothing writes nothing. Everything works as a plain form | |
| 17 | 18 | * POST; a browser with HTML validation checks a schema's bounds inline. |
| 18 | * A file that is not valid JSON goes back to its source view, with the | |
| 19 | * error and its line. | |
| 19 | * A file that is not valid goes back to its source view, with the error and | |
| 20 | * its line. | |
| 20 | 21 | */ |
| 21 | 22 | |
| 22 | 23 | import { editFiles } from "../../actions/contents.ts"; |
| @@ -28,16 +29,9 @@ import { loadRepo, notFoundPage, pageHead, signInFirst } from "../chrome.ts"; | ||
| 28 | 29 | import { field as formValue, readForm, type Form } from "../form.ts"; |
| 29 | 30 | import { html, when, type Html } from "../html.ts"; |
| 30 | 31 | import { flashTo, page } from "../layout.ts"; |
| 31 | import { isFormJson } from "../render/config.ts"; | |
| 32 | import { isFormConfig, languageOf, parseConfig, writeConfig } from "../render/config-format.ts"; | |
| 32 | 33 | import { applyOp, configFields, countFields, MAX_FIELDS, readValues } from "../render/config-form.ts"; |
| 33 | import { | |
| 34 | detectStyle, | |
| 35 | lineDiff, | |
| 36 | parseJson, | |
| 37 | sameDoc, | |
| 38 | stringify, | |
| 39 | type Json, | |
| 40 | } from "../render/json-doc.ts"; | |
| 34 | import { lineDiff, parseJson, sameDoc, stringify, type Json } from "../render/json-doc.ts"; | |
| 41 | 35 | import { findSchema, validate, type FoundSchema } from "../render/json-schema.ts"; |
| 42 | 36 | import { localScope, type RepoScope } from "../scope.ts"; |
| 43 | 37 | import { btn, field, icon } from "../ui.ts"; |
| @@ -70,7 +64,7 @@ function configContext(ctx: Ctx, ref: string, path: string): RepoScope | Respons | ||
| 70 | 64 | const repo = loadRepo(ctx); |
| 71 | 65 | if (!repo) return notFoundPage(ctx); |
| 72 | 66 | if (!ctx.auth.user) return signInFirst(ctx); |
| 73 | if (!isSafeRef(ref) || !isSafeTreePath(path) || !isFormJson(path)) return notFoundPage(ctx); | |
| 67 | if (!isSafeRef(ref) || !isSafeTreePath(path) || !isFormConfig(path)) return notFoundPage(ctx); | |
| 74 | 68 | const scope = localScope(ctx, repo); |
| 75 | 69 | // A mirror you cannot push to proposes through its origin: the source editor's card. |
| 76 | 70 | if (scope.propose !== undefined) |
| @@ -210,10 +204,10 @@ export async function configPage(ctx: Ctx): Promise<Response> { | ||
| 210 | 204 | if (blob.value.kind !== "text" || blob.value.size > MAX_FORM_BYTES) |
| 211 | 205 | return tooBig("This file is too large to edit as a form. Edit it as text, or with git."); |
| 212 | 206 | const text = new TextDecoder().decode(blob.value.body); |
| 213 | const parsed = parseJson(text); | |
| 207 | const parsed = parseConfig(path, text); | |
| 214 | 208 | if (!parsed.ok) { |
| 215 | 209 | const e = parsed.error; |
| 216 | return tooBig(`Not valid JSON: ${e.message} at line ${e.line}, column ${e.column}. Fix it in the source first.`); | |
| 210 | return tooBig(`Not valid ${languageOf(path)}: ${e.message} at line ${e.line}, column ${e.column}. Fix it in the source first.`); | |
| 217 | 211 | } |
| 218 | 212 | if (countFields(parsed.value) > MAX_FIELDS) |
| 219 | 213 | return tooBig(`This file has more than ${MAX_FIELDS.toLocaleString()} values, too many for a form. Edit it as text.`); |
| @@ -257,7 +251,12 @@ export async function configSubmit(ctx: Ctx): Promise<Response> { | ||
| 257 | 251 | if (read === undefined) return flashTo(ctx, blobHref(scope, ref, path), { tone: "danger", message: "That form could not be read. Open the file again." }); |
| 258 | 252 | const current = await readText(scope, ref, path, MAX_FORM_BYTES); |
| 259 | 253 | if (current === undefined) return notFoundPage(ctx); |
| 260 | const original = parseJson(current); | |
| 254 | const original = parseConfig(path, current); | |
| 255 | if (!original.ok) | |
| 256 | return flashTo(ctx, blobHref(scope, ref, path, "?view=source"), { | |
| 257 | tone: "danger", | |
| 258 | message: `${path} is no longer valid ${languageOf(path)} on ${ref}. Fix it in the source first.`, | |
| 259 | }); | |
| 261 | 260 | const found = await schemaFor(scope, ref, path, read.doc); |
| 262 | 261 | let state: FormState = { ref, path, base: formValue(form, "base"), doc: read.doc, found }; |
| 263 | 262 | |
| @@ -285,10 +284,21 @@ export async function configSubmit(ctx: Ctx): Promise<Response> { | ||
| 285 | 284 | flash: { tone: "danger", message: `${path} changed on ${ref} since you opened it. Check your values, then review again.` }, |
| 286 | 285 | }); |
| 287 | 286 | } |
| 288 | if (original.ok && sameDoc(original.value, read.doc)) | |
| 287 | if (sameDoc(original.value, read.doc)) | |
| 289 | 288 | return formPage(ctx, scope, { ...state, flash: { tone: "success", message: "Nothing changed, so there is nothing to save." } }); |
| 290 | 289 | |
| 291 | const after = stringify(read.doc, detectStyle(current)); | |
| 290 | const written = writeConfig(path, current, original.value, read.doc); | |
| 291 | // What is written must read back as exactly the form's document. | |
| 292 | const check = written.ok ? parseConfig(path, written.text) : undefined; | |
| 293 | if (!written.ok || check === undefined || !check.ok || !sameDoc(check.value, read.doc)) | |
| 294 | return formPage(ctx, scope, { | |
| 295 | ...state, | |
| 296 | flash: { | |
| 297 | tone: "danger", | |
| 298 | message: written.ok ? "That change could not be written back safely. Edit the file as text." : written.message, | |
| 299 | }, | |
| 300 | }); | |
| 301 | const after = written.text; | |
| 292 | 302 | if (step !== "save") return reviewPage(ctx, scope, state, { before: current, after }); |
| 293 | 303 | |
| 294 | 304 | const subject = formValue(form, "message").trim(); |
src/web/render/config-diff.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * What changed between two config documents, as edits at paths. A format | |
| 3 | * that keeps comments (YAML, TOML) applies these to its own parsed document | |
| 4 | * instead of writing the whole file again, so a line nobody touched stays | |
| 5 | * byte for byte as it was. | |
| 6 | * | |
| 7 | * Key order is not an edit: the form never reorders keys, and a format that | |
| 8 | * keeps the file's order keeps it. | |
| 9 | */ | |
| 10 | ||
| 11 | import { stringify, type Json } from "./json-doc.ts"; | |
| 12 | ||
| 13 | /** Keys and array indexes from the document's root to a value. */ | |
| 14 | export type Path = readonly (string | number)[]; | |
| 15 | ||
| 16 | export type Edit = | |
| 17 | /** Put `value` at `path`: replace it, add the key, or (index == length) append. */ | |
| 18 | | { readonly op: "set"; readonly path: Path; readonly value: Json } | |
| 19 | /** Remove the key, or the array item (later items shift down). */ | |
| 20 | | { readonly op: "remove"; readonly path: Path }; | |
| 21 | ||
| 22 | /** The edits that turn `before` into `after`, in an order that is safe to apply one by one. */ | |
| 23 | export function editsBetween(before: Json, after: Json, path: Path = []): Edit[] { | |
| 24 | if (before.t === "object" && after.t === "object") { | |
| 25 | const out: Edit[] = []; | |
| 26 | const kept = new Map(after.entries); | |
| 27 | for (const [k] of before.entries) if (!kept.has(k)) out.push({ op: "remove", path: [...path, k] }); | |
| 28 | const had = new Map(before.entries); | |
| 29 | for (const [k, v] of after.entries) { | |
| 30 | const old = had.get(k); | |
| 31 | if (old === undefined) out.push({ op: "set", path: [...path, k], value: v }); | |
| 32 | else out.push(...editsBetween(old, v, [...path, k])); | |
| 33 | } | |
| 34 | return out; | |
| 35 | } | |
| 36 | if (before.t === "array" && after.t === "array") { | |
| 37 | const out: Edit[] = []; | |
| 38 | const common = Math.min(before.items.length, after.items.length); | |
| 39 | for (let i = 0; i < common; i++) out.push(...editsBetween(before.items[i]!, after.items[i]!, [...path, i])); | |
| 40 | // Highest first, so each index is still the one that was meant. | |
| 41 | for (let i = before.items.length - 1; i >= common; i--) out.push({ op: "remove", path: [...path, i] }); | |
| 42 | for (let i = common; i < after.items.length; i++) out.push({ op: "set", path: [...path, i], value: after.items[i]! }); | |
| 43 | return out; | |
| 44 | } | |
| 45 | return stringify(before) === stringify(after) ? [] : [{ op: "set", path, value: after }]; | |
| 46 | } | |
| 47 | ||
| 48 | /** A value as plain data, for a library that takes plain data. Numbers past 2^53 stay exact as bigint. */ | |
| 49 | export function plainOf(v: Json): unknown { | |
| 50 | switch (v.t) { | |
| 51 | case "string": | |
| 52 | return v.v; | |
| 53 | case "number": { | |
| 54 | const n = Number(v.v); | |
| 55 | return /^-?\d+$/.test(v.v) && !Number.isSafeInteger(n) ? BigInt(v.v) : n; | |
| 56 | } | |
| 57 | case "boolean": | |
| 58 | return v.v; | |
| 59 | case "null": | |
| 60 | return null; | |
| 61 | case "array": | |
| 62 | return v.items.map(plainOf); | |
| 63 | case "object": | |
| 64 | return Object.fromEntries(v.entries.map(([k, x]) => [k, plainOf(x)])); | |
| 65 | } | |
| 66 | } |
src/web/render/config-format.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * Which config files open as a form, and how each is read and written back. | |
| 3 | * The form holds a `Json` whatever the file's language; this is the one | |
| 4 | * place that knows the language. JSON is rewritten whole in its own style; | |
| 5 | * YAML and TOML are patched, so their comments survive. | |
| 6 | */ | |
| 7 | ||
| 8 | import { detectStyle, parseJson, stringify, type Json, type Parsed } from "./json-doc.ts"; | |
| 9 | import { parseToml, writeToml } from "./toml-config.ts"; | |
| 10 | import { parseYaml, writeYaml } from "./yaml-config.ts"; | |
| 11 | ||
| 12 | export type ConfigFormat = "json" | "yaml" | "toml"; | |
| 13 | ||
| 14 | export function formatOf(path: string): ConfigFormat | undefined { | |
| 15 | if (/\.json$/i.test(path)) return "json"; | |
| 16 | if (/\.ya?ml$/i.test(path)) return "yaml"; | |
| 17 | if (/\.toml$/i.test(path)) return "toml"; | |
| 18 | return undefined; | |
| 19 | } | |
| 20 | ||
| 21 | /** Whether the file opens as a form. */ | |
| 22 | export const isFormConfig = (path: string): boolean => formatOf(path) !== undefined; | |
| 23 | ||
| 24 | const LANGUAGE: Readonly<Record<ConfigFormat, string>> = { json: "JSON", yaml: "YAML", toml: "TOML" }; | |
| 25 | ||
| 26 | /** The file's language, for messages: "Not valid YAML". Anything else reads as JSON. */ | |
| 27 | export const languageOf = (path: string): string => LANGUAGE[formatOf(path) ?? "json"]; | |
| 28 | ||
| 29 | /** The file's text as a document, or why it is not one. */ | |
| 30 | export function parseConfig(path: string, text: string): Parsed { | |
| 31 | switch (formatOf(path)) { | |
| 32 | case "yaml": | |
| 33 | return parseYaml(text); | |
| 34 | case "toml": | |
| 35 | return parseToml(text); | |
| 36 | default: | |
| 37 | return parseJson(text); | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | export type Written = { readonly ok: true; readonly text: string } | { readonly ok: false; readonly message: string }; | |
| 42 | ||
| 43 | /** The file's new text: `original`, with `after` (which it parsed to `before`) written in. */ | |
| 44 | export function writeConfig(path: string, original: string, before: Json, after: Json): Written { | |
| 45 | switch (formatOf(path)) { | |
| 46 | case "yaml": | |
| 47 | return writeYaml(original, before, after); | |
| 48 | case "toml": | |
| 49 | return writeToml(original, before, after); | |
| 50 | default: | |
| 51 | return { ok: true, text: stringify(after, detectStyle(original)) }; | |
| 52 | } | |
| 53 | } |
src/web/render/config.ts +11 −13
| @@ -1,19 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | * A JSON config file on the blob page: its values as a form, read-only, with | |
| 3 | * "Edit as form" one click away (`pages/config.ts` is the editor, and draws | |
| 4 | * the schema the file names). A file that is not valid JSON says why, at | |
| 5 | * which line, above its text; Source keeps its highlighted view. | |
| 2 | * A JSON, YAML or TOML config file on the blob page: its values as a form, | |
| 3 | * read-only, with "Edit as form" one click away (`pages/config.ts` is the | |
| 4 | * editor, and draws the schema the file names). A file that is not valid says | |
| 5 | * why, at which line, above its text; Source keeps its highlighted view. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { html, type Html } from "../html.ts"; |
| 9 | 9 | import { btn } from "../ui.ts"; |
| 10 | 10 | import { configFields, countFields, MAX_FIELDS } from "./config-form.ts"; |
| 11 | import { parseJson } from "./json-doc.ts"; | |
| 11 | import { languageOf, parseConfig } from "./config-format.ts"; | |
| 12 | 12 | import type { Rendered, RenderInput } from "./renderers.ts"; |
| 13 | 13 | |
| 14 | /** Only JSON draws as a form; YAML, TOML and the rest keep their source. */ | |
| 15 | export const isFormJson = (path: string): boolean => /\.json$/i.test(path); | |
| 16 | ||
| 17 | 14 | /** |
| 18 | 15 | * `/:owner/:name/media/:ref/*path` → `/:owner/:name/config/:ref/*path`: the |
| 19 | 16 | * ref is encoded whole, so the last `/media/` before it is the route's. A |
| @@ -26,11 +23,11 @@ function editHref(input: RenderInput): string | undefined { | ||
| 26 | 23 | return `${head.slice(0, cut)}/config/${head.slice(cut + "/media/".length)}/${input.path}`; |
| 27 | 24 | } |
| 28 | 25 | |
| 29 | function brokenJson(text: string, line: number, column: number, message: string): Html { | |
| 26 | function brokenConfig(language: string, text: string, line: number, column: number, message: string): Html { | |
| 30 | 27 | const lines = text.split("\n"); |
| 31 | 28 | return html`<div class="card" role="alert"> |
| 32 | <p><strong>Not valid JSON:</strong> ${message} at line ${String(line)}, column ${String(column)}.</p> | |
| 33 | <p class="muted small">The form needs valid JSON. Source shows the file highlighted.</p> | |
| 29 | <p><strong>Not valid ${language}:</strong> ${message} at line ${String(line)}, column ${String(column)}.</p> | |
| 30 | <p class="muted small">The form needs valid ${language}. Source shows the file highlighted.</p> | |
| 34 | 31 | </div> |
| 35 | 32 | <pre class="card mono">${lines.map((l, i) => (i + 1 === line ? html`<mark>${l}</mark>\n` : `${l}\n`))}</pre>`; |
| 36 | 33 | } |
| @@ -38,10 +35,11 @@ function brokenJson(text: string, line: number, column: number, message: string) | ||
| 38 | 35 | export function config(input: RenderInput): Rendered { |
| 39 | 36 | const text = new TextDecoder().decode(input.body); |
| 40 | 37 | const lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length; |
| 41 | const parsed = parseJson(text); | |
| 38 | const language = languageOf(input.path); | |
| 39 | const parsed = parseConfig(input.path, text); | |
| 42 | 40 | if (!parsed.ok) { |
| 43 | 41 | const e = parsed.error; |
| 44 | return { lines, content: brokenJson(text, e.line, e.column, e.message) }; | |
| 42 | return { lines, content: brokenConfig(language, text, e.line, e.column, e.message) }; | |
| 45 | 43 | } |
| 46 | 44 | if (countFields(parsed.value) > MAX_FIELDS) |
| 47 | 45 | return { |
src/web/render/json-schema.ts +7 −6
| @@ -13,7 +13,8 @@ | ||
| 13 | 13 | * browser; the server never runs a regular expression a repository wrote. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { NUMBER, getAt, parseJson, pointer, splitPointer, type Json } from "./json-doc.ts"; | |
| 16 | import { isFormConfig, parseConfig } from "./config-format.ts"; | |
| 17 | import { NUMBER, getAt, pointer, splitPointer, type Json } from "./json-doc.ts"; | |
| 17 | 18 | |
| 18 | 19 | export interface Schema { |
| 19 | 20 | readonly type?: string | readonly string[]; |
| @@ -204,7 +205,7 @@ export async function findSchema( | ||
| 204 | 205 | if (found !== undefined) return found; |
| 205 | 206 | } |
| 206 | 207 | |
| 207 | const name = path.slice(dir.length).replace(/\.json$/i, ""); | |
| 208 | const name = path.slice(dir.length).replace(/\.(?:json|ya?ml|toml)$/i, ""); | |
| 208 | 209 | const sibling = await tryRead(`${dir}${name}.schema.json`); |
| 209 | 210 | if (sibling !== undefined) return sibling; |
| 210 | 211 | |
| @@ -221,8 +222,8 @@ export async function findSchema( | ||
| 221 | 222 | } |
| 222 | 223 | |
| 223 | 224 | /** |
| 224 | * Why a JSON file's new text breaks the schema it names, as one sentence, or | |
| 225 | * undefined when it keeps it (or is not JSON, or has no schema). The | |
| 225 | * Why a config file's new text breaks the schema it names, as one sentence, or | |
| 226 | * undefined when it keeps it (or is not a config file, or has no schema). The | |
| 226 | 227 | * terminal asks this before a commit, as the form does before a save. |
| 227 | 228 | */ |
| 228 | 229 | export async function schemaRefusal( |
| @@ -230,8 +231,8 @@ export async function schemaRefusal( | ||
| 230 | 231 | text: string, |
| 231 | 232 | read: (path: string) => Promise<string | undefined>, |
| 232 | 233 | ): Promise<string | undefined> { |
| 233 | if (!/\.json$/i.test(path)) return undefined; | |
| 234 | const parsed = parseJson(text); | |
| 234 | if (!isFormConfig(path)) return undefined; | |
| 235 | const parsed = parseConfig(path, text); | |
| 235 | 236 | if (!parsed.ok) return undefined; |
| 236 | 237 | const found = await findSchema(path, parsed.value, read); |
| 237 | 238 | const errors = [...validate(parsed.value, found)]; |
src/web/render/renderers.ts +5 −4
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | import type { Html } from "../html.ts"; |
| 8 | 8 | import type { Blob } from "../../view/source.ts"; |
| 9 | 9 | import { audio } from "./audio.ts"; |
| 10 | import { config, isFormJson } from "./config.ts"; | |
| 10 | import { config } from "./config.ts"; | |
| 11 | import { isFormConfig } from "./config-format.ts"; | |
| 11 | 12 | import { csv } from "./csv.ts"; |
| 12 | 13 | import { image } from "./image.ts"; |
| 13 | 14 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| @@ -85,9 +86,9 @@ export function rendererFor( | ||
| 85 | 86 | : claimedMedia(path)?.kind; |
| 86 | 87 | if (kind === undefined || blob.size > RENDER_CAP[kind]) return undefined; |
| 87 | 88 | if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined; |
| 88 | // A kind may draw only some of its files: `config` draws JSON as a form, | |
| 89 | // and leaves YAML and TOML their source. | |
| 90 | const render = kind === "config" && !isFormJson(path) ? undefined : renderers[kind]; | |
| 89 | // A kind may draw only some of its files: `config` draws JSON, YAML and | |
| 90 | // TOML as a form, and leaves the rest (INI, .env …) their source. | |
| 91 | const render = kind === "config" && !isFormConfig(path) ? undefined : renderers[kind]; | |
| 91 | 92 | if (render === undefined) return undefined; |
| 92 | 93 | return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body }; |
| 93 | 94 | } |
src/web/render/toml-config.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * TOML as the config form holds it (#7337): `toml-patch` reads it into a | |
| 3 | * `Json`, and a save patches the original text with only the values that | |
| 4 | * changed, so comments, table order and the spacing of every untouched line | |
| 5 | * stay as written. | |
| 6 | * | |
| 7 | * Dates and times have no `Json` type: they show as the text they were | |
| 8 | * written as, and writing text that parses as a date back over one keeps it | |
| 9 | * a date. TOML has no null; a form that would write one is refused. | |
| 10 | */ | |
| 11 | ||
| 12 | import { parse, patch } from "./vendor/toml-patch.mjs"; | |
| 13 | import { editsBetween, plainOf } from "./config-diff.ts"; | |
| 14 | import type { Json, ParseError } from "./json-doc.ts"; | |
| 15 | ||
| 16 | export type TomlParsed = | |
| 17 | | { readonly ok: true; readonly value: Json } | |
| 18 | | { readonly ok: false; readonly error: ParseError }; | |
| 19 | ||
| 20 | const MAX_DEPTH = 64; | |
| 21 | ||
| 22 | /** A date, time or date-time: the parser's own subclasses of `Date`. */ | |
| 23 | const isDate = (v: unknown): v is Date => v instanceof Date; | |
| 24 | ||
| 25 | function toJson(v: unknown, depth: number): Json { | |
| 26 | if (depth > MAX_DEPTH) throw new RangeError("nested too deeply"); | |
| 27 | if (typeof v === "string") return { t: "string", v }; | |
| 28 | if (typeof v === "boolean") return { t: "boolean", v }; | |
| 29 | // inf and nan have no JSON spelling: shown as text, which only an edit changes. | |
| 30 | if (typeof v === "number") return Number.isFinite(v) ? { t: "number", v: String(v) } : { t: "string", v: v > 0 ? "inf" : v < 0 ? "-inf" : "nan" }; | |
| 31 | if (typeof v === "bigint") return { t: "number", v: String(v) }; | |
| 32 | if (isDate(v)) return { t: "string", v: v.toISOString() }; | |
| 33 | if (Array.isArray(v)) return { t: "array", items: v.map((x) => toJson(x, depth + 1)) }; | |
| 34 | if (v !== null && typeof v === "object") | |
| 35 | return { t: "object", entries: Object.entries(v).map(([k, x]) => [k, toJson(x, depth + 1)] as const) }; | |
| 36 | return { t: "null" }; | |
| 37 | } | |
| 38 | ||
| 39 | const reason = (e: unknown): { message: string; line: number; column: number } => { | |
| 40 | const err = e as { message?: string; line?: number; column?: number }; | |
| 41 | const lines = String(err.message ?? "invalid TOML").split("\n"); | |
| 42 | return { message: lines[lines.length - 1]!, line: err.line ?? 1, column: (err.column ?? 0) + 1 }; | |
| 43 | }; | |
| 44 | ||
| 45 | export function parseToml(text: string): TomlParsed { | |
| 46 | try { | |
| 47 | return { ok: true, value: toJson(parse(text), 0) }; | |
| 48 | } catch (e) { | |
| 49 | return { ok: false, error: reason(e) }; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | export type TomlWritten = { readonly ok: true; readonly text: string } | { readonly ok: false; readonly message: string }; | |
| 54 | ||
| 55 | const hasNull = (v: Json): boolean => | |
| 56 | v.t === "null" || (v.t === "array" && v.items.some(hasNull)) || (v.t === "object" && v.entries.some(([, x]) => hasNull(x))); | |
| 57 | ||
| 58 | type Container = Record<string | number, unknown>; | |
| 59 | ||
| 60 | const isContainer = (v: unknown): v is Container => v !== null && typeof v === "object"; | |
| 61 | ||
| 62 | /** The table or array a path's parent names, or undefined when the tree has no such place. */ | |
| 63 | function parentOf(tree: unknown, path: readonly (string | number)[]): Container | undefined { | |
| 64 | let at: unknown = tree; | |
| 65 | for (const k of path.slice(0, -1)) { | |
| 66 | if (!isContainer(at)) return undefined; | |
| 67 | at = at[k]; | |
| 68 | } | |
| 69 | return isContainer(at) ? at : undefined; | |
| 70 | } | |
| 71 | ||
| 72 | /** `text` as a TOML date, time or date-time, or undefined when it does not read as one. */ | |
| 73 | function asDate(text: string): Date | undefined { | |
| 74 | try { | |
| 75 | const probe: unknown = parse(`v = ${text}`)["v"]; | |
| 76 | return isDate(probe) ? probe : undefined; | |
| 77 | } catch { | |
| 78 | // Text that is not a date is the common case, not a fault: it stays text. | |
| 79 | return undefined; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | const UNWRITABLE = "This change cannot be written into the TOML without losing its structure. Edit it as text."; | |
| 84 | ||
| 85 | /** `original` with the edits from `before` to `after` patched in. */ | |
| 86 | export function writeToml(original: string, before: Json, after: Json): TomlWritten { | |
| 87 | const edits = editsBetween(before, after); | |
| 88 | if (edits.length === 0) return { ok: true, text: original }; | |
| 89 | if (hasNull(after)) return { ok: false, message: "TOML has no null, so a field cannot be left empty. Give it a value, or edit the file as text." }; | |
| 90 | try { | |
| 91 | const tree = parse(original); | |
| 92 | for (const edit of edits) { | |
| 93 | const parent = parentOf(tree, edit.path); | |
| 94 | const key = edit.path[edit.path.length - 1]; | |
| 95 | if (parent === undefined || key === undefined) return { ok: false, message: UNWRITABLE }; | |
| 96 | if (edit.op === "remove") { | |
| 97 | if (Array.isArray(parent) && typeof key === "number") parent.splice(key, 1); | |
| 98 | else delete parent[key]; | |
| 99 | continue; | |
| 100 | } | |
| 101 | let value = plainOf(edit.value); | |
| 102 | // Text over a date stays a date, when it still reads as one. | |
| 103 | if (isDate(parent[key]) && typeof value === "string") value = asDate(value) ?? value; | |
| 104 | parent[key] = value; | |
| 105 | } | |
| 106 | return { ok: true, text: patch(original, tree) }; | |
| 107 | } catch { | |
| 108 | return { ok: false, message: UNWRITABLE }; | |
| 109 | } | |
| 110 | } |
src/web/render/vendor/TOML-PATCH-LICENSE added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2019–2025 Tim Hall | |
| 4 | Copyright (c) 2025 Martin Leduc | |
| 5 | ||
| 6 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 7 | of this software and associated documentation files (the "Software"), to deal | |
| 8 | in the Software without restriction, including without limitation the rights | |
| 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 10 | copies of the Software, and to permit persons to whom the Software is | |
| 11 | furnished to do so, subject to the following conditions: | |
| 12 | ||
| 13 | The above copyright notice and this permission notice shall be included in all | |
| 14 | copies or substantial portions of the Software. | |
| 15 | ||
| 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 22 | SOFTWARE. |
src/web/render/vendor/YAML-LICENSE added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | Copyright Eemeli Aro <[email protected]> | |
| 2 | ||
| 3 | Permission to use, copy, modify, and/or distribute this software for any purpose | |
| 4 | with or without fee is hereby granted, provided that the above copyright notice | |
| 5 | and this permission notice appear in all copies. | |
| 6 | ||
| 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH | |
| 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND | |
| 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, | |
| 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS | |
| 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER | |
| 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF | |
| 13 | THIS SOFTWARE. |
src/web/render/vendor/toml-patch.d.mts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | /** The part of `@decimalturn/toml-patch` the config form uses (`render/toml-config.ts`). */ | |
| 2 | ||
| 3 | /** A TOML document as plain data; dates come back as `Date` subclasses. */ | |
| 4 | export function parse(source: string): Record<string, unknown>; | |
| 5 | /** `source` with only what differs in `updated` rewritten: comments and layout stay. */ | |
| 6 | export function patch(source: string, updated: unknown): string; |
src/web/render/vendor/toml-patch.mjs added +6844
Diff not shown — 6,844 changed lines (limit 5,000). +6844
View filesrc/web/render/vendor/yaml.d.mts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** The part of the `yaml` package the config form uses (`render/yaml-config.ts`). */ | |
| 2 | ||
| 3 | export interface Node { | |
| 4 | /** Comment lines above the node. */ | |
| 5 | commentBefore?: string | null; | |
| 6 | comment?: string | null; | |
| 7 | } | |
| 8 | export interface Scalar extends Node { | |
| 9 | value: unknown; | |
| 10 | source?: string; | |
| 11 | } | |
| 12 | export interface Pair { | |
| 13 | key: unknown; | |
| 14 | value: unknown; | |
| 15 | } | |
| 16 | export interface YAMLMap extends Node { | |
| 17 | items: Pair[]; | |
| 18 | } | |
| 19 | export interface YAMLSeq extends Node { | |
| 20 | items: unknown[]; | |
| 21 | } | |
| 22 | export interface Alias extends Node { | |
| 23 | resolve(doc: Document): unknown; | |
| 24 | } | |
| 25 | export interface YAMLError { | |
| 26 | message: string; | |
| 27 | linePos?: [{ line: number; col: number }, { line: number; col: number }?]; | |
| 28 | } | |
| 29 | export interface Document { | |
| 30 | contents: unknown; | |
| 31 | /** Offsets into the source: start, end of the value, end of the document. */ | |
| 32 | range: [number, number, number]; | |
| 33 | errors: YAMLError[]; | |
| 34 | warnings: YAMLError[]; | |
| 35 | getIn(path: readonly (string | number)[], keepScalar?: boolean): unknown; | |
| 36 | setIn(path: readonly (string | number)[], value: unknown): void; | |
| 37 | deleteIn(path: readonly (string | number)[]): boolean; | |
| 38 | toString(options?: { lineWidth?: number; indent?: number; indentSeq?: boolean }): string; | |
| 39 | } | |
| 40 | export function parseAllDocuments(source: string, options?: { prettyErrors?: boolean; uniqueKeys?: boolean; maxAliasCount?: number }): Document[]; | |
| 41 | export function isScalar(node: unknown): node is Scalar; | |
| 42 | export function isMap(node: unknown): node is YAMLMap; | |
| 43 | export function isSeq(node: unknown): node is YAMLSeq; | |
| 44 | export function isAlias(node: unknown): node is Alias; |
src/web/render/vendor/yaml.mjs added +6516
Diff not shown — 6,516 changed lines (limit 5,000). +6516
View filesrc/web/render/yaml-config.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * YAML as the config form holds it (#7337): the `yaml` package's Document | |
| 3 | * API reads it into a `Json`, and a save edits that same Document, so | |
| 4 | * comments, anchors, quoting and key order survive and an untouched line is | |
| 5 | * written byte for byte as it was. | |
| 6 | * | |
| 7 | * A file of several documents (`---`) reads as one object whose keys are | |
| 8 | * "Document 1", "Document 2" …, so the form draws one fieldset each. An | |
| 9 | * alias reads as the value it points at; changing a value that only an | |
| 10 | * alias holds is refused rather than guessed. | |
| 11 | */ | |
| 12 | ||
| 13 | import { isAlias, isMap, isScalar, isSeq, parseAllDocuments, type Document } from "./vendor/yaml.mjs"; | |
| 14 | import { editsBetween, plainOf, type Path } from "./config-diff.ts"; | |
| 15 | import { MAX_FIELDS } from "./config-form.ts"; | |
| 16 | import type { Json, ParseError } from "./json-doc.ts"; | |
| 17 | ||
| 18 | export type YamlParsed = | |
| 19 | | { readonly ok: true; readonly value: Json } | |
| 20 | | { readonly ok: false; readonly error: ParseError }; | |
| 21 | ||
| 22 | const MAX_DEPTH = 64; | |
| 23 | ||
| 24 | class Unsupported extends Error {} | |
| 25 | ||
| 26 | const docKey = (i: number): string => `Document ${i + 1}`; | |
| 27 | ||
| 28 | function toJson(doc: Document, node: unknown, depth: number, budget: { left: number }): Json { | |
| 29 | if (depth > MAX_DEPTH) throw new Unsupported("nested too deeply"); | |
| 30 | // Aliases can fan out (a few lines of YAML naming a billion values). | |
| 31 | if (--budget.left < 0) throw new Unsupported(`more than ${MAX_FIELDS.toLocaleString()} values`); | |
| 32 | if (node === null || node === undefined) return { t: "null" }; | |
| 33 | if (isAlias(node)) return toJson(doc, node.resolve(doc), depth + 1, budget); | |
| 34 | if (isScalar(node)) { | |
| 35 | const v = node.value; | |
| 36 | if (v === null || v === undefined) return { t: "null" }; | |
| 37 | if (typeof v === "string") return { t: "string", v }; | |
| 38 | if (typeof v === "boolean") return { t: "boolean", v }; | |
| 39 | if (typeof v === "number" && Number.isFinite(v)) return { t: "number", v: String(v) }; | |
| 40 | if (typeof v === "bigint") return { t: "number", v: String(v) }; | |
| 41 | // .inf, .nan, binary and the like: shown as the text they were written as. | |
| 42 | return { t: "string", v: node.source ?? String(v) }; | |
| 43 | } | |
| 44 | if (isMap(node)) { | |
| 45 | const entries: (readonly [string, Json])[] = []; | |
| 46 | for (const pair of node.items) { | |
| 47 | if (!isScalar(pair.key) || typeof pair.key.value !== "string") | |
| 48 | throw new Unsupported("a key that is not plain text"); | |
| 49 | entries.push([pair.key.value, toJson(doc, pair.value, depth + 1, budget)]); | |
| 50 | } | |
| 51 | return { t: "object", entries }; | |
| 52 | } | |
| 53 | if (isSeq(node)) return { t: "array", items: node.items.map((x) => toJson(doc, x, depth + 1, budget)) }; | |
| 54 | throw new Unsupported("a value of a kind the form does not hold"); | |
| 55 | } | |
| 56 | ||
| 57 | /** The first problem in any document, or undefined. */ | |
| 58 | function firstError(docs: readonly Document[]): ParseError | undefined { | |
| 59 | for (const d of docs) { | |
| 60 | const e = d.errors[0]; | |
| 61 | if (e === undefined) continue; | |
| 62 | const at = e.linePos?.[0]; | |
| 63 | return { message: e.message.split("\n")[0]!.replace(/ at line \d+, column \d+:?$/, ""), line: at?.line ?? 1, column: at?.col ?? 1 }; | |
| 64 | } | |
| 65 | return undefined; | |
| 66 | } | |
| 67 | ||
| 68 | export function parseYaml(text: string): YamlParsed { | |
| 69 | const docs = parseAllDocuments(text, { prettyErrors: true }); | |
| 70 | const error = firstError(docs); | |
| 71 | if (error !== undefined) return { ok: false, error }; | |
| 72 | try { | |
| 73 | const budget = { left: MAX_FIELDS * 2 }; | |
| 74 | const values = docs.map((d) => toJson(d, d.contents, 0, budget)); | |
| 75 | if (values.length === 1) return { ok: true, value: values[0]! }; | |
| 76 | return { ok: true, value: { t: "object", entries: values.map((v, i) => [docKey(i), v] as const) } }; | |
| 77 | } catch (e) { | |
| 78 | if (!(e instanceof Unsupported)) throw e; | |
| 79 | return { ok: false, error: { message: `The form cannot hold ${e.message}`, line: 1, column: 1 } }; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /** How the file indents, so the rewritten lines match the untouched ones. */ | |
| 84 | function layout(text: string): { indent: number; indentSeq: boolean } { | |
| 85 | let indent = 0; | |
| 86 | for (const line of text.split("\n")) { | |
| 87 | const m = /^( +)[^\s#]/.exec(line); | |
| 88 | if (m !== null && (indent === 0 || m[1]!.length < indent)) indent = m[1]!.length; | |
| 89 | } | |
| 90 | // `key:` then `- item` at the key's own column. | |
| 91 | const flush = /^( *)[^\s#-][^\n]*:[ \t]*\r?\n\1- /m.test(text); | |
| 92 | const deep = /^( *)[^\s#-][^\n]*:[ \t]*\r?\n\1 +- /m.test(text); | |
| 93 | return { indent: Math.min(Math.max(indent, 2), 8), indentSeq: !(flush && !deep) }; | |
| 94 | } | |
| 95 | ||
| 96 | export type YamlWritten = { readonly ok: true; readonly text: string } | { readonly ok: false; readonly message: string }; | |
| 97 | ||
| 98 | /** `original` with `after` written into it; only the documents that changed are rewritten. */ | |
| 99 | export function writeYaml(original: string, before: Json, after: Json): YamlWritten { | |
| 100 | const docs = parseAllDocuments(original); | |
| 101 | const multi = docs.length > 1; | |
| 102 | const edits = editsBetween(before, after); | |
| 103 | if (edits.length === 0) return { ok: true, text: original }; | |
| 104 | const changed = new Set<number>(); | |
| 105 | try { | |
| 106 | for (const edit of edits) { | |
| 107 | let path: Path = edit.path; | |
| 108 | let index = 0; | |
| 109 | if (multi) { | |
| 110 | const [head, ...rest] = path; | |
| 111 | index = typeof head === "string" ? Number(/^Document (\d+)$/.exec(head)?.[1]) - 1 : NaN; | |
| 112 | if (!(index >= 0 && index < docs.length) || rest.length === 0) throw new Unsupported("a whole document"); | |
| 113 | path = rest; | |
| 114 | } else if (path.length === 0) throw new Unsupported("the whole file"); | |
| 115 | const doc = docs[index]!; | |
| 116 | if (edit.op === "remove") doc.deleteIn(path); | |
| 117 | else doc.setIn(path, plainOf(edit.value)); | |
| 118 | changed.add(index); | |
| 119 | } | |
| 120 | } catch (e) { | |
| 121 | const why = e instanceof Unsupported ? e.message : "a value that comes from an alias or an unusual node"; | |
| 122 | return { ok: false, message: `This change cannot be written into the YAML without losing its structure (${why}). Edit it as text.` }; | |
| 123 | } | |
| 124 | const { indent, indentSeq } = layout(original); | |
| 125 | const eol = original.includes("\r\n") ? "\r\n" : "\n"; | |
| 126 | const cuts = docs.map((d, i) => (i === 0 ? 0 : d.range[0])); | |
| 127 | let out = ""; | |
| 128 | docs.forEach((d, i) => { | |
| 129 | const end = i + 1 < docs.length ? cuts[i + 1]! : original.length; | |
| 130 | out += changed.has(i) | |
| 131 | ? d.toString({ lineWidth: 0, indent, indentSeq }).replace(/\n/g, eol) | |
| 132 | : original.slice(cuts[i]!, end); | |
| 133 | }); | |
| 134 | if (!/\n$/.test(original)) out = out.replace(/\r?\n$/, ""); | |
| 135 | return { ok: true, text: out }; | |
| 136 | } |
test/config-form.test.ts +68 −1
| @@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { setRole } from "../src/actions/roles.ts"; |
| 14 | 14 | import { createSession } from "../src/core/sessions.ts"; |
| 15 | import { getUserByName } from "../src/core/users.ts"; | |
| 15 | 16 | import { getRepo, updateRepoSettings } from "../src/core/repos.ts"; |
| 16 | 17 | import { createAgent } from "../src/actions/crew.ts"; |
| 17 | 18 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; |
| @@ -25,7 +26,7 @@ import { localSource } from "../src/view/local.ts"; | ||
| 25 | 26 | import { applyOp, configFields, opValue, readValues } from "../src/web/render/config-form.ts"; |
| 26 | 27 | import { detectStyle, parseJson, sameDoc, stringify, type Json } from "../src/web/render/json-doc.ts"; |
| 27 | 28 | import { findSchema, globMatches, validate } from "../src/web/render/json-schema.ts"; |
| 28 | import { digestOf } from "../src/web/pages/config.ts"; | |
| 29 | import { digestOf, MAX_FORM_BYTES } from "../src/web/pages/config.ts"; | |
| 29 | 30 | import { seedAdmin } from "./helpers/admin.ts"; |
| 30 | 31 | import { signUpProven } from "./helpers/verified.ts"; |
| 31 | 32 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| @@ -173,6 +174,13 @@ describe("the config form on the web", () => { | ||
| 173 | 174 | let origin = ""; |
| 174 | 175 | let alice = ""; |
| 175 | 176 | let kim = ""; |
| 177 | const DEPLOY = "# how it runs\nname: lamp # the app\nbase: &b\n port: 80\nreplicas: 2\n"; | |
| 178 | // Hostile shapes a form must refuse, not expand: never past MAX_FORM_BYTES, so read only after the size check. | |
| 179 | const HOSTILE: Record<string, string> = { | |
| 180 | "flow.yml": `a: ${"[".repeat(10_000)}${"]".repeat(10_000)}\n`, | |
| 181 | "merge.yml": `a0: &a0 {x: 1}\n${Array.from({ length: 11 }, (_, i) => `a${i + 1}: &a${i + 1}\n <<: [${Array(9).fill(`*a${i}`).join(", ")}]`).join("\n")}\n`, | |
| 182 | "deep.toml": `a = ${"[".repeat(70)}${"]".repeat(70)}\n`, | |
| 183 | }; | |
| 176 | 184 | const SETTINGS = '{\n "name": "lamp",\n "port": 8080,\n "tags": [\n "a"\n ]\n}\n'; |
| 177 | 185 | |
| 178 | 186 | const get = (path: string, cookie = ""): Promise<Response> => |
| @@ -228,6 +236,8 @@ describe("the config form on the web", () => { | ||
| 228 | 236 | files: [ |
| 229 | 237 | { path: "settings.json", content: SETTINGS }, |
| 230 | 238 | { path: "broken.json", content: '{\n "a": 1,\n oops\n}\n' }, |
| 239 | { path: "deploy.yml", content: DEPLOY }, | |
| 240 | ...Object.entries(HOSTILE).map(([path, content]) => ({ path, content })), | |
| 231 | 241 | ], |
| 232 | 242 | }); |
| 233 | 243 | } |
| @@ -301,6 +311,63 @@ describe("the config form on the web", () => { | ||
| 301 | 311 | expect(await fileAt("alice", "lamp", "main", "settings.json")).toBe(current); |
| 302 | 312 | }); |
| 303 | 313 | |
| 314 | test("a YAML file opens as a form and a save keeps its comments and anchors", async () => { | |
| 315 | const page = await (await get("/alice/lamp/blob/main/deploy.yml")).text(); | |
| 316 | expect(page).toContain('class="card config-form"'); | |
| 317 | expect(page).toContain('href="/alice/lamp/config/main/deploy.yml"'); | |
| 318 | const form = await (await get("/alice/lamp/config/main/deploy.yml", alice)).text(); | |
| 319 | expect(form).toContain('name="v/replicas"'); | |
| 320 | // A plain form post: nothing here needs a script. | |
| 321 | expect(form).toContain('<form method="post" action="/alice/lamp/config" class="card config-form">'); | |
| 322 | const before = await commitCount("alice", "lamp"); | |
| 323 | const res = await post("/alice/lamp/config", { | |
| 324 | ref: "main", | |
| 325 | path: "deploy.yml", | |
| 326 | base: digestOf(DEPLOY), | |
| 327 | doc: '{"name":"lamp","base":{"port":80},"replicas":3}', | |
| 328 | step: "save", | |
| 329 | message: "Three replicas", | |
| 330 | }, alice); | |
| 331 | expect(res.status).toBe(303); | |
| 332 | expect(await commitCount("alice", "lamp")).toBe(before + 1); | |
| 333 | expect(await fileAt("alice", "lamp", "main", "deploy.yml")).toBe(DEPLOY.replace("replicas: 2", "replicas: 3")); | |
| 334 | }); | |
| 335 | ||
| 336 | test("hostile YAML and TOML fall back to the source on the page and are refused on submit", async () => { | |
| 337 | for (const [path, content] of Object.entries(HOSTILE)) { | |
| 338 | const res = await get(`/alice/lamp/config/main/${path}`, alice); | |
| 339 | expect(res.status).toBe(303); | |
| 340 | expect(res.headers.get("location")).toContain(`/alice/lamp/blob/main/${path}?view=source`); | |
| 341 | const before = await commitCount("alice", "lamp"); | |
| 342 | const sub = await post("/alice/lamp/config", { | |
| 343 | ref: "main", | |
| 344 | path, | |
| 345 | base: digestOf(content), | |
| 346 | doc: '{"a":1}', | |
| 347 | step: "save", | |
| 348 | message: "Nope", | |
| 349 | }, alice); | |
| 350 | expect(sub.status).toBe(303); | |
| 351 | expect(sub.headers.get("location")).toContain(`?view=source`); | |
| 352 | expect(await commitCount("alice", "lamp")).toBe(before); | |
| 353 | } | |
| 354 | }); | |
| 355 | ||
| 356 | test("a file over MAX_FORM_BYTES is refused before it is parsed, on the page and on submit", async () => { | |
| 357 | const big = `# ${"x".repeat(MAX_FORM_BYTES)}\na: 1\n`; | |
| 358 | const made = await commitToBranch(app, getUserByName(app.db, "alice")!, getRepo(app.db, "alice", "lamp")!, { | |
| 359 | message: "big", | |
| 360 | files: [{ path: "big.yml", content: big }], | |
| 361 | }); | |
| 362 | expect(made.ok).toBe(true); | |
| 363 | const page = await get("/alice/lamp/config/main/big.yml", alice); | |
| 364 | expect(page.status).toBe(303); | |
| 365 | expect(page.headers.get("location")).toContain("?view=source"); | |
| 366 | const sub = await post("/alice/lamp/config", { ref: "main", path: "big.yml", base: digestOf(big), doc: '{"a":2}', step: "save", message: "x" }, alice); | |
| 367 | expect(sub.status).not.toBe(200); | |
| 368 | expect(await fileAt("alice", "lamp", "main", "big.yml")).toBe(big); | |
| 369 | }); | |
| 370 | ||
| 304 | 371 | test("invalid JSON falls back to the source with the error and its line", async () => { |
| 305 | 372 | const blob = await (await get("/alice/lamp/blob/main/broken.json")).text(); |
| 306 | 373 | expect(blob).toContain("Not valid JSON"); |
test/config-yaml-toml.test.ts added +269
| @@ -0,0 +1,269 @@ | ||
| 1 | /** | |
| 2 | * YAML and TOML config files as a form (#7337): read into the same document | |
| 3 | * JSON uses, and written back with their comments, anchors and layout intact. | |
| 4 | */ | |
| 5 | ||
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { formatOf, isFormConfig, languageOf, parseConfig, writeConfig } from "../src/web/render/config-format.ts"; | |
| 8 | import { configFields } from "../src/web/render/config-form.ts"; | |
| 9 | import { getAt, updateAt, type Json } from "../src/web/render/json-doc.ts"; | |
| 10 | import { findSchema, schemaRefusal } from "../src/web/render/json-schema.ts"; | |
| 11 | import { rendererFor } from "../src/web/render/renderers.ts"; | |
| 12 | import { config } from "../src/web/render/config.ts"; | |
| 13 | ||
| 14 | const read = (path: string, text: string): Json => { | |
| 15 | const p = parseConfig(path, text); | |
| 16 | if (!p.ok) throw new Error(p.error.message); | |
| 17 | return p.value; | |
| 18 | }; | |
| 19 | ||
| 20 | /** Change the string at `at`, write the file back, and return its new text. */ | |
| 21 | const edit = (path: string, text: string, at: string, to: Json): string => { | |
| 22 | const before = read(path, text); | |
| 23 | const w = writeConfig(path, text, before, updateAt(before, at, () => to)); | |
| 24 | if (!w.ok) throw new Error(w.message); | |
| 25 | return w.text; | |
| 26 | }; | |
| 27 | ||
| 28 | const YAML = `# service settings | |
| 29 | name: forge # the app | |
| 30 | base: &base | |
| 31 | retries: 3 | |
| 32 | verbose: false | |
| 33 | child: | |
| 34 | <<: *base | |
| 35 | url: 'http://x' | |
| 36 | tags: | |
| 37 | - a | |
| 38 | - b # second | |
| 39 | servers: | |
| 40 | - host: one | |
| 41 | port: 80 | |
| 42 | - host: two | |
| 43 | port: 81 | |
| 44 | `; | |
| 45 | ||
| 46 | describe("which files open as a form", () => { | |
| 47 | test("JSON, YAML and TOML do; INI does not", () => { | |
| 48 | expect(["a.json", "a.yml", "a.YAML", "a.toml"].map(formatOf)).toEqual(["json", "yaml", "yaml", "toml"]); | |
| 49 | expect(isFormConfig("a.ini")).toBe(false); | |
| 50 | const body = new TextEncoder().encode("a: 1\n"); | |
| 51 | const blob = { kind: "text" as const, size: body.length, body } as never; | |
| 52 | expect(rendererFor("x/a.yml", blob)).toBeDefined(); | |
| 53 | expect(rendererFor("x/a.toml", blob)).toBeDefined(); | |
| 54 | expect(rendererFor("x/a.ini", blob)).toBeUndefined(); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("YAML", () => { | |
| 59 | test("reads values, aliases as what they point at, in the file's order", () => { | |
| 60 | const doc = read("a.yml", YAML); | |
| 61 | expect(getAt(doc, "/name")).toEqual({ t: "string", v: "forge" }); | |
| 62 | expect(getAt(doc, "/child/<</retries")).toEqual({ t: "number", v: "3" }); | |
| 63 | expect(getAt(doc, "/servers/1/port")).toEqual({ t: "number", v: "81" }); | |
| 64 | expect(doc.t === "object" && doc.entries.map(([k]) => k)).toEqual(["name", "base", "child", "tags", "servers"]); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a file nobody changed is written byte for byte", () => { | |
| 68 | const doc = read("a.yml", YAML); | |
| 69 | expect(writeConfig("a.yml", YAML, doc, doc)).toEqual({ ok: true, text: YAML }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("changing one value changes only that line, comments and anchors kept", () => { | |
| 73 | const next = edit("a.yml", YAML, "/servers/0/port", { t: "number", v: "8080" }); | |
| 74 | const was = YAML.split("\n"); | |
| 75 | const now = next.split("\n"); | |
| 76 | expect(now.length).toBe(was.length); | |
| 77 | const differing = now.filter((l, i) => l !== was[i]); | |
| 78 | expect(differing).toEqual([" port: 8080"]); | |
| 79 | expect(next).toContain("# service settings"); | |
| 80 | expect(next).toContain("base: &base"); | |
| 81 | expect(next).toContain("<<: *base"); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a changed string keeps its quotes and its trailing comment", () => { | |
| 85 | const next = edit("a.yml", YAML, "/name", { t: "string", v: "anvil" }); | |
| 86 | expect(next).toContain("name: anvil # the app"); | |
| 87 | expect(edit("a.yml", YAML, "/child/url", { t: "string", v: "http://y" })).toContain("url: 'http://y'"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("an added and a removed card are written", () => { | |
| 91 | const before = read("a.yml", YAML); | |
| 92 | const added = updateAt(before, "/servers", (s) => | |
| 93 | s.t === "array" | |
| 94 | ? { t: "array", items: [...s.items, { t: "object", entries: [["host", { t: "string", v: "three" }], ["port", { t: "number", v: "82" }]] }] } | |
| 95 | : s, | |
| 96 | ); | |
| 97 | const w = writeConfig("a.yml", YAML, before, added); | |
| 98 | expect(w.ok && w.text).toContain("host: three"); | |
| 99 | expect(w.ok && w.text).toContain("# second"); | |
| 100 | const removed = updateAt(before, "/servers", (s) => (s.t === "array" ? { t: "array", items: s.items.slice(0, 1) } : s)); | |
| 101 | const r = writeConfig("a.yml", YAML, before, removed); | |
| 102 | expect(r.ok && r.text).not.toContain("two"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a file that indents sequences flush is not reflowed", () => { | |
| 106 | // A mixed file cannot be kept (one setting per document); a consistent one is. | |
| 107 | expect(edit("a.yml", YAML, "/name", { t: "string", v: "z" })).toContain("tags:\n- a\n- b # second"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("several documents are one fieldset each; only the changed one is rewritten", () => { | |
| 111 | const text = "# one\na: 1\n---\n# two\nb: 2 # odd spacing\n---\nc: 3\n"; | |
| 112 | const doc = read("m.yaml", text); | |
| 113 | expect(doc.t === "object" && doc.entries.map(([k]) => k)).toEqual(["Document 1", "Document 2", "Document 3"]); | |
| 114 | const html = String(configFields(doc, {})); | |
| 115 | expect(html.match(/<fieldset/g)?.length).toBeGreaterThanOrEqual(3); | |
| 116 | expect(edit("m.yaml", text, "/Document 1/a", { t: "number", v: "9" })).toBe("# one\na: 9\n---\n# two\nb: 2 # odd spacing\n---\nc: 3\n"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a syntax error names its line", () => { | |
| 120 | const p = parseConfig("a.yml", "a: 1\nb: [1, 2\nc: 3\n"); | |
| 121 | expect(p.ok).toBe(false); | |
| 122 | if (!p.ok) expect(p.error.line).toBeGreaterThanOrEqual(2); | |
| 123 | }); | |
| 124 | ||
| 125 | test("an alias bomb is refused, not expanded", () => { | |
| 126 | let text = "a0: &a0 [x, x, x, x, x, x, x, x, x]\n"; | |
| 127 | for (let i = 1; i < 12; i++) text += `a${i}: &a${i} [${Array(9).fill(`*a${i - 1}`).join(", ")}]\n`; | |
| 128 | expect(parseConfig("bomb.yml", text).ok).toBe(false); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a key that is not text is refused, with the reason", () => { | |
| 132 | const p = parseConfig("a.yml", "1: one\n"); | |
| 133 | expect(p.ok).toBe(false); | |
| 134 | }); | |
| 135 | ||
| 136 | test("the schema lookup is JSON's: a sibling <name>.schema.json", async () => { | |
| 137 | const files: Record<string, string> = { "deploy/app.schema.json": '{"properties":{"name":{"description":"The app"}}}' }; | |
| 138 | const found = await findSchema("deploy/app.yml", read("deploy/app.yml", "name: x\n"), async (p) => files[p]); | |
| 139 | expect(found?.from).toBe("deploy/app.schema.json"); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the blob page draws the form, and says so for a broken file", () => { | |
| 143 | const input = (text: string) => { | |
| 144 | const body = new TextEncoder().encode(text); | |
| 145 | return { path: "a.yml", size: body.length, body, media: "/o/r/media/main/a.yml" }; | |
| 146 | }; | |
| 147 | expect(String(config(input(YAML)).content)).toContain("config-form"); | |
| 148 | expect(String(config(input("a: [1\n")).content)).toContain("Not valid YAML"); | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 152 | const TOML = `# top comment | |
| 153 | title = "forge" # inline | |
| 154 | ||
| 155 | [server] | |
| 156 | # the port | |
| 157 | port = 80 | |
| 158 | since = 1979-05-27T07:32:00Z | |
| 159 | ||
| 160 | [[servers]] | |
| 161 | host = "one" | |
| 162 | ||
| 163 | [[servers]] | |
| 164 | host = "two" | |
| 165 | `; | |
| 166 | ||
| 167 | describe("TOML", () => { | |
| 168 | test("a table is a fieldset and an array of tables is cards", () => { | |
| 169 | const html = String(configFields(read("a.toml", TOML), {})); | |
| 170 | expect(html).toContain("<fieldset"); | |
| 171 | expect(html).toContain("config-card"); | |
| 172 | expect(html.match(/config-card/g)?.length).toBeGreaterThanOrEqual(2); | |
| 173 | }); | |
| 174 | ||
| 175 | test("dates show as the text they were written as", () => { | |
| 176 | expect(getAt(read("a.toml", TOML), "/server/since")).toEqual({ t: "string", v: "1979-05-27T07:32:00Z" }); | |
| 177 | }); | |
| 178 | ||
| 179 | test("an untouched file is written as it was; a change touches only its line", () => { | |
| 180 | const doc = read("a.toml", TOML); | |
| 181 | expect(writeConfig("a.toml", TOML, doc, doc)).toEqual({ ok: true, text: TOML }); | |
| 182 | const next = edit("a.toml", TOML, "/server/port", { t: "number", v: "8080" }); | |
| 183 | expect(next).toBe(TOML.replace("port = 80", "port = 8080")); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a date stays a date when its text is edited", () => { | |
| 187 | const next = edit("a.toml", TOML, "/server/since", { t: "string", v: "1980-01-01T00:00:00Z" }); | |
| 188 | expect(next).toContain("since = 1980-01-01T00:00:00Z"); | |
| 189 | }); | |
| 190 | ||
| 191 | test("adding and removing a card keeps the comments", () => { | |
| 192 | const before = read("a.toml", TOML); | |
| 193 | const card = (host: string): Json => ({ t: "object", entries: [["host", { t: "string", v: host }]] }); | |
| 194 | const grown = updateAt(before, "/servers", () => ({ t: "array", items: [card("one"), card("two"), card("three")] })); | |
| 195 | const w = writeConfig("a.toml", TOML, before, grown); | |
| 196 | expect(w.ok && w.text).toContain('host = "three"'); | |
| 197 | expect(w.ok && w.text).toContain("# the port"); | |
| 198 | const shrunk = updateAt(before, "/servers", () => ({ t: "array", items: [card("two")] })); | |
| 199 | const s = writeConfig("a.toml", TOML, before, shrunk); | |
| 200 | expect(s.ok && s.text).not.toContain('"one"'); | |
| 201 | expect(s.ok && s.text).toContain("# top comment"); | |
| 202 | }); | |
| 203 | ||
| 204 | test("TOML cannot hold null, and says so", () => { | |
| 205 | const before = read("a.toml", TOML); | |
| 206 | const w = writeConfig("a.toml", TOML, before, updateAt(before, "/title", () => ({ t: "null" }))); | |
| 207 | expect(w.ok).toBe(false); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a syntax error names its line", () => { | |
| 211 | const p = parseConfig("a.toml", "a = 1\nb = \nc = 2\n"); | |
| 212 | expect(p.ok).toBe(false); | |
| 213 | if (!p.ok) expect(p.error.line).toBe(3); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("hostile input is bounded, whatever the language", () => { | |
| 218 | // Parsing only ever sees text under MAX_FORM_BYTES: the page and the submit path both check the size first. | |
| 219 | test("deeply nested flow collections, merge keys and TOML arrays are refused, not walked", () => { | |
| 220 | expect(parseConfig("a.yml", `a: ${"[".repeat(10_000)}${"]".repeat(10_000)}\n`).ok).toBe(false); | |
| 221 | let merge = "a0: &a0 {x: 1}\n"; | |
| 222 | for (let i = 1; i <= 11; i++) merge += `a${i}: &a${i}\n <<: [${Array(9).fill(`*a${i - 1}`).join(", ")}]\n`; | |
| 223 | expect(parseConfig("m.yml", merge).ok).toBe(false); | |
| 224 | expect(parseConfig("a.toml", `a = ${"[".repeat(65)}${"]".repeat(65)}\n`).ok).toBe(false); | |
| 225 | expect(parseConfig("a.toml", `a = ${"[".repeat(20)}${"]".repeat(20)}\n`).ok).toBe(true); | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 229 | describe("the terminal checks YAML and TOML against a schema, as the page does", () => { | |
| 230 | const SCHEMA = JSON.stringify({ type: "object", properties: { port: { type: "number" } }, required: ["port"] }); | |
| 231 | const files: Record<string, string> = { "svc.schema.json": SCHEMA }; | |
| 232 | const readFile = async (p: string): Promise<string | undefined> => files[p]; | |
| 233 | ||
| 234 | test("a file that breaks the sibling schema is refused; one that keeps it is not", async () => { | |
| 235 | expect(await schemaRefusal("svc.yml", "port: x\n", readFile)).toContain("svc.yml breaks"); | |
| 236 | expect(await schemaRefusal("svc.toml", 'port = "x"\n', readFile)).toContain("svc.toml breaks"); | |
| 237 | expect(await schemaRefusal("svc.yml", "port: 80\n", readFile)).toBeUndefined(); | |
| 238 | expect(await schemaRefusal("svc.toml", "port = 80\n", readFile)).toBeUndefined(); | |
| 239 | expect(await schemaRefusal("svc.ini", "port=x\n", readFile)).toBeUndefined(); | |
| 240 | }); | |
| 241 | }); | |
| 242 | ||
| 243 | test("the language is named in one place", () => { | |
| 244 | expect([languageOf("a.json"), languageOf("a.yaml"), languageOf("a.toml"), languageOf("a.txt")]).toEqual(["JSON", "YAML", "TOML", "JSON"]); | |
| 245 | }); | |
| 246 | ||
| 247 | describe("the vendored bundles say what they are", () => { | |
| 248 | const dir = new URL("../src/web/render/vendor/", import.meta.url); | |
| 249 | const text = (name: string): Promise<string> => Bun.file(new URL(name, dir)).text(); | |
| 250 | ||
| 251 | test("each bundle names its pinned version and its licence file, and the licence is there", async () => { | |
| 252 | const yaml = await text("yaml.mjs"); | |
| 253 | const toml = await text("toml-patch.mjs"); | |
| 254 | const yamlVersion = /^\/\/ yaml (\d+\.\d+\.\d+) /.exec(yaml)?.[1]; | |
| 255 | const tomlVersion = /^\/\/ @decimalturn\/toml-patch (\d+\.\d+\.\d+) /.exec(toml)?.[1]; | |
| 256 | expect(yamlVersion).toBeDefined(); | |
| 257 | expect(tomlVersion).toBeDefined(); | |
| 258 | expect(await text("YAML-LICENSE")).toContain("Eemeli Aro"); | |
| 259 | expect(await text("TOML-PATCH-LICENSE")).toContain("MIT License"); | |
| 260 | // docs/features/config-form.md records the same pins and how to regenerate. | |
| 261 | const doc = await Bun.file(new URL("../../../../docs/features/config-form.md", dir)).text(); | |
| 262 | expect(doc).toContain(`yaml ${yamlVersion}`); | |
| 263 | expect(doc).toContain(`toml-patch ${tomlVersion}`); | |
| 264 | // The bundle is the one the page's commands rebuild, byte for byte. | |
| 265 | const sha = (t: string): string => new Bun.CryptoHasher("sha256").update(t).digest("hex"); | |
| 266 | expect(doc).toContain(`\`yaml.mjs\` sha256 \`${sha(yaml)}\``); | |
| 267 | expect(doc).toContain(`\`toml-patch.mjs\` sha256 \`${sha(toml)}\``); | |
| 268 | }); | |
| 269 | }); |
test/looks-owner.test.ts +30
| @@ -35,6 +35,9 @@ import type { Capture, LookInput } from "../src/looks/capture.ts"; | ||
| 35 | 35 | import { startLooks, type LooksHandle } from "../src/looks/service.ts"; |
| 36 | 36 | import { previewIdentityKey } from "../src/spawn/preview-owner.ts"; |
| 37 | 37 | import { PREVIEW_ADMIN, PREVIEW_PULL, PREVIEW_REPO, PREVIEW_WAVE, PREVIEW_WAVE_HEADLINE, seedPreview } from "../src/spawn/preview-seed.ts"; |
| 38 | import { PREVIEW_CONFIG_FILES } from "../src/spawn/preview-config.ts"; | |
| 39 | import { localSource } from "../src/view/local.ts"; | |
| 40 | import { isFormConfig, parseConfig } from "../src/web/render/config-format.ts"; | |
| 38 | 41 | import { quietLogs } from "./helpers/globals.ts"; |
| 39 | 42 | |
| 40 | 43 | quietLogs(); |
| @@ -133,6 +136,33 @@ describe("the preview seed", () => { | ||
| 133 | 136 | expect(await seedPreview(app, { FORGE_APP_ENV: "preview" })).toEqual(ok({ seeded: false })); |
| 134 | 137 | expect(listPins(app.db, admin.id).length).toBe(2); |
| 135 | 138 | }, 30_000); |
| 139 | ||
| 140 | test("the sample holds a YAML and a TOML file that open as a form; a sample seeded before them gets them once", async () => { | |
| 141 | const admin = getUserByName(app.db, PREVIEW_ADMIN)!; | |
| 142 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 143 | const fileAt = async (path: string): Promise<string | undefined> => { | |
| 144 | const blob = await localSource(app, repo, undefined).blob(repo.defaultBranch, path); | |
| 145 | return blob.ok && blob.value.kind === "text" ? new TextDecoder().decode(blob.value.body) : undefined; | |
| 146 | }; | |
| 147 | for (const { path, content } of PREVIEW_CONFIG_FILES) { | |
| 148 | expect(isFormConfig(path)).toBe(true); | |
| 149 | expect(parseConfig(path, content).ok).toBe(true); | |
| 150 | expect(await fileAt(path)).toBe(content); | |
| 151 | } | |
| 152 | // An older preview: seeded, without the files or the note that it has them. | |
| 153 | const gone = await commitToBranch(app, admin, repo, { | |
| 154 | message: "as before #7337", | |
| 155 | files: PREVIEW_CONFIG_FILES.map(({ path }) => ({ path, content: null })), | |
| 156 | }); | |
| 157 | expect(gone.ok).toBe(true); | |
| 158 | app.db.query("DELETE FROM user_setting WHERE user_id = ?1 AND key = 'preview_config_seeded'").run(admin.id); | |
| 159 | expect(await seedPreview(app, { FORGE_APP_ENV: "preview" })).toEqual(ok({ seeded: false })); | |
| 160 | for (const { path, content } of PREVIEW_CONFIG_FILES) expect(await fileAt(path)).toBe(content); | |
| 161 | // And only once: removed again, a later boot leaves them removed. | |
| 162 | await commitToBranch(app, admin, repo, { files: PREVIEW_CONFIG_FILES.map(({ path }) => ({ path, content: null })) }); | |
| 163 | expect(await seedPreview(app, { FORGE_APP_ENV: "preview" })).toEqual(ok({ seeded: false })); | |
| 164 | expect(await fileAt("service.yml")).toBeUndefined(); | |
| 165 | }, 30_000); | |
| 136 | 166 | }); |
| 137 | 167 | |
| 138 | 168 | describe("a preview hears its parent's admin as the preview admin", () => { |
new above
offline