Skip to content

Commit dd52b11

Browse files
authored
Merge branch 'main' into copilot/add-typed-query-selector-strict-again
2 parents 775a9d8 + 41f9fe6 commit dd52b11

File tree

6 files changed

+191
-26
lines changed

6 files changed

+191
-26
lines changed

.github/workflows/npm-publish.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ on:
1515
description: 'Version accepted by `npm version *`'
1616
required: true
1717

18+
permissions:
19+
contents: write
20+
id-token: write
21+
1822
jobs:
1923
NPM:
2024
runs-on: ubuntu-latest
@@ -23,17 +27,14 @@ jobs:
2327
- uses: actions/setup-node@v6
2428
with:
2529
node-version-file: package.json
26-
registry-url: https://registry.npmjs.org
2730
- run: npm ci || npm install
2831
- uses: fregante/setup-git-user@v2
2932
- name: Create version
3033
# Get the generated version, this enables support for keywords: `npm version patch`
3134
run: |
3235
VERSION="$(npm version "${{ github.event.inputs.Version }}")"
3336
echo "VERSION=$VERSION" >> $GITHUB_ENV
34-
- run: npm publish
35-
env:
36-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
37+
- run: npm publish --provenance --access public
3738
- run: git push --follow-tags
3839
- run: gh release create "$VERSION" --generate-notes
3940
env:

index.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@ import stripIndent from 'strip-indent';
55
import {getAllUrls, getTests} from './collector.js';
66
import * as pageDetect from './index.js';
77

8-
(globalThis as any).document = {title: ''};
8+
(globalThis as any).document = {title: '', readyState: 'loading'};
99
(globalThis as any).location = new URL('https://github.com/');
10+
(globalThis as any).requestAnimationFrame = (callback: FrameRequestCallback) => setTimeout(() => {
11+
callback(Date.now());
12+
}, 0) as unknown as number;
13+
14+
(globalThis as any).cancelAnimationFrame = (id: number) => {
15+
clearTimeout(id);
16+
};
1017

1118
const allUrls = getAllUrls();
1219

@@ -281,3 +288,45 @@ test('parseRepoExplorerTitle', () => {
281288
undefined,
282289
);
283290
});
291+
292+
test('waitFor - immediately true', async () => {
293+
const detection = () => true;
294+
const result = await pageDetect.utils.waitFor(detection);
295+
assert.equal(result, true);
296+
});
297+
298+
test('waitFor - becomes true', async () => {
299+
let callCount = 0;
300+
const detection = () => {
301+
callCount++;
302+
return callCount >= 3;
303+
};
304+
305+
const result = await pageDetect.utils.waitFor(detection);
306+
assert.equal(result, true);
307+
assert.ok(callCount >= 3);
308+
});
309+
310+
test('waitFor - false when document complete', async () => {
311+
// Save original state
312+
const originalReadyState = Object.getOwnPropertyDescriptor(document, 'readyState');
313+
314+
// Mock document.readyState to be 'complete'
315+
Object.defineProperty(document, 'readyState', {
316+
writable: true,
317+
configurable: true,
318+
value: 'complete',
319+
});
320+
321+
const detection = () => false;
322+
const result = await pageDetect.utils.waitFor(detection);
323+
assert.equal(result, false);
324+
325+
// Restore original state
326+
if (originalReadyState) {
327+
Object.defineProperty(document, 'readyState', originalReadyState);
328+
} else {
329+
// If readyState wasn't a property before, delete it
330+
delete (document as any).readyState;
331+
}
332+
});

index.ts

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ function exists(selector: string): boolean {
2020
return Boolean(document.querySelector(selector));
2121
}
2222

23+
/**
24+
* Waits for a detection to return true by repeatedly checking it on each animation frame.
25+
* Useful for DOM-based detections that need to wait for elements to appear.
26+
* @param detection - A detection function to check repeatedly
27+
* @returns A promise that resolves to the final result of the detection
28+
* @example
29+
* ```
30+
* import {utils} from 'github-url-detection';
31+
*
32+
* async function init() {
33+
* if (!await utils.waitFor(isOrganizationProfile)) {
34+
* return;
35+
* }
36+
* // Do something when on organization profile
37+
* }
38+
* ```
39+
*/
40+
async function waitFor(detection: () => boolean): Promise<boolean> {
41+
// eslint-disable-next-line no-await-in-loop -- We need to wait on each frame
42+
while (!detection() && document.readyState !== 'complete') {
43+
// eslint-disable-next-line no-await-in-loop
44+
await new Promise(resolve => {
45+
requestAnimationFrame(resolve);
46+
});
47+
}
48+
49+
return detection();
50+
}
51+
2352
const combinedTestOnly = ['combinedTestOnly']; // To be used only to skip tests of combined functions, i.e. isPageA() || isPageB()
2453

