Skip to content

Commit 625cd4a

Browse files
committed
feat: add packages annotations in package.json
1 parent 77b6d3f commit 625cd4a

File tree

8 files changed

+298
-29
lines changed

8 files changed

+298
-29
lines changed

.oxfmtrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"bracketSameLine": true,
77
"trailingComma": "es5",
88
"arrowParens": "avoid",
9-
"ignorePatterns": ["**/build", "**/node_modules", "package.json", "CHANGELOG.md"]
9+
"experimentalSortPackageJson": false,
10+
"ignorePatterns": ["**/build", "**/node_modules", "CHANGELOG.md"]
1011
}

bun.lock

Lines changed: 20 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
},
2121
"icon": "./assets/icon.png",
2222
"activationEvents": [
23-
"onCommand:extension.showQuickPick"
23+
"onCommand:extension.showQuickPick",
24+
"onCommand:extension.annotatePackageJsonDependencies",
25+
"onCommand:extension.clearPackageJsonDependencyAnnotations",
26+
"workspaceContains:package.json"
2427
],
2528
"main": "./build/index.js",
2629
"contributes": {
@@ -51,8 +54,8 @@
5154
"conventional-changelog-conventionalcommits": "^9.1.0",
5255
"esbuild": "^0.27.2",
5356
"ovsx": "^0.10.8",
54-
"oxfmt": "^0.24.0",
55-
"oxlint": "^1.39.0",
57+
"oxfmt": "^0.26.0",
58+
"oxlint": "^1.41.0",
5659
"oxlint-tsgolint": "^0.11.1",
5760
"rimraf": "6.1.2",
5861
"semantic-release": "^25.0.2",

src/annotatePackageJson.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import {
2+
type DecorationOptions,
3+
ThemeColor,
4+
Range,
5+
type TextEditor,
6+
window,
7+
workspace,
8+
type Disposable,
9+
MarkdownString,
10+
} from 'vscode';
11+
12+
import { CHECK_API_URL } from './constants';
13+
import { type PackageJSONDeps, type APICheckResponseData, type DependencyRef } from './types';
14+
import { getCompatibilityList, getDetailLabel, getPlatformsList, numberFormatter } from './utils';
15+
16+
function tryParsePackageJson(text: string): PackageJSONDeps | null {
17+
try {
18+
return JSON.parse(text) as PackageJSONDeps;
19+
} catch {
20+
return null;
21+
}
22+
}
23+
24+
function isPackageJsonEditor(editor?: TextEditor): editor is TextEditor {
25+
if (!editor) {
26+
return false;
27+
}
28+
29+
const doc = editor.document;
30+
31+
if (!/package\.json$/i.test(doc.fileName)) {
32+
return false;
33+
}
34+
35+
return doc.languageId === 'json' || doc.languageId === 'jsonc';
36+
}
37+
38+
const ENTRY_REGEXP = /^\s*"(?<name>[^"]+)"\s*:\s*"(?<version>(?:\\\\"|[^"])*)"/;
39+
40+
function getDependencyRefsFromPackageJsonText(text: string, editor: TextEditor): DependencyRef[] {
41+
const parsed = tryParsePackageJson(text);
42+
if (!parsed) {
43+
return [];
44+
}
45+
46+
const wantedNames = new Set<string>([
47+
...Object.keys(parsed.dependencies ?? {}),
48+
...Object.keys(parsed.peerDependencies ?? {}),
49+
]);
50+
51+
if (wantedNames.size === 0) {
52+
return [];
53+
}
54+
55+
const refs: DependencyRef[] = [];
56+
const lines = text.split(/\r?\n/);
57+
58+
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
59+
const lineText = lines[lineNumber];
60+
const entryMatch = ENTRY_REGEXP.exec(lineText);
61+
const pkgName = entryMatch?.groups?.name;
62+
63+
if (!pkgName || !wantedNames.has(pkgName)) {
64+
continue;
65+
}
66+
67+
let endChar = lineText.length;
68+
while (endChar > 0 && /\s/.test(lineText[endChar - 1])) {
69+
endChar--;
70+
}
71+
72+
const anchorPos = editor.document.lineAt(lineNumber).range.start.translate(0, endChar);
73+
74+
refs.push({ name: pkgName, anchor: new Range(anchorPos, anchorPos) });
75+
}
76+
77+
return refs;
78+
}
79+
80+
async function fetchDirectoryInfo(packagesList: string, signal: AbortSignal): Promise<APICheckResponseData | null> {
81+
const response = await fetch(`${CHECK_API_URL}?name=${encodeURIComponent(packagesList)}`, { signal });
82+
83+
if (!response.ok) {
84+
return null;
85+
}
86+
87+
return (await response.json()) as APICheckResponseData;
88+
}
89+
90+
export function createPackageJsonDependencyAnnotator(): {
91+
refreshActiveEditor: () => Promise<void>;
92+
clearActiveEditor: () => void;
93+
dispose: () => void;
94+
} {
95+
const decorationType = window.createTextEditorDecorationType({
96+
after: {
97+
margin: '0 0 0 1rem',
98+
},
99+
});
100+
101+
let abortController = new AbortController();
102+
let refreshTimer: NodeJS.Timeout | undefined;
103+
104+
function clearActiveEditor() {
105+
const editor = window.activeTextEditor;
106+
if (!editor) {
107+
return;
108+
}
109+
editor.setDecorations(decorationType, []);
110+
}
111+
112+
function scheduleRefresh(delayMs = 500) {
113+
if (refreshTimer) {
114+
clearTimeout(refreshTimer);
115+
}
116+
refreshTimer = setTimeout(() => {
117+
void refreshActiveEditor();
118+
}, delayMs);
119+
}
120+
121+
async function refreshActiveEditor() {
122+
const editor = window.activeTextEditor;
123+
124+
if (!isPackageJsonEditor(editor)) {
125+
return;
126+
}
127+
128+
abortController.abort();
129+
abortController = new AbortController();
130+
131+
const doc = editor.document;
132+
const localAbort = abortController;
133+
const refs = getDependencyRefsFromPackageJsonText(doc.getText(), editor);
134+
135+
if (refs.length === 0) {
136+
clearActiveEditor();
137+
return;
138+
}
139+
140+
window.setStatusBarMessage(`React Native Directory: annotating ${refs.length} dependencies…`, 2500);
141+
142+
const decorations: DecorationOptions[] = [];
143+
const depsList = refs.map(({ name }) => name).join(',');
144+
const result = await fetchDirectoryInfo(depsList, localAbort.signal).catch(() => null);
145+
146+
if (!result) {
147+
window.setStatusBarMessage('React Native Directory: failed to load annotations', 2500);
148+
return;
149+
}
150+
151+
for (const lib of Object.values(result)) {
152+
const anchor = refs.find(({ name }) => name === lib.npmPkg)?.anchor;
153+
154+
if (!anchor) {
155+
continue;
156+
}
157+
158+
const parts = [
159+
`★ ${numberFormatter.format(lib.github.stats.stars)}`,
160+
lib.npm?.downloads ? `⧨ ${numberFormatter.format(lib.npm.downloads)}` : undefined,
161+
lib.unmaintained ? ` • Unmaintained` : undefined,
162+
lib.newArchitecture || lib.expoGo
163+
? ` • New Architecture${lib.newArchitecture === 'new-arch-only' ? ' only' : ''}`
164+
: undefined,
165+
].filter(Boolean);
166+
167+
const tooltip = new MarkdownString(undefined, true);
168+
tooltip.appendMarkdown(`$(package) **${lib.npmPkg}**${lib.unmaintained ? ' $(warning) Unmaintained' : ''}\n\n`);
169+
170+
if (lib.github.description) {
171+
tooltip.appendMarkdown(`${lib.github.description}\n\n`);
172+
}
173+
174+
tooltip.appendMarkdown(`- **Platforms:** ${getPlatformsList(lib).join(', ')}\n`);
175+
176+
const compatibility = getCompatibilityList(lib);
177+
if (compatibility.length > 0) {
178+
tooltip.appendMarkdown(`- **Compatibility:** ${compatibility.join(', ')}\n`);
179+
}
180+
181+
if (lib.unmaintained && lib.alternatives && lib.alternatives.length > 0) {
182+
tooltip.appendMarkdown(`- **Alternatives:** \`${lib.alternatives.join(', ')}\`\n`);
183+
}
184+
185+
tooltip.appendMarkdown(`- **Directory score:** ${lib.score}/100\n\n`);
186+
tooltip.appendMarkdown(getDetailLabel(lib, true));
187+
tooltip.appendMarkdown(`\n\n---\n`);
188+
tooltip.appendMarkdown(`[React Native Directory](https://reactnative.directory/package/${lib.npmPkg}) 🞄 `);
189+
tooltip.appendMarkdown(`[GitHub](${lib.githubUrl}) 🞄 `);
190+
tooltip.appendMarkdown(`[npm](https://www.npmjs.com/package/${lib.npmPkg}) 🞄 `);
191+
tooltip.appendMarkdown(`[Bundlephobia](https://bundlephobia.com/package/${lib.npmPkg})`);
192+
193+
decorations.push({
194+
range: anchor,
195+
hoverMessage: tooltip,
196+
renderOptions: {
197+
after: {
198+
contentText: `${parts.join(' ')}`,
199+
color: lib.unmaintained
200+
? new ThemeColor('statusBarItem.warningBackground')
201+
: new ThemeColor('editorLineNumber.foreground'),
202+
},
203+
},
204+
});
205+
}
206+
207+
if (localAbort.signal.aborted) {
208+
return;
209+
}
210+
211+
editor.setDecorations(decorationType, decorations);
212+
}
213+
214+
const disposables: Disposable[] = [
215+
decorationType,
216+
window.onDidChangeActiveTextEditor(() => scheduleRefresh()),
217+
workspace.onDidOpenTextDocument(() => scheduleRefresh()),
218+
workspace.onDidChangeTextDocument(event => {
219+
const active = window.activeTextEditor;
220+
if (!isPackageJsonEditor(active) || event.document.uri.toString() !== active.document.uri.toString()) {
221+
return;
222+
}
223+
scheduleRefresh();
224+
}),
225+
];
226+
227+
function dispose() {
228+
abortController.abort();
229+
if (refreshTimer) {
230+
clearTimeout(refreshTimer);
231+
}
232+
for (const disposable of disposables) {
233+
disposable.dispose();
234+
}
235+
}
236+
237+
scheduleRefresh(0);
238+
239+
return { refreshActiveEditor, clearActiveEditor, dispose };
240+
}

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const BASE_API_URL = 'https://reactnative.directory/api/libraries';
2+
export const CHECK_API_URL = 'https://reactnative.directory/api/library';
23
export const KEYWORD_REGEX = /:\w+/g;
34

45
export enum ENTRY_OPTION {

0 commit comments

Comments
 (0)