diff --git a/package.json b/package.json index d05ed01..4736291 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-use": "^17.3.2", "react-window": "^1.8.7", "styled-components": "^5.3.5", + "superstruct": "^1.0.3", "webext-domain-permission-toggle": "^3.0.0", "webext-dynamic-content-scripts": "^8.1.1", "webextension-polyfill": "^0.10.0" diff --git a/src/platforms/GitHub/DOMHelper.ts b/src/platforms/GitHub/DOMHelper.ts index 8740965..bb6dc80 100644 --- a/src/platforms/GitHub/DOMHelper.ts +++ b/src/platforms/GitHub/DOMHelper.ts @@ -1,13 +1,16 @@ import { raiseError } from 'analytics' import { Clippy, ClippyClassName } from 'components/Clippy' import * as React from 'react' +import * as s from 'superstruct' import { $ } from 'utils/$' import { formatClass, parseIntFromElement } from 'utils/DOMHelper' import { renderReact } from 'utils/general' +import { embeddedDataStruct } from './embeddedDataStructures' const selectors = { normal: { reactApp: `react-app[app-name="react-code-view"] [data-target="react-app.reactRoot"]`, + codeTab: '#code-tab', branchSwitcher: [`summary[title="Switch branches or tags"]`, `#branch-select-menu`].join(), fileNavigation: `.file-navigation`, breadcrumbs: `[data-testid="breadcrumbs"]`, @@ -27,28 +30,28 @@ const selectors = { pathContext: '[data-testid="breadcrumbs"]', pathContextFileName: '[data-testid="breadcrumbs-filename"]', pathContextScreenReaderHeading: '[data-testid="screen-reader-heading"]', + embeddedData: { + app: 'script[type="application/json"][data-target="react-app.embeddedData"]', + reposOverview: + '[partial-name="repos-overview"] script[type="application/json"][data-target="react-partial.embeddedData"]', + }, }, } -export function resolveMetaFromDOMJSON(): { defaultBranch: string; metaData: MetaData } | void { - // in code page, there is a JSON script tag in DOM with meta data - const json = $('script[type="application/json"][data-target="react-app.embeddedData"]', e => { +const getDOMJSON = (selector: string) => + $(selector, e => { try { return JSON.parse(e.textContent || '') } catch (error) { return null } }) - if (!json) return - - const { payload } = json - if (!payload) return +function getMetaFromPayload(payload: s.Infer) { const { repo, refInfo } = payload - if (!repo || !refInfo) return - const { defaultBranch, name: repoName, ownerLogin: userName } = repo const { name: branchName } = refInfo + return { defaultBranch, metaData: { @@ -59,8 +62,27 @@ export function resolveMetaFromDOMJSON(): { defaultBranch: string; metaData: Met } } +// in code page, there is a JSON script tag in DOM with meta data +function resolveEmbeddedAppData() { + const data = getDOMJSON(selectors.globalNavigation.embeddedData.app) + if (s.is(data, embeddedDataStruct.app)) return getMetaFromPayload(data.payload) +} + +function resolveEmbeddedReposOverviewData() { + const data = getDOMJSON(selectors.globalNavigation.embeddedData.reposOverview) + if (s.is(data, embeddedDataStruct.reposOverview)) + return getMetaFromPayload(data.props.initialPayload) +} + +export function resolveEmbeddedData(): { + defaultBranch: string + metaData: MetaData +} | void { + return resolveEmbeddedAppData() || resolveEmbeddedReposOverviewData() +} + export function resolveMeta(): Partial { - const dataFromJSON = resolveMetaFromDOMJSON() + const dataFromJSON = resolveEmbeddedData() if (dataFromJSON) return dataFromJSON.metaData const metaData = { @@ -129,7 +151,9 @@ export function getCurrentBranch(passive = false) { ].join() const branchButtonElement = $(selectedBranchButtonSelector) if (branchButtonElement) { - const branchNameSpanElement = branchButtonElement.querySelector('span') + const branchNameSpanElement = branchButtonElement.querySelector( + ['.ref-selector-button-text-container', 'span'].join(), + ) if (branchNameSpanElement) { const partialBranchNameFromInnerText = branchNameSpanElement.textContent?.trim() || '' if (partialBranchNameFromInnerText && !partialBranchNameFromInnerText.includes('…')) @@ -137,7 +161,7 @@ export function getCurrentBranch(passive = false) { } const defaultTitle = 'Switch branches or tags' const title = branchButtonElement.title.trim() - if (title !== defaultTitle && !title.includes(' ')) return title + if (title && title !== defaultTitle && !title.includes(' ')) return title } const findFileButtonSelector = 'main .file-navigation a[data-hotkey="t"]' @@ -154,6 +178,17 @@ export function getCurrentBranch(passive = false) { } } + const branchNameFromCodeTab = $(selectors.normal.codeTab, e => { + if (e instanceof HTMLAnchorElement) { + const chunks = e.href.split('/') + const indexOfTree = chunks.indexOf('tree') + if (indexOfTree === -1) return + const branchName = chunks.slice(indexOfTree + 1).join('/') + return branchName + } + }) + if (branchNameFromCodeTab) return branchNameFromCodeTab + if (!passive) raiseError(new Error('cannot get current branch')) } diff --git a/src/platforms/GitHub/embeddedDataStructures.ts b/src/platforms/GitHub/embeddedDataStructures.ts new file mode 100644 index 0000000..676b93e --- /dev/null +++ b/src/platforms/GitHub/embeddedDataStructures.ts @@ -0,0 +1,81 @@ +import * as s from 'superstruct' + +const repo = s.object({ + id: s.number(), + defaultBranch: s.string(), + name: s.string(), + ownerLogin: s.string(), + currentUserCanPush: s.boolean(), + isFork: s.boolean(), + isEmpty: s.boolean(), + createdAt: s.string(), + ownerAvatar: s.string(), + public: s.boolean(), + private: s.boolean(), + isOrgOwned: s.boolean(), +}) + +const user = s.object({ + id: s.number(), + login: s.string(), + userEmail: s.string(), +}) + +const rel = s.object({ + name: s.string(), + listCacheKey: s.string(), + canEdit: s.boolean(), + refType: s.string(), + currentOid: s.string(), +}) + +const treeItem = s.object({ + name: s.string(), + path: s.string(), + contentType: s.string(), +}) + +const tree = s.object({ + items: s.array(treeItem), + templateDirectorySuggestionUrl: s.nullable(s.never()), + readme: s.nullable(s.never()), + totalCount: s.number(), + showBranchInfobar: s.boolean(), +}) + +const repoPayload = s.object({ + allShortcutsEnabled: s.boolean(), + path: s.string(), + repo: repo, + currentUser: user, + refInfo: rel, + tree: tree, + fileTree: s.nullable(s.never()), + fileTreeProcessingTime: s.nullable(s.never()), + foldersToFetch: s.array(s.unknown()), + treeExpanded: s.boolean(), + symbolsExpanded: s.boolean(), + isOverview: s.boolean(), + overview: s.unknown(), +}) + +const reposOverview = s.object({ + props: s.object({ + initialPayload: repoPayload, + appPayload: s.unknown(), + }), +}) +const app = s.object({ + payload: repoPayload, +}) + +export const embeddedDataStruct = { + repo, + user, + rel, + treeItem, + tree, + repoPayload, + reposOverview, + app, +} diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index eb84969..b06bb35 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -7,12 +7,12 @@ import { resolveGitModules } from 'utils/gitSubmodule' import { sortFoldersToFront } from 'utils/treeParser' import * as API from './API' import * as DOMHelper from './DOMHelper' +import * as URLHelper from './URLHelper' import { getCommitTreeData } from './getCommitTreeData' import { getPullRequestTreeData } from './getPullRequestTreeData' import { useEnterpriseStatBarStyleFix } from './hooks/useEnterpriseStatBarStyleFix' import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton' import { useGitHubCodeFold } from './hooks/useGitHubCodeFold' -import * as URLHelper from './URLHelper' export function processTree(tree: TreeNode[]): TreeNode { // nodes are created from items and put onto tree @@ -109,7 +109,7 @@ export const GitHub: Platform = { } const { type } = metaFromURL - let branchName + let branchName = metaFromDOM.branchName if (URLHelper.isInPullPage()) { branchName = DOMHelper.getIssueTitle() } else if (URLHelper.isInCommitPage()) { @@ -130,7 +130,7 @@ export const GitHub: Platform = { return metaData }, async getDefaultBranchName({ userName, repoName }, accessToken) { - const dataFromJSON = DOMHelper.resolveMetaFromDOMJSON() + const dataFromJSON = DOMHelper.resolveEmbeddedData() if (dataFromJSON?.defaultBranch) return dataFromJSON.defaultBranch return (await API.getRepoMeta(userName, repoName, accessToken)).default_branch diff --git a/webpack.config.js b/webpack.config.js index 30839d9..8a1f1af 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -113,10 +113,10 @@ module.exports = { sideEffects: false, }, { - test: /\.js$/, + test: /\.m?js$/, loader: 'babel-loader', // Transpile as least files under node_modules - include: /node_modules\/(webext-content-scripts|webext-detect-page)\/.*\.js$/, + include: /node_modules\/(webext-content-scripts|webext-detect-page|superstruct)\/.*\.m?js$/, options: { cacheDirectory: true, }, diff --git a/yarn.lock b/yarn.lock index bc5a776..3585039 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11325,6 +11325,11 @@ stylis@^4.0.6: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.1.1.tgz#e46c6a9bbf7c58db1e65bb730be157311ae1fe12" integrity sha512-lVrM/bNdhVX2OgBFNa2YJ9Lxj7kPzylieHd3TNjuGE0Re9JB7joL5VUKOVH1kdNNJTgGPpT8hmwIAPLaSyEVFQ== +superstruct@^1.0.3: + version "1.0.3" + resolved "https://registry.npmmirror.com/superstruct/-/superstruct-1.0.3.tgz#de626a5b49c6641ff4d37da3c7598e7a87697046" + integrity sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg== + supports-color@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"