tosijs-ui 1.16.23 → 1.16.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.25
4
+
5
+ ### Dev server
6
+
7
+ - **`editableData`: folders an editor on the dev page may save to** (#3365). A level or
8
+ scene editor running on a dev-server page can list, read and save the project's data files
9
+ through `/__docstore/data`, behind the same gate as source: this machine, an edit session
10
+ over the tunnel, or the LAN under `trustLan`. Share links stay read-only.
11
+ `editableData: ['static/levels']`, or `{ dirs, extensions, commit }`. Only data formats may
12
+ be allowed (`.json` by default; also `.jsonc` `.json5` `.ndjson` `.txt` `.csv` `.tsv`
13
+ `.yaml` `.yml` `.toml`), so nothing saved there runs or renders. Dot-files and symlinks are
14
+ never written, nor are common configuration names (`package.json`, `tsconfig.json`,
15
+ `firebase.json`, `*.config.*`), though the folders you name are the real boundary. A `.json`
16
+ save must parse, and a save replaces the file whole. A folder outside the project, the
17
+ project itself, one in or around `.git` or `node_modules`, or one overlapping the build
18
+ output stops the server at launch. `tosijs-ui/site` exports the `EditableDataOptions` type
19
+ and the `DATA_EXTENSIONS` list.
20
+ - **`commit: true` makes every save a version.** Each save is its own commit
21
+ (`data: save <file>`, naming who saved it), and other staged work is left alone. The
22
+ repository's hooks run, and each git step gets 20 seconds. Nothing is committed while the
23
+ repository is mid-rebase, mid-merge, cherry-picking, reverting, bisecting or on a detached
24
+ HEAD. `?history=<file>` lists a file's latest 100 versions and `?file=<file>&at=<commit>` reads
25
+ one back. A save that wrote the file but did not commit answers with `commitError` instead of
26
+ reporting a clean save. Each committed save waits for git, so it suits an explicit Save, not
27
+ autosave.
28
+ - **`tosijs-ui/doc-system/data-client`** wraps the endpoint for an editor: `listData`,
29
+ `readData` (text, or `{ json: true }`, or `{ at }`), `saveData` (a string as is, anything
30
+ else as formatted JSON) and `dataHistory`. A refusal rejects with a `DataStoreError` carrying
31
+ the status, and so does a page answering for the endpoint (a static host's catch-all), so
32
+ `listData()` tells an editor whether to offer saving.
33
+ - **`trustLan` can name your network**, so a committed setting trusts nobody on anyone else's:
34
+ `trustLan: '192.168.1.0/24'` or a list, IPv4 or IPv6. Only private addresses inside it are
35
+ trusted. **`TOSIJS_TRUST_LAN`** sets it for one run and wins over the config either way: `1`,
36
+ a network, or `0` to turn a committed setting off. A network that cannot be parsed stops the
37
+ server at launch, and the launch warning names who is trusted and where the setting came from.
38
+ A refused device outside the named networks is told so in the log, and the messages readers
39
+ see when the LAN is refused now suggest these forms rather than `trustLan: true`.
40
+ - **The SPA fallback shows the page the URL names.** Was: a path with no file of its own got
41
+ the root page, which then asked for its assets relative to that path, got 404s and never
42
+ hydrated; with `home` set, a reader saw the home doc. Now the dev server serves the root page
43
+ with its relative URLs made root-relative (in-page `#` links still stay on the page), and
44
+ hydration adopts a pre-rendered article only when it is the doc being shown. Generated pages
45
+ stamp the article with `data-doc` for that. The asset fix is the dev server's; a static host's
46
+ own catch-all rewrite is unchanged.
47
+
48
+ ## 1.16.24
49
+
50
+ `dist/iife.js` grows by about 0.2 kB gzip.
51
+
52
+ - **Composition rules chain.** A rule may apply to another rule, so `un:check:square` slashes
53
+ the checked square, and `unCheckLock` draws instead of the placeholder. `iconSvg` reports a
54
+ chained rule as needing the renderer, as it does any rule. Chains stop at four rules deep,
55
+ which keeps a hostile icon name cheap to resolve, and a custom rule whose prefix can match
56
+ nothing no longer recurses. `defineIcons` may now warn about a new name that reads as a
57
+ chain (`unCheckFoo`); it is a warning, and your definition still wins.
58
+ - **The icon composition demo shows the bracket form.** It printed `icons.check:square()`,
59
+ which is not JavaScript; a name with a `:` now shows as `icons['check:square']()`.
60
+ - **Prettier never formats SVG files.** `*.svg` is in `.prettierignore` and a `requirePragma`
61
+ override, and `tosijs-format` excludes it on the command line as it does markdown. Prettier
62
+ has no SVG parser of its own, but an editor association or a plugin such as
63
+ `prettier-plugin-xml` would reflow a drawing tool's export. Adopters using `tosijs-format`
64
+ get the command-line exclusion. To cover editors too, add `*.svg` to `.prettierignore` and
65
+ `{ "files": ["*.svg"], "options": { "requirePragma": true } }` to your Prettier `overrides`.
66
+
67
+ ### Dev-server access
68
+
69
+ - **A live example's "Save to source" is offered wherever the dev server admits the page**:
70
+ a LAN device under `trustLan`, or a tunnel visitor with an edit session, not only
71
+ `localhost`. The dev server marks the pages it serves with `editableSources` on, and an
72
+ example on such a page asks with a `HEAD` request when it connects, once per source file; it
73
+ believes only an answer carrying the dev server's own header. A deployed static site is never
74
+ asked.
75
+ - **Every source write admitted by `trustLan` is logged** with the device's address, the file
76
+ (quoted, so a crafted name cannot forge a log line) and whether it was saved.
77
+ - **The dev server suggests `trustLan` only to a refused device on a private network**, the
78
+ one case it would admit; anyone else is pointed at an edit link.
79
+
3
80
  ## 1.16.23
4
81
 
5
82
  - **`rule:icon` applies an icon composition rule explicitly.** A `:` marks the boundary
package/bin/format.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  /*
3
3
  tosijs-format — the house format step, shipped as a command (#187).
4
4
 
5
- tosijs-format eslint --fix, then prettier --write (markdown excluded)
6
- tosijs-format --check eslint, then prettier --check (markdown excluded); exit 1 on any finding
5
+ tosijs-format eslint --fix, then prettier --write (markdown and SVG excluded)
6
+ tosijs-format --check eslint, then prettier --check (markdown and SVG excluded); exit 1 on any finding
7
7
 
8
8
  **Prettier never touches markdown** in this stack. The rule itself lives in the Prettier
9
9
  CONFIG, where every entry point reads it (editors, lint-staged, a bare `prettier --write`):
@@ -31,10 +31,11 @@ import { parseArgv } from './resolve-site-config.ts'
31
31
 
32
32
  const { has } = parseArgv(process.argv.slice(2), {
33
33
  bin: 'tosijs-format',
34
- summary: 'the house format step: eslint, then prettier (never markdown)',
34
+ summary:
35
+ 'the house format step: eslint, then prettier (never markdown or SVG)',
35
36
  flags: ['check'],
36
37
  usage:
37
- ` tosijs-format eslint --fix ., then prettier --write . (markdown excluded)\n` +
38
+ ` tosijs-format eslint --fix ., then prettier --write . (markdown and SVG excluded)\n` +
38
39
  ` tosijs-format --check check only: exit 1 on anything unformatted or any lint error\n\n` +
39
40
  `Uses the project's own prettier and eslint (node_modules/.bin). ESLint runs only when the\n` +
40
41
  `project has an eslint.config.* file.`,
@@ -81,13 +82,18 @@ if (hasEslintConfig) {
81
82
  }
82
83
  }
83
84
 
84
- // The exclusion is the point of this command: prettier never touches markdown.
85
+ /*
86
+ The exclusion is the point of this command: prettier never touches markdown, or SVG.
87
+ Prettier has no SVG parser of its own, but an editor association or a plugin
88
+ (prettier-plugin-xml) gives it one, and an SVG is a drawing tool's export, not source to
89
+ reflow.
90
+ */
85
91
  if (
86
92
  run([
87
93
  bin('prettier'),
88
94
  check ? '--check' : '--write',
89
95
  '.',
90
- '!**/*.{md,markdown,mdx}',
96
+ '!**/*.{md,markdown,mdx,svg}',
91
97
  ]) !== 0
92
98
  ) {
93
99
  failed = true
@@ -111,6 +111,7 @@ import { tosiLocalized } from './localize.js';
111
111
  import { popMenu } from './menu.js';
112
112
  import { codeEditor } from './code-editor.js';
113
113
  import { tosiDiff } from './diff.js';
114
+ import { LAN_REFUSAL_ADVICE } from './live-example/source-access.js';
114
115
  /*
115
116
  Does this doc contain a REAL ` ```test ` block, as opposed to mentioning one?
116
117
 
@@ -954,8 +955,18 @@ export function createDocBrowser(options) {
954
955
  };
955
956
  let adoptInitialContent = contentElement !== undefined && !memoryRouting;
956
957
  const showDoc = (doc) => {
957
- if (adoptInitialContent) {
958
- adoptInitialContent = false; // leave the pre-rendered HTML untouched
958
+ /*
959
+ Adopt the pre-render only if it IS this doc. The dev server's SPA fallback serves the root
960
+ page for a path with no file of its own, so `/guide/` can arrive holding the home page's
961
+ article (#3346). The generator stamps `data-doc`; a page without it predates the stamp
962
+ and is adopted as before.
963
+ */
964
+ const prerendered = contentElement?.dataset.doc;
965
+ const adopt = adoptInitialContent &&
966
+ (prerendered === undefined || prerendered === doc.filename);
967
+ adoptInitialContent = false;
968
+ if (adopt) {
969
+ // leave the pre-rendered HTML untouched
959
970
  }
960
971
  else {
961
972
  // Pass the doc's build-time tjs bakes so this client-rendered page embeds the
@@ -1291,7 +1302,7 @@ export function createDocBrowser(options) {
1291
1302
  window.alert('This workspace needs an invite link before it can save.\n\n' +
1292
1303
  'Run `tosijs-tunnel --link` on the machine hosting it, open the link once, ' +
1293
1304
  'then try again. Your edit is still here.\n\n' +
1294
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.');
1305
+ LAN_REFUSAL_ADVICE);
1295
1306
  }
1296
1307
  else {
1297
1308
  // Genuinely no write endpoint (a deployed static site) — hand the file back.
@@ -1306,7 +1317,7 @@ export function createDocBrowser(options) {
1306
1317
  window.alert(sourceUnauthorized
1307
1318
  ? 'This workspace needs an invite link before you can edit.\n\n' +
1308
1319
  'Run `tosijs-tunnel --link` on the machine hosting it and open the link once.\n\n' +
1309
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.'
1320
+ LAN_REFUSAL_ADVICE
1310
1321
  : `Could not load source for ${doc.path}`);
1311
1322
  return;
1312
1323
  }
@@ -0,0 +1,35 @@
1
+ export declare class DataStoreError extends Error {
2
+ status: number;
3
+ constructor(message: string, status: number);
4
+ }
5
+ /** Every editable file, project-relative, newest first. */
6
+ export declare function listData(): Promise<{
7
+ files: {
8
+ file: string;
9
+ size: number;
10
+ modified: string;
11
+ }[];
12
+ }>;
13
+ /** A file's text, or parsed JSON with `json: true`; `at` reads it as of a commit. */
14
+ export declare function readData(file: string, options: {
15
+ at?: string;
16
+ json: true;
17
+ }): Promise<any>;
18
+ export declare function readData(file: string, options?: {
19
+ at?: string;
20
+ json?: false;
21
+ }): Promise<string>;
22
+ /** Save a file: a string as it is, anything else as formatted JSON. */
23
+ export declare function saveData(file: string, content: unknown): Promise<{
24
+ ok: true;
25
+ file: string;
26
+ commit?: string | null;
27
+ commitError?: string;
28
+ }>;
29
+ /** A file's committed versions, newest first. */
30
+ export declare function dataHistory(file: string): Promise<{
31
+ commit: string;
32
+ date: string;
33
+ author: string;
34
+ subject: string;
35
+ }[]>;
@@ -0,0 +1,105 @@
1
+ /*{ "parent": "Documentation System", "order": 85 }*/
2
+ /*#
3
+ # data client
4
+
5
+ For an editor running on a dev-server page (a level editor, a scene editor) to open and save
6
+ the project's data files through `/__docstore/data`. Turn it on by naming the folders in the
7
+ site config; [`editableData`](/doc-site-system/) in the doc-site system reference has the
8
+ rules.
9
+
10
+ ```typescript
11
+ // tosijs-site.config.ts
12
+ editableData: { dirs: ['static/levels'], commit: true }
13
+ ```
14
+
15
+ ```typescript
16
+ import { listData, readData, saveData, dataHistory } from 'tosijs-ui/doc-system/data-client'
17
+
18
+ const { files } = await listData()
19
+ const level = await readData('static/levels/ocean.json', { json: true })
20
+ level.spawn = [0, 2, 0]
21
+ const { commit } = await saveData('static/levels/ocean.json', level)
22
+ const versions = await dataHistory('static/levels/ocean.json')
23
+ if (versions.length > 1) {
24
+ const before = await readData('static/levels/ocean.json', { at: versions[1].commit })
25
+ }
26
+ ```
27
+
28
+ `saveData` writes an object as JSON (two-space indent, trailing newline) and a string as it
29
+ is. With `commit: true` in the config each save is its own commit, and `commit` in the result
30
+ is its hash (`null` when the save changed nothing). A save that wrote but could not commit
31
+ (a failing hook, or a repository mid-rebase) resolves with `commitError` set rather than
32
+ rejecting, because the file was saved. Each committed save runs git and the repository's
33
+ hooks before it answers, so it suits an explicit Save, not autosave.
34
+
35
+ **Dev server only.** Everything else rejects with a `DataStoreError` whose `status` says why:
36
+ `501` when the server has no `editableData`, `403` when this page may not edit files here or
37
+ the file is not editable, `400` for content it refuses (JSON that does not parse). A response
38
+ that is not the endpoint's (a deployed site's catch-all answering with a page) rejects too, so
39
+ `listData()` is the probe for whether to offer saving at all.
40
+
41
+ File names may use letters, digits, `-`, `_`, `.`, `/` and spaces. `dataHistory` returns the
42
+ latest 100 versions.
43
+ */
44
+ const ENDPOINT = '/__docstore/data';
45
+ export class DataStoreError extends Error {
46
+ status;
47
+ constructor(message, status) {
48
+ super(message);
49
+ this.status = status;
50
+ this.name = 'DataStoreError';
51
+ }
52
+ }
53
+ async function ask(query, init) {
54
+ const response = await fetch(`${ENDPOINT}${query}`, {
55
+ credentials: 'same-origin',
56
+ ...init,
57
+ });
58
+ /*
59
+ The endpoint answers JSON or the file's text, never a page. An HTML answer is something
60
+ else's: a static host's catch-all serving its index for every path.
61
+ */
62
+ const type = response.headers.get('content-type') ?? '';
63
+ if (response.ok && type.includes('text/html')) {
64
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: answered with a page, not the data endpoint (no dev server here?)`, response.status);
65
+ }
66
+ if (!response.ok) {
67
+ const text = await response.text();
68
+ let message = text;
69
+ try {
70
+ message = JSON.parse(text).error ?? text;
71
+ }
72
+ catch {
73
+ // plain text
74
+ }
75
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: ${response.status} ${message}`, response.status);
76
+ }
77
+ return response;
78
+ }
79
+ const q = (params) => '?' + new URLSearchParams(params).toString();
80
+ /** Every editable file, project-relative, newest first. */
81
+ export async function listData() {
82
+ return (await ask('?list')).json();
83
+ }
84
+ export async function readData(file, options = {}) {
85
+ const params = { file };
86
+ if (options.at)
87
+ params.at = options.at;
88
+ const text = await (await ask(q(params))).text();
89
+ return options.json ? JSON.parse(text) : text;
90
+ }
91
+ /** Save a file: a string as it is, anything else as formatted JSON. */
92
+ export async function saveData(file, content) {
93
+ const text = typeof content === 'string'
94
+ ? content
95
+ : JSON.stringify(content, null, 2) + '\n';
96
+ return (await ask('', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ file, content: text }),
100
+ })).json();
101
+ }
102
+ /** A file's committed versions, newest first. */
103
+ export async function dataHistory(file) {
104
+ return (await (await ask(q({ history: file }))).json()).versions;
105
+ }
@@ -0,0 +1,71 @@
1
+ /** Formats a data folder may hold. Text only: the endpoint takes and returns strings. */
2
+ export declare const DATA_EXTENSIONS: readonly [".json", ".jsonc", ".json5", ".ndjson", ".txt", ".csv", ".tsv", ".yaml", ".yml", ".toml"];
3
+ /** The largest save accepted, in bytes. A level is kilobytes; this stops a runaway client. */
4
+ export declare const MAX_DATA_BYTES: number;
5
+ export interface EditableDataOptions {
6
+ /** Folders, relative to the project root. */
7
+ dirs: string[];
8
+ /** Extensions to allow, from DATA_EXTENSIONS. Default `['.json']`. */
9
+ extensions?: string[];
10
+ /** Commit each save to git, so every save is a version. Default false. */
11
+ commit?: boolean;
12
+ }
13
+ export interface DataStore {
14
+ root: string;
15
+ /** Real paths of the folders, and the names they were configured as. */
16
+ dirs: {
17
+ real: string;
18
+ name: string;
19
+ }[];
20
+ extensions: string[];
21
+ commit: boolean;
22
+ }
23
+ /** The folder setting, checked and resolved, or null when the project has none. Throws. */
24
+ export declare function resolveDataStore(setting: string[] | EditableDataOptions | undefined, root: string,
25
+ /** The site's build output, which every build deletes and rewrites. */
26
+ outputDir?: string): DataStore | null;
27
+ /**
28
+ * The absolute path a client's file name may be read or written at, or the reason it may not.
29
+ * `file` is project-relative (`static/levels/ocean.json`).
30
+ */
31
+ export declare function resolveDataFile(store: DataStore, file: unknown): {
32
+ path: string;
33
+ rel: string;
34
+ } | {
35
+ refused: string;
36
+ };
37
+ /** Why `content` may not be saved as `rel`, or null. JSON must parse: a half-edit is not a level. */
38
+ export declare function dataContentProblem(rel: string, content: unknown): string | null;
39
+ /** Write through a temporary file and a rename, so a reader never sees half a file. */
40
+ export declare function writeDataFile(target: string, content: string): void;
41
+ /** Every editable file in the named folders, project-relative, newest first. */
42
+ export declare function listDataFiles(store: DataStore): {
43
+ file: string;
44
+ size: number;
45
+ modified: string;
46
+ }[];
47
+ /** Whether `root` is in a git work tree, for `commit: true` to be checked at launch. */
48
+ export declare function isGitWorkTree(root: string): boolean;
49
+ /**
50
+ * Save a file. With `commit` on, also commit it, alone, leaving other staged work as it was:
51
+ * `commit` is the hash, or null when the save changed nothing. A save that wrote the file but
52
+ * could not commit it resolves with `commitError`, because the file WAS saved. The repository's
53
+ * hooks run, as for any commit; a repository mid-rebase, mid-merge, bisecting or on a detached
54
+ * HEAD is not committed to, since that commit would land somewhere the developer did not choose.
55
+ */
56
+ export declare function saveDataFile(store: DataStore, target: {
57
+ path: string;
58
+ rel: string;
59
+ }, content: string, savedBy: string): Promise<{
60
+ commit?: string | null;
61
+ commitError?: string;
62
+ }>;
63
+ /** One file's committed versions, newest first. */
64
+ export declare function dataHistory(store: DataStore, rel: string, limit?: number): Promise<{
65
+ commit: string;
66
+ date: string;
67
+ author: string;
68
+ subject: string;
69
+ }[]>;
70
+ /** A file as it was at `commit`, or null if the commit is not a hash or lacks the file. */
71
+ export declare function readDataAt(store: DataStore, rel: string, commit: string): Promise<string | null>;