@khorsheed/dsh-reader 0.2.0

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.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Shared vocabulary of `@khorsheed/dsh-reader`: the persisted state document,
3
+ * the Remote wire payloads, and the pure helpers both faces share.
4
+ *
5
+ * Two responsibilities are deliberately split across the process boundary:
6
+ *
7
+ * - the HOST half owns the network (the sanctioned `ctx.web` fetch seam) and
8
+ * the disk (one version-guarded JSON document over `ctx.fs`). It stores the
9
+ * LAST RAW PAYLOAD per source — feed XML or article HTML — plus fetch
10
+ * metadata, and never parses either.
11
+ * - the BROWSER half owns parsing. The host runtime has no XML/DOM parser at
12
+ * all (Node's `globalThis.DOMParser` is absent), so the single parser lives
13
+ * in the browser where `DOMParser` is native, and its output never needs to
14
+ * be persisted: entries and extracted article bodies are session state.
15
+ *
16
+ * @module @khorsheed/dsh-reader/types
17
+ */
18
+ /**
19
+ * Whether an automatic retry is worth the request.
20
+ *
21
+ * Only a transport failure is: a bot wall, a login wall, a PDF and a 404 all
22
+ * answer the same way forever, so retrying them on a timer would be a crawler
23
+ * with a grudge rather than a reader. The manual refresh stays available.
24
+ *
25
+ * @param code - the recorded failure.
26
+ * @returns true when the entry may be retried automatically.
27
+ */
28
+ export function isRetryablePreviewFailure(code) {
29
+ return code === 'unreachable';
30
+ }
31
+ /** The default cache policy: a day, and a few hundred articles. */
32
+ export const DEFAULT_CACHE_POLICY = { ttlHours: 24, maxEntries: 500 };
33
+ /* ---------------------------------------------------- translation memory */
34
+ /**
35
+ * The schema version of the persisted translation tiers (the global sentence
36
+ * memory's table, and each entry's segment map). A browser Translator model
37
+ * upgrade quietly changes what the same sentence translates to, and a
38
+ * segmentation change here changes what a "sentence" is — bumping this turns
39
+ * every old record into a MISS. Records are evicted LAZILY (the next write or
40
+ * budget pass drops them), never wiped eagerly.
41
+ */
42
+ export const TRANSLATION_STORAGE_VERSION = 1;
43
+ /**
44
+ * The default total budget for both translation tiers, in characters — 64 MB
45
+ * worth of text. Counted in characters, like every other budget in this
46
+ * document (a CJK sentence costs more bytes than that reads; the approximation
47
+ * is the store's existing convention).
48
+ */
49
+ export const DEFAULT_TRANSLATION_BUDGET_CHARS = 64 * 1024 * 1024;
50
+ /** How many sentences the global memory keeps before its own LRU end is evicted. */
51
+ export const MAX_TRANSLATION_MEMORY_ENTRIES = 50_000;
52
+ /* ------------------------------------------------------------------ helpers */
53
+ /** Default state root segment under `$DSH_HOME/state`. */
54
+ export const STATE_ROOT_SEGMENT = 'dsh-reader';
55
+ /** Default daily refresh time. */
56
+ export const DEFAULT_REFRESH_TIME = '10:00';
57
+ /**
58
+ * Per-source raw-payload budget in characters.
59
+ *
60
+ * Sized to the HOST's egress cap rather than below it: a deployment can raise
61
+ * `web-fetch-http`'s `maxBodyChars` (the acceptance instance runs it at
62
+ * 2,000,000), and a per-source budget smaller than what the seam is willing to
63
+ * deliver would silently re-truncate every large feed here — the reader would
64
+ * pay for the fetch and still get half a document, with the note blaming the
65
+ * fetch. The fetch cap is the outer bound; this one must not be the inner one.
66
+ */
67
+ export const MAX_BODY_CHARS_PER_SOURCE = 2 * 1024 * 1024;
68
+ /** Total raw-payload budget in characters across all sources (newest kept). */
69
+ export const MAX_TOTAL_BODY_CHARS = 12 * 1024 * 1024;
70
+ /**
71
+ * How large an extracted body may be before it lives in its own file.
72
+ *
73
+ * `state.json` is read and rewritten as a whole on every mutation, so a research
74
+ * paper whose figures are inlined as base64 (transformer-circuits.pub's emotions
75
+ * paper is a 41.8 MB HTML document and extracts to tens of megabytes) would make
76
+ * every reader operation pay a multi-megabyte JSON round trip. Above this
77
+ * threshold the body is written to one file under `bodies/` and the document
78
+ * keeps only its metadata — so the cache policy (`ttlHours`, `maxEntries`) still
79
+ * governs it exactly like an inline body, and eviction deletes the file.
80
+ */
81
+ export const INLINE_BODY_MAX_CHARS = 256 * 1024;
82
+ /**
83
+ * Build a display label for a source from its URL: the registrable-ish host
84
+ * plus the last meaningful path segment, which is what distinguishes several
85
+ * feeds from one publisher.
86
+ *
87
+ * @param url - the source URL (any string; a malformed one degrades to its own head).
88
+ * @returns a short human label.
89
+ */
90
+ export function defaultSourceLabel(url) {
91
+ try {
92
+ const parsed = new URL(url);
93
+ const host = parsed.hostname.replace(/^www\./, '');
94
+ const segments = parsed.pathname.split('/').filter(segment => segment.length > 0);
95
+ const last = segments.at(-1) ?? '';
96
+ // Drop a bare feed file name that repeats the host ("example.com/feed.xml"
97
+ // reads better as "example.com/feed"); keep the host alone when nothing
98
+ // useful is left.
99
+ const tail = last.replace(/\.(xml|rss|atom|json)$/i, '');
100
+ return tail.length > 0 ? `${host}/${tail}` : host;
101
+ }
102
+ catch {
103
+ return url.slice(0, 60);
104
+ }
105
+ }
106
+ /**
107
+ * Build a stable entry id. Feeds are inconsistent about identifiers, so the
108
+ * order is: explicit guid/id, then the entry link, then a hash over title and
109
+ * link together (a feed with neither must still deduplicate across refreshes).
110
+ *
111
+ * @param entry - the identifying fields an entry may carry.
112
+ * @returns an id stable across refreshes of the same feed content.
113
+ */
114
+ export function stableEntryId(entry) {
115
+ const explicit = entry.guid?.trim();
116
+ if (explicit !== undefined && explicit.length > 0)
117
+ return `g:${explicit}`;
118
+ const link = entry.link?.trim();
119
+ if (link !== undefined && link.length > 0)
120
+ return `l:${link}`;
121
+ return `h:${hash32(`${entry.title}\u0000${entry.link ?? ''}`).toString(36)}`;
122
+ }
123
+ /**
124
+ * The entry id a saved link's single entry carries.
125
+ *
126
+ * A `link` source is exactly one entry, so the host must be able to name that
127
+ * entry when it records a per-entry annotation (the fetch failure the detail
128
+ * view reads). Both halves use this one function: a magic string duplicated
129
+ * across the process boundary is a bug waiting for a rename.
130
+ *
131
+ * @param sourceId - the link source's id.
132
+ * @returns the entry id the browser will build for it.
133
+ */
134
+ export function linkEntryId(sourceId) {
135
+ return `link:${sourceId}`;
136
+ }
137
+ /**
138
+ * The query that selects every entry of one source KIND.
139
+ *
140
+ * The same local-predicate mechanism as a source or tag filter, so a kind
141
+ * narrowing is visible in the search box and can be cleared there. The prefix
142
+ * cannot collide with a real source id: ids are always `<kind>-<hash>`.
143
+ *
144
+ * @param kind - the source kind to select.
145
+ * @returns the query string.
146
+ */
147
+ export function kindQuery(kind) {
148
+ return `#${kind}`;
149
+ }
150
+ /** The source kinds a kind query can name, for the predicate that reads one. */
151
+ export const READER_SOURCE_KINDS = ['rss', 'link'];
152
+ /**
153
+ * Shorten a summary for a card, collapsing whitespace so multi-line feed
154
+ * descriptions do not blow up the layout.
155
+ *
156
+ * @param text - the raw summary or content text.
157
+ * @param maxLength - the maximum returned length, ellipsis included.
158
+ * @returns the collapsed, shortened text, or `undefined` when there is none.
159
+ */
160
+ export function summarize(text, maxLength = 280) {
161
+ if (text === undefined)
162
+ return undefined;
163
+ const collapsed = text.replace(/\s+/g, ' ').trim();
164
+ if (collapsed.length === 0)
165
+ return undefined;
166
+ if (collapsed.length <= maxLength)
167
+ return collapsed;
168
+ return `${collapsed.slice(0, maxLength - 1)}…`;
169
+ }
170
+ /** Coerce an unknown thrown value into a message string. */
171
+ export function errorMessage(error) {
172
+ return error instanceof Error ? error.message : String(error);
173
+ }
174
+ /** 32-bit FNV-1a hash, used only to build stable ids (never for security). */
175
+ function hash32(input) {
176
+ let hash = 0x811c9dc5;
177
+ for (let index = 0; index < input.length; index++) {
178
+ hash ^= input.charCodeAt(index);
179
+ hash = Math.imul(hash, 0x01000193);
180
+ }
181
+ return hash >>> 0;
182
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Reader",
4
+ "description": "Read RSS feeds and saved article links on a card wall."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "灵感空间",
4
+ "description": "订阅 RSS 源、保存文章链接,在灵感墙上阅读正文。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,128 @@
1
+ {
2
+ "name": "@khorsheed/dsh-reader",
3
+ "description": "Link reader tab: RSS/Atom subscriptions plus pasted article links, a card feed with a readable detail view, host-side fetch and daily refresh over a Typert Remote data face",
4
+ "version": "0.2.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./invariant": {
14
+ "types": "./lib/types/invariant.d.ts",
15
+ "default": "./lib/invariant.js"
16
+ },
17
+ "./types": {
18
+ "types": "./lib/types/types.d.ts",
19
+ "default": "./lib/types.js"
20
+ },
21
+ "./client": {
22
+ "types": "./lib/types/client/index.d.ts",
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./typert": {
26
+ "types": "./lib/typert.host.d.ts",
27
+ "default": "./lib/typert.host.js"
28
+ },
29
+ "./remote": {
30
+ "types": "./lib/typert.remote-client.d.ts",
31
+ "default": "./lib/typert.remote-client.js"
32
+ },
33
+ "./src/*": "./src/*",
34
+ "./locale/*.json": "./locale/*.json",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "lib",
39
+ "lib/typert.host.d.ts",
40
+ "lib/typert.host.js",
41
+ "lib/typert.remote-client.d.ts",
42
+ "lib/typert.remote-client.js",
43
+ "cordis.patch.yml",
44
+ "locale/*.json"
45
+ ],
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "zod": "^4.4.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": "^4.0.1",
52
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1",
57
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
60
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
61
+ "react": "^18.2.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@deepseek-ai/dsh-api-remotes": {
65
+ "optional": true
66
+ },
67
+ "@deepseek-ai/dsh-client-locale": {
68
+ "optional": true
69
+ },
70
+ "@deepseek-ai/dsh-client-ui-primitives": {
71
+ "optional": true
72
+ },
73
+ "@deepseek-ai/dsh-client-ui-session": {
74
+ "optional": true
75
+ },
76
+ "@deepseek-ai/dsh-client-ui-sidebar-right": {
77
+ "optional": true
78
+ },
79
+ "@deepseek-ai/dsh-client-ui-slots": {
80
+ "optional": true
81
+ },
82
+ "@deepseek-ai/dsh-invariants": {
83
+ "optional": true
84
+ },
85
+ "@deepseek-ai/dsh-session": {
86
+ "optional": true
87
+ },
88
+ "react": {
89
+ "optional": true
90
+ }
91
+ },
92
+ "keywords": [
93
+ "dsh",
94
+ "dsh-plugin",
95
+ "cordis",
96
+ "reader",
97
+ "rss",
98
+ "atom",
99
+ "feed",
100
+ "readability",
101
+ "sidebar"
102
+ ],
103
+ "dsh": {
104
+ "bundle": {
105
+ "patch": "./cordis.patch.yml"
106
+ },
107
+ "client": {
108
+ "inject": [
109
+ "@deepseek-ai/dsh-api-remotes",
110
+ "@deepseek-ai/dsh-client-locale",
111
+ "@deepseek-ai/dsh-client-ui-primitives",
112
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
113
+ "@deepseek-ai/dsh-client-ui-session",
114
+ "@deepseek-ai/dsh-client-ui-slots"
115
+ ],
116
+ "platform": "web"
117
+ },
118
+ "compat": {
119
+ "minHost": "0.1.5-rc.1",
120
+ "notes": "A page-type right-Sidebar tab (minted kind `reader`) over a Typert Remote data face. Fetching runs on the HOST through the sanctioned `ctx.web` seam; state is written with plain `node:fs` (atomic temp+rename) under `$DSH_HOME/state/dsh-reader`, NOT through `ctx.fs` — that service is the session-fenced sandboxed filesystem, so a workspace-write session refuses a write to the state root (observed on the acceptance instance). Every optional capability is probed at apply time and degrades (no `web` → domain refusals; no `sideChat` → the side-chat gesture hides; no `quote` → selection quoting is unavailable while the entry-level quote button still works). KNOWN LIMIT: the host fetch seam caps a decoded body at 100,000 chars and truncates silently, so articles larger than that cannot be extracted — the detail view falls back to a 「受限篇幅,内容未完整呈现」 notice with a 「阅读原文」 button (measured: Anthropic research pages, theverge.com, newyorker.com). On-device translation (0.2.0) runs the page's own Translator API (Chrome 138+/Edge 148+, desktop): the model never leaves the device and no model quota is spent, while the derived sentence pairs persist on the host (a global memory file plus per-entry segment maps under the state root, one shared budget, no TTL) so a reload re-paints instead of re-translating. Where that API is absent or the pair is unavailable, the globe is simply not rendered — the pane keeps working exactly as before. Source bodies that are already Chinese are not offered translation at all. On host 0.1.6-alpha.2+ the spots that want a real browser (the script-figure notice, a bot-wall card's action) open the in-app Sidebar Browser tab, probed through the `browser` tab kind; on older hosts they are the ordinary external link. Pages that inline real figures as base64 data URIs are now kept whole (substantive payloads over a 512-char threshold; 1px placeholders still yield to lazy-loading attributes), so a figure-heavy paper on a cap-raised deployment becomes a multi-MB cached body by design — bodies are bounded by entry count + TTL, not by a char budget.",
121
+ "verifiedHost": "0.1.5-rc.1"
122
+ },
123
+ "references": [
124
+ "@khorsheed/dsh-quote",
125
+ "@khorsheed/dsh-sidechat"
126
+ ]
127
+ }
128
+ }