links-notation 0.27.0 → 0.27.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
@@ -27,6 +27,14 @@ bun add links-notation
27
27
  import { Parser, Link } from 'npm:links-notation@^0.6.0';
28
28
  ```
29
29
 
30
+ #### In browsers
31
+
32
+ The package has no runtime dependencies and imports no Node.js built-in
33
+ modules, so bundlers need no polyfills. For example, esbuild bundles it with
34
+ `esbuild entry.js --bundle --platform=browser`. Before 0.27.1, `StreamParser`
35
+ imported Node's `events` module, and browser bundles needed the `events`
36
+ package installed as a polyfill.
37
+
30
38
  ### Local Development Setup
31
39
 
32
40
  For contributors working on the source code:
@@ -443,7 +451,11 @@ Container for grouping related links.
443
451
 
444
452
  #### `StreamParser`
445
453
 
446
- EventEmitter-based streaming parser for incremental parsing.
454
+ Event-emitting streaming parser for incremental parsing. Its built-in emitter
455
+ has the Node.js `EventEmitter` listener methods (`on`, `once`, `off`,
456
+ `addListener`, `removeListener`, `prependListener`, `prependOnceListener`,
457
+ `removeAllListeners`, `emit`, `listeners`, `listenerCount`, `eventNames`) and
458
+ works in browsers without a polyfill. It is not an `instanceof EventEmitter`.
447
459
 
448
460
  - `constructor(options)` - Create a new streaming parser
449
461
  - `options.maxInputSize` - Maximum input size in bytes (default: 10MB)
package/README.ru.md CHANGED
@@ -174,6 +174,13 @@ stream.end('следующая связь');
174
174
  `drain()`, `reset()` и ограничение размера незавершённой записи. См.
175
175
  [исполняемый пример](../examples/js_streaming_parser.js).
176
176
 
177
+ Пакет не импортирует встроенные модули Node.js, поэтому сборщикам для браузера
178
+ не нужны полифилы: например, `esbuild entry.js --bundle --platform=browser`.
179
+ Встроенный в `StreamParser` эмиттер поддерживает методы подписки
180
+ `EventEmitter` из Node.js (`on`, `once`, `off`, `removeListener`, `emit`,
181
+ `listenerCount` и другие). До версии 0.27.1 `StreamParser` импортировал модуль
182
+ `events`, и для браузерной сборки требовался пакет-полифил `events`.
183
+
177
184
  ## Примеры синтаксиса
178
185
 
179
186
  ### Дуплеты (2-кортежи)
package/dist/index.js CHANGED
@@ -3072,8 +3072,104 @@ function formatIndentedDocument(entries) {
3072
3072
  `) + `
3073
3073
  ` : "";
3074
3074
  }
3075
+ // src/Emitter.js
3076
+ class Emitter {
3077
+ #listeners = new Map;
3078
+ on(event, listener) {
3079
+ return this.#add(event, listener, false, false);
3080
+ }
3081
+ addListener(event, listener) {
3082
+ return this.on(event, listener);
3083
+ }
3084
+ prependListener(event, listener) {
3085
+ return this.#add(event, listener, false, true);
3086
+ }
3087
+ once(event, listener) {
3088
+ return this.#add(event, listener, true, false);
3089
+ }
3090
+ prependOnceListener(event, listener) {
3091
+ return this.#add(event, listener, true, true);
3092
+ }
3093
+ off(event, listener) {
3094
+ checkListener(listener);
3095
+ const entries = this.#listeners.get(event);
3096
+ if (!entries)
3097
+ return this;
3098
+ const entry = entries.findLast((entry) => entry.listener === listener);
3099
+ if (entry)
3100
+ this.#remove(event, entry);
3101
+ return this;
3102
+ }
3103
+ removeListener(event, listener) {
3104
+ return this.off(event, listener);
3105
+ }
3106
+ removeAllListeners(event) {
3107
+ if (event === undefined) {
3108
+ this.#listeners.clear();
3109
+ } else {
3110
+ this.#listeners.delete(event);
3111
+ }
3112
+ return this;
3113
+ }
3114
+ emit(event, ...arguments_) {
3115
+ const entries = this.#listeners.get(event);
3116
+ if (!entries || entries.length === 0) {
3117
+ if (event === "error") {
3118
+ const error = arguments_[0];
3119
+ if (error instanceof Error)
3120
+ throw error;
3121
+ throw new Error(`Unhandled error. (${String(error)})`);
3122
+ }
3123
+ return false;
3124
+ }
3125
+ for (const entry of [...entries]) {
3126
+ if (entry.once)
3127
+ this.#remove(event, entry);
3128
+ entry.listener.apply(this, arguments_);
3129
+ }
3130
+ return true;
3131
+ }
3132
+ listeners(event) {
3133
+ return (this.#listeners.get(event) ?? []).map((entry) => entry.listener);
3134
+ }
3135
+ listenerCount(event) {
3136
+ return this.#listeners.get(event)?.length ?? 0;
3137
+ }
3138
+ eventNames() {
3139
+ return [...this.#listeners.keys()];
3140
+ }
3141
+ #remove(event, entry) {
3142
+ const entries = this.#listeners.get(event);
3143
+ const index = entries?.indexOf(entry) ?? -1;
3144
+ if (index === -1)
3145
+ return;
3146
+ entries.splice(index, 1);
3147
+ if (entries.length === 0)
3148
+ this.#listeners.delete(event);
3149
+ }
3150
+ #add(event, listener, once, prepend) {
3151
+ checkListener(listener);
3152
+ let entries = this.#listeners.get(event);
3153
+ if (!entries) {
3154
+ entries = [];
3155
+ this.#listeners.set(event, entries);
3156
+ }
3157
+ const entry = { listener, once };
3158
+ if (prepend) {
3159
+ entries.unshift(entry);
3160
+ } else {
3161
+ entries.push(entry);
3162
+ }
3163
+ return this;
3164
+ }
3165
+ }
3166
+ function checkListener(listener) {
3167
+ if (typeof listener !== "function") {
3168
+ throw new TypeError(`The "listener" argument must be a function. Received ${typeof listener}`);
3169
+ }
3170
+ }
3171
+
3075
3172
  // src/StreamParser.js
3076
- import { EventEmitter } from "events";
3077
3173
  var DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
3078
3174
 
3079
3175
  class StreamParseError extends Error {
@@ -3094,7 +3190,7 @@ class StreamParseError extends Error {
3094
3190
  }
3095
3191
  }
3096
3192
 
3097
- class StreamParser extends EventEmitter {
3193
+ class StreamParser extends Emitter {
3098
3194
  constructor(options = {}) {
3099
3195
  super();
3100
3196
  this.parser = options.parser ?? new Parser(options);
package/index.d.ts CHANGED
@@ -251,7 +251,10 @@ export class StreamParseError extends Error {
251
251
  snippet: string;
252
252
  }
253
253
 
254
- /** Incrementally emits complete top-level Links Notation records. */
254
+ /**
255
+ * Incrementally emits complete top-level Links Notation records. Its listener
256
+ * methods mirror Node.js EventEmitter without depending on Node built-ins.
257
+ */
255
258
  export class StreamParser {
256
259
  parser: Parser;
257
260
  comments: boolean;
@@ -282,6 +285,21 @@ export class StreamParser {
282
285
  once(event: 'end', listener: (links: Link[]) => void): this;
283
286
  once(event: 'error', listener: (error: Error) => void): this;
284
287
  off(event: string, listener: (...arguments_: any[]) => void): this;
288
+ addListener(event: string, listener: (...arguments_: any[]) => void): this;
289
+ removeListener(event: string, listener: (...arguments_: any[]) => void): this;
290
+ prependListener(
291
+ event: string,
292
+ listener: (...arguments_: any[]) => void
293
+ ): this;
294
+ prependOnceListener(
295
+ event: string,
296
+ listener: (...arguments_: any[]) => void
297
+ ): this;
298
+ removeAllListeners(event?: string): this;
299
+ emit(event: string, ...arguments_: any[]): boolean;
300
+ listeners(event: string): Array<(...arguments_: any[]) => void>;
301
+ listenerCount(event: string): number;
302
+ eventNames(): string[];
285
303
 
286
304
  /** Lazily parse a synchronous iterable of chunks. */
287
305
  static parse(
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
7
7
  "type": "module",
8
8
  "scripts": {
9
- "build": "bun run build:grammar && bun build src/index.js --outdir=dist --target=node",
9
+ "build": "bun run build:grammar && bun build src/index.js --outdir=dist --target=browser",
10
10
  "build:grammar": "peggy --format es -o src/parser-generated.js src/grammar.pegjs",
11
11
  "test": "bun test",
12
12
  "test:watch": "bun test --watch",
package/src/Emitter.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A dependency-free event emitter with the Node.js `EventEmitter` methods that
3
+ * StreamParser documents.
4
+ *
5
+ * Importing Node's built-in `events` module made every bundle of the package
6
+ * entry point, including parser-only browser bundles, fail to resolve `events`
7
+ * unless the consumer installed a polyfill (issue #351). This class runs
8
+ * unchanged in browsers, Node.js, Bun, and Deno. It keeps Node's semantics:
9
+ * listeners run synchronously in registration order, and emitting `error`
10
+ * without a listener throws the error. It also works with Node's
11
+ * `events.once()` and `events.on()` helpers.
12
+ */
13
+ export class Emitter {
14
+ #listeners = new Map();
15
+
16
+ /** Add a listener for an event. */
17
+ on(event, listener) {
18
+ return this.#add(event, listener, false, false);
19
+ }
20
+
21
+ /** Alias of on(), as in Node.js. */
22
+ addListener(event, listener) {
23
+ return this.on(event, listener);
24
+ }
25
+
26
+ /** Add a listener that runs before the existing ones. */
27
+ prependListener(event, listener) {
28
+ return this.#add(event, listener, false, true);
29
+ }
30
+
31
+ /** Add a listener that is removed after its first call. */
32
+ once(event, listener) {
33
+ return this.#add(event, listener, true, false);
34
+ }
35
+
36
+ /** Add a one-time listener that runs before the existing ones. */
37
+ prependOnceListener(event, listener) {
38
+ return this.#add(event, listener, true, true);
39
+ }
40
+
41
+ /** Remove the most recently added registration of a listener. */
42
+ off(event, listener) {
43
+ checkListener(listener);
44
+ const entries = this.#listeners.get(event);
45
+ if (!entries) return this;
46
+ const entry = entries.findLast((entry) => entry.listener === listener);
47
+ if (entry) this.#remove(event, entry);
48
+ return this;
49
+ }
50
+
51
+ /** Alias of off(), as in Node.js. */
52
+ removeListener(event, listener) {
53
+ return this.off(event, listener);
54
+ }
55
+
56
+ /** Remove the listeners of one event, or of every event. */
57
+ removeAllListeners(event) {
58
+ if (event === undefined) {
59
+ this.#listeners.clear();
60
+ } else {
61
+ this.#listeners.delete(event);
62
+ }
63
+ return this;
64
+ }
65
+
66
+ /**
67
+ * Call the listeners of an event with the given arguments and return whether
68
+ * there were any. An `error` event without listeners throws its argument.
69
+ */
70
+ emit(event, ...arguments_) {
71
+ const entries = this.#listeners.get(event);
72
+ if (!entries || entries.length === 0) {
73
+ if (event === 'error') {
74
+ const error = arguments_[0];
75
+ if (error instanceof Error) throw error;
76
+ throw new Error(`Unhandled error. (${String(error)})`);
77
+ }
78
+ return false;
79
+ }
80
+ // Listeners added or removed while emitting take effect on the next emit.
81
+ for (const entry of [...entries]) {
82
+ if (entry.once) this.#remove(event, entry);
83
+ entry.listener.apply(this, arguments_);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ /** Return a copy of the listeners of an event. */
89
+ listeners(event) {
90
+ return (this.#listeners.get(event) ?? []).map((entry) => entry.listener);
91
+ }
92
+
93
+ /** Return the number of listeners of an event. */
94
+ listenerCount(event) {
95
+ return this.#listeners.get(event)?.length ?? 0;
96
+ }
97
+
98
+ /** Return the names of events that have listeners. */
99
+ eventNames() {
100
+ return [...this.#listeners.keys()];
101
+ }
102
+
103
+ #remove(event, entry) {
104
+ const entries = this.#listeners.get(event);
105
+ const index = entries?.indexOf(entry) ?? -1;
106
+ if (index === -1) return;
107
+ entries.splice(index, 1);
108
+ if (entries.length === 0) this.#listeners.delete(event);
109
+ }
110
+
111
+ #add(event, listener, once, prepend) {
112
+ checkListener(listener);
113
+ let entries = this.#listeners.get(event);
114
+ if (!entries) {
115
+ entries = [];
116
+ this.#listeners.set(event, entries);
117
+ }
118
+ const entry = { listener, once };
119
+ if (prepend) {
120
+ entries.unshift(entry);
121
+ } else {
122
+ entries.push(entry);
123
+ }
124
+ return this;
125
+ }
126
+ }
127
+
128
+ function checkListener(listener) {
129
+ if (typeof listener !== 'function') {
130
+ throw new TypeError(
131
+ `The "listener" argument must be a function. Received ${typeof listener}`
132
+ );
133
+ }
134
+ }
@@ -1,4 +1,4 @@
1
- import { EventEmitter } from 'events';
1
+ import { Emitter } from './Emitter.js';
2
2
  import { Parser } from './Parser.js';
3
3
  import { DelimitedReferences } from './quotes.js';
4
4
 
@@ -39,7 +39,7 @@ export class StreamParseError extends Error {
39
39
  * uses the regular Parser as the source of truth, which keeps quoted strings,
40
40
  * nested contexts, comments, and future grammar changes in one place.
41
41
  */
42
- export class StreamParser extends EventEmitter {
42
+ export class StreamParser extends Emitter {
43
43
  constructor(options = {}) {
44
44
  super();
45
45
  this.parser = options.parser ?? new Parser(options);
@@ -0,0 +1,187 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { on, once } from 'node:events';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath, URL } from 'node:url';
5
+ import { Emitter } from '../src/Emitter.js';
6
+ import { Parser, StreamParser } from '../src/index.js';
7
+
8
+ const source = fileURLToPath(new URL('../src/index.js', import.meta.url));
9
+ const published = fileURLToPath(new URL('../dist/index.js', import.meta.url));
10
+
11
+ // Bundlers such as esbuild (`--platform=browser`) cannot resolve Node built-ins
12
+ // like `events`, so any bare import in the package graph breaks browser builds
13
+ // unless consumers add a polyfill (issue #351). Bun's own browser target would
14
+ // silently polyfill them, so this plugin fails on every bare import instead.
15
+ const rejectBareImports = (imports) => ({
16
+ name: 'reject-bare-imports',
17
+ setup(build) {
18
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
19
+ imports.push(`${args.path} (from ${args.importer})`);
20
+ return { path: args.path, external: true };
21
+ });
22
+ },
23
+ });
24
+
25
+ async function bundleForBrowser(entrypoint) {
26
+ const imports = [];
27
+ const result = await globalThis.Bun.build({
28
+ entrypoints: [entrypoint],
29
+ target: 'browser',
30
+ plugins: [rejectBareImports(imports)],
31
+ });
32
+ return { result, imports };
33
+ }
34
+
35
+ describe('browser bundling (issue #351)', () => {
36
+ test('the source entry point imports no Node built-ins or packages', async () => {
37
+ const { result, imports } = await bundleForBrowser(source);
38
+ expect(result.success).toBe(true);
39
+ expect(imports).toEqual([]);
40
+ });
41
+
42
+ test('the published dist entry point imports no Node built-ins or packages', async () => {
43
+ const { result, imports } = await bundleForBrowser(published);
44
+ expect(result.success).toBe(true);
45
+ expect(imports).toEqual([]);
46
+
47
+ const text = readFileSync(published, 'utf8');
48
+ expect(text).not.toMatch(/^\s*import\s[^;]*from\s*["'][^./]/m);
49
+ expect(text).not.toMatch(/\brequire\(\s*["']/);
50
+ });
51
+
52
+ test('the dist bundle parses and streams like the source', async () => {
53
+ const dist = await import(published);
54
+ const input = '(law: text)\nnext value';
55
+ expect(dist.formatLinks(new dist.Parser().parse(input))).toBe(
56
+ dist.formatLinks(new Parser().parse(input))
57
+ );
58
+
59
+ const stream = new dist.StreamParser();
60
+ const seen = [];
61
+ stream.on('link', (link) => seen.push(link.toString()));
62
+ stream.end(input);
63
+ expect(seen).toEqual(['(law: text)', '(next value)']);
64
+ });
65
+ });
66
+
67
+ describe('Emitter', () => {
68
+ test('calls listeners in order with the emitter as this', () => {
69
+ const emitter = new Emitter();
70
+ const calls = [];
71
+ emitter.on('event', function (value) {
72
+ calls.push(['first', value, this === emitter]);
73
+ });
74
+ emitter.addListener('event', (value) => calls.push(['second', value]));
75
+ emitter.prependListener('event', (value) => calls.push(['zeroth', value]));
76
+
77
+ expect(emitter.emit('event', 1)).toBe(true);
78
+ expect(calls).toEqual([
79
+ ['zeroth', 1],
80
+ ['first', 1, true],
81
+ ['second', 1],
82
+ ]);
83
+ expect(emitter.emit('missing')).toBe(false);
84
+ });
85
+
86
+ test('once listeners run a single time', () => {
87
+ const emitter = new Emitter();
88
+ const calls = [];
89
+ emitter.once('event', (value) => calls.push(`once ${value}`));
90
+ emitter.prependOnceListener('event', (value) => calls.push(`pre ${value}`));
91
+ emitter.emit('event', 1);
92
+ emitter.emit('event', 2);
93
+ expect(calls).toEqual(['pre 1', 'once 1']);
94
+ expect(emitter.listenerCount('event')).toBe(0);
95
+ });
96
+
97
+ test('off removes the latest registration, including once listeners', () => {
98
+ const emitter = new Emitter();
99
+ const calls = [];
100
+ const listener = (value) => calls.push(value);
101
+ emitter.on('event', listener);
102
+ emitter.on('event', listener);
103
+ emitter.off('event', listener);
104
+ emitter.emit('event', 'a');
105
+ emitter.removeListener('event', listener);
106
+ emitter.emit('event', 'b');
107
+ emitter.once('event', listener);
108
+ emitter.off('event', listener);
109
+ emitter.emit('event', 'c');
110
+ expect(calls).toEqual(['a']);
111
+ expect(emitter.eventNames()).toEqual([]);
112
+ });
113
+
114
+ test('listener changes during emit take effect on the next emit', () => {
115
+ const emitter = new Emitter();
116
+ const calls = [];
117
+ const second = () => calls.push('second');
118
+ emitter.on('event', () => {
119
+ calls.push('first');
120
+ emitter.off('event', second);
121
+ emitter.on('event', () => calls.push('added'));
122
+ });
123
+ emitter.on('event', second);
124
+ emitter.emit('event');
125
+ expect(calls).toEqual(['first', 'second']);
126
+ });
127
+
128
+ test('inspects and removes listeners', () => {
129
+ const emitter = new Emitter();
130
+ const listener = () => {};
131
+ emitter.on('a', listener).on('b', listener).on('b', listener);
132
+ expect(emitter.listeners('b')).toEqual([listener, listener]);
133
+ expect(emitter.listenerCount('b')).toBe(2);
134
+ expect(emitter.eventNames()).toEqual(['a', 'b']);
135
+ emitter.removeAllListeners('b');
136
+ expect(emitter.eventNames()).toEqual(['a']);
137
+ emitter.removeAllListeners();
138
+ expect(emitter.listenerCount('a')).toBe(0);
139
+ });
140
+
141
+ test('an unhandled error event throws like Node.js', () => {
142
+ const emitter = new Emitter();
143
+ const error = new Error('boom');
144
+ expect(() => emitter.emit('error', error)).toThrow(error);
145
+ expect(() => emitter.emit('error', 'text')).toThrow(
146
+ 'Unhandled error. (text)'
147
+ );
148
+ emitter.on('error', () => {});
149
+ expect(emitter.emit('error', error)).toBe(true);
150
+ });
151
+
152
+ test('rejects listeners that are not functions', () => {
153
+ expect(() => new Emitter().on('event', 'nope')).toThrow(TypeError);
154
+ });
155
+
156
+ test('works with the Node.js events.once helper', async () => {
157
+ const stream = new StreamParser();
158
+ const ended = once(stream, 'end');
159
+ stream.end('a b');
160
+ const [links] = await ended;
161
+ expect(links.map(String)).toEqual(['(a b)']);
162
+ });
163
+
164
+ test('works with the Node.js events.on async iterator', async () => {
165
+ const stream = new StreamParser();
166
+ const controller = new globalThis.AbortController();
167
+ const links = on(stream, 'link', { signal: controller.signal });
168
+ globalThis.setTimeout(() => stream.end('a b\nc d'), 0);
169
+ const seen = [];
170
+ try {
171
+ for await (const [link] of links) {
172
+ seen.push(String(link));
173
+ if (seen.length === 2) controller.abort();
174
+ }
175
+ } catch (error) {
176
+ if (error.name !== 'AbortError') throw error;
177
+ }
178
+ expect(seen).toEqual(['(a b)', '(c d)']);
179
+ expect(stream.listenerCount('link')).toBe(0);
180
+ expect(stream.listenerCount('error')).toBe(0);
181
+ });
182
+
183
+ test('StreamParser still throws the stream error without a listener', () => {
184
+ const stream = new StreamParser();
185
+ expect(() => stream.write(1)).toThrow(TypeError);
186
+ });
187
+ });