2554
TEST: addTests('__urls_that_dont_match__', [
@@ -68,9 +97,7 @@ TEST: addTests('isCompare', [
6897
'https://github.com/sindresorhus/refined-github/compare',
6998
'https://github.com/sindresorhus/refined-github/compare/',
7099
'https://github.com/sindresorhus/refined-github/compare/master...branch-name',
71-
'https://github.com/sindresorhus/refined-github/compare/master...branch-name?quick_pull=1',
72-
'https://github.com/sindresorhus/refined-github/compare/branch-1...branch-2?quick_pull=1',
73-
'https://github.com/sindresorhus/refined-github/compare/test-branch?quick_pull=1',
100+
'isQuickPR',
74101
]);
75102

76103
export const isCompareWikiPage = (url: URL | HTMLAnchorElement | Location = location): boolean => isRepoWiki(url) && getCleanPathname(url).split('/').slice(3, 5).includes('_compare');
@@ -79,17 +106,19 @@ TEST: addTests('isCompareWikiPage', [
79106
'https://github.com/brookhong/Surfingkeys/wiki/Color-Themes/_compare/8ebb46b1a12d16fc1af442b7df0ca13ca3bb34dc...80e51eeabe69b15a3f23880ecc36f800b71e6c6d',
80107
]);
81108

109+
/**
110+
* @deprecated Use `isHome` and/or `isFeed` instead
111+
*/
82112
export const isDashboard = (url: URL | HTMLAnchorElement | Location = location): boolean => !isGist(url) && /^$|^(orgs\/[^/]+\/)?dashboard(-feed)?(\/|$)/.test(getCleanPathname(url));
83113
TEST: addTests('isDashboard', [
84114
'https://github.com///',
85115
'https://github.com//',
86116
'https://github.com/',
87117
'https://github.com',
88-
'https://github.com/orgs/test/dashboard',
118+
'https://github.com/orgs/refined-github/dashboard',
89119
'https://github.com/dashboard/index/2',
90120
'https://github.com//dashboard',
91121
'https://github.com/dashboard',
92-
'https://github.com/orgs/edit/dashboard',
93122
'https://github.big-corp.com/',
94123
'https://not-github.com/',
95124
'https://my-little-hub.com/',
@@ -102,6 +131,31 @@ TEST: addTests('isDashboard', [
102131
'https://github.com/dashboard-feed',
103132
]);
104133

134+
export const isHome = (url: URL | HTMLAnchorElement | Location = location): boolean => !isGist(url) && /^$|^dashboard\/?$/.test(getCleanPathname(url));
135+
TEST: addTests('isHome', [
136+
'https://github.com',
137+
'https://github.com//dashboard',
138+
'https://github.com///',
139+
'https://github.com//',
140+
'https://github.com/',
141+
'https://github.com/dashboard',
142+
'https://github.big-corp.com/',
143+
'https://not-github.com/',
144+
'https://my-little-hub.com/',
145+
'https://github.com/?tab=repositories', // Gotcha for `isUserProfileRepoTab`
146+
'https://github.com/?tab=stars', // Gotcha for `isUserProfileStarsTab`
147+
'https://github.com/?tab=followers', // Gotcha for `isUserProfileFollowersTab`
148+
'https://github.com/?tab=following', // Gotcha for `isUserProfileFollowingTab`
149+
'https://github.com/?tab=overview', // Gotcha for `isUserProfileMainTab`
150+
'https://github.com?search=1', // Gotcha for `isRepoTree`
151+
]);
152+
153+
export const isFeed = (url: URL | HTMLAnchorElement | Location = location): boolean => !isGist(url) && /^(feed|orgs\/[^/]+\/dashboard)\/?$/.test(getCleanPathname(url));
154+
TEST: addTests('isFeed', [
155+
'https://github.com/feed',
156+
'https://github.com/orgs/refined-github/dashboard',
157+
]);
158+
105159
export const isEnterprise = (url: URL | HTMLAnchorElement | Location = location): boolean => url.hostname !== 'github.com' && url.hostname !== 'gist.github.com';
106160
TEST: addTests('isEnterprise', [
107161
'https://github.big-corp.com/',
@@ -204,7 +258,11 @@ TEST: addTests('isNotifications', [
204258

205259
export const isOrganizationProfile = (): boolean => exists('meta[name="hovercard-subject-tag"][content^="organization"]');
206260

207-
export const isOrganizationRepo = (): boolean => exists('.AppHeader-context-full [data-hovercard-type="organization"]');
261+
export const isOrganizationRepo = (): boolean => exists([
262+
'qbsearch-input[data-current-repository][data-current-org]:not([data-current-repository=""], [data-current-org=""])',
263+
// TODO: Remove after June 2026
264+
'.AppHeader-context-full [data-hovercard-type="organization"]',
265+
].join(','));
208266

209267
export const isTeamDiscussion = (url: URL | HTMLAnchorElement | Location = location): boolean => Boolean(getOrg(url)?.path.startsWith('teams'));
210268
TEST: addTests('isTeamDiscussion', [
@@ -304,18 +362,20 @@ TEST: addTests('isPRFiles', [
304362
'https://github.com/refined-github/refined-github/pull/148/changes/1e27d7998afdd3608d9fc3bf95ccf27fa5010641..e1aba6febb3fe38aafd1137cff28b536eeeabe7e',
305363
]);
306364

307-
export const isQuickPR = (url: URL | HTMLAnchorElement | Location = location): boolean => isCompare(url) && /[?&]quick_pull=1(&|$)/.test(url.search);
365+
export const isQuickPR = (url: URL | HTMLAnchorElement | Location = location): boolean => isCompare(url) && /[?&](quick_pull|expand)=1(&|$)/.test(url.search);
308366
TEST: addTests('isQuickPR', [
309367
'https://github.com/sindresorhus/refined-github/compare/master...branch-name?quick_pull=1',
310368
'https://github.com/sindresorhus/refined-github/compare/branch-1...branch-2?quick_pull=1',
311369
'https://github.com/sindresorhus/refined-github/compare/test-branch?quick_pull=1',
370+
'https://github.com/refined-github/sandbox/compare/fregante-patch-2?expand=1',
371+
'https://github.com/refined-github/sandbox/compare/default-a...fregante-patch-2?expand=1',
312372
]);
313373

314374
const getStateLabel = (): string | undefined => $([
315375
'.State', // Old view
316376
// React versions
317-
'[class^="StateLabel"]',
318-
'[data-testid="header-state"]',
377+
'[class^="StateLabel"]', // TODO: Remove after July 2026
378+
'[class^="prc-StateLabel-StateLabel"]',
319379
].join(','))?.textContent?.trim();
320380

321381
export const isMergedPR = (): boolean => getStateLabel() === 'Merged';
@@ -409,9 +469,16 @@ TEST: addTests('isRepo', [
409469
export const hasRepoHeader = (url: URL | HTMLAnchorElement | Location = location): boolean => isRepo(url) && !isRepoSearch(url);
410470
TEST: addTests('hasRepoHeader', combinedTestOnly);
411471

412-
// On empty repos, there's only isRepoHome; this element is found in `<head>`
413-
export const isEmptyRepoRoot = (): boolean => isRepoHome() && !exists('link[rel="canonical"]');
472+
export const isEmptyRepoRoot = (): boolean => isRepoHome() && exists([
473+
// If you don't have write access
474+
'.blankslate-icon',
475+
// If you have write access
476+
'#empty-setup-clone-url',
477+
].join(','));
414478

479+
/**
480+
* @deprecated Doesn't work anymore. Use `isEmptyRepoRoot` or API instead.
481+
*/
415482
export const isEmptyRepo = (): boolean => exists('[aria-label="Cannot fork because repository is empty."]');
416483

417484
export const isPublicRepo = (): boolean => exists('meta[name="octolytics-dimension-repository_public"][content="true"]');
@@ -677,6 +744,16 @@ TEST: addTests('isGistRevision', [
677744
'https://gist.github.com/kidonng/0d16c7f17045f486751fad1b602204a0/revisions',
678745
]);
679746

747+
export const isGistFile = (url: URL | HTMLAnchorElement | Location = location): boolean => {
748+
const pathname = getCleanGistPathname(url);
749+
return pathname?.replace(/[^/]/g, '').length === 1;
750+
};
751+
752+
TEST: addTests('isGistFile', [
753+
'https://gist.github.com/fregante/2205329b71218fa2c1d3',
754+
'https://gist.github.com/sindresorhus/0ea3c2845718a0a0f0beb579ff14f064',
755+
]);
756+
680757
export const isTrending = (url: URL | HTMLAnchorElement | Location = location): boolean => url.pathname === '/trending' || url.pathname.startsWith('/trending/');
681758
TEST: addTests('isTrending', [
682759
'https://github.com/trending',
@@ -728,7 +805,7 @@ TEST: addTests('isGistProfile', [
728805

729806
export const isUserProfile = (): boolean => isProfile() && !isOrganizationProfile();
730807

731-
export const isPrivateUserProfile = (): boolean => isUserProfile() && !exists('.UnderlineNav-item[href$="tab=stars"]');
808+
export const isPrivateUserProfile = (): boolean => isUserProfile() && exists('#user-private-profile-frame');
732809

733810
export const isUserProfileMainTab = (): boolean =>
734811
isUserProfile()
@@ -860,7 +937,20 @@ TEST: addTests('isRepositoryActions', [
860937

861938
export const isUserTheOrganizationOwner = (): boolean => isOrganizationProfile() && exists('[aria-label="Organization"] [data-tab-item="org-header-settings-tab"]');
862939

863-
export const canUserAdminRepo = (): boolean => isRepo() && exists('.reponav-item[href$="/settings"], [data-tab-item$="settings-tab"]');
940+
/**
941+
* @deprecated Use canUserAccessRepoSettings or API instead.
942+
*/
943+
export const canUserAdminRepo = (): boolean => {
944+
const repo = getRepo();
945+
return Boolean(repo && exists(`:is(${[
946+
'.GlobalNav',
947+
// Remove after June 2026
948+
'.js-repo-nav',
949+
].join(',')}) a[href="/${repo.nameWithOwner}/settings"]`));
950+
};
951+
952+
// eslint-disable-next-line @typescript-eslint/no-deprecated
953+
export const canUserAccessRepoSettings = canUserAdminRepo;
864954

865955
export const isNewRepo = (url: URL | HTMLAnchorElement | Location = location): boolean => !isGist(url) && (url.pathname === '/new' || /^organizations\/[^/]+\/repositories\/new$/.test(getCleanPathname(url)));
866956
TEST: addTests('isNewRepo', [
@@ -968,4 +1058,5 @@ export const utils = {
9681058
getCleanGistPathname,
9691059
getRepositoryInfo: getRepo,
9701060
parseRepoExplorerTitle,
1061+
waitFor,
9711062
};

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "github-url-detection",
3-
"version": "11.0.1",
3+
"version": "11.1.3",
44
"description": "Which GitHub page are you on? Is it an issue? Is it a list? Perfect for your WebExtension or userscript.",
55
"keywords": [
66
"github",
@@ -42,7 +42,7 @@
4242
"xo": "xo"
4343
},
4444
"dependencies": {
45-
"github-reserved-names": "^2.1.1"
45+
"github-reserved-names": "^2.1.3"
4646
},
4747
"devDependencies": {
4848
"@sindresorhus/tsconfig": "^8.1.0",

readme.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,30 @@ if (pageDetect.isOrganizationProfile()) {
6767
}
6868
```
6969

70+
### Async detections with `waitFor`
71+
72+
The `waitFor` helper function allows you to wait for a detection to become true by repeatedly checking it on each animation frame. This is useful for DOM-based detections that need to wait for elements to appear before the document is fully loaded.
73+
74+
```js
75+
import {utils, isOrganizationProfile} from 'github-url-detection';
76+
77+
async function init() {
78+
// Wait for the detection to return true or for the document to be complete
79+
if (!await utils.waitFor(isOrganizationProfile)) {
80+
return; // Not an organization profile
81+
}
82+
83+
// The page is now confirmed to be an organization profile
84+
console.log('On organization profile!');
85+
}
86+
```
87+
88+
The `waitFor` function:
89+
- Repeatedly calls the detection function on each animation frame
90+
- Stops when the detection returns `true` or when `document.readyState` is `'complete'`
91+
- Returns the final result of the detection
92+
- Works with any detection function that returns a boolean
93+
7094
## Related
7195

7296
- [github-reserved-names](https://github.com/Mottie/github-reserved-names) - Get a list, or check if a user or organization name is reserved by GitHub.

0 commit comments

Comments
 (0)