feat: add IETF RFC search extension
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
vicinae-env.d.ts
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 yuta
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,50 @@
|
||||
# IETF RFC Search for Vicinae
|
||||
|
||||
Search and inspect the complete RFC catalog without leaving Vicinae.
|
||||
|
||||
## Features
|
||||
|
||||
- Search 9,000+ RFCs by number, title, author, keyword, or abstract
|
||||
- Use natural number formats such as `9110`, `RFC 9110`, or `rfc9110`
|
||||
- Filter by standards status, including Internet Standard, Proposed Standard, BCP, Informational, Experimental, and Historic
|
||||
- Read the abstract and publication metadata in the list detail pane
|
||||
- See prominent warnings and relationships for obsoleted or updated RFCs
|
||||
- Open the official HTML, TXT, PDF, or XML document when that format is available
|
||||
- Open RFC Editor info, IETF Datatracker, errata, and DOI pages
|
||||
- Copy the canonical URL or a short citation
|
||||
- Browse the newest RFCs before entering a query
|
||||
|
||||
## Data source and caching
|
||||
|
||||
The extension uses the official [RFC Editor index](https://www.rfc-editor.org/rfc-index.xml) as its only search data source. The index is downloaded on first launch and cached locally for 24 hours. Search is then performed locally, so typing does not send search terms to a remote service and repeated searches are immediate.
|
||||
|
||||
If a refresh fails after a successful download, the extension continues to use the cached index. Use **Refresh RFC Index** from an item's action panel to request the latest catalog manually.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 20 or newer
|
||||
- A running [Vicinae](https://vicinae.com/) installation
|
||||
|
||||
With Nix and direnv, enter the included development shell with `direnv allow` (or `nix develop`).
|
||||
|
||||
Install dependencies and start development mode:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run formatting, linting, tests, type checking, and a production build:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
npm run check
|
||||
```
|
||||
|
||||
The focused commands are also available as `npm run lint`, `npm test`, and `npm run build`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.2/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": {
|
||||
"test": "recommended"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785454630,
|
||||
"narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=",
|
||||
"rev": "1559d3daa3ecc813a650b79375ea61b6741b8746",
|
||||
"revCount": 1044798,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.1044798%2Brev-1559d3daa3ecc813a650b79375ea61b6741b8746/019fb7b4-037e-75a0-a315-35190eb80bd3/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
description = "A Nix-flake-based Node.js development environment";
|
||||
|
||||
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; # unstable Nixpkgs
|
||||
|
||||
outputs =
|
||||
{ self, ... }@inputs:
|
||||
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forEachSupportedSystem =
|
||||
f:
|
||||
inputs.nixpkgs.lib.genAttrs supportedSystems (
|
||||
system:
|
||||
f {
|
||||
inherit system;
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ inputs.self.overlays.default ];
|
||||
};
|
||||
}
|
||||
);
|
||||
in
|
||||
{
|
||||
overlays.default = final: prev: rec {
|
||||
nodejs = prev.nodejs;
|
||||
yarn = (prev.yarn.override { inherit nodejs; });
|
||||
};
|
||||
|
||||
devShells = forEachSupportedSystem (
|
||||
{ pkgs, system }:
|
||||
{
|
||||
default = pkgs.mkShellNoCC {
|
||||
packages = with pkgs; [
|
||||
nodejs
|
||||
pnpm
|
||||
yarn
|
||||
self.formatter.${system}
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
formatter = forEachSupportedSystem ({ pkgs, ... }: pkgs.nixfmt);
|
||||
};
|
||||
}
|
||||
Generated
+2176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json",
|
||||
"name": "vicinae-extension-ietf-rfc",
|
||||
"title": "IETF RFC Search",
|
||||
"description": "Search and inspect the complete RFC Editor index",
|
||||
"categories": [],
|
||||
"license": "MIT",
|
||||
"author": "yuta",
|
||||
"contributors": [],
|
||||
"pastContributors": [],
|
||||
"icon": "extension_icon-v2.png",
|
||||
"commands": [
|
||||
{
|
||||
"name": "search-rfc",
|
||||
"title": "Search IETF RFCs",
|
||||
"description": "Search RFCs by number, title, author, keyword, or abstract",
|
||||
"mode": "view"
|
||||
}
|
||||
],
|
||||
"preferences": [],
|
||||
"scripts": {
|
||||
"build": "vici build",
|
||||
"dev": "vici develop",
|
||||
"format": "biome format --write src",
|
||||
"lint": "biome check src && vici lint",
|
||||
"test": "vitest run",
|
||||
"check": "npm run lint && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicinae/api": "^0.23.2",
|
||||
"fast-xml-parser": "^5.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRfcIndex, type Rfc, rfcUrl, searchRfcs } from "./rfc-index";
|
||||
|
||||
const fixture = `<?xml version="1.0"?>
|
||||
<rfc-index xmlns="https://www.rfc-editor.org/rfc-index">
|
||||
<rfc-entry>
|
||||
<doc-id>RFC6749</doc-id>
|
||||
<title>The OAuth 2.0 Authorization Framework</title>
|
||||
<author><name>D. Hardt</name></author>
|
||||
<date><month>October</month><year>2012</year></date>
|
||||
<format><file-format>TXT</file-format><file-format>HTML</file-format></format>
|
||||
<page-count>76</page-count>
|
||||
<keywords><kw>authorization</kw><kw>access token</kw></keywords>
|
||||
<abstract><p>This document specifies OAuth 2.0.</p></abstract>
|
||||
<obsoletes><doc-id>RFC5849</doc-id></obsoletes>
|
||||
<updated-by><doc-id>RFC8252</doc-id><doc-id>RFC8996</doc-id></updated-by>
|
||||
<current-status>PROPOSED STANDARD</current-status>
|
||||
<stream>IETF</stream>
|
||||
<area>sec</area>
|
||||
<wg_acronym>oauth</wg_acronym>
|
||||
<errata-url>https://www.rfc-editor.org/errata/rfc6749</errata-url>
|
||||
<doi>10.17487/RFC6749</doi>
|
||||
</rfc-entry>
|
||||
<rfc-entry>
|
||||
<doc-id>RFC8259</doc-id>
|
||||
<title>The JavaScript Object Notation (JSON) Data Interchange Format</title>
|
||||
<author><name>T. Bray</name></author>
|
||||
<date><month>December</month><year>2017</year></date>
|
||||
<format><file-format>HTML</file-format></format>
|
||||
<page-count>16</page-count>
|
||||
<keywords><kw>JSON</kw></keywords>
|
||||
<abstract><p>A lightweight, text-based data interchange format.</p></abstract>
|
||||
<is-also><doc-id>STD90</doc-id></is-also>
|
||||
<current-status>INTERNET STANDARD</current-status>
|
||||
<stream>IETF</stream>
|
||||
</rfc-entry>
|
||||
<rfc-entry>
|
||||
<doc-id>RFC7159</doc-id>
|
||||
<title>The JavaScript Object Notation (JSON) Data Interchange Format</title>
|
||||
<author><name>T. Bray</name></author>
|
||||
<date><month>March</month><year>2014</year></date>
|
||||
<format><file-format>HTML</file-format></format>
|
||||
<page-count>15</page-count>
|
||||
<abstract><p>An older JSON specification.</p></abstract>
|
||||
<obsoleted-by><doc-id>RFC8259</doc-id></obsoleted-by>
|
||||
<current-status>INTERNET STANDARD</current-status>
|
||||
<stream>IETF</stream>
|
||||
</rfc-entry>
|
||||
</rfc-index>`;
|
||||
|
||||
const rfcs = parseRfcIndex(fixture);
|
||||
|
||||
describe("parseRfcIndex", () => {
|
||||
it("normalizes RFC Editor fields and relationships", () => {
|
||||
expect(rfcs[0]).toMatchObject({
|
||||
number: 6749,
|
||||
docId: "RFC6749",
|
||||
title: "The OAuth 2.0 Authorization Framework",
|
||||
authors: ["D. Hardt"],
|
||||
month: "October",
|
||||
year: 2012,
|
||||
formats: ["TXT", "HTML"],
|
||||
pageCount: 76,
|
||||
keywords: ["authorization", "access token"],
|
||||
abstract: "This document specifies OAuth 2.0.",
|
||||
obsoletes: ["RFC5849"],
|
||||
updatedBy: ["RFC8252", "RFC8996"],
|
||||
status: "PROPOSED STANDARD",
|
||||
workingGroup: "oauth",
|
||||
doi: "10.17487/RFC6749",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a document without RFC entries", () => {
|
||||
expect(() => parseRfcIndex("<rfc-index />")).toThrow(
|
||||
"The RFC index did not contain any RFC entries.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchRfcs", () => {
|
||||
it.each(["6749", "RFC 6749", "rfc6749", "RFC-06749"])(
|
||||
"treats %s as an exact RFC number",
|
||||
(query) => {
|
||||
expect(searchRfcs(rfcs, query).map((rfc) => rfc.docId)).toEqual([
|
||||
"RFC6749",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("searches titles, authors, keywords, and abstracts", () => {
|
||||
expect(searchRfcs(rfcs, "D. Hardt")[0]?.docId).toBe("RFC6749");
|
||||
expect(searchRfcs(rfcs, "access token")[0]?.docId).toBe("RFC6749");
|
||||
expect(searchRfcs(rfcs, "lightweight text-based")[0]?.docId).toBe(
|
||||
"RFC8259",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders equally relevant results from newest RFC number to oldest", () => {
|
||||
expect(searchRfcs(rfcs, "JSON").map((rfc) => rfc.docId)).toEqual([
|
||||
"RFC8259",
|
||||
"RFC7159",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the latest RFCs when the search is empty", () => {
|
||||
expect(searchRfcs(rfcs, "", "all", 2).map((rfc) => rfc.docId)).toEqual([
|
||||
"RFC8259",
|
||||
"RFC7159",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters results by current status", () => {
|
||||
expect(
|
||||
searchRfcs(rfcs, "", "PROPOSED STANDARD").map((rfc) => rfc.docId),
|
||||
).toEqual(["RFC6749"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rfcUrl", () => {
|
||||
it("builds a canonical RFC Editor format URL", () => {
|
||||
expect(rfcUrl(rfcs[0] as Rfc, "PDF")).toBe(
|
||||
"https://www.rfc-editor.org/rfc/rfc6749.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export const RFC_INDEX_URL = "https://www.rfc-editor.org/rfc-index.xml";
|
||||
|
||||
export type Rfc = {
|
||||
number: number;
|
||||
docId: string;
|
||||
title: string;
|
||||
authors: string[];
|
||||
month?: string;
|
||||
year?: number;
|
||||
formats: string[];
|
||||
pageCount?: number;
|
||||
keywords: string[];
|
||||
abstract: string;
|
||||
status: string;
|
||||
stream?: string;
|
||||
area?: string;
|
||||
workingGroup?: string;
|
||||
doi?: string;
|
||||
errataUrl?: string;
|
||||
isAlso: string[];
|
||||
obsoletes: string[];
|
||||
obsoletedBy: string[];
|
||||
updates: string[];
|
||||
updatedBy: string[];
|
||||
};
|
||||
|
||||
type XmlValue = string | number | Record<string, unknown> | XmlValue[];
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: true,
|
||||
parseTagValue: false,
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
function arrayOf<T>(value: T | T[] | undefined): T[] {
|
||||
if (value === undefined) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value).trim() || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function childText(value: unknown, key: string): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const child = (value as Record<string, XmlValue>)[key];
|
||||
return arrayOf(child)
|
||||
.map(text)
|
||||
.filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function paragraphs(value: unknown): string {
|
||||
return childText(value, "p").join("\n\n");
|
||||
}
|
||||
|
||||
function relation(value: unknown): string[] {
|
||||
return childText(value, "doc-id");
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: Record<string, unknown>): Rfc | undefined {
|
||||
const docId = text(raw["doc-id"]);
|
||||
const title = text(raw.title);
|
||||
const number = docId?.match(/^RFC(\d+)$/)?.[1];
|
||||
if (!docId || !title || !number) return undefined;
|
||||
|
||||
const authors = arrayOf(raw.author)
|
||||
.flatMap((author) => childText(author, "name"))
|
||||
.filter(Boolean);
|
||||
const date = raw.date as Record<string, unknown> | undefined;
|
||||
const year = Number(text(date?.year));
|
||||
const pageCount = Number(text(raw["page-count"]));
|
||||
|
||||
return {
|
||||
number: Number(number),
|
||||
docId,
|
||||
title,
|
||||
authors,
|
||||
month: text(date?.month),
|
||||
year: Number.isFinite(year) ? year : undefined,
|
||||
formats: childText(raw.format, "file-format"),
|
||||
pageCount: Number.isFinite(pageCount) ? pageCount : undefined,
|
||||
keywords: childText(raw.keywords, "kw"),
|
||||
abstract: paragraphs(raw.abstract),
|
||||
status: text(raw["current-status"]) ?? "UNKNOWN",
|
||||
stream: text(raw.stream),
|
||||
area: text(raw.area),
|
||||
workingGroup: text(raw.wg_acronym),
|
||||
doi: text(raw.doi),
|
||||
errataUrl: text(raw["errata-url"]),
|
||||
isAlso: relation(raw["is-also"]),
|
||||
obsoletes: relation(raw.obsoletes),
|
||||
obsoletedBy: relation(raw["obsoleted-by"]),
|
||||
updates: relation(raw.updates),
|
||||
updatedBy: relation(raw["updated-by"]),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRfcIndex(xml: string): Rfc[] {
|
||||
const parsed = parser.parse(xml) as Record<string, Record<string, unknown>>;
|
||||
const entries = parsed["rfc-index"]?.["rfc-entry"];
|
||||
if (!entries)
|
||||
throw new Error("The RFC index did not contain any RFC entries.");
|
||||
|
||||
return arrayOf(entries)
|
||||
.map((entry) => normalizeEntry(entry as Record<string, unknown>))
|
||||
.filter((entry): entry is Rfc => Boolean(entry));
|
||||
}
|
||||
|
||||
export type StatusFilter = "all" | Rfc["status"];
|
||||
|
||||
function searchableText(rfc: Rfc): string {
|
||||
return [
|
||||
rfc.docId,
|
||||
rfc.title,
|
||||
...rfc.authors,
|
||||
...rfc.keywords,
|
||||
rfc.abstract,
|
||||
...rfc.isAlso,
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function scoreRfc(rfc: Rfc, query: string): number | undefined {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return 0;
|
||||
|
||||
const exactNumber = normalized.match(/^(?:rfc[\s-]*)?0*(\d{1,5})$/)?.[1];
|
||||
if (exactNumber) return rfc.number === Number(exactNumber) ? 0 : undefined;
|
||||
|
||||
const tokens = normalized.split(/\s+/).filter(Boolean);
|
||||
const title = rfc.title.toLocaleLowerCase();
|
||||
const primary = [rfc.docId, rfc.title, ...rfc.keywords, ...rfc.authors]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
|
||||
if (title.startsWith(normalized)) return 10;
|
||||
if (title.includes(normalized)) return 20;
|
||||
if (tokens.every((token) => primary.includes(token))) return 30;
|
||||
if (tokens.every((token) => searchableText(rfc).includes(token))) return 40;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function searchRfcs(
|
||||
rfcs: Rfc[],
|
||||
query: string,
|
||||
status: StatusFilter = "all",
|
||||
limit = 75,
|
||||
): Rfc[] {
|
||||
return rfcs
|
||||
.filter((rfc) => status === "all" || rfc.status === status)
|
||||
.map((rfc) => ({ rfc, score: scoreRfc(rfc, query) }))
|
||||
.filter(
|
||||
(result): result is { rfc: Rfc; score: number } =>
|
||||
result.score !== undefined,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.score - right.score || right.rfc.number - left.rfc.number,
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map(({ rfc }) => rfc);
|
||||
}
|
||||
|
||||
export function rfcUrl(rfc: Rfc, format: string): string {
|
||||
return `https://www.rfc-editor.org/rfc/rfc${rfc.number}.${format.toLocaleLowerCase()}`;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Cache,
|
||||
Color,
|
||||
Icon,
|
||||
List,
|
||||
showToast,
|
||||
Toast,
|
||||
} from "@vicinae/api";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
parseRfcIndex,
|
||||
RFC_INDEX_URL,
|
||||
type Rfc,
|
||||
rfcUrl,
|
||||
type StatusFilter,
|
||||
searchRfcs,
|
||||
} from "./rfc-index";
|
||||
|
||||
const cache = new Cache({ namespace: "rfc-index", capacity: 30 * 1024 * 1024 });
|
||||
const CACHE_KEY = "index-v1";
|
||||
const MAX_CACHE_AGE = 24 * 60 * 60 * 1000;
|
||||
|
||||
type CachedIndex = {
|
||||
updatedAt: number;
|
||||
rfcs: Rfc[];
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
["all", "All statuses"],
|
||||
["INTERNET STANDARD", "Internet Standard"],
|
||||
["PROPOSED STANDARD", "Proposed Standard"],
|
||||
["DRAFT STANDARD", "Draft Standard"],
|
||||
["BEST CURRENT PRACTICE", "Best Current Practice"],
|
||||
["INFORMATIONAL", "Informational"],
|
||||
["EXPERIMENTAL", "Experimental"],
|
||||
["HISTORIC", "Historic"],
|
||||
["UNKNOWN", "Unknown"],
|
||||
] as const;
|
||||
|
||||
function readCachedIndex(): CachedIndex | undefined {
|
||||
const stored = cache.get(CACHE_KEY);
|
||||
if (!stored) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as CachedIndex;
|
||||
if (!Array.isArray(parsed.rfcs) || typeof parsed.updatedAt !== "number") {
|
||||
throw new Error("Invalid cached index");
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
cache.remove(CACHE_KEY);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function labelForStatus(status: string): string {
|
||||
return STATUS_OPTIONS.find(([value]) => value === status)?.[1] ?? status;
|
||||
}
|
||||
|
||||
function escapeMarkdown(value: string): string {
|
||||
return value.replace(/([\\`*_{}[\]()#+.!|>-])/g, "\\$1");
|
||||
}
|
||||
|
||||
function relationText(ids: string[]): string {
|
||||
return ids.join(", ");
|
||||
}
|
||||
|
||||
function RfcDetail({ rfc }: { rfc: Rfc }) {
|
||||
const obsoleteWarning = rfc.obsoletedBy.length
|
||||
? `> **Obsoleted by ${relationText(rfc.obsoletedBy)}.** Prefer the newer specification unless you specifically need this RFC.\n\n`
|
||||
: "";
|
||||
const markdown = `# ${rfc.docId}\n\n## ${escapeMarkdown(rfc.title)}\n\n${obsoleteWarning}${escapeMarkdown(rfc.abstract || "No abstract is available for this RFC.")}`;
|
||||
|
||||
return (
|
||||
<List.Item.Detail
|
||||
markdown={markdown}
|
||||
metadata={
|
||||
<List.Item.Detail.Metadata>
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Status"
|
||||
text={
|
||||
rfc.obsoletedBy.length
|
||||
? { value: "Obsoleted", color: Color.Red }
|
||||
: labelForStatus(rfc.status)
|
||||
}
|
||||
/>
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Published"
|
||||
text={[rfc.month, rfc.year].filter(Boolean).join(" ") || "Unknown"}
|
||||
/>
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Authors"
|
||||
text={rfc.authors.join(", ") || "Unknown"}
|
||||
/>
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Stream"
|
||||
text={rfc.stream ?? "Unknown"}
|
||||
/>
|
||||
{rfc.pageCount ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Pages"
|
||||
text={String(rfc.pageCount)}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.workingGroup ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Working group"
|
||||
text={rfc.workingGroup}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.isAlso.length ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Also"
|
||||
text={relationText(rfc.isAlso)}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.obsoletes.length ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Obsoletes"
|
||||
text={relationText(rfc.obsoletes)}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.obsoletedBy.length ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Obsoleted by"
|
||||
text={{ value: relationText(rfc.obsoletedBy), color: Color.Red }}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.updates.length ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Updates"
|
||||
text={relationText(rfc.updates)}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.updatedBy.length ? (
|
||||
<List.Item.Detail.Metadata.Label
|
||||
title="Updated by"
|
||||
text={relationText(rfc.updatedBy)}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.keywords.length ? (
|
||||
<List.Item.Detail.Metadata.TagList title="Keywords">
|
||||
{rfc.keywords.map((keyword) => (
|
||||
<List.Item.Detail.Metadata.TagList.Item
|
||||
key={keyword}
|
||||
text={keyword}
|
||||
/>
|
||||
))}
|
||||
</List.Item.Detail.Metadata.TagList>
|
||||
) : null}
|
||||
</List.Item.Detail.Metadata>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RfcActions({ rfc, refresh }: { rfc: Rfc; refresh: () => void }) {
|
||||
const htmlUrl = rfcUrl(rfc, "html");
|
||||
return (
|
||||
<ActionPanel>
|
||||
<ActionPanel.Section>
|
||||
<Action.OpenInBrowser
|
||||
title="Open RFC"
|
||||
url={htmlUrl}
|
||||
icon={Icon.Book}
|
||||
shortcut="open"
|
||||
/>
|
||||
<Action.CopyToClipboard
|
||||
title="Copy RFC URL"
|
||||
content={htmlUrl}
|
||||
shortcut="copy"
|
||||
/>
|
||||
<Action.CopyToClipboard
|
||||
title="Copy Citation"
|
||||
content={`${rfc.docId}: ${rfc.title}`}
|
||||
/>
|
||||
</ActionPanel.Section>
|
||||
<ActionPanel.Section title="More Formats">
|
||||
{rfc.formats
|
||||
.filter((format) => format !== "HTML")
|
||||
.map((format) => (
|
||||
<Action.OpenInBrowser
|
||||
key={format}
|
||||
title={`Open ${format}`}
|
||||
url={rfcUrl(rfc, format)}
|
||||
icon={Icon.BlankDocument}
|
||||
/>
|
||||
))}
|
||||
<Action.OpenInBrowser
|
||||
title="Open RFC Editor Info"
|
||||
url={`https://www.rfc-editor.org/info/rfc${rfc.number}`}
|
||||
icon={Icon.Globe01}
|
||||
/>
|
||||
<Action.OpenInBrowser
|
||||
title="Open IETF Datatracker"
|
||||
url={`https://datatracker.ietf.org/doc/rfc${rfc.number}/`}
|
||||
icon={Icon.Globe01}
|
||||
/>
|
||||
{rfc.errataUrl ? (
|
||||
<Action.OpenInBrowser
|
||||
title="View Errata"
|
||||
url={rfc.errataUrl}
|
||||
icon={Icon.Bug}
|
||||
/>
|
||||
) : null}
|
||||
{rfc.doi ? (
|
||||
<Action.OpenInBrowser
|
||||
title="Open DOI"
|
||||
url={`https://doi.org/${rfc.doi}`}
|
||||
icon={Icon.Link}
|
||||
/>
|
||||
) : null}
|
||||
</ActionPanel.Section>
|
||||
<ActionPanel.Section>
|
||||
<Action
|
||||
title="Refresh RFC Index"
|
||||
icon={Icon.ArrowClockwise}
|
||||
shortcut="refresh"
|
||||
onAction={refresh}
|
||||
/>
|
||||
</ActionPanel.Section>
|
||||
</ActionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchRfc() {
|
||||
const cached = useMemo(readCachedIndex, []);
|
||||
const [rfcs, setRfcs] = useState<Rfc[]>(cached?.rfcs ?? []);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [isLoading, setIsLoading] = useState(!cached);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const loadIndex = useCallback(async (force = false, signal?: AbortSignal) => {
|
||||
const existing = readCachedIndex();
|
||||
if (!force && existing && Date.now() - existing.updatedAt < MAX_CACHE_AGE) {
|
||||
setRfcs(existing.rfcs);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch(RFC_INDEX_URL, { signal });
|
||||
if (!response.ok)
|
||||
throw new Error(`RFC Editor returned HTTP ${response.status}`);
|
||||
const nextRfcs = parseRfcIndex(await response.text());
|
||||
const nextCache = { updatedAt: Date.now(), rfcs: nextRfcs };
|
||||
cache.set(CACHE_KEY, JSON.stringify(nextCache));
|
||||
setRfcs(nextRfcs);
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||||
const message = cause instanceof Error ? cause.message : "Unknown error";
|
||||
if (existing) {
|
||||
setRfcs(existing.rfcs);
|
||||
await showToast(
|
||||
Toast.Style.Failure,
|
||||
"Couldn't refresh the RFC index",
|
||||
"Showing cached data",
|
||||
);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadIndex(false, controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loadIndex]);
|
||||
|
||||
const results = useMemo(
|
||||
() => searchRfcs(rfcs, query, status),
|
||||
[rfcs, query, status],
|
||||
);
|
||||
const refresh = () => void loadIndex(true);
|
||||
|
||||
return (
|
||||
<List
|
||||
isLoading={isLoading}
|
||||
isShowingDetail={results.length > 0}
|
||||
filtering={false}
|
||||
throttle
|
||||
searchText={query}
|
||||
onSearchTextChange={setQuery}
|
||||
searchBarPlaceholder="Search by RFC number, title, author, keyword, or abstract…"
|
||||
searchBarAccessory={
|
||||
<List.Dropdown
|
||||
tooltip="Filter by status"
|
||||
value={status}
|
||||
onChange={(value) => setStatus(value as StatusFilter)}
|
||||
>
|
||||
{STATUS_OPTIONS.map(([value, label]) => (
|
||||
<List.Dropdown.Item key={value} title={label} value={value} />
|
||||
))}
|
||||
</List.Dropdown>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.CloudLightning}
|
||||
title="Couldn't load the RFC index"
|
||||
description={`${error}. Check your connection and try again.`}
|
||||
actions={
|
||||
<ActionPanel>
|
||||
<Action
|
||||
title="Retry"
|
||||
icon={Icon.ArrowClockwise}
|
||||
onAction={refresh}
|
||||
/>
|
||||
</ActionPanel>
|
||||
}
|
||||
/>
|
||||
) : !isLoading && results.length === 0 ? (
|
||||
<List.EmptyView
|
||||
icon={Icon.MagnifyingGlass}
|
||||
title={
|
||||
query ? `No RFCs found for “${query}”` : "No RFCs match this status"
|
||||
}
|
||||
description="Try a broader term, a different status, or an RFC number such as 9110."
|
||||
/>
|
||||
) : (
|
||||
<List.Section
|
||||
title={query ? `${results.length} results` : "Latest RFCs"}
|
||||
>
|
||||
{results.map((rfc) => (
|
||||
<List.Item
|
||||
key={rfc.docId}
|
||||
id={rfc.docId}
|
||||
icon={rfc.obsoletedBy.length ? Icon.Warning : Icon.Book}
|
||||
title={rfc.docId}
|
||||
subtitle={rfc.title}
|
||||
accessories={[
|
||||
{
|
||||
tag: rfc.obsoletedBy.length
|
||||
? { value: "Obsoleted", color: Color.Red }
|
||||
: labelForStatus(rfc.status),
|
||||
},
|
||||
{ text: rfc.year ? String(rfc.year) : undefined },
|
||||
]}
|
||||
detail={<RfcDetail rfc={rfc} />}
|
||||
actions={<RfcActions rfc={rfc} refresh={refresh} />}
|
||||
/>
|
||||
))}
|
||||
</List.Section>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Node 16",
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
//"lib": ["es2020"],
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user