@pkcprotocol/active-page-sort 0.0.4 → 0.0.6

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/AGENTS.md CHANGED
@@ -25,23 +25,25 @@ Conventions specific to this package:
25
25
  - `NO_BUMP_KEYWORDS_OPTION` (`noBumpKeywords`) is the wire contract. UIs read it off
26
26
  `community.pageSorts.posts.active.publicOptions` to render a sage marker, and most of them never install
27
27
  this package. A rename is a new contract, never an edit. The literal is pinned in `test/unit/no-bump.test.ts`.
28
+ `BUMP_LIMIT_OPTION` (`bumpLimit`) is the same kind of contract and is pinned next to it.
28
29
  - The match is a whole line equal to the keyword, exact and case-sensitive. `src/no-bump.ts` imports
29
30
  nothing and must keep matching what the README describes, because UIs copy or bundle it for the browser.
30
31
  - `score` is sync and never returns `null`: a saged thread sinks, it is not filtered. It reads
31
- `comment.timestamp`, `commentUpdate.cid`, the reserved `exclude*` options, and per reply `parentCid`,
32
- `timestamp`, `content`, and the CommentUpdate's `cid`, `removed`, `approved`, `pendingApproval` and
33
- `edit.deleted`. There is no database access and no network; a need for either is a later iteration of
34
- the pkc-js contract, not a reason to reach around it.
35
- - `applyReplyExclusions` (in `src/no-bump.ts`) re-applies pkc-js's reply exclusions inside `score`. On the
36
- community it is a no-op, the list is already filtered; on a client it turns a raw reply-page walk into
37
- the set the community scored. It must keep matching pkc-js's `applyPageSortExclusions` and its subtree
38
- cut (`createDescendantsLookup`, `src/pages/page-sort-scoring.ts`), flag parsing included (trimmed,
39
- case-insensitive `true`). A new reserved exclusion in pkc-js means a release here.
40
- `excludeCommentsWithDifferentCommunityAddress` is left out on purpose: `score` has no community address
41
- (pkc-js#401).
32
+ `comment.timestamp`, the reserved `exclude*` options, and per reply `timestamp`, `content`, and the
33
+ CommentUpdate's `cid`, `removed`, `approved`, `pendingApproval` and `edit.deleted`. There is no database access and
34
+ no network; a need for either is a later iteration of the pkc-js contract, not a reason to reach around it.
35
+ - `isCountedReply` / `bumpTimestamp` (in `src/no-bump.ts`) apply the `exclude*` options to each reply's own
36
+ CommentUpdate, the tombstone rule of pkc-js's "Which replies count" (`docs/protocol/comment-lifecycle.md`):
37
+ a dropped reply does not bump, the replies under it are judged on their own, never cut. pkc-js passes every
38
+ reply to `score` except foreign and CommentUpdate-less ones (pkc-js#407). Flag parsing matches pkc-js
39
+ (trimmed, case-insensitive `true`). With the `posts` defaults it must match the built-in `active`. A new
40
+ reserved exclusion in pkc-js means a release here. `excludeCommentsWithDifferentCommunityAddress` is left
41
+ out on purpose: pkc-js applies it, and `score` has no community address (pkc-js#401).
42
+ - The bump limit counts the replies `isCountedReply` keeps, saged or not, ordered by `(timestamp, cid)` with the
43
+ CID compared as a plain string, so the community and a client re-sort agree on a tie at the boundary.
42
44
  - The scorer ignores `commentUpdate.lastReplyTimestamp` on purpose. That aggregate counts saged replies.
43
45
  - `validatePageSortSettings` is sync and rejects only what can never work (empty list, line break, size
44
- bounds). An unset option is valid and means plain bump order.
46
+ bounds, a `bumpLimit` that is not a positive integer). An unset option is valid and means plain bump order.
45
47
  - Test fixtures are typed against pkc-js's own types through `test/unit/fixtures.ts`. Do not cast a
46
48
  fixture through `unknown`; a field that does not exist on the wire must fail to compile.
47
49
  - Never use the `any` type.
package/README.md CHANGED
@@ -9,24 +9,26 @@ A board's catalog shows threads in bump order: the thread with the newest reply
9
9
  - A thread's **bump time** is the newest of the post's own timestamp and the timestamps of its replies that are **not** no-bump replies.
10
10
  - A **no-bump reply** is a reply whose content has a whole line equal to one of the configured keywords. The match is exact and case-sensitive: a content of `sage`, or a multi-line content with a line that is exactly `sage`, does not bump. `sage is overused` in prose, `Sage`, `#sage` and ` sage` all bump.
11
11
  - Replies **under** a no-bump reply still count, at any depth. Someone answering a saged reply bumps the thread as usual.
12
- - A reply the sort's exclusion options drop (by default: removed, deleted, disapproved or pending approval) never counts, and neither does anything under it. Deleting a saged reply also takes the answers under it out of the bump.
13
- - A saged thread **sinks, it is never hidden**. The sort ranks, it does not filter.
14
- - With the keyword option unset the sort is plain bump order, identical to pkc-js's built-in `active`.
12
+ - A reply the sort's exclusion options drop (by default: removed, deleted, disapproved or pending approval) does not bump the thread, but the present replies under it still do, at any depth: a removed or deleted reply is a **tombstone**, not a cut. This is pkc-js's [Which replies count](https://github.com/pkcprotocol/pkc-js/blob/master/docs/protocol/comment-lifecycle.md#which-replies-count) rule, the one the built-in `active` follows through `lastReplyTimestamp`. A board that turns a flag off (`excludeDeletedComments: "false"`) lets those replies bump again.
13
+ - With a **bump limit** set, only a thread's first `bumpLimit` counted replies can bump it. Past the limit the thread stays open and keeps taking replies, but none of them bump it, so it sinks, the imageboard bump limit. Every counted reply counts toward the limit, saged or not, at any depth. "First" is by reply timestamp, ties broken by the reply's CID.
14
+ - A saged thread, or one past its bump limit, **sinks, it is never hidden**. The sort ranks, it does not filter.
15
+ - With both options unset the sort is plain bump order, identical to pkc-js's built-in `active`.
15
16
 
16
17
  The sort publishes under the wire key `active`, the same key as the built-in, so a UI that already reads `community.posts.pages.active` shows the saged order without any change. The community also publishes what the key means in `community.pageSorts.posts.active`, including the keyword list, so a UI can tell this sort from the built-in and render a sage marker on matching replies.
17
18
 
18
- The word `sage` appears nowhere in this package. The board supplies its vocabulary through the `noBumpKeywords` option.
19
+ The word `sage` appears nowhere in this package. The board supplies its vocabulary through the `noBumpKeywords` option, and its limit through `bumpLimit`.
19
20
 
20
21
  ## How the sorting works
21
22
 
22
- pkc-js regenerates a community's post pages on every publish cycle. For each configured sort it calls the sort file's `score` once per post, higher scores first, equal scores in insertion order. Because this sort declares `requireReplies`, pkc-js hands `score` every descendant of the post as one flat list (reduced by the sort's exclusion options, so removed or deleted replies are already gone, together with their subtrees), and the score is:
23
+ pkc-js regenerates a community's post pages on every publish cycle. For each configured sort it calls the sort file's `score` once per post, higher scores first, equal scores in insertion order. Because this sort declares `requireReplies`, pkc-js hands `score` every descendant of the post as one flat list. pkc-js drops only replies from another community and replies with no CommentUpdate yet; removed, deleted, disapproved and pending replies all arrive, and the sort applies its `exclude*` options to them itself ([pkc-js#407](https://github.com/pkcprotocol/pkc-js/issues/407)). The score is:
23
24
 
24
25
  ```
25
- counted = replies reachable from the post through replies the exclusion options keep
26
- max(post.timestamp, ...counted.filter(not no-bump).map(reply => reply.timestamp))
26
+ counted = replies no exclude* option that is on drops (each judged on its own)
27
+ within = counted sorted by (timestamp, cid), the first bumpLimit of them (all of them without a limit)
28
+ max(post.timestamp, ...within.filter(not no-bump).map(reply => reply.timestamp))
27
29
  ```
28
30
 
29
- On the community the first line changes nothing: pkc-js already applied the exclusions. It is there for clients. The reply pages a UI walks keep deleted and removed replies, so `score` applies the published `exclude*` options itself, which makes a raw walk land on the community's order (see [Re-sort a page locally](#4-re-sort-a-page-locally-optional)). That is the whole algorithm. pkc-js owns everything around it: pinned threads first (`pinnedFirst`), the optional age window (`maxAge`), chunking into pages, the moderation exclusions. The file contains no date arithmetic and never sees a pinned flag.
31
+ Each reply is judged on its own, so a skipped reply never hides the replies under it. The reply pages a UI walks keep removed and deleted replies as tombstones too, so a raw walk scores like the community (see [Re-sort a page locally](#4-re-sort-a-page-locally-optional)). That is the whole algorithm. pkc-js owns everything around it: pinned threads first (`pinnedFirst`), the optional age window (`maxAge`), chunking into pages, the moderation exclusions on the threads being ranked. The file contains no date arithmetic and never sees a pinned flag.
30
32
 
31
33
  The same `score` function runs on a client that installed this package, so a UI can re-sort a page it holds and land on the community's order. See [For UI developers](#for-ui-developers).
32
34
 
@@ -36,7 +38,7 @@ The built-in `active` reads one number off each post's CommentUpdate (`lastReply
36
38
 
37
39
  ## Requirements
38
40
 
39
- - pkc-js `>=0.0.110` (configurable page sorts, `settings.pages`, and the page sort types exported from the root)
41
+ - pkc-js `>=0.0.114` (configurable page sorts, `settings.pages`, the page sort types exported from the root, and tombstone replies passed to `score`, pkc-js#407)
40
42
  - Node.js `>=22` on the machine running the community
41
43
  - ESM-only environment
42
44
 
@@ -119,11 +121,12 @@ Page sort options are strings, like challenge options. Unlike challenge options
119
121
  | Option | Required | Published in `publicOptions` | Default | Description |
120
122
  |---|---|---|---|---|
121
123
  | `noBumpKeywords` | no | yes, unless listed in `privateOptions` | unset (plain bump order) | Comma-separated keywords, e.g. `sage` or `sage,nobump`. A reply whose content has a whole line equal to one of these does not bump its thread. Exact and case-sensitive |
124
+ | `bumpLimit` | no | yes, unless listed in `privateOptions` | unset (no limit) | A positive integer, e.g. `"300"`. Only a thread's first this many counted replies, saged or not, can bump it; past the limit it sinks while staying open. Counted replies are the ones the `exclude*` options keep, the same set as the thread's `replyCount` |
122
125
  | `maxAge` | no | always (reserved) | none | pkc-js reserved: a sliding window such as `"36h"`, `"7d"`, `"2w"`, `"1M"`, `"1y"`. Threads older than the window drop out of the pages (pinned threads bypass it) |
123
126
  | `pinnedFirst` | no | always (reserved) | `"true"` | pkc-js reserved: pinned threads first and outside the window. `"false"` treats them as ordinary threads |
124
- | `excludeRemovedComments`, `excludeDeletedComments`, `excludeCommentPendingApproval`, `excludeCommentWithApprovedFalse`, `excludeCommentsWithDifferentCommunityAddress` | no | always (reserved) | `"true"` for the `posts` scope | pkc-js reserved moderation filters on the comment set the sort starts from. A reply excluded here, and everything under it, does not count toward a bump. `score` re-applies the first four to the replies it receives, so a client's raw walk scores like the community; `excludeCommentsWithDifferentCommunityAddress` needs the community's address, which `score` does not get, and is left to pkc-js ([pkc-js#401](https://github.com/pkcprotocol/pkc-js/issues/401)) |
127
+ | `excludeRemovedComments`, `excludeDeletedComments`, `excludeCommentPendingApproval`, `excludeCommentWithApprovedFalse`, `excludeCommentsWithDifferentCommunityAddress` | no | always (reserved) | `"true"` for the `posts` scope | pkc-js reserved moderation filters on the threads the sort ranks. `score` also applies the first four to the replies: a reply one of them drops does not bump, the replies under it still do. `excludeCommentsWithDifferentCommunityAddress` is applied to replies by pkc-js before `score` runs (checking it needs the community's address, which `score` does not get, [pkc-js#401](https://github.com/pkcprotocol/pkc-js/issues/401)) |
125
128
 
126
- Keep `noBumpKeywords` public unless you have a reason not to: a UI reads it to know the board supports sage, to put the keyword on the wire the way the sort reads it, and to mark saged replies. With the option private the order still applies but UIs cannot do any of that. The reserved options cannot be made private (`ERR_PAGE_SORT_RESERVED_OPTION_CANNOT_BE_PRIVATE`): a client re-sorting a page must know how its comment set was filtered. Their full semantics are in the pkc-js page sorts doc, "Reserved options". `score` mirrors pkc-js's handling of the exclusion flags it can apply; if pkc-js adds a reserved exclusion, this package needs a release that applies it too, until then a client re-sort counts replies the new flag drops.
129
+ Keep `noBumpKeywords` public unless you have a reason not to: a UI reads it to know the board supports sage, to put the keyword on the wire the way the sort reads it, and to mark saged replies. With the option private the order still applies but UIs cannot do any of that. The same goes for `bumpLimit`: a UI compares it to a thread's `replyCount` to show that the thread no longer bumps. The reserved options cannot be made private (`ERR_PAGE_SORT_RESERVED_OPTION_CANNOT_BE_PRIVATE`): a client re-sorting a page must know how its comment set was filtered. Their full semantics are in the pkc-js page sorts doc, "Reserved options". If pkc-js adds a reserved exclusion, this package needs a release that applies it to replies too.
127
130
 
128
131
  ### Validation
129
132
 
@@ -132,6 +135,7 @@ The entry is validated when the community is created, on every edit (rejecting t
132
135
  - `noBumpKeywords` set but holding no keyword (`""`, `" , ,"`): that would silently be plain bump order. Unset it instead.
133
136
  - More than 64 keywords, or a keyword over 128 characters.
134
137
  - A keyword containing a line break, which can never equal a line.
138
+ - `bumpLimit` set to anything but a positive integer (`"0"`, `"-1"`, `"1.5"`, `"300 replies"`, `""`). Surrounding whitespace is trimmed. Unset it for no limit.
135
139
 
136
140
  Failures surface as `ERR_PAGE_SORT_SETTINGS_VALIDATION_FAILED` inside pkc-js's aggregated `ERR_PAGE_SORT_SETTINGS_VALIDATION_FAILED_FOR_PAGE_SORTS`.
137
141
 
@@ -142,9 +146,10 @@ community.pageSorts = {
142
146
  posts: {
143
147
  active: {
144
148
  name: "@pkcprotocol/active-page-sort", // absent for a path entry
145
- description: "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread",
149
+ description: "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread, with an optional bump limit",
146
150
  publicOptions: {
147
151
  noBumpKeywords: "sage",
152
+ bumpLimit: "300",
148
153
  pinnedFirst: "true",
149
154
  excludeRemovedComments: "true",
150
155
  excludeDeletedComments: "true",
@@ -190,11 +195,11 @@ import { isNoBumpReplyForOptions } from "@pkcprotocol/active-page-sort";
190
195
  const saged = isNoBumpReplyForOptions(reply.content, community.pageSorts?.posts?.active?.publicOptions);
191
196
  ```
192
197
 
193
- `isNoBumpContent(content, keywords)` and `splitKeywords(raw)` are the same test without the options lookup, and `bumpTimestamp({ postTimestamp, replies, keywords })` gives a thread's effective bump time. For a reply tree walked from reply pages, pass it through `applyReplyExclusions({ postCid, replies, options })` first, with the published `publicOptions`, to drop what the community did not count. `src/no-bump.ts` imports nothing and bundles for a browser.
198
+ `isNoBumpContent(content, keywords)` and `splitKeywords(raw)` are the same test without the options lookup, and `bumpTimestamp({ postTimestamp, replies, keywords, options, bumpLimit })` gives a thread's effective bump time from a reply tree walked from reply pages, tombstones included, with the published `publicOptions` as `options` and `parseBumpLimit(publicOptions.bumpLimit)` as `bumpLimit`. `isCountedReply(reply, options)` is the exclusion check on its own. `src/no-bump.ts` imports nothing and bundles for a browser.
194
199
 
195
200
  ### 4. Re-sort a page locally (optional)
196
201
 
197
- pkc-js exports no sorter: the package's `score` is the whole contract. A UI that wants to re-order threads between community publishes (a user's own reply just landed, say) applies `score` itself. This sort declares `requireReplies`, so it needs each thread's replies: walk a flat reply sort (`newFlat` / `oldFlat`) when the post carries one, otherwise the nested sort's pages recursively, at every depth. Walk all of it: every page of every chain, for every thread you re-sort. `score` receives a plain list and cannot tell a partial walk from a complete one, so a thread whose newest bump is on a page you did not fetch ranks too low, with no error. Hand each post its descendants by `parentCid`, as walked, and pass the published `publicOptions` as `options`. Reply pages keep deleted and removed replies, which the community did not count; `score` drops them, and everything under them, using the published `exclude*` options, so no filtering is needed on the UI side. Pass the options as published: without the `exclude*` flags nothing is dropped. The worked example of the walk and the re-sort is `test/node-and-browser/pages/page-sorts-client-test-util.ts` in pkc-js; this package's own copy of the re-sort is `test/unit/resort-like-a-ui.ts`. A re-sorted page is still one slice of one sort: re-sorting it gives that slice in another order, not the whole board.
202
+ pkc-js exports no sorter: the package's `score` is the whole contract. A UI that wants to re-order threads between community publishes (a user's own reply just landed, say) applies `score` itself. This sort declares `requireReplies`, so it needs each thread's replies: walk a flat reply sort (`newFlat` / `oldFlat`) when the post carries one, otherwise the nested sort's pages recursively, at every depth. Walk all of it: every page of every chain, for every thread you re-sort. `score` receives a plain list and cannot tell a partial walk from a complete one, so a thread whose newest bump is on a page you did not fetch ranks too low, with no error. Hand each post its descendants by `parentCid`, as walked, tombstones included, and pass the published `publicOptions` as `options`. `score` skips what the published `exclude*` options drop, keeping the replies under it, so no filtering is needed on the UI side. Pass the options as published: without the `exclude*` flags nothing is skipped. Reply pages keep removed and deleted replies only while the reply sort's `excludeRemovedComments` and `excludeDeletedComments` are `"false"` (the default). A reply sort with either turned on leaves tombstones out, and a nested one loses the replies under them too, so check `community.pageSorts.replies` and walk a published reply sort that keeps them. If none does, a client cannot reproduce the community's order. The worked example of the walk and the re-sort is `test/node-and-browser/pages/page-sorts-client-test-util.ts` in pkc-js; this package's own copy of the re-sort is `test/unit/resort-like-a-ui.ts`. A re-sorted page is still one slice of one sort: re-sorting it gives that slice in another order, not the whole board.
198
203
 
199
204
  ```ts
200
205
  import activePageSort from "@pkcprotocol/active-page-sort";
@@ -216,9 +221,9 @@ Treat `community.pageSorts[].name` as data: look it up in your own registry, nev
216
221
  |---|---|
217
222
  | `default` / `activePageSort` | The `PageSortFileFactory` pkc-js calls |
218
223
  | `SORT_NAME`, `DESCRIPTION`, `optionInputs`, `validatePageSortSettings` | The file's declared surface, for config UIs and tests |
219
- | `NO_BUMP_KEYWORDS_OPTION` | `"noBumpKeywords"`, the option key. Renaming it is a new contract |
220
- | `splitKeywords`, `isNoBumpContent`, `bumpTimestamp`, `isNoBumpReplyForOptions` | The matching rules, import-free, for browsers |
221
- | `applyReplyExclusions`, `EXCLUSION_OPTIONS` | The replies a thread's bump counts: the published `exclude*` options applied to a walked reply tree, subtrees of dropped replies cut. Import-free |
224
+ | `NO_BUMP_KEYWORDS_OPTION`, `BUMP_LIMIT_OPTION` | `"noBumpKeywords"` and `"bumpLimit"`, the option keys. Renaming one is a new contract |
225
+ | `splitKeywords`, `isNoBumpContent`, `parseBumpLimit`, `bumpTimestamp`, `isNoBumpReplyForOptions` | The matching rules, import-free, for browsers |
226
+ | `isCountedReply`, `EXCLUSION_OPTIONS` | Whether a reply can bump at all: the published `exclude*` options checked against its own CommentUpdate, never its ancestors. Import-free |
222
227
  | `PageSortFile`, `PageSortFileInput`, `PageSortFileFactory`, `PageSortFileFactoryInput`, `PageSortFileFactoryArgs`, `PageSortScoreArgs`, `PageSortReplyEntry`, `PageSortScope`, `CommunityPageSortSetting` | Re-exported pkc-js types (root exports since pkc-js 0.0.110) |
223
228
  | `PageSortSettingsEntry` | Alias of `CommunityPageSortSetting`, the `settings.pages` entry the factory receives |
224
229
 
@@ -234,10 +239,10 @@ npm run test:e2e # builds, starts a throwaway kubo daemon, runs a real commun
234
239
  | Path | What it is |
235
240
  |---|---|
236
241
  | `src/active-page-sort.ts` | The factory: `sortName`, `scope`, `requireReplies`, `optionInputs`, `score`, `validatePageSortSettings` |
237
- | `src/no-bump.ts` | Keyword splitting, the line match, the bump-time fold. Imports nothing |
242
+ | `src/no-bump.ts` | Keyword splitting, the line match, the tombstone rule, the bump-time fold. Imports nothing |
238
243
  | `src/types.ts` | Re-exports of the pkc-js page sort types |
239
244
  | `test/unit/` | The contract: match semantics, scorer, validation, and a client re-sort reproducing the community order |
240
- | `test/e2e/` | A kubo daemon from the `kubo` npm package on ports 15301/18380/24301 (override with `KUBO_E2E_*_PORT`), a pkc-js community configured by `path`, `sage` and normal replies at depths 1 to 3, author deletions that cut a subtree, read back by a second PKC over IPNS and re-sorted from the walked reply tree |
245
+ | `test/e2e/` | A kubo daemon from the `kubo` npm package on ports 15301/18380/24301 (override with `KUBO_E2E_*_PORT`), a pkc-js community configured by `path`, `sage` and normal replies at depths 1 to 3, author deletions, including a deleted sage reply whose answer still bumps, read back by a second PKC over IPNS and re-sorted from the walked reply tree |
241
246
 
242
247
  The e2e writes everything under `.tmp/` in the repository and removes it afterwards.
243
248
 
@@ -1,7 +1,7 @@
1
1
  import type { PageSortFile, PageSortFileFactory, PageSortSettingsEntry } from "./types.js";
2
2
  /** The wire key: this sort publishes under `community.posts.pages.active` / `pageCids.active`, like the built-in it replaces. */
3
3
  export declare const SORT_NAME = "active";
4
- export declare const DESCRIPTION = "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread";
4
+ export declare const DESCRIPTION = "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread, with an optional bump limit";
5
5
  export declare const MAX_KEYWORDS = 64;
6
6
  export declare const MAX_KEYWORD_LENGTH = 128;
7
7
  export declare const optionInputs: NonNullable<PageSortFile["optionInputs"]>;
@@ -1,7 +1,7 @@
1
- import { NO_BUMP_KEYWORDS_OPTION, applyReplyExclusions, bumpTimestamp, isNoBumpContent, splitKeywords } from "./no-bump.js";
1
+ import { BUMP_LIMIT_OPTION, NO_BUMP_KEYWORDS_OPTION, bumpTimestamp, isNoBumpContent, parseBumpLimit, splitKeywords } from "./no-bump.js";
2
2
  /** The wire key: this sort publishes under `community.posts.pages.active` / `pageCids.active`, like the built-in it replaces. */
3
3
  export const SORT_NAME = "active";
4
- export const DESCRIPTION = "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread";
4
+ export const DESCRIPTION = "Bump order where a reply whose content has a line equal to a configured keyword does not bump its thread, with an optional bump limit";
5
5
  export const MAX_KEYWORDS = 64;
6
6
  export const MAX_KEYWORD_LENGTH = 128;
7
7
  export const optionInputs = [
@@ -10,6 +10,12 @@ export const optionInputs = [
10
10
  label: "No-bump keywords",
11
11
  description: "Comma-separated. A reply whose content has a whole line equal to one of these does not bump its thread (exact, case-sensitive). Leave unset for plain bump order.",
12
12
  placeholder: "sage"
13
+ },
14
+ {
15
+ option: BUMP_LIMIT_OPTION,
16
+ label: "Bump limit",
17
+ description: "A positive integer. Only a thread's first this many replies, saged or not, can bump it; past the limit it sinks while staying open. Leave unset for no limit.",
18
+ placeholder: "300"
13
19
  }
14
20
  ];
15
21
  const keywordsOf = (pageSortSettings) => splitKeywords(pageSortSettings.options?.[NO_BUMP_KEYWORDS_OPTION]);
@@ -18,6 +24,9 @@ const keywordsOf = (pageSortSettings) => splitKeywords(pageSortSettings.options?
18
24
  * cannot match anything, which would silently turn the board into plain bump order.
19
25
  */
20
26
  export const validatePageSortSettings = ({ pageSortSettings }) => {
27
+ const rawLimit = pageSortSettings.options?.[BUMP_LIMIT_OPTION];
28
+ if (rawLimit !== undefined && parseBumpLimit(rawLimit) === undefined)
29
+ throw new Error(`${BUMP_LIMIT_OPTION} must be a positive integer, got "${rawLimit.slice(0, 32)}": unset it for no limit`);
21
30
  const raw = pageSortSettings.options?.[NO_BUMP_KEYWORDS_OPTION];
22
31
  if (raw === undefined)
23
32
  return;
@@ -41,6 +50,7 @@ export const validatePageSortSettings = ({ pageSortSettings }) => {
41
50
  */
42
51
  export const activePageSort = ({ pageSortSettings }) => {
43
52
  const keywords = keywordsOf(pageSortSettings);
53
+ const bumpLimit = parseBumpLimit(pageSortSettings.options?.[BUMP_LIMIT_OPTION]);
44
54
  return {
45
55
  sortName: SORT_NAME,
46
56
  description: DESCRIPTION,
@@ -49,13 +59,9 @@ export const activePageSort = ({ pageSortSettings }) => {
49
59
  optionInputs,
50
60
  defaultOptions: {},
51
61
  // MAX(timestamp) over the post and its counted, non-no-bump descendants. pkc-js hands `replies` as one flat
52
- // list of every descendant that survived this sort's exclusion options, so the re-filter is a no-op there; a
53
- // client passes the reply pages it walked, which keep deleted and removed replies, and the re-filter drops them.
54
- score: ({ comment, commentUpdate, options, replies }) => bumpTimestamp({
55
- postTimestamp: comment.timestamp,
56
- replies: applyReplyExclusions({ postCid: commentUpdate.cid, replies: replies ?? [], options }),
57
- keywords
58
- }),
62
+ // list of every descendant, tombstones and pending replies included (pkc-js#407), and so does a client's walk
63
+ // of the reply pages; bumpTimestamp skips what the exclude* options drop and keeps the replies under them.
64
+ score: ({ comment, options, replies }) => bumpTimestamp({ postTimestamp: comment.timestamp, replies: replies ?? [], keywords, options, bumpLimit }),
59
65
  validatePageSortSettings
60
66
  };
61
67
  };
@@ -1 +1 @@
1
- {"version":3,"file":"active-page-sort.js","sourceRoot":"","sources":["../src/active-page-sort.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,aAAa,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE5H,iIAAiI;AACjI,MAAM,CAAC,MAAM,SAAS,GAAG,QAAQ,CAAC;AAElC,MAAM,CAAC,MAAM,WAAW,GAAG,0GAA0G,CAAC;AAEtI,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAC/B,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAEtC,MAAM,CAAC,MAAM,YAAY,GAA8C;IACrE;QACE,MAAM,EAAE,uBAAuB;QAC/B,KAAK,EAAE,kBAAkB;QACzB,WAAW,EACT,mKAAmK;QACrK,WAAW,EAAE,MAAM;KACpB;CACF,CAAC;AAEF,MAAM,UAAU,GAAG,CAAC,gBAAuC,EAAY,EAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAC;AAE7I;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,EAAE,gBAAgB,EAA+C,EAAQ,EAAE;IAClH,MAAM,GAAG,GAAG,gBAAgB,CAAC,OAAO,EAAE,CAAC,uBAAuB,CAAC,CAAC;IAChE,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO;IAC9B,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,uBAAuB,6DAA6D,CAAC,CAAC;IACpI,IAAI,QAAQ,CAAC,MAAM,GAAG,YAAY;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,uBAAuB,UAAU,QAAQ,CAAC,MAAM,6BAA6B,YAAY,EAAE,CAAC,CAAC;IACpJ,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,MAAM,GAAG,kBAAkB;YACrC,MAAM,IAAI,KAAK,CAAC,GAAG,uBAAuB,aAAa,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,OAAO,CAAC,MAAM,0BAA0B,kBAAkB,EAAE,CAAC,CAAC;QACtJ,IAAI,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,GAAG,uBAAuB,2DAA2D,CAAC,CAAC;IACrI,CAAC;AACH,CAAC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwB,CAAC,EAAE,gBAAgB,EAA2B,EAAgB,EAAE;IACjH,MAAM,QAAQ,GAAG,UAAU,CAAC,gBAAgB,CAAC,CAAC;IAC9C,OAAO;QACL,QAAQ,EAAE,SAAS;QACnB,WAAW,EAAE,WAAW;QACxB,KAAK,EAAE,OAAO;QACd,cAAc,EAAE,IAAI;QACpB,YAAY;QACZ,cAAc,EAAE,EAAE;QAClB,4GAA4G;QAC5G,6GAA6G;QAC7G,iHAAiH;QACjH,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,CACtD,aAAa,CAAC;YACZ,aAAa,EAAE,OAAO,CAAC,SAAS;YAChC,OAAO,EAAE,oBAAoB,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,IAAI,EAAE,EAAE,OAAO,EAAE,CAAC;YAC9F,QAAQ;SACT,CAAC;QACJ,wBAAwB;KACzB,CAAC;AACJ,CAAC,CAAC;AAEF,qHAAqH;AACrH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,OAAgB,EAAE,OAA2C,EAAW,EAAE,CAChH,eAAe,CAAC,OAAO,EAAE,aAAa,CAAC,OAAO,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC;AAE9E,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"active-page-sort.js","sourceRoot":"","sources":["../src/active-page-sort.ts"],"names":[],"mappings":"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"}
package/dist/no-bump.d.ts CHANGED
@@ -1,8 +1,15 @@
1
- import type { ExcludableReplyLike, NoBumpReplyLike } from "./types.js";
1
+ import type { NoBumpReplyLike } from "./types.js";
2
2
  /** The option key under `settings.pages.posts[i].options`. Published as `community.pageSorts.posts.active.publicOptions.noBumpKeywords`. */
3
3
  export declare const NO_BUMP_KEYWORDS_OPTION = "noBumpKeywords";
4
+ /** The option key for the bump limit. Published as `community.pageSorts.posts.active.publicOptions.bumpLimit`. */
5
+ export declare const BUMP_LIMIT_OPTION = "bumpLimit";
4
6
  /** Comma-separated list to keywords: trimmed, empty entries dropped, duplicates kept out. */
5
7
  export declare const splitKeywords: (raw: string | undefined) => string[];
8
+ /**
9
+ * The bump limit an option value holds: a positive integer, surrounding whitespace trimmed. `undefined` for an
10
+ * unset option (no limit) and for anything else, which `validatePageSortSettings` rejects on the community.
11
+ */
12
+ export declare const parseBumpLimit: (raw: string | undefined) => number | undefined;
6
13
  /**
7
14
  * Whether a reply is a no-bump reply: its content has a whole line equal to one of the keywords. Exact and
8
15
  * case-sensitive, so `sage` in prose ("sage is overused") still bumps, while a content of `sage`, or a
@@ -11,35 +18,36 @@ export declare const splitKeywords: (raw: string | undefined) => string[];
11
18
  */
12
19
  export declare const isNoBumpContent: (content: unknown, keywords: readonly string[]) => boolean;
13
20
  /**
14
- * A thread's bump time: the newest of the post's own timestamp and the timestamps of its replies that are
15
- * not no-bump replies. Descendants of a no-bump reply still count: a normal reply under a saged reply bumps.
16
- * This is exactly what `score` returns, exposed so a UI can show "last bump" without the page sort machinery.
17
- */
18
- export declare const bumpTimestamp: ({ postTimestamp, replies, keywords }: {
19
- postTimestamp: number;
20
- replies: readonly NoBumpReplyLike[];
21
- keywords: readonly string[];
22
- }) => number;
23
- /**
24
- * The reserved exclusion options this package re-applies, and what each drops. `excludeCommentsWithDifferentCommunityAddress`
25
- * is not here: it compares against the community's address, which a sort file is never given (pkc-js#401).
21
+ * The reserved exclusion options a reply's own CommentUpdate can be checked against, and what each drops.
22
+ * `excludeCommentsWithDifferentCommunityAddress` is not here: pkc-js drops foreign replies before `score` runs,
23
+ * and checking it needs the community's address, which a sort file is never given (pkc-js#401).
26
24
  */
27
25
  export declare const EXCLUSION_OPTIONS: {
28
- readonly excludeRemovedComments: (update: ExcludableReplyLike["commentUpdate"]) => boolean;
29
- readonly excludeDeletedComments: (update: ExcludableReplyLike["commentUpdate"]) => boolean;
30
- readonly excludeCommentWithApprovedFalse: (update: ExcludableReplyLike["commentUpdate"]) => boolean;
31
- readonly excludeCommentPendingApproval: (update: ExcludableReplyLike["commentUpdate"]) => boolean;
26
+ readonly excludeRemovedComments: (update: NoBumpReplyLike["commentUpdate"]) => boolean;
27
+ readonly excludeDeletedComments: (update: NoBumpReplyLike["commentUpdate"]) => boolean;
28
+ readonly excludeCommentWithApprovedFalse: (update: NoBumpReplyLike["commentUpdate"]) => boolean;
29
+ readonly excludeCommentPendingApproval: (update: NoBumpReplyLike["commentUpdate"]) => boolean;
32
30
  };
33
31
  /**
34
- * The replies a thread's bump counts: those reachable from the post through replies the `exclude*` options keep.
35
- * A dropped reply takes everything under it along, and so does a reply whose parent is not in the list. This is
36
- * pkc-js's own filter and subtree cut for the replies it hands `score`, so on the community it changes nothing.
37
- * On a client it turns a walk of the reply pages, which keep deleted and removed replies, into the set the
38
- * community scored. Flags parse like pkc-js parses them: trimmed, case-insensitive `"true"` is on, anything else
39
- * off. Pass the published `publicOptions`; without the flags nothing is dropped. Linear in the thread's replies.
32
+ * Whether a reply's own timestamp can bump its thread: no `exclude*` option that is on drops it. The tombstone rule
33
+ * of pkc-js's "Which replies count": a dropped reply does not bump, the replies under it are judged on their own, so
34
+ * a present reply under a removed or deleted one still bumps. With the `posts` defaults (every flag on) this is what
35
+ * the built-in `active` counts. Flags parse like pkc-js parses them: trimmed, case-insensitive `"true"` is on,
36
+ * anything else off. Pass the published `publicOptions`; without the flags nothing is dropped.
37
+ */
38
+ export declare const isCountedReply: (reply: NoBumpReplyLike, options: Readonly<Record<string, string>>) => boolean;
39
+ /**
40
+ * A thread's bump time: the newest of the post's own timestamp and the timestamps of its counted replies (see
41
+ * `isCountedReply`) that are not no-bump replies. Each reply is judged on its own: a normal reply under a saged
42
+ * reply or a tombstone bumps. With a `bumpLimit`, only the first `bumpLimit` counted replies by `(timestamp, cid)`
43
+ * can bump, saged ones included in the count. This is exactly what `score` returns, exposed so a UI can show
44
+ * "last bump" without the page sort machinery. Pass the replies as pkc-js or a reply-page walk gives them,
45
+ * tombstones included, the published `publicOptions` as `options`, and `parseBumpLimit` of its `bumpLimit`.
40
46
  */
41
- export declare const applyReplyExclusions: <T extends ExcludableReplyLike>({ postCid, replies, options }: {
42
- postCid: string;
43
- replies: readonly T[];
44
- options: Readonly<Record<string, string>>;
45
- }) => T[];
47
+ export declare const bumpTimestamp: ({ postTimestamp, replies, keywords, options, bumpLimit }: {
48
+ postTimestamp: number;
49
+ replies: readonly NoBumpReplyLike[];
50
+ keywords: readonly string[];
51
+ options?: Readonly<Record<string, string>>;
52
+ bumpLimit?: number | undefined;
53
+ }) => number;
package/dist/no-bump.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // README's description of the match: both are the contract a board's users rely on.
4
4
  /** The option key under `settings.pages.posts[i].options`. Published as `community.pageSorts.posts.active.publicOptions.noBumpKeywords`. */
5
5
  export const NO_BUMP_KEYWORDS_OPTION = "noBumpKeywords";
6
+ /** The option key for the bump limit. Published as `community.pageSorts.posts.active.publicOptions.bumpLimit`. */
7
+ export const BUMP_LIMIT_OPTION = "bumpLimit";
6
8
  /** Comma-separated list to keywords: trimmed, empty entries dropped, duplicates kept out. */
7
9
  export const splitKeywords = (raw) => {
8
10
  const keywords = [];
@@ -13,6 +15,17 @@ export const splitKeywords = (raw) => {
13
15
  }
14
16
  return keywords;
15
17
  };
18
+ /**
19
+ * The bump limit an option value holds: a positive integer, surrounding whitespace trimmed. `undefined` for an
20
+ * unset option (no limit) and for anything else, which `validatePageSortSettings` rejects on the community.
21
+ */
22
+ export const parseBumpLimit = (raw) => {
23
+ const trimmed = raw?.trim();
24
+ if (!trimmed || !/^[1-9][0-9]*$/.test(trimmed))
25
+ return undefined;
26
+ const limit = Number(trimmed);
27
+ return Number.isSafeInteger(limit) ? limit : undefined;
28
+ };
16
29
  /**
17
30
  * Whether a reply is a no-bump reply: its content has a whole line equal to one of the keywords. Exact and
18
31
  * case-sensitive, so `sage` in prose ("sage is overused") still bumps, while a content of `sage`, or a
@@ -26,23 +39,9 @@ export const isNoBumpContent = (content, keywords) => {
26
39
  return keywords.some((keyword) => lines.includes(keyword));
27
40
  };
28
41
  /**
29
- * A thread's bump time: the newest of the post's own timestamp and the timestamps of its replies that are
30
- * not no-bump replies. Descendants of a no-bump reply still count: a normal reply under a saged reply bumps.
31
- * This is exactly what `score` returns, exposed so a UI can show "last bump" without the page sort machinery.
32
- */
33
- export const bumpTimestamp = ({ postTimestamp, replies, keywords }) => {
34
- let bump = postTimestamp;
35
- for (const reply of replies) {
36
- if (isNoBumpContent(reply.comment.content, keywords))
37
- continue;
38
- if (reply.comment.timestamp > bump)
39
- bump = reply.comment.timestamp;
40
- }
41
- return bump;
42
- };
43
- /**
44
- * The reserved exclusion options this package re-applies, and what each drops. `excludeCommentsWithDifferentCommunityAddress`
45
- * is not here: it compares against the community's address, which a sort file is never given (pkc-js#401).
42
+ * The reserved exclusion options a reply's own CommentUpdate can be checked against, and what each drops.
43
+ * `excludeCommentsWithDifferentCommunityAddress` is not here: pkc-js drops foreign replies before `score` runs,
44
+ * and checking it needs the community's address, which a sort file is never given (pkc-js#401).
46
45
  */
47
46
  export const EXCLUSION_OPTIONS = {
48
47
  excludeRemovedComments: (update) => update.removed === true,
@@ -50,37 +49,40 @@ export const EXCLUSION_OPTIONS = {
50
49
  excludeCommentWithApprovedFalse: (update) => update.approved === false,
51
50
  excludeCommentPendingApproval: (update) => update.pendingApproval === true
52
51
  };
52
+ const exclusionsOf = (options) => Object.entries(EXCLUSION_OPTIONS)
53
+ .filter(([option]) => options[option]?.trim().toLowerCase() === "true")
54
+ .map(([, drops]) => drops);
53
55
  /**
54
- * The replies a thread's bump counts: those reachable from the post through replies the `exclude*` options keep.
55
- * A dropped reply takes everything under it along, and so does a reply whose parent is not in the list. This is
56
- * pkc-js's own filter and subtree cut for the replies it hands `score`, so on the community it changes nothing.
57
- * On a client it turns a walk of the reply pages, which keep deleted and removed replies, into the set the
58
- * community scored. Flags parse like pkc-js parses them: trimmed, case-insensitive `"true"` is on, anything else
59
- * off. Pass the published `publicOptions`; without the flags nothing is dropped. Linear in the thread's replies.
56
+ * Whether a reply's own timestamp can bump its thread: no `exclude*` option that is on drops it. The tombstone rule
57
+ * of pkc-js's "Which replies count": a dropped reply does not bump, the replies under it are judged on their own, so
58
+ * a present reply under a removed or deleted one still bumps. With the `posts` defaults (every flag on) this is what
59
+ * the built-in `active` counts. Flags parse like pkc-js parses them: trimmed, case-insensitive `"true"` is on,
60
+ * anything else off. Pass the published `publicOptions`; without the flags nothing is dropped.
60
61
  */
61
- export const applyReplyExclusions = ({ postCid, replies, options }) => {
62
- const active = Object.entries(EXCLUSION_OPTIONS)
63
- .filter(([option]) => options[option]?.trim().toLowerCase() === "true")
64
- .map(([, drops]) => drops);
65
- const childrenOf = new Map();
66
- for (const reply of replies) {
67
- const parentCid = reply.comment.parentCid;
68
- if (!parentCid || active.some((drops) => drops(reply.commentUpdate)))
62
+ export const isCountedReply = (reply, options) => !exclusionsOf(options).some((drops) => drops(reply.commentUpdate));
63
+ /**
64
+ * A thread's bump time: the newest of the post's own timestamp and the timestamps of its counted replies (see
65
+ * `isCountedReply`) that are not no-bump replies. Each reply is judged on its own: a normal reply under a saged
66
+ * reply or a tombstone bumps. With a `bumpLimit`, only the first `bumpLimit` counted replies by `(timestamp, cid)`
67
+ * can bump, saged ones included in the count. This is exactly what `score` returns, exposed so a UI can show
68
+ * "last bump" without the page sort machinery. Pass the replies as pkc-js or a reply-page walk gives them,
69
+ * tombstones included, the published `publicOptions` as `options`, and `parseBumpLimit` of its `bumpLimit`.
70
+ */
71
+ export const bumpTimestamp = ({ postTimestamp, replies, keywords, options = {}, bumpLimit }) => {
72
+ const exclusions = exclusionsOf(options);
73
+ let counted = replies.filter((reply) => !exclusions.some((drops) => drops(reply.commentUpdate)));
74
+ if (bumpLimit !== undefined && counted.length > bumpLimit)
75
+ counted = [...counted].sort(byTimestampThenCid).slice(0, bumpLimit);
76
+ let bump = postTimestamp;
77
+ for (const reply of counted) {
78
+ if (isNoBumpContent(reply.comment.content, keywords))
69
79
  continue;
70
- const siblings = childrenOf.get(parentCid);
71
- if (siblings)
72
- siblings.push(reply);
73
- else
74
- childrenOf.set(parentCid, [reply]);
75
- }
76
- const counted = [];
77
- const stack = [postCid];
78
- for (let cid = stack.pop(); cid !== undefined; cid = stack.pop()) {
79
- for (const child of childrenOf.get(cid) ?? []) {
80
- counted.push(child);
81
- stack.push(child.commentUpdate.cid);
82
- }
80
+ if (reply.comment.timestamp > bump)
81
+ bump = reply.comment.timestamp;
83
82
  }
84
- return counted;
83
+ return bump;
85
84
  };
85
+ // The order "the first bumpLimit replies" is taken in. The CID breaks a tie in the same second, compared as a
86
+ // plain string so the community and every client agree.
87
+ const byTimestampThenCid = (a, b) => a.comment.timestamp - b.comment.timestamp || (a.commentUpdate.cid < b.commentUpdate.cid ? -1 : a.commentUpdate.cid > b.commentUpdate.cid ? 1 : 0);
86
88
  //# sourceMappingURL=no-bump.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"no-bump.js","sourceRoot":"","sources":["../src/no-bump.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"no-bump.js","sourceRoot":"","sources":["../src/no-bump.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -2,21 +2,15 @@ import type { CommunityPageSortSetting, PageSortFile, PageSortFileFactory, PageS
2
2
  export type { CommunityPageSortSetting, PageSortFile, PageSortFileFactory, PageSortFileFactoryArgs, PageSortFileFactoryInput, PageSortFileInput, PageSortReplyEntry, PageSortScope, PageSortScoreArgs };
3
3
  /** One `settings.pages.posts[i]` entry as pkc-js hands it to the factory. Alias of pkc-js's `CommunityPageSortSetting`. */
4
4
  export type PageSortSettingsEntry = CommunityPageSortSetting;
5
- /** The subset of a reply this package reads. A `PageSortReplyEntry` and a page entry are both supersets. */
5
+ /**
6
+ * The subset of a reply this package reads. A `PageSortReplyEntry` and a page entry's `raw` are both supersets;
7
+ * the optional fields accept an explicit `undefined`, as page entries carry them.
8
+ */
6
9
  export interface NoBumpReplyLike {
7
10
  comment: {
8
11
  timestamp: number;
9
12
  content?: string | undefined;
10
13
  };
11
- }
12
- /**
13
- * The subset of a reply `applyReplyExclusions` reads. A `PageSortReplyEntry` and a page entry's `raw` are both
14
- * supersets; the optional fields accept an explicit `undefined`, as page entries carry them.
15
- */
16
- export interface ExcludableReplyLike {
17
- comment: {
18
- parentCid?: string | undefined;
19
- };
20
14
  commentUpdate: {
21
15
  cid: string;
22
16
  removed?: boolean | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pkcprotocol/active-page-sort",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Bump-order (active) page sort for pkc-js communities where replies carrying a configured keyword, such as sage, do not bump the thread",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "devDependencies": {
47
47
  "@commitlint/cli": "20.4.3",
48
48
  "@commitlint/config-conventional": "20.4.3",
49
- "@pkcprotocol/pkc-js": "0.0.110",
49
+ "@pkcprotocol/pkc-js": "0.0.114",
50
50
  "@release-it/conventional-changelog": "10.0.5",
51
51
  "@types/node": "25.3.5",
52
52
  "husky": "9.1.7",
@@ -56,7 +56,7 @@
56
56
  "vitest": "4.0.18"
57
57
  },
58
58
  "peerDependencies": {
59
- "@pkcprotocol/pkc-js": ">=0.0.110"
59
+ "@pkcprotocol/pkc-js": ">=0.0.114"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "@pkcprotocol/pkc-js": {