@inquivent/research-ids 0.0.0-stage → 1.0.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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Research identifiers and handoff URLs, per contracts/handoff-v1/README.md (V1 spec §6).
3
+ * The Python package inquivent-research-ids implements the same contract; both must pass
4
+ * contracts/handoff-v1/test-vectors.json.
5
+ */
6
+ export type Scheme = "doi" | "arxiv" | "openalex";
7
+ export interface Doi {
8
+ doi: string;
9
+ doi_key: string;
10
+ }
11
+ export interface Arxiv {
12
+ arxiv_base: string;
13
+ arxiv_version: number | null;
14
+ }
15
+ export interface OpenAlex {
16
+ openalex_id: string;
17
+ }
18
+ export type IdentifierError = "empty" | "too_long" | "bad_encoding" | "invalid_doi" | "invalid_arxiv" | "invalid_openalex";
19
+ export type ParseResult<T> = {
20
+ ok: true;
21
+ value: T;
22
+ } | {
23
+ ok: false;
24
+ error: IdentifierError;
25
+ };
26
+ export interface Identifiers {
27
+ doi?: Doi;
28
+ arxiv?: Arxiv;
29
+ openalex?: OpenAlex;
30
+ }
31
+ export declare const INTENTS: readonly ["explain", "reproduce", "summarize", "view"];
32
+ export type Intent = (typeof INTENTS)[number];
33
+ export declare const SOURCES: readonly ["paperinsight", "axiomizer", "dataxiom", "inquivent"];
34
+ export type Source = (typeof SOURCES)[number];
35
+ /** README §2.1: unreserved characters stay, everything else is %XX with uppercase hex. */
36
+ export declare function encodeComponent(value: string): string;
37
+ export declare function parseDoi(input: string, options?: {
38
+ decode?: boolean;
39
+ }): ParseResult<Doi>;
40
+ export declare function parseArxiv(input: string): ParseResult<Arxiv>;
41
+ export declare function formatArxiv(value: Arxiv): string;
42
+ export declare function parseOpenAlex(input: string): ParseResult<OpenAlex>;
43
+ export declare function parseIdentifier(scheme: "doi", input: string): ParseResult<Doi>;
44
+ export declare function parseIdentifier(scheme: "arxiv", input: string): ParseResult<Arxiv>;
45
+ export declare function parseIdentifier(scheme: "openalex", input: string): ParseResult<OpenAlex>;
46
+ export declare function parseIdentifier(scheme: Scheme, input: string): ParseResult<Doi | Arxiv | OpenAlex>;
47
+ export declare function sameWork(a: Doi, b: Doi): boolean;
48
+ export declare function sameWork(a: Arxiv, b: Arxiv): boolean;
49
+ export declare function sameWork(a: OpenAlex, b: OpenAlex): boolean;
50
+ /** Same arXiv document: same work and both versions given and equal (README §1.2). */
51
+ export declare function sameDocument(a: Arxiv, b: Arxiv): boolean;
52
+ export type BuildError = "missing_identifier" | "invalid_identifier" | "invalid_intent" | "invalid_from" | "invalid_section";
53
+ export declare class HandoffError extends Error {
54
+ readonly code: BuildError;
55
+ constructor(code: BuildError);
56
+ }
57
+ export interface BuildHandoffInput {
58
+ /** Raw identifier strings, parsed with README §1. */
59
+ identifiers: {
60
+ doi?: string | null;
61
+ arxiv?: string | null;
62
+ openalex?: string | null;
63
+ };
64
+ intent: Intent;
65
+ from: Source;
66
+ section?: string | null;
67
+ }
68
+ /** Builds a handoff URL (README §2.1). Throws HandoffError on any invalid input. */
69
+ export declare function buildHandoffUrl(baseUrl: string, input: BuildHandoffInput): string;
70
+ export type QueryError = "missing_version" | "unsupported_version" | "missing_identifier" | "invalid_identifier";
71
+ export type HandoffQuery = {
72
+ status: "ok";
73
+ identifiers: Identifiers;
74
+ intent: Intent;
75
+ from: Source | null;
76
+ section: string | null;
77
+ } | {
78
+ status: "invalid";
79
+ error: QueryError;
80
+ };
81
+ /** Parses an incoming /open (or availability) query (README §2.2). */
82
+ export declare function parseHandoffQuery(query: string | URLSearchParams): HandoffQuery;
package/dist/index.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Research identifiers and handoff URLs, per contracts/handoff-v1/README.md (V1 spec §6).
3
+ * The Python package inquivent-research-ids implements the same contract; both must pass
4
+ * contracts/handoff-v1/test-vectors.json.
5
+ */
6
+ export const INTENTS = ["explain", "reproduce", "summarize", "view"];
7
+ export const SOURCES = ["paperinsight", "axiomizer", "dataxiom", "inquivent"];
8
+ const MAX_LENGTH = 300;
9
+ /** Whitespace, defined explicitly so that the Python implementation can match it exactly. */
10
+ const WS = "\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff";
11
+ const TRIM_RE = new RegExp(`^[${WS}]+|[${WS}]+$`, "g");
12
+ const SECTION_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ // ------------------------------------------------------------------ helpers
14
+ function asciiLower(value) {
15
+ return value.replace(/[A-Z]/g, (c) => c.toLowerCase());
16
+ }
17
+ /** README §1.1 step 1: decode every %XX once; malformed escapes or invalid UTF-8 are errors. */
18
+ function percentDecodeOnce(value) {
19
+ if (!value.includes("%"))
20
+ return value;
21
+ if (/%(?![0-9A-Fa-f]{2})/.test(value))
22
+ return null;
23
+ const bytes = [];
24
+ const encoder = new TextEncoder();
25
+ for (let i = 0; i < value.length;) {
26
+ if (value[i] === "%") {
27
+ bytes.push(parseInt(value.slice(i + 1, i + 3), 16));
28
+ i += 3;
29
+ }
30
+ else {
31
+ const cp = value.codePointAt(i);
32
+ const ch = String.fromCodePoint(cp);
33
+ bytes.push(...encoder.encode(ch));
34
+ i += ch.length;
35
+ }
36
+ }
37
+ try {
38
+ return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function precheck(input) {
45
+ const value = input.replace(TRIM_RE, "");
46
+ if (value === "")
47
+ return { ok: false, error: "empty" };
48
+ if ([...value].length > MAX_LENGTH)
49
+ return { ok: false, error: "too_long" };
50
+ return { ok: true, value };
51
+ }
52
+ /** README §2.1: unreserved characters stay, everything else is %XX with uppercase hex. */
53
+ export function encodeComponent(value) {
54
+ return encodeURIComponent(value).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
55
+ }
56
+ // ------------------------------------------------------------------ DOI
57
+ const DOI_PREFIX = new RegExp(`^(?:https?://(?:dx\\.)?doi\\.org/|doi:[${WS}]*)`, "i");
58
+ const DOI_RE = new RegExp(`^10\\.[0-9]{4,9}(?:\\.[0-9]+)*/[^${WS}]+$`, "u");
59
+ export function parseDoi(input, options = {}) {
60
+ const pre = precheck(input);
61
+ if (!pre.ok)
62
+ return pre;
63
+ let value = pre.value;
64
+ if (options.decode !== false) {
65
+ const decoded = percentDecodeOnce(value);
66
+ if (decoded === null)
67
+ return { ok: false, error: "bad_encoding" };
68
+ value = decoded;
69
+ }
70
+ value = value.replace(DOI_PREFIX, "");
71
+ if (!DOI_RE.test(value))
72
+ return { ok: false, error: "invalid_doi" };
73
+ return { ok: true, value: { doi: value, doi_key: asciiLower(value) } };
74
+ }
75
+ // ------------------------------------------------------------------ arXiv
76
+ const ARXIV_PREFIX = /^(?:arxiv:|https?:\/\/(?:www\.)?arxiv\.org\/(abs|pdf)\/)/i;
77
+ const ARXIV_NEW = /^(\d{2})(\d{2})\.(\d{4,5})(?:v([1-9]\d*))?$/;
78
+ const ARXIV_OLD = /^([a-z]+(?:-[a-z]+)?)(?:\.[a-z]{2})?\/(\d{2})(\d{2})(\d{3})(?:v([1-9]\d*))?$/i;
79
+ export function parseArxiv(input) {
80
+ const pre = precheck(input);
81
+ if (!pre.ok)
82
+ return pre;
83
+ let value = pre.value;
84
+ const prefix = ARXIV_PREFIX.exec(value);
85
+ if (prefix) {
86
+ value = value.slice(prefix[0].length);
87
+ if (prefix[1]?.toLowerCase() === "pdf")
88
+ value = value.replace(/\.pdf$/i, "");
89
+ }
90
+ const invalid = { ok: false, error: "invalid_arxiv" };
91
+ const modern = ARXIV_NEW.exec(value);
92
+ if (modern) {
93
+ const [, yy, mm, number, version] = modern;
94
+ const yymm = Number(yy + mm);
95
+ const month = Number(mm);
96
+ if (month < 1 || month > 12)
97
+ return invalid;
98
+ if (number.length === 4 && !(yymm >= 704 && yymm <= 1412))
99
+ return invalid;
100
+ if (number.length === 5 && yymm < 1501)
101
+ return invalid;
102
+ return {
103
+ ok: true,
104
+ value: { arxiv_base: `${yy}${mm}.${number}`, arxiv_version: version ? Number(version) : null },
105
+ };
106
+ }
107
+ const legacy = ARXIV_OLD.exec(value);
108
+ if (legacy) {
109
+ const [, archive, yy, mm, number, version] = legacy;
110
+ const month = Number(mm);
111
+ const year = Number(yy);
112
+ if (month < 1 || month > 12)
113
+ return invalid;
114
+ const yymm = year * 100 + month;
115
+ const inRange = (year >= 91 && yymm >= 9108) || (year <= 7 && yymm <= 703);
116
+ if (!inRange)
117
+ return invalid;
118
+ return {
119
+ ok: true,
120
+ value: {
121
+ arxiv_base: `${archive.toLowerCase()}/${yy}${mm}${number}`,
122
+ arxiv_version: version ? Number(version) : null,
123
+ },
124
+ };
125
+ }
126
+ return invalid;
127
+ }
128
+ export function formatArxiv(value) {
129
+ return value.arxiv_version === null ? value.arxiv_base : `${value.arxiv_base}v${value.arxiv_version}`;
130
+ }
131
+ // ------------------------------------------------------------------ OpenAlex
132
+ const OPENALEX_PREFIX = /^https?:\/\/openalex\.org\//i;
133
+ const OPENALEX_RE = /^[Ww]([1-9][0-9]{0,14})$/;
134
+ export function parseOpenAlex(input) {
135
+ const pre = precheck(input);
136
+ if (!pre.ok)
137
+ return pre;
138
+ const match = OPENALEX_RE.exec(pre.value.replace(OPENALEX_PREFIX, ""));
139
+ if (!match)
140
+ return { ok: false, error: "invalid_openalex" };
141
+ return { ok: true, value: { openalex_id: `W${match[1]}` } };
142
+ }
143
+ export function parseIdentifier(scheme, input) {
144
+ if (scheme === "doi")
145
+ return parseDoi(input);
146
+ if (scheme === "arxiv")
147
+ return parseArxiv(input);
148
+ return parseOpenAlex(input);
149
+ }
150
+ export function sameWork(a, b) {
151
+ if ("doi_key" in a && "doi_key" in b)
152
+ return a.doi_key === b.doi_key;
153
+ if ("arxiv_base" in a && "arxiv_base" in b)
154
+ return a.arxiv_base === b.arxiv_base;
155
+ if ("openalex_id" in a && "openalex_id" in b)
156
+ return a.openalex_id === b.openalex_id;
157
+ return false;
158
+ }
159
+ /** Same arXiv document: same work and both versions given and equal (README §1.2). */
160
+ export function sameDocument(a, b) {
161
+ return (a.arxiv_base === b.arxiv_base &&
162
+ a.arxiv_version !== null &&
163
+ a.arxiv_version === b.arxiv_version);
164
+ }
165
+ export class HandoffError extends Error {
166
+ code;
167
+ constructor(code) {
168
+ super(`handoff: ${code}`);
169
+ this.code = code;
170
+ this.name = "HandoffError";
171
+ }
172
+ }
173
+ /** Builds a handoff URL (README §2.1). Throws HandoffError on any invalid input. */
174
+ export function buildHandoffUrl(baseUrl, input) {
175
+ const { identifiers } = input;
176
+ if (!identifiers.doi && !identifiers.arxiv && !identifiers.openalex) {
177
+ throw new HandoffError("missing_identifier");
178
+ }
179
+ if (!INTENTS.includes(input.intent))
180
+ throw new HandoffError("invalid_intent");
181
+ if (!SOURCES.includes(input.from))
182
+ throw new HandoffError("invalid_from");
183
+ const section = input.section ?? null;
184
+ if (section !== null && (section.length > 64 || !SECTION_RE.test(section))) {
185
+ throw new HandoffError("invalid_section");
186
+ }
187
+ const params = [["v", "1"]];
188
+ if (identifiers.doi) {
189
+ const doi = parseDoi(identifiers.doi);
190
+ if (!doi.ok)
191
+ throw new HandoffError("invalid_identifier");
192
+ params.push(["doi", doi.value.doi]);
193
+ }
194
+ if (identifiers.arxiv) {
195
+ const arxiv = parseArxiv(identifiers.arxiv);
196
+ if (!arxiv.ok)
197
+ throw new HandoffError("invalid_identifier");
198
+ params.push(["arxiv", formatArxiv(arxiv.value)]);
199
+ }
200
+ if (identifiers.openalex) {
201
+ const openalex = parseOpenAlex(identifiers.openalex);
202
+ if (!openalex.ok)
203
+ throw new HandoffError("invalid_identifier");
204
+ params.push(["openalex", openalex.value.openalex_id]);
205
+ }
206
+ params.push(["intent", input.intent], ["from", input.from]);
207
+ if (section !== null)
208
+ params.push(["section", section]);
209
+ const query = params.map(([k, v]) => `${k}=${encodeComponent(v)}`).join("&");
210
+ return `${baseUrl.replace(/\/$/, "")}/open?${query}`;
211
+ }
212
+ /** Parses an incoming /open (or availability) query (README §2.2). */
213
+ export function parseHandoffQuery(query) {
214
+ const params = typeof query === "string" ? new URLSearchParams(query.replace(/^\?/, "")) : query;
215
+ const get = (key) => {
216
+ const value = params.get(key);
217
+ return value === null || value === "" ? null : value;
218
+ };
219
+ const version = get("v");
220
+ if (version === null)
221
+ return { status: "invalid", error: "missing_version" };
222
+ if (version !== "1")
223
+ return { status: "invalid", error: "unsupported_version" };
224
+ const raw = { doi: get("doi"), arxiv: get("arxiv"), openalex: get("openalex") };
225
+ if (!raw.doi && !raw.arxiv && !raw.openalex)
226
+ return { status: "invalid", error: "missing_identifier" };
227
+ const identifiers = {};
228
+ if (raw.doi) {
229
+ const doi = parseDoi(raw.doi, { decode: false });
230
+ if (!doi.ok)
231
+ return { status: "invalid", error: "invalid_identifier" };
232
+ identifiers.doi = doi.value;
233
+ }
234
+ if (raw.arxiv) {
235
+ const arxiv = parseArxiv(raw.arxiv);
236
+ if (!arxiv.ok)
237
+ return { status: "invalid", error: "invalid_identifier" };
238
+ identifiers.arxiv = arxiv.value;
239
+ }
240
+ if (raw.openalex) {
241
+ const openalex = parseOpenAlex(raw.openalex);
242
+ if (!openalex.ok)
243
+ return { status: "invalid", error: "invalid_identifier" };
244
+ identifiers.openalex = openalex.value;
245
+ }
246
+ const intentRaw = get("intent");
247
+ const intent = INTENTS.includes(intentRaw ?? "") ? intentRaw : "view";
248
+ const fromRaw = get("from");
249
+ const from = SOURCES.includes(fromRaw ?? "") ? fromRaw : null;
250
+ const sectionRaw = get("section");
251
+ const section = sectionRaw !== null && sectionRaw.length <= 64 && SECTION_RE.test(sectionRaw) ? sectionRaw : null;
252
+ return { status: "ok", identifiers, intent, from, section };
253
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@inquivent/research-ids",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "DOI, arXiv and OpenAlex parsing and handoff URLs for the Inquivent products (handoff contract v1).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/inquivent/shared.git",
10
+ "directory": "packages/research-ids"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./contract/*": "./contract/*"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "files": [
22
+ "dist",
23
+ "contract",
24
+ "README.md"
25
+ ],
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.mkdirSync('contract',{recursive:true});for(const f of ['test-vectors.json','open-vectors.json','availability.schema.json','README.md'])fs.copyFileSync('../../contracts/handoff-v1/'+f,'contract/'+f)\"",
32
+ "test": "npm run build && node --test test/*.test.mjs",
33
+ "prepack": "npm run build"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "5.9.3"
37
+ }
38
+ }