notefeed 0.8.0 → 0.9.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # notefeed
2
2
 
3
- Post and read markdown notes on a [notefeed](https://github.com/Marceltov/notefeed) server from Node or the command line. No dependencies; Node 20 or newer.
3
+ Post and read markdown notes on a [notefeed](https://github.com/notefeed/notefeed) server from Node or the command line. No dependencies; Node 20 or newer.
4
4
 
5
5
  ```sh
6
6
  npm install notefeed
@@ -31,6 +31,6 @@ A posted note can be changed or removed: `await client.edit(id, content)` replac
31
31
 
32
32
  A feed can have a title and a description, and can be deleted: `await client.feedInfo()` returns `{ name, title, description, protected, read_url }`, `await client.updateFeed({ title, description })` replaces both (an empty string clears one) and returns the feed, and `await client.deleteFeed()` removes the feed with all its notes for good. All three take the same `{ feed, feedPassword }` options as `post`, and a feed that doesn't exist is a `NotFoundError`. Anyone who can post to a feed can do this, and deleting is permanent; there are no `notefeed` commands for it.
33
33
 
34
- A picture is a note of its own: `await client.post(bytes, { type: "image/png", alt: "…" })` takes a `Uint8Array` or a `Blob` (PNG, JPEG, GIF or WebP; a Blob's own type is used if you leave `type` out) and returns what a markdown post returns, with the picture's `file` and `file_url` added. Write `![](file)` in a markdown note to show it; the feed is created by its first note, and a picture that is too large is a `NoteTooLargeError`. The server checks the bytes against the declared type. `await client.update(id, { title, alt })` changes a note's title or a picture's alt text, and `await client.edit(id, content)` replaces its content. `await client.updateFeed({ title, description, image: file })` makes a picture the feed's title image (`image: ""` removes it; leaving `image` out keeps it). `notefeed post --file photo.png` posts a file (the type comes from the extension, or `--type`). To post a note with its pictures in one call, `await client.post(text, { attachments: [{ name: "chart.png", content: bytes, type: "image/png" }] })` posts them first and swaps `![](chart.png)` for each file name (`notefeed post "…" --attach chart.png` does the same); a failure carries the pictures already posted as `posted`. Pictures are public to anyone with the feed's read link, and metadata such as EXIF is not removed.
34
+ A picture is a note of its own: `await client.post(bytes, { type: "image/png", alt: "…" })` takes a `Uint8Array` or a `Blob` (PNG, JPEG, GIF or WebP; a Blob's own type is used if you leave `type` out) and returns what a markdown post returns, with the picture's `file` and `file_url` added. Write `![](file)` in a markdown note to show it; the feed is created by its first note, and a picture that is too large is a `NoteTooLargeError`. The server checks the bytes against the declared type. `await client.update(id, { title, alt })` changes a note's title or a picture's alt text, and `await client.edit(id, content)` replaces its content. `await client.updateFeed({ title, description, image: file })` makes a picture the feed's title image (`image: ""` removes it; leaving `image` out keeps it). `notefeed post --file photo.png` posts a file (the type comes from the extension, or `--type`). To post a note with its pictures in one call, `await client.post(text, { attachments: [{ name: "chart.png", content: bytes, type: "image/png" }] })` posts them with the text in one request, and the server swaps `![](chart.png)` for each file name (`null` content posts only the pictures) (`notefeed post "…" --attach chart.png` does the same); nothing is posted when one is refused. Pictures are public to anyone with the feed's read link, and metadata such as EXIF is not removed.
35
35
 
36
- Full documentation: https://docs.notefeed.me/clients/
36
+ Full documentation: https://docs.notefeed.me/integrations/clients/
package/dist/cli.js CHANGED
@@ -48,10 +48,13 @@ export async function main(argv, io = process) {
48
48
  }
49
49
  const [command, text, ...rest] = positionals;
50
50
  if (command === "post" && !rest.length) {
51
- const input = await read(text, values.file, values.type, io);
52
51
  const attachments = await Promise.all((values.attach ?? []).map(attachment));
53
- const note = await client(values).post(input.content, { type: input.type, title: values.title, tags: values.tag, name: input.name, attachments });
54
- io.stdout.write(`${[note.url, ...note.attachments.map((a) => a.url)].join("\n")}\n`);
52
+ // Pictures alone need no text; the result is then the first picture, whose URL is also in `attachments`.
53
+ const noText = attachments.length > 0 && text === undefined && !values.file;
54
+ const input = noText ? { content: null, type: undefined, name: undefined } : await read(text, values.file, values.type, io);
55
+ // The server refuses a file name next to attachments: the text has none then.
56
+ const note = await client(values).post(input.content, { type: input.type, title: values.title, tags: values.tag, name: attachments.length ? undefined : input.name, attachments });
57
+ io.stdout.write(`${[...(noText ? [] : [note.url]), ...note.attachments.map((a) => a.url)].join("\n")}\n`);
55
58
  return 0;
56
59
  }
57
60
  if (command === "edit" && text !== undefined && rest.length <= 1) {
@@ -91,9 +94,6 @@ export async function main(argv, io = process) {
91
94
  }
92
95
  catch (e) {
93
96
  io.stderr.write(`notefeed: ${e.message.split("\n")[0]}\n`);
94
- if (e instanceof NotefeedError)
95
- for (const p of e.posted)
96
- io.stderr.write(`posted: ${p.url}\n`);
97
97
  if (e instanceof ConfigError || e instanceof UsageError)
98
98
  return 2;
99
99
  if (e instanceof NotefeedError)
@@ -141,16 +141,13 @@ async function read(text, file, type, io) {
141
141
  throw new UsageError('give the note text, "-" for stdin, or --file PATH');
142
142
  return { content: text, type: type ?? "text/markdown" };
143
143
  }
144
- // A picture to post first: its name is the file's, its type comes from the extension.
144
+ // A picture to post with the note: its name is the file's (the client checks it), its type comes from the extension.
145
145
  async function attachment(path) {
146
146
  const type = TYPES[extname(path).toLowerCase()];
147
147
  if (!type || type === "text/markdown")
148
148
  throw new UsageError(`cannot attach ${path}: a picture (${Object.keys(TYPES).filter((e) => TYPES[e] !== "text/markdown").join(", ")}) is expected`);
149
- const name = basename(path);
150
- if (!/^(?!\.+$)[A-Za-z0-9._-]+$/.test(name))
151
- throw new UsageError(`cannot attach ${path}: its file name "${name}" is what the text refers to it by, so it may only have letters, digits, ., _ and -: rename the file`);
152
149
  try {
153
- return { name, content: await readFile(path), type };
150
+ return { name: basename(path), content: await readFile(path), type };
154
151
  }
155
152
  catch (e) {
156
153
  throw new UsageError(`cannot read ${path}: ${e.code ?? e.message}`);
package/dist/client.d.ts CHANGED
@@ -22,9 +22,6 @@ export declare class NotefeedError extends Error {
22
22
  readonly status: number | null;
23
23
  readonly code: ErrorCode | null;
24
24
  constructor(message: string, status?: number | null, code?: ErrorCode | null);
25
- /** Set when a post with `attachments` failed on one of them: its name, and the images already posted (the text note is not). */
26
- attachment: string | null;
27
- posted: Created[];
28
25
  }
29
26
  /** Raised before sending: no URL, no feed, an invalid feed name or password. */
30
27
  export declare class ConfigError extends NotefeedError {
@@ -70,12 +67,14 @@ export declare class Client {
70
67
  * text. `alt` is a picture's alternative text, `name` the file's original name. `readId` is the read id the feed gets when this post creates it
71
68
  * (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; random when left out; ignored for a feed that exists; a `taken` error when another feed has it).
72
69
  *
73
- * `attachments` (only with a markdown string) are pictures posted first, one note each, in order: `![](name)` in the markdown shows one (an
74
- * attachment the text never refers to is added at the end), and `Created.attachments` lists them. `tags` label the images too. The options
75
- * are checked before anything is sent. If an upload fails, its error is thrown with `attachment` (the name) and `posted` (the images already
76
- * posted, which stay) set; the text note is not posted.
70
+ * `attachments` are pictures posted with the note in one request: `![](name)` in the markdown shows one (a picture the text never refers to is
71
+ * added at the end), and `Created.attachments` lists them in order. `tags` label the pictures too. `content` may be `null` when there are
72
+ * attachments: only the pictures are posted, `title` and `tags` go on each, and the result is the first picture plus `attachments`. Checked
73
+ * here, before anything is sent (ConfigError): a name given twice, an attachment that is not a picture, content that is not markdown, `alt` or
74
+ * `name` with attachments. What a name may be is the server's rule (docs/using/pictures.md, "Posting a note with its pictures"): a name it refuses is an
75
+ * InvalidRequestError, `attachment "<name>": ...`, and whenever the server refuses any part (its message names it) nothing is posted.
77
76
  */
78
- post(content: string | Uint8Array | Blob, options?: {
77
+ post(content: string | Uint8Array | Blob | null, options?: {
79
78
  feed?: string;
80
79
  feedPassword?: string;
81
80
  type?: string;
package/dist/client.js CHANGED
@@ -14,9 +14,6 @@ export class NotefeedError extends Error {
14
14
  this.code = code;
15
15
  this.name = new.target.name;
16
16
  }
17
- /** Set when a post with `attachments` failed on one of them: its name, and the images already posted (the text note is not). */
18
- attachment = null;
19
- posted = [];
20
17
  }
21
18
  /** Raised before sending: no URL, no feed, an invalid feed name or password. */
22
19
  export class ConfigError extends NotefeedError {
@@ -60,7 +57,6 @@ const BY_CODE = {
60
57
  taken: InvalidRequestError,
61
58
  unsupported_type: InvalidRequestError,
62
59
  };
63
- const ATTACHMENT_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/; // same rule as the server's MCP tool: safe to write in ![](name)
64
60
  const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
65
61
  const FEED_RE = /^[a-z0-9_-]{1,64}$/; // same rule as the server; reserved names still come back as a 400
66
62
  // A header holds bytes, and fetch refuses a character above U+00FF: text that may not be ASCII goes as its UTF-8 bytes written as
@@ -75,18 +71,6 @@ function asFile(content, type) {
75
71
  throw new ConfigError("give the file's media type as `type`, e.g. image/png");
76
72
  return { body: content instanceof Blob ? content : new Blob([content]), type: media };
77
73
  }
78
- // The markdown with each `![](name)` of `sent` (name → file) pointing at its file; a name the text never refers to is appended as `![](file)`.
79
- function placeImages(markdown, sent) {
80
- const used = new Set();
81
- const text = markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)(\))/g, (m, head, dest, tail) => {
82
- if (!sent.has(dest))
83
- return m;
84
- used.add(dest);
85
- return head + sent.get(dest) + tail;
86
- });
87
- const rest = [...sent].filter(([name]) => !used.has(name)).map(([, file]) => `![](${file})`);
88
- return rest.length === 0 ? text : [text.trimEnd(), ...rest].filter((p) => p !== "").join("\n\n");
89
- }
90
74
  export class Client {
91
75
  url;
92
76
  feed;
@@ -128,55 +112,46 @@ export class Client {
128
112
  * text. `alt` is a picture's alternative text, `name` the file's original name. `readId` is the read id the feed gets when this post creates it
129
113
  * (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; random when left out; ignored for a feed that exists; a `taken` error when another feed has it).
130
114
  *
131
- * `attachments` (only with a markdown string) are pictures posted first, one note each, in order: `![](name)` in the markdown shows one (an
132
- * attachment the text never refers to is added at the end), and `Created.attachments` lists them. `tags` label the images too. The options
133
- * are checked before anything is sent. If an upload fails, its error is thrown with `attachment` (the name) and `posted` (the images already
134
- * posted, which stay) set; the text note is not posted.
115
+ * `attachments` are pictures posted with the note in one request: `![](name)` in the markdown shows one (a picture the text never refers to is
116
+ * added at the end), and `Created.attachments` lists them in order. `tags` label the pictures too. `content` may be `null` when there are
117
+ * attachments: only the pictures are posted, `title` and `tags` go on each, and the result is the first picture plus `attachments`. Checked
118
+ * here, before anything is sent (ConfigError): a name given twice, an attachment that is not a picture, content that is not markdown, `alt` or
119
+ * `name` with attachments. What a name may be is the server's rule (docs/using/pictures.md, "Posting a note with its pictures"): a name it refuses is an
120
+ * InvalidRequestError, `attachment "<name>": ...`, and whenever the server refuses any part (its message names it) nothing is posted.
135
121
  */
136
122
  async post(content, options = {}) {
137
123
  const { attachments = [], ...own } = options;
124
+ if (content === null && attachments.length === 0)
125
+ throw new ConfigError("no content and no attachments");
138
126
  if (attachments.length === 0)
139
127
  return { ...(await this.postOne(content, own)), attachments: [] };
140
- if (typeof content !== "string")
141
- throw new ConfigError("attachments go with a markdown string");
142
- if ((own.type ?? "text/markdown") !== "text/markdown")
128
+ if (content !== null && (typeof content !== "string" || (own.type ?? "text/markdown") !== "text/markdown"))
143
129
  throw new ConfigError("attachments go with a markdown string");
144
- const types = attachments.map((a) => {
145
- if (!ATTACHMENT_NAME.test(a.name))
146
- throw new ConfigError(`attachment name "${a.name}": use letters, digits, ., _ and - only`);
147
- const type = asFile(a.content, a.type).type;
130
+ if (own.alt || own.name)
131
+ throw new ConfigError("alt and name do not go with attachments: give each attachment its alt");
132
+ const form = new FormData();
133
+ if (content !== null)
134
+ form.append("text", new Blob([content], { type: "text/markdown" }));
135
+ for (const a of attachments) {
136
+ if (attachments.findIndex((b) => b.name === a.name) !== attachments.indexOf(a))
137
+ throw new ConfigError(`attachment "${a.name}" is given twice`);
138
+ const { body, type } = asFile(a.content, a.type);
148
139
  if (!IMAGE_TYPES.includes(type))
149
140
  throw new ConfigError(`attachment "${a.name}" must be a picture (${IMAGE_TYPES.join(", ")})`);
150
- return type;
151
- });
152
- const dup = attachments.find((a, i) => attachments.findIndex((b) => b.name === a.name) !== i);
153
- if (dup)
154
- throw new ConfigError(`attachment "${dup.name}" is given twice`);
155
- const posted = [];
156
- const sent = new Map();
157
- for (const [i, a] of attachments.entries()) {
158
- try {
159
- const done = await this.postOne(a.content, { feed: own.feed, feedPassword: own.feedPassword, type: types[i], alt: a.alt, name: a.name, tags: own.tags, readId: own.readId });
160
- posted.push(done);
161
- sent.set(a.name, done.file);
162
- }
163
- catch (e) {
164
- if (e instanceof NotefeedError) {
165
- e.message = `attachment "${a.name}": ${e.message}`;
166
- e.attachment = a.name;
167
- e.posted = posted;
168
- }
169
- throw e;
170
- }
171
- }
172
- try {
173
- return { ...(await this.postOne(placeImages(content, sent), own)), attachments: posted };
174
- }
175
- catch (e) {
176
- if (e instanceof NotefeedError)
177
- e.posted = posted; // the text failed: the images stay, `attachment` stays null
178
- throw e;
141
+ form.append("file", new Blob([body], { type }), a.name);
142
+ if (a.alt)
143
+ form.append(`alt.${a.name}`, a.alt);
179
144
  }
145
+ const feed = this.feedFor(own.feed);
146
+ const headers = {
147
+ ...this.opts(own.feedPassword).headers,
148
+ "Content-Type": null, // fetch sets multipart/form-data with its boundary
149
+ ...(own.title && { "X-Note-Title": headerValue(own.title) }),
150
+ ...(own.tags?.length && { "X-Note-Tags": own.tags.join(",") }),
151
+ ...(own.readId && { "X-Read-Id": own.readId }),
152
+ };
153
+ const created = await this.call(postNote({ client: this.api, path: { feed }, body: form, headers, signal: AbortSignal.timeout(this.timeoutMs) }));
154
+ return { ...created, attachments: created.attachments ?? [] };
180
155
  }
181
156
  async postOne(content, options) {
182
157
  const feed = this.feedFor(options.feed);
@@ -1,2 +1,2 @@
1
1
  export { changeFeedPassword, deleteFeed, deleteNote, editNote, getFeed, getNote, getOpenApi, getReadFeed, getReadNote, listNotes, listReadNotes, type Options, patchNote, postNote, removeFeedPassword, updateFeed } from './sdk.gen.js';
2
- export type { ChangeFeedPasswordData, ChangeFeedPasswordError, ChangeFeedPasswordErrors, ChangeFeedPasswordResponse, ChangeFeedPasswordResponses, ClientOptions, Created, DeleteFeedData, DeleteFeedError, DeleteFeedErrors, DeleteFeedResponse, DeleteFeedResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, EditNoteData, EditNoteError, EditNoteErrors, EditNoteResponse, EditNoteResponses, Error, Feed, FeedSettings, GetFeedData, GetFeedError, GetFeedErrors, GetFeedResponse, GetFeedResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetOpenApiData, GetOpenApiResponse, GetOpenApiResponses, GetReadFeedData, GetReadFeedError, GetReadFeedErrors, GetReadFeedResponse, GetReadFeedResponses, GetReadNoteData, GetReadNoteError, GetReadNoteErrors, GetReadNoteResponse, GetReadNoteResponses, ListNotesData, ListNotesError, ListNotesErrors, ListNotesResponse, ListNotesResponses, ListReadNotesData, ListReadNotesError, ListReadNotesErrors, ListReadNotesResponse, ListReadNotesResponses, Note, NoteList, NoteMeta, PasswordJson, PatchNoteData, PatchNoteError, PatchNoteErrors, PatchNoteResponse, PatchNoteResponses, PostNoteData, PostNoteError, PostNoteErrors, PostNoteResponse, PostNoteResponses, ReadFeed, RemoveFeedPasswordData, RemoveFeedPasswordError, RemoveFeedPasswordErrors, RemoveFeedPasswordResponse, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedError, UpdateFeedErrors, UpdateFeedResponse, UpdateFeedResponses } from './types.gen.js';
2
+ export type { ChangeFeedPasswordData, ChangeFeedPasswordError, ChangeFeedPasswordErrors, ChangeFeedPasswordResponse, ChangeFeedPasswordResponses, ClientOptions, Created, DeleteFeedData, DeleteFeedError, DeleteFeedErrors, DeleteFeedResponse, DeleteFeedResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, EditNoteData, EditNoteError, EditNoteErrors, EditNoteResponse, EditNoteResponses, Error, Feed, FeedSettings, GetFeedData, GetFeedError, GetFeedErrors, GetFeedResponse, GetFeedResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetOpenApiData, GetOpenApiResponse, GetOpenApiResponses, GetReadFeedData, GetReadFeedError, GetReadFeedErrors, GetReadFeedResponse, GetReadFeedResponses, GetReadNoteData, GetReadNoteError, GetReadNoteErrors, GetReadNoteResponse, GetReadNoteResponses, ListNotesData, ListNotesError, ListNotesErrors, ListNotesResponse, ListNotesResponses, ListReadNotesData, ListReadNotesError, ListReadNotesErrors, ListReadNotesResponse, ListReadNotesResponses, MultipartNote, Note, NoteEdited, NoteList, NoteMeta, PasswordJson, PatchNoteData, PatchNoteError, PatchNoteErrors, PatchNoteResponse, PatchNoteResponses, Posted, PostNoteData, PostNoteError, PostNoteErrors, PostNoteResponse, PostNoteResponses, ReadFeed, RemoveFeedPasswordData, RemoveFeedPasswordError, RemoveFeedPasswordErrors, RemoveFeedPasswordResponse, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedError, UpdateFeedErrors, UpdateFeedResponse, UpdateFeedResponses } from './types.gen.js';
@@ -22,7 +22,7 @@ export declare const listNotes: <ThrowOnError extends boolean = false>(options:
22
22
  /**
23
23
  * Post a note
24
24
  *
25
- * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name).
25
+ * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name). A markdown note with its pictures is one `multipart/form-data` request: a `text` part (the markdown, a field or a file part; a file part keeps its line breaks, a field's are sent as CRLF), `file` parts (up to 10 pictures, each with its file name and image `Content-Type`) and `alt.<file name>` fields. The pictures are stored first, each as its own note named by its file name, then the text with its references to them (`![](chart.png)`, `[x]: chart.png`, as written or percent-decoded) swapped for the stored files; a picture it never refers to is appended as `![](file)`. Everything is checked before the first write, and a failed write removes what the request stored: all or nothing. `X-Note-Alt` and `X-Note-Name` are 400. The answer has `attachments`, the stored pictures in the order of the `file` parts. The `text` part is optional: `X-Note-Title` goes on the text note and `X-Note-Tags` on every note; with no `text` part only the pictures are stored, each with the title, and the answer's top level is the first picture. A refused multipart post that would have created a protected feed creates nothing.
26
26
  */
27
27
  export declare const postNote: <ThrowOnError extends boolean = false>(options: Options<PostNoteData, ThrowOnError>) => RequestResult<PostNoteResponses, PostNoteErrors, ThrowOnError>;
28
28
  /**
@@ -44,7 +44,7 @@ export declare const patchNote: <ThrowOnError extends boolean = false>(options:
44
44
  /**
45
45
  * Replace a note's content
46
46
  *
47
- * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit.
47
+ * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit. A markdown note with its pictures is one `multipart/form-data` request: a `text` part (the markdown, a field or a file part; a file part keeps its line breaks, a field's are sent as CRLF), `file` parts (up to 10 pictures, each with its file name and image `Content-Type`) and `alt.<file name>` fields. The pictures are stored first, each as its own note named by its file name, then the text with its references to them (`![](chart.png)`, `[x]: chart.png`, as written or percent-decoded) swapped for the stored files; a picture it never refers to is appended as `![](file)`. Everything is checked before the first write, and a failed write removes what the request stored: all or nothing. `X-Note-Alt` and `X-Note-Name` are 400. The answer has `attachments`, the stored pictures in the order of the `file` parts. On a `PUT` the `text` part is required and the note must be a markdown note; the note keeps its own title and tags, `X-Note-Tags` goes on the new pictures. A raw body ignores `X-Note-Tags`. The note is looked up first: a missing note is `404` and an image note `415` whatever the body holds.
48
48
  */
49
49
  export declare const editNote: <ThrowOnError extends boolean = false>(options: Options<EditNoteData, ThrowOnError>) => RequestResult<EditNoteResponses, EditNoteErrors, ThrowOnError>;
50
50
  /**
@@ -62,7 +62,7 @@ export declare const getFeed: <ThrowOnError extends boolean = false>(options: Op
62
62
  /**
63
63
  * Change a feed's settings
64
64
  *
65
- * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
65
+ * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control and text-direction override characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
66
66
  */
67
67
  export declare const updateFeed: <ThrowOnError extends boolean = false>(options: Options<UpdateFeedData, ThrowOnError>) => RequestResult<UpdateFeedResponses, UpdateFeedErrors, ThrowOnError>;
68
68
  /**
@@ -13,7 +13,7 @@ export const listNotes = (options) => (options.client ?? client).get({
13
13
  /**
14
14
  * Post a note
15
15
  *
16
- * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name).
16
+ * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name). A markdown note with its pictures is one `multipart/form-data` request: a `text` part (the markdown, a field or a file part; a file part keeps its line breaks, a field's are sent as CRLF), `file` parts (up to 10 pictures, each with its file name and image `Content-Type`) and `alt.<file name>` fields. The pictures are stored first, each as its own note named by its file name, then the text with its references to them (`![](chart.png)`, `[x]: chart.png`, as written or percent-decoded) swapped for the stored files; a picture it never refers to is appended as `![](file)`. Everything is checked before the first write, and a failed write removes what the request stored: all or nothing. `X-Note-Alt` and `X-Note-Name` are 400. The answer has `attachments`, the stored pictures in the order of the `file` parts. The `text` part is optional: `X-Note-Title` goes on the text note and `X-Note-Tags` on every note; with no `text` part only the pictures are stored, each with the title, and the answer's top level is the first picture. A refused multipart post that would have created a protected feed creates nothing.
17
17
  */
18
18
  export const postNote = (options) => (options.client ?? client).post({
19
19
  bodySerializer: null,
@@ -60,7 +60,7 @@ export const patchNote = (options) => (options.client ?? client).patch({
60
60
  /**
61
61
  * Replace a note's content
62
62
  *
63
- * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit.
63
+ * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit. A markdown note with its pictures is one `multipart/form-data` request: a `text` part (the markdown, a field or a file part; a file part keeps its line breaks, a field's are sent as CRLF), `file` parts (up to 10 pictures, each with its file name and image `Content-Type`) and `alt.<file name>` fields. The pictures are stored first, each as its own note named by its file name, then the text with its references to them (`![](chart.png)`, `[x]: chart.png`, as written or percent-decoded) swapped for the stored files; a picture it never refers to is appended as `![](file)`. Everything is checked before the first write, and a failed write removes what the request stored: all or nothing. `X-Note-Alt` and `X-Note-Name` are 400. The answer has `attachments`, the stored pictures in the order of the `file` parts. On a `PUT` the `text` part is required and the note must be a markdown note; the note keeps its own title and tags, `X-Note-Tags` goes on the new pictures. A raw body ignores `X-Note-Tags`. The note is looked up first: a missing note is `404` and an image note `415` whatever the body holds.
64
64
  */
65
65
  export const editNote = (options) => (options.client ?? client).put({
66
66
  bodySerializer: null,
@@ -95,7 +95,7 @@ export const getFeed = (options) => (options.client ?? client).get({
95
95
  /**
96
96
  * Change a feed's settings
97
97
  *
98
- * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
98
+ * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control and text-direction override characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
99
99
  */
100
100
  export const updateFeed = (options) => (options.client ?? client).put({
101
101
  security: [{ scheme: 'bearer', type: 'http' }],
@@ -88,6 +88,104 @@ export type Created = {
88
88
  */
89
89
  file_url: string | null;
90
90
  };
91
+ /**
92
+ * The note the post is about: its text note, or with no `text` part its first picture
93
+ */
94
+ export type Posted = {
95
+ id: string;
96
+ /**
97
+ * The note's page in the web UI
98
+ */
99
+ url: string;
100
+ /**
101
+ * The feed's page in the web UI
102
+ */
103
+ feed_url: string;
104
+ /**
105
+ * The feed's read-only RSS link, safe to share; null only if the server can't read the feed's stored read id, or if that id and the derived one both belong to other feeds
106
+ */
107
+ read_url: string | null;
108
+ /**
109
+ * The note's file name, `<id>.<extension>`. For an image note, pass it as a feed's `image` setting
110
+ */
111
+ file: string;
112
+ /**
113
+ * Where the note's file is served, absolute, under the feed's read id (public like the read link); null while the feed has no read link
114
+ */
115
+ file_url: string | null;
116
+ /**
117
+ * A multipart request's pictures, one per `file` part, in their order; absent for a raw body
118
+ */
119
+ attachments?: Array<Created>;
120
+ };
121
+ export type NoteEdited = {
122
+ /**
123
+ * The note's id: a UTC time to the second plus a random UUID for notes made here; any name without a dot for a file placed by hand
124
+ */
125
+ id: string;
126
+ /**
127
+ * The media type of the note's file: `text/markdown`, `image/png`, `image/jpeg`, `image/gif` or `image/webp`
128
+ */
129
+ type: string;
130
+ /**
131
+ * The note's file name, `<id>.<extension>`
132
+ */
133
+ file: string;
134
+ /**
135
+ * Where the note's file is served, absolute, under the feed's read id (public like the read link); null while the feed has no read link
136
+ */
137
+ file_url: string | null;
138
+ /**
139
+ * The size of the note's file in bytes
140
+ */
141
+ size: number;
142
+ /**
143
+ * The title set for the note, else the first heading or the first non-empty line of a markdown note, else the picture's alt text; may be empty
144
+ */
145
+ title: string;
146
+ /**
147
+ * The note's text, byte-for-byte as posted: for a text type (markdown) only; absent for a picture
148
+ */
149
+ content?: string;
150
+ /**
151
+ * Alternative text of an image note, when it has one
152
+ */
153
+ alt?: string;
154
+ /**
155
+ * The file name an image was posted with, when it came with one
156
+ */
157
+ name?: string;
158
+ /**
159
+ * When the note was posted (UTC)
160
+ */
161
+ created_at: string;
162
+ /**
163
+ * The note's page in the web UI
164
+ */
165
+ url: string;
166
+ /**
167
+ * Verified sign-in name of the poster; absent when the note was posted without a sign-in
168
+ */
169
+ sender?: string | null;
170
+ /**
171
+ * Labels the poster gave the note (not verified, and shown to readers like the note itself); empty when none
172
+ */
173
+ tags: Array<string>;
174
+ /**
175
+ * A multipart request's pictures, one per `file` part, in their order; absent for a raw body
176
+ */
177
+ attachments?: Array<Created>;
178
+ };
179
+ export type MultipartNote = {
180
+ /**
181
+ * The markdown note (UTF-8): a field, or a file part (its name is ignored; its type, if any, `text/markdown` or `application/octet-stream`) to keep its line breaks as they are, since a form field's are sent as CRLF. At most one; required on a PUT. Its references to the files (`![](chart.png)`, `[x]: chart.png`) are swapped for the stored files; a file it never refers to is appended as `![](file)`
182
+ */
183
+ text?: string;
184
+ /**
185
+ * A picture: up to 10 parts, each with a file name (one path segment, 1 to 200 characters, unique in the request) and its image `Content-Type`. An `alt.<file name>` field gives one its alternative text
186
+ */
187
+ file?: Array<Blob | File>;
188
+ };
91
189
  export type Error = {
92
190
  /**
93
191
  * A short reason, for people
@@ -100,11 +198,11 @@ export type Error = {
100
198
  };
101
199
  export type NoteMeta = {
102
200
  /**
103
- * The note's title, at most 100 characters, one line; empty or left out means the title is taken from the text (a markdown note) or there is none (an image)
201
+ * The note's title, at most 100 characters, one line (control and text-direction override characters are refused); empty or left out means the title is taken from the text (a markdown note) or there is none (an image)
104
202
  */
105
203
  title?: string;
106
204
  /**
107
- * Alternative text of an image note, at most 500 characters, one line
205
+ * Alternative text of an image note, at most 500 characters, one line (control and text-direction override characters are refused)
108
206
  */
109
207
  alt?: string;
110
208
  };
@@ -116,11 +214,11 @@ export type PasswordJson = {
116
214
  };
117
215
  export type FeedSettings = {
118
216
  /**
119
- * Display title, at most 100 characters, one line; empty means none (the feed's name is shown)
217
+ * Display title, at most 100 characters, one line (control and text-direction override characters are refused); empty means none (the feed's name is shown)
120
218
  */
121
219
  title: string;
122
220
  /**
123
- * Description, at most 500 characters, one line; may be empty
221
+ * Description, at most 500 characters, one line (control and text-direction override characters are refused); may be empty
124
222
  */
125
223
  description: string;
126
224
  /**
@@ -142,11 +240,11 @@ export type Feed = {
142
240
  */
143
241
  name: string;
144
242
  /**
145
- * Display title, at most 100 characters, one line; empty means none (the feed's name is shown)
243
+ * Display title, at most 100 characters, one line (control and text-direction override characters are refused); empty means none (the feed's name is shown)
146
244
  */
147
245
  title: string;
148
246
  /**
149
- * Description, at most 500 characters, one line; may be empty
247
+ * Description, at most 500 characters, one line (control and text-direction override characters are refused); may be empty
150
248
  */
151
249
  description: string;
152
250
  /**
@@ -168,11 +266,11 @@ export type Feed = {
168
266
  };
169
267
  export type ReadFeed = {
170
268
  /**
171
- * Display title, at most 100 characters, one line; empty means none (the feed's name is shown)
269
+ * Display title, at most 100 characters, one line (control and text-direction override characters are refused); empty means none (the feed's name is shown)
172
270
  */
173
271
  title: string;
174
272
  /**
175
- * Description, at most 500 characters, one line; may be empty
273
+ * Description, at most 500 characters, one line (control and text-direction override characters are refused); may be empty
176
274
  */
177
275
  description: string;
178
276
  /**
@@ -247,7 +345,7 @@ export type PostNoteData = {
247
345
  */
248
346
  'X-Read-Id'?: string;
249
347
  /**
250
- * The note's title, for a raw body; a JSON or form body's own `title` wins. At most 100 characters, one line.
348
+ * The note's title, for a raw body; a JSON or form body's own `title` wins. At most 100 characters, one line, with no control or text-direction override characters.
251
349
  */
252
350
  'X-Note-Title'?: string;
253
351
  /**
@@ -274,7 +372,7 @@ export type PostNoteData = {
274
372
  };
275
373
  export type PostNoteErrors = {
276
374
  /**
277
- * Invalid or reserved feed name; a blank note; a bad title, alt text or tags; a new password that is not printable ASCII
375
+ * Invalid or reserved feed name; a blank note; a bad title, alt text or tags; a new password that is not printable ASCII; a multipart body with an unexpected part, no `text` and no `file`, more than 10 files, a bad or repeated file name, a text sent with pictures that takes too long to read (`NOTEFEED_PARSE_TIMEOUT_MS`), or `X-Note-Alt` / `X-Note-Name`
278
376
  */
279
377
  400: Error;
280
378
  /**
@@ -286,11 +384,11 @@ export type PostNoteErrors = {
286
384
  */
287
385
  409: Error;
288
386
  /**
289
- * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES
387
+ * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES (each picture of a multipart body too), or a multipart body over 102400 bytes plus 10 images plus 64 KiB
290
388
  */
291
389
  413: Error;
292
390
  /**
293
- * Content-Type missing or not one of the accepted types, or the body is not what it declares
391
+ * Content-Type missing or not one of the accepted types, or the body (or a multipart body's picture) is not what it declares
294
392
  */
295
393
  415: Error;
296
394
  /**
@@ -305,9 +403,9 @@ export type PostNoteErrors = {
305
403
  export type PostNoteError = PostNoteErrors[keyof PostNoteErrors];
306
404
  export type PostNoteResponses = {
307
405
  /**
308
- * Stored
406
+ * Stored: the note, and with a multipart body its pictures
309
407
  */
310
- 201: Created;
408
+ 201: Posted;
311
409
  };
312
410
  export type PostNoteResponse = PostNoteResponses[keyof PostNoteResponses];
313
411
  export type DeleteNoteData = {
@@ -465,6 +563,10 @@ export type EditNoteData = {
465
563
  * The feed's own password, when it has one: to post, list or get, and (as the current password) to change or remove it. On the `POST` that creates a feed it sets the feed's password; on a `POST` to an existing feed that has none it answers 409. An empty value is the same as no header, so a `POST` with an empty one creates an open feed.
466
564
  */
467
565
  'X-Feed-Password'?: string;
566
+ /**
567
+ * Only for a multipart body: the tags go on the new pictures (the note keeps its own). A raw body ignores it. Comma-separated, empty is none.
568
+ */
569
+ 'X-Note-Tags'?: string;
468
570
  };
469
571
  path: {
470
572
  /**
@@ -481,7 +583,7 @@ export type EditNoteData = {
481
583
  };
482
584
  export type EditNoteErrors = {
483
585
  /**
484
- * Invalid or reserved feed name; a blank note
586
+ * Invalid or reserved feed name; a blank note; a multipart body with an unexpected part, no `text`, more than 10 files, a bad or repeated file name, a text sent with pictures that takes too long to read (`NOTEFEED_PARSE_TIMEOUT_MS`), or `X-Note-Alt` / `X-Note-Name`
485
587
  */
486
588
  400: Error;
487
589
  /**
@@ -493,24 +595,28 @@ export type EditNoteErrors = {
493
595
  */
494
596
  404: Error;
495
597
  /**
496
- * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES
598
+ * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES (each picture of a multipart body too), or a multipart body over 102400 bytes plus 10 images plus 64 KiB
497
599
  */
498
600
  413: Error;
499
601
  /**
500
- * Content-Type missing, not accepted or not the note's own type, or the body is not what it declares
602
+ * Content-Type missing, not accepted or not the note's own type (a multipart body is for a markdown note), or the body (or a picture) is not what it declares
501
603
  */
502
604
  415: Error;
503
605
  /**
504
606
  * Too many posts, edits and deletes, or wrong passwords, from this client
505
607
  */
506
608
  429: Error;
609
+ /**
610
+ * NOTEFEED_MAX_IMAGES_PER_FEED reached
611
+ */
612
+ 507: Error;
507
613
  };
508
614
  export type EditNoteError = EditNoteErrors[keyof EditNoteErrors];
509
615
  export type EditNoteResponses = {
510
616
  /**
511
- * The note as it is now
617
+ * The note as it is now, and with a multipart body the new pictures
512
618
  */
513
- 200: Note;
619
+ 200: NoteEdited;
514
620
  };
515
621
  export type EditNoteResponse = EditNoteResponses[keyof EditNoteResponses];
516
622
  export type DeleteFeedData = {
@@ -618,7 +724,7 @@ export type UpdateFeedData = {
618
724
  };
619
725
  export type UpdateFeedErrors = {
620
726
  /**
621
- * Invalid or reserved feed name, bad JSON, a title or description that is too long or has control characters, a malformed read id, a reserved feed's read id, or chosen read ids turned off
727
+ * Invalid or reserved feed name, bad JSON, a title or description that is too long or has control or text-direction override characters, a malformed read id, a reserved feed's read id, or chosen read ids turned off
622
728
  */
623
729
  400: Error;
624
730
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notefeed",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Client and CLI for notefeed, a self-hosted inbox for markdown notes that you read back as RSS.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Marcel Bruckner",
@@ -36,12 +36,12 @@
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "git+https://github.com/Marceltov/notefeed.git",
39
+ "url": "git+https://github.com/notefeed/notefeed.git",
40
40
  "directory": "packages/js"
41
41
  },
42
- "homepage": "https://docs.notefeed.me/clients/",
42
+ "homepage": "https://docs.notefeed.me/integrations/clients/",
43
43
  "bugs": {
44
- "url": "https://github.com/Marceltov/notefeed/issues"
44
+ "url": "https://github.com/notefeed/notefeed/issues"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^20",