├── assets
├── graphic.png
└── graphic-2.png
├── src
├── public
│ ├── icons
│ │ ├── dev
│ │ │ ├── 128.png
│ │ │ ├── 16.png
│ │ │ ├── 24.png
│ │ │ ├── 256.png
│ │ │ ├── 300.png
│ │ │ ├── 32.png
│ │ │ ├── 48.png
│ │ │ └── 64.png
│ │ └── production
│ │ │ ├── 128.png
│ │ │ ├── 16.png
│ │ │ ├── 24.png
│ │ │ ├── 256.png
│ │ │ ├── 32.png
│ │ │ ├── 48.png
│ │ │ └── 64.png
│ ├── index.html
│ └── onboarding.html
├── components
│ ├── index.ts
│ ├── Layout.tsx
│ ├── Tooltip.tsx
│ ├── icons
│ │ ├── MoonIcon.tsx
│ │ ├── ArrowRightIcon.tsx
│ │ ├── index.ts
│ │ ├── SheetIcon.tsx
│ │ ├── MoreIcon.tsx
│ │ ├── AlertIcon.tsx
│ │ ├── InfoIcon.tsx
│ │ ├── BrokenLinkIcon.tsx
│ │ ├── BookmarkIcon.tsx
│ │ ├── ExternalLink.tsx
│ │ ├── NewTab.tsx
│ │ ├── WarningIcon.tsx
│ │ ├── CsvFileIcon.tsx
│ │ ├── JsonFileIcon.tsx
│ │ ├── JsonCopyIcon.tsx
│ │ ├── SunIcon.tsx
│ │ ├── CsvCopyIcon.tsx
│ │ └── ExportIcon.tsx
│ ├── LinkList
│ │ ├── index.tsx
│ │ ├── RedirectedLink.tsx
│ │ ├── LinkSuffix.tsx
│ │ ├── LinkItem.tsx
│ │ └── Domain.tsx
│ ├── Header
│ │ ├── ThemeToggle.tsx
│ │ ├── FetchLoader.tsx
│ │ └── index.tsx
│ ├── ResponseTag.tsx
│ ├── Favicon.tsx
│ ├── Tabs
│ │ ├── Tab.tsx
│ │ ├── SearchFilter.tsx
│ │ ├── SelectAll.tsx
│ │ └── index.tsx
│ ├── ActionsBar
│ │ ├── index.tsx
│ │ ├── OverflowActions.tsx
│ │ ├── BookmarkModal.tsx
│ │ └── TabGroupPopover.tsx
│ ├── LinkRoamerLogo.tsx
│ └── EmptyState.tsx
├── types.ts
├── scripts
│ ├── execute-scripts.ts
│ ├── data-debugger.ts
│ ├── Link.ts
│ ├── LinksHandler.ts
│ ├── LinkActions.ts
│ └── Chrome.ts
├── pages
│ ├── App.tsx
│ └── Onboarding.tsx
├── providers
│ ├── CheckedItems.tsx
│ ├── ThemeProvider.tsx
│ └── DataProvider.tsx
├── v3-manifest-dev.json
├── v2-manifest.json
├── v3-manifest-prod.json
├── background.ts
├── api
│ ├── index.ts
│ └── LinkStatus.ts
└── status-codes.ts
├── tsconfig.json
├── .eslintrc.json
├── LICENSE
├── rollup.config.js
├── .gitignore
├── package.json
└── README.md
/assets/graphic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/assets/graphic.png
--------------------------------------------------------------------------------
/assets/graphic-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/assets/graphic-2.png
--------------------------------------------------------------------------------
/src/public/icons/dev/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/128.png
--------------------------------------------------------------------------------
/src/public/icons/dev/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/16.png
--------------------------------------------------------------------------------
/src/public/icons/dev/24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/24.png
--------------------------------------------------------------------------------
/src/public/icons/dev/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/256.png
--------------------------------------------------------------------------------
/src/public/icons/dev/300.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/300.png
--------------------------------------------------------------------------------
/src/public/icons/dev/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/32.png
--------------------------------------------------------------------------------
/src/public/icons/dev/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/48.png
--------------------------------------------------------------------------------
/src/public/icons/dev/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/dev/64.png
--------------------------------------------------------------------------------
/src/public/icons/production/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/128.png
--------------------------------------------------------------------------------
/src/public/icons/production/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/16.png
--------------------------------------------------------------------------------
/src/public/icons/production/24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/24.png
--------------------------------------------------------------------------------
/src/public/icons/production/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/256.png
--------------------------------------------------------------------------------
/src/public/icons/production/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/32.png
--------------------------------------------------------------------------------
/src/public/icons/production/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/48.png
--------------------------------------------------------------------------------
/src/public/icons/production/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/link-roamer/HEAD/src/public/icons/production/64.png
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as CategoryTabs } from './Tabs'
2 | export { default as Header } from './Header'
3 | export { default as Layout } from './Layout'
4 |
--------------------------------------------------------------------------------
/src/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/react'
2 | import React from 'react'
3 | import { Children } from '../types'
4 |
5 | const Layout = ({ children }: Children) => (
6 |
7 | {children}
8 |
9 | )
10 |
11 | export default Layout
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "esModuleInterop": true,
5 | "jsx": "react",
6 | "lib": ["dom", "es2019"],
7 | "module": "ESNext",
8 | "moduleResolution": "node",
9 | "noImplicitReturns": true,
10 | "noUnusedLocals": true,
11 | "strict": true,
12 | "target": "es2018"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from './scripts/Link'
3 |
4 | export type CategorizedLinks = Record
5 |
6 | export type Message = {
7 | action: 'fetchLinks'
8 | data: string
9 | }
10 |
11 | export type LinkData = {
12 | loading: boolean
13 | links: Link[]
14 | }
15 |
16 | export type Children = {
17 | children: React.ReactNode
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip as ChakraTooltip, TooltipProps } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | const Tooltip = (props: TooltipProps) => (
5 | {props.children}
6 | )
7 |
8 | Tooltip.defaultProps = {
9 | hasArrow: true,
10 | fontSize: '12px',
11 | placement: 'left',
12 | borderRadius: 'lg',
13 | textAlign: 'center',
14 | p: 2,
15 | }
16 |
17 | export default Tooltip
18 |
--------------------------------------------------------------------------------
/src/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | Link Roamer
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/public/onboarding.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | Link Roamer
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/icons/MoonIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const MoonIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: ,
17 | })
18 |
--------------------------------------------------------------------------------
/src/components/LinkList/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Accordion } from '@chakra-ui/react'
3 | import Domain from './Domain'
4 | import { CategorizedLinks } from '../../types'
5 |
6 | type Props = {
7 | categorizedLinks: CategorizedLinks
8 | }
9 |
10 | const LinkList = ({ categorizedLinks }: Props) => (
11 |
12 | {Object.entries(categorizedLinks).map(([domain, links]) => (
13 |
14 | ))}
15 |
16 | )
17 |
18 | export default LinkList
19 |
--------------------------------------------------------------------------------
/src/components/icons/ArrowRightIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const ArrowRight = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 | >
21 | ),
22 | })
23 |
--------------------------------------------------------------------------------
/src/components/icons/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AlertIcon'
2 | export * from './ArrowRightIcon'
3 | export * from './BookmarkIcon'
4 | export * from './BrokenLinkIcon'
5 | export * from './CsvCopyIcon'
6 | export * from './CsvFileIcon'
7 | export * from './ExportIcon'
8 | export * from './ExternalLink'
9 | export * from './InfoIcon'
10 | export * from './JsonCopyIcon'
11 | export * from './JsonFileIcon'
12 | export * from './MoonIcon'
13 | export * from './MoreIcon'
14 | export * from './NewTab'
15 | export * from './SheetIcon'
16 | export * from './SunIcon'
17 | export * from './WarningIcon'
18 |
--------------------------------------------------------------------------------
/src/scripts/execute-scripts.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Get all the hrefs on a given page and return an array of unique values. If no hrefs, returns an empty array. Must be an isolated function declaration because of v3 manifest's strict policies on script injection using executeScript.
3 | */
4 | export function gatherHrefs() {
5 | const links = Array.from(document.links)
6 | const hrefs = links.map((link) => link.href)
7 | return [...new Set(hrefs)]
8 | }
9 |
10 | /**
11 | * Gets the target tabs domain name
12 | */
13 | export function getDomain() {
14 | return document.location.host
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/icons/SheetIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const SheetIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 | ),
19 | })
20 |
--------------------------------------------------------------------------------
/src/components/icons/MoreIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const MoreIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/icons/AlertIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const AlertIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/icons/InfoIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const InfoIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/scripts/data-debugger.ts:
--------------------------------------------------------------------------------
1 | import Link from './Link'
2 | import LinksHandler from './LinksHandler'
3 |
4 | function dataDebugger(links: Link[]) {
5 | const lp = new LinksHandler(links)
6 | const invalid = links.filter((link) => !link.status.validResponse)
7 |
8 | console.log(
9 | 'Redirected links:',
10 | lp.redirectedLinks,
11 | lp.redirectedLinks.length
12 | )
13 | console.log('Links with reponse status info:', links, links.length)
14 | console.log('Links that are not OK:', lp.notOkLinks, lp.notOkLinks.length)
15 | console.log('Empty link status:', invalid, invalid.length)
16 | }
17 |
18 | export default dataDebugger
19 |
--------------------------------------------------------------------------------
/src/components/icons/BrokenLinkIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const BrokenLinkIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/Header/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, useColorMode } from '@chakra-ui/react'
2 | import React from 'react'
3 | import c from '../../scripts/Chrome'
4 | import { MoonIcon, SunIcon } from '../icons'
5 |
6 | const ThemeToggle = () => {
7 | const { colorMode, toggleColorMode } = useColorMode()
8 |
9 | const handleClick = () => {
10 | toggleColorMode()
11 | c.setStorage('mode', colorMode)
12 | }
13 | return (
14 | : }
17 | onClick={handleClick}
18 | />
19 | )
20 | }
21 |
22 | export default ThemeToggle
23 |
--------------------------------------------------------------------------------
/src/components/icons/BookmarkIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const BookmarkIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 | ),
19 | })
20 |
--------------------------------------------------------------------------------
/src/components/icons/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const ExternalLinkIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/ResponseTag.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Tag, TagProps, Text } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | type Props = TagProps & {
5 | quantity: number
6 | }
7 |
8 | const QuantityTag = (props: Props) => {
9 | const { quantity, children, colorScheme, ...rest } = props
10 |
11 | return (
12 |
13 | {quantity > 1 && (
14 |
15 | {quantity}
16 |
17 | x
18 |
19 |
20 | )}
21 | {children}
22 |
23 | )
24 | }
25 |
26 | export default QuantityTag
27 |
--------------------------------------------------------------------------------
/src/components/icons/NewTab.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const NewTabIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/icons/WarningIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const WarningIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 | >
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/Favicon.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Image, useColorModeValue } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | type Props = {
5 | domain: string
6 | size?: number
7 | faviconSize?: number
8 | }
9 |
10 | const Favicon = ({ domain, size = 24, faviconSize = 16 }: Props) => {
11 | const bg = useColorModeValue('gray.100', 'gray.700')
12 |
13 | return (
14 |
15 |
20 |
21 | )
22 | }
23 |
24 | function faviconUrl(domain: string) {
25 | const url = 'https://s2.googleusercontent.com/s2/favicons?domain='
26 | return url + domain
27 | }
28 |
29 | export default Favicon
30 |
--------------------------------------------------------------------------------
/src/components/LinkList/RedirectedLink.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Link as ChakraLink, Tag, Text } from '@chakra-ui/react'
2 | import React from 'react'
3 | import Link from '../../scripts/Link'
4 |
5 | type Props = {
6 | link: Link
7 | }
8 |
9 | const RedirectedLink = ({ link }: Props) => {
10 | if (!link.status.redirected) return null
11 |
12 | return (
13 |
14 |
15 | Redirect
16 |
17 |
23 |
24 | {link.status.url}
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | export default RedirectedLink
32 |
--------------------------------------------------------------------------------
/src/components/Tabs/Tab.tsx:
--------------------------------------------------------------------------------
1 | import { Badge, Tab, useColorModeValue } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | type CustomTabProps = {
5 | linksQty: number
6 | title: string
7 | alwaysShow?: boolean
8 | }
9 |
10 | const CustomTab = React.forwardRef(
11 | (props, ref) => {
12 | const border = useColorModeValue('blurple.500', 'blurple.400')
13 |
14 | if (props.linksQty < 1 && !props.alwaysShow) return null
15 |
16 | return (
17 |
26 | {props.title} {props.linksQty}
27 |
28 | )
29 | }
30 | )
31 |
32 | export default CustomTab
33 |
--------------------------------------------------------------------------------
/src/pages/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOMClient from 'react-dom/client'
3 | import { CategoryTabs, Header, Layout } from '../components'
4 | import ActionsBar from '../components/ActionsBar'
5 | import { CheckedItemsProvider } from '../providers/CheckedItems'
6 | import { DataProvider } from '../providers/DataProvider'
7 | import ThemeProvider from '../providers/ThemeProvider'
8 |
9 | const App = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | const root = document.getElementById('root')!
26 | ReactDOMClient.createRoot(root).render( )
27 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true,
6 | "webextensions": true
7 | },
8 | "extends": [
9 | "eslint:recommended",
10 | "plugin:react/recommended",
11 | "plugin:@typescript-eslint/recommended"
12 | ],
13 | "parser": "@typescript-eslint/parser",
14 | "parserOptions": {
15 | "ecmaFeatures": {
16 | "jsx": true
17 | },
18 | "ecmaVersion": 12,
19 | "sourceType": "module"
20 | },
21 | "plugins": ["react", "@typescript-eslint"],
22 | "rules": {
23 | "react/display-name": 0,
24 | "react/react-in-jsx-scope": 0,
25 | "@typescript-eslint/no-non-null-assertion": 0,
26 | "@typescript-eslint/no-explicit-any": 0,
27 | "@typescript-eslint/explicit-module-boundary-types": 0,
28 | "no-constant-condition": 0,
29 | "@typescript-eslint/ban-ts-comment": 0
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/icons/CsvFileIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const CsvFileIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 |
19 |
20 |
21 |
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/icons/JsonFileIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const JsonFileIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 |
19 |
20 |
21 |
22 |
23 | ),
24 | })
25 |
--------------------------------------------------------------------------------
/src/components/Tabs/SearchFilter.tsx:
--------------------------------------------------------------------------------
1 | import { Box, FormLabel, Input, VisuallyHidden } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | type Props = {
5 | setFilter: React.Dispatch>
6 | }
7 |
8 | const SearchFilter = ({ setFilter }: Props) => {
9 | const handleChange = (event: React.ChangeEvent) => {
10 | setFilter(event.target.value)
11 | }
12 |
13 | return (
14 |
15 |
16 |
17 | Filter links by typing in a keyword
18 |
19 |
20 |
29 |
30 | )
31 | }
32 |
33 | export default SearchFilter
34 |
--------------------------------------------------------------------------------
/src/providers/CheckedItems.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Children } from '../types'
3 |
4 | type CheckedItemsContextProps = {
5 | checkedItems: string[]
6 | setCheckedItems: React.Dispatch>
7 | }
8 |
9 | const CheckedItemsContext = React.createContext({} as CheckedItemsContextProps)
10 |
11 | export const CheckedItemsProvider = ({ children }: Children) => {
12 | const [checkedItems, setCheckedItems] = React.useState([])
13 |
14 | const checkedMemo = React.useMemo(() => {
15 | const sorted = checkedItems.sort()
16 |
17 | return {
18 | checkedItems: sorted,
19 | setCheckedItems,
20 | }
21 | }, [checkedItems])
22 |
23 | return (
24 |
25 | {children}
26 |
27 | )
28 | }
29 |
30 | export const useCheckedItems = () => React.useContext(CheckedItemsContext)
31 |
--------------------------------------------------------------------------------
/src/components/icons/JsonCopyIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const JsonCopyIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 |
19 |
20 |
21 |
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/src/components/icons/SunIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const SunIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 | <>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | >
28 | ),
29 | })
30 |
--------------------------------------------------------------------------------
/src/components/icons/CsvCopyIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const CsvCopyIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | stroke: 'currentColor',
9 | strokeWidth: '2px',
10 | strokeLinecap: 'round',
11 | strokeLinejoin: 'round',
12 | fill: 'none',
13 | 'aria-hidden': true,
14 | },
15 | viewBox: '0 0 24 24',
16 | path: (
17 |
18 |
19 |
20 |
21 |
22 | ),
23 | })
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Ross Moody
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/src/v3-manifest-dev.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Link Roamer Dev",
4 | "permissions": [
5 | "activeTab",
6 | "scripting",
7 | "tabGroups",
8 | "bookmarks",
9 | "declarativeContent",
10 | "storage"
11 | ],
12 | "background": {
13 | "service_worker": "background.ts"
14 | },
15 | "action": {
16 | "default_icon": {
17 | "16": "public/icons/dev/16.png",
18 | "24": "public/icons/dev/24.png",
19 | "32": "public/icons/dev/32.png"
20 | },
21 | "default_popup": "public/index.html"
22 | },
23 | "icons": {
24 | "48": "public/icons/dev/48.png",
25 | "64": "public/icons/dev/64.png",
26 | "128": "public/icons/dev/128.png",
27 | "256": "public/icons/dev/256.png"
28 | },
29 | "commands": {
30 | "_execute_action": {
31 | "suggested_key": {
32 | "default": "Ctrl+U",
33 | "mac": "Command+U"
34 | }
35 | }
36 | },
37 | "web_accessible_resources": [
38 | {
39 | "resources": ["public/onboarding.html"],
40 | "matches": ["*://*/*"]
41 | }
42 | ],
43 | "host_permissions": [
44 | "https://fetch-fav-h57lsidp3a-uc.a.run.app/*",
45 | "http://localhost:8080/*"
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/src/v2-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "Link Roamer",
4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.",
5 | "homepage_url": "https://www.linkroamer.com",
6 | "permissions": [
7 | "activeTab",
8 | "bookmarks",
9 | "storage",
10 | "https://fetch-fav-h57lsidp3a-uc.a.run.app/*"
11 | ],
12 | "background": {
13 | "scripts": ["background.ts"],
14 | "persistent": false
15 | },
16 | "browser_action": {
17 | "default_icon": {
18 | "16": "public/icons/production/16.png",
19 | "24": "public/icons/production/24.png",
20 | "32": "public/icons/production/32.png"
21 | },
22 | "default_popup": "public/index.html"
23 | },
24 | "commands": {
25 | "_execute_browser_action": {
26 | "suggested_key": {
27 | "default": "Ctrl+U",
28 | "mac": "Command+U"
29 | }
30 | }
31 | },
32 | "web_accessible_resources": ["public/onboarding.html"],
33 | "icons": {
34 | "48": "public/icons/production/48.png",
35 | "64": "public/icons/production/64.png",
36 | "128": "public/icons/production/128.png",
37 | "256": "public/icons/production/256.png"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/Tabs/SelectAll.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox } from '@chakra-ui/react'
2 | import React from 'react'
3 | import { useCheckedItems } from '../../providers/CheckedItems'
4 | import Link from '../../scripts/Link'
5 |
6 | type Props = {
7 | filteredLinks: Link[]
8 | }
9 |
10 | const SelectAll = ({ filteredLinks }: Props) => {
11 | const { checkedItems, setCheckedItems } = useCheckedItems()
12 |
13 | const allChecked =
14 | filteredLinks.every((link) => checkedItems.includes(link.href)) &&
15 | filteredLinks.length > 0
16 |
17 | const isIndeterminate =
18 | filteredLinks.some((link) => checkedItems.includes(link.href)) &&
19 | !allChecked
20 |
21 | const handleChange = () => {
22 | const hrefs = filteredLinks.map((link) => link.href)
23 |
24 | allChecked
25 | ? setCheckedItems((prev) => prev.filter((item) => !hrefs.includes(item)))
26 | : setCheckedItems((prev) => [...prev, ...hrefs])
27 | }
28 |
29 | return (
30 |
31 |
40 |
41 | )
42 | }
43 |
44 | export default SelectAll
45 |
--------------------------------------------------------------------------------
/src/v3-manifest-prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Link Roamer",
4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.",
5 | "homepage_url": "https://www.linkroamer.com",
6 | "permissions": [
7 | "activeTab",
8 | "scripting",
9 | "tabGroups",
10 | "bookmarks",
11 | "declarativeContent",
12 | "storage"
13 | ],
14 | "background": {
15 | "service_worker": "background.ts"
16 | },
17 | "action": {
18 | "default_icon": {
19 | "16": "public/icons/production/16.png",
20 | "24": "public/icons/production/24.png",
21 | "32": "public/icons/production/32.png"
22 | },
23 | "default_popup": "public/index.html"
24 | },
25 | "icons": {
26 | "48": "public/icons/production/48.png",
27 | "64": "public/icons/production/64.png",
28 | "128": "public/icons/production/128.png",
29 | "256": "public/icons/production/256.png"
30 | },
31 | "commands": {
32 | "_execute_action": {
33 | "suggested_key": {
34 | "default": "Ctrl+U",
35 | "mac": "Command+U"
36 | }
37 | }
38 | },
39 | "web_accessible_resources": [
40 | {
41 | "resources": ["public/onboarding.html"],
42 | "matches": ["*://*/*"]
43 | }
44 | ],
45 | "host_permissions": ["https://fetch-fav-h57lsidp3a-uc.a.run.app/*"]
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/Header/FetchLoader.tsx:
--------------------------------------------------------------------------------
1 | import { Fade, Spinner, Tag, TagLabel } from '@chakra-ui/react'
2 | import React, { useEffect, useState } from 'react'
3 | import { useData } from '../../providers/DataProvider'
4 | import LinksHandler from '../../scripts/LinksHandler'
5 |
6 | const initialConfig = {
7 | label: 'Checking links',
8 | colorScheme: 'gray',
9 | }
10 |
11 | const FetchLoader = () => {
12 | const [loading, setLoading] = useState(true)
13 | const [state, setState] = useState(initialConfig)
14 | const { data } = useData()
15 |
16 | useEffect(() => {
17 | if (!data.loading) {
18 | const brokenQty = new LinksHandler(data.links).fourOhFourLinks.length
19 |
20 | brokenQty
21 | ? setState({
22 | label: `404${brokenQty < 2 ? '' : 's'} found`,
23 | colorScheme: 'red',
24 | })
25 | : setState({
26 | label: 'No 404s',
27 | colorScheme: 'green',
28 | })
29 |
30 | setTimeout(setLoading, 2400, false)
31 | }
32 | }, [data.loading])
33 |
34 | return (
35 |
36 |
37 | {state.label}
38 | {data.loading && (
39 |
47 | )}
48 |
49 |
50 | )
51 | }
52 |
53 | export default FetchLoader
54 |
--------------------------------------------------------------------------------
/src/providers/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider, ColorMode, extendTheme } from '@chakra-ui/react'
2 | import React from 'react'
3 | import c from '../scripts/Chrome'
4 | import { Children } from '../types'
5 |
6 | const theme = extendTheme({
7 | styles: {
8 | global: {
9 | body: {
10 | fontSize: '100%',
11 | },
12 | },
13 | },
14 | config: {
15 | initialColorMode: async () => await c.getStorage('mode'),
16 | },
17 | semanticTokens: {
18 | colors: {
19 | textMuted: {
20 | default: 'gray.500',
21 | _dark: 'gray.400',
22 | },
23 | },
24 | },
25 | colors: {
26 | blurple: {
27 | '50': '#E9E8FC',
28 | '100': '#C2C0F7',
29 | '200': '#9C97F2',
30 | '300': '#756EEC',
31 | '400': '#4E46E7',
32 | '500': '#271DE2',
33 | '600': '#1F17B5',
34 | '700': '#171188',
35 | '800': '#0F0C5A',
36 | '900': '#08062D',
37 | },
38 | },
39 | shadows: {
40 | outline: '0 0 0 2px var(--chakra-colors-blurple-200)',
41 | },
42 | components: {
43 | Button: {
44 | defaultProps: {
45 | variant: 'outline',
46 | size: 'sm',
47 | },
48 | },
49 | Input: {
50 | defaultProps: {
51 | focusBorderColor: 'blurple.200',
52 | },
53 | },
54 | Tabs: {
55 | defaultProps: {
56 | colorScheme: 'blurple',
57 | },
58 | },
59 | Checkbox: {
60 | defaultProps: {
61 | colorScheme: 'blurple',
62 | },
63 | },
64 | },
65 | })
66 |
67 | const ThemeProvider = ({ children }: Children) => (
68 | {children}
69 | )
70 |
71 | export default ThemeProvider
72 |
--------------------------------------------------------------------------------
/src/background.ts:
--------------------------------------------------------------------------------
1 | import { Message } from './types'
2 |
3 | const url =
4 | process.env.NODE_ENV === 'production'
5 | ? 'https://fetch-fav-h57lsidp3a-uc.a.run.app'
6 | : 'http://localhost:8080'
7 |
8 | /**
9 | * Listens for a message from the extension to fetch HEAD information
10 | * about each given link to check if it returns a 404 or not.
11 | */
12 | chrome.runtime.onMessage.addListener(
13 | (message: Message, sender, sendResponse) => {
14 | if (message.action === 'fetchLinks') {
15 | const init: RequestInit = {
16 | method: 'POST',
17 | headers: { 'Content-Type': 'text/plain' },
18 | body: message.data,
19 | }
20 |
21 | try {
22 | fetch(url, init)
23 | .then((result) => result.json())
24 | .then(sendResponse)
25 | } catch (error) {
26 | sendResponse([])
27 | }
28 | }
29 |
30 | return true
31 | }
32 | )
33 |
34 | /**
35 | * Sets the extension to disabled by default and makes it possible
36 | * to invoke only on http schemed pages.
37 | */
38 | if ('isV3Manifest') {
39 | chrome.runtime.onInstalled.addListener(() => {
40 | chrome.action.disable()
41 |
42 | const enableOnHttpPages = {
43 | conditions: [
44 | new chrome.declarativeContent.PageStateMatcher({
45 | pageUrl: { schemes: ['https', 'http'] },
46 | }),
47 | ],
48 | actions: [new chrome.declarativeContent.ShowAction()],
49 | }
50 |
51 | chrome.declarativeContent.onPageChanged.addRules([enableOnHttpPages])
52 | })
53 | }
54 |
55 | chrome.runtime.onInstalled.addListener((details) => {
56 | if (details.reason === 'install') {
57 | chrome.tabs.create({ url: 'public/onboarding.html' })
58 | }
59 | })
60 |
--------------------------------------------------------------------------------
/src/components/LinkList/LinkSuffix.tsx:
--------------------------------------------------------------------------------
1 | import { Fade, HStack, IconButton, Tag, TagLeftIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 | import c from '../../scripts/Chrome'
4 | import Link from '../../scripts/Link'
5 | import statusCodes from '../../status-codes'
6 | import { ExternalLinkIcon, InfoIcon } from '../icons'
7 | import Tooltip from '../Tooltip'
8 |
9 | type Props = {
10 | link: Link
11 | hover: boolean
12 | }
13 |
14 | const LinkSuffix = ({ link, hover }: Props) => {
15 | const isHttp = link.protocol === 'http:'
16 | const isNotOk = !link.status.ok
17 | const statusCode = link.status.status as keyof typeof statusCodes
18 |
19 | return (
20 |
21 |
22 |
26 | }
30 | onClick={() => c.createBackgroundTab(link.href)}
31 | />
32 |
33 |
34 | {isHttp && (
35 |
36 | HTTP
37 |
38 | )}
39 | {isNotOk && (
40 |
46 |
47 |
48 | {statusCode}
49 |
50 |
51 | )}
52 |
53 | )
54 | }
55 |
56 | export default LinkSuffix
57 |
--------------------------------------------------------------------------------
/src/scripts/Link.ts:
--------------------------------------------------------------------------------
1 | import LinkStatus from '../api/LinkStatus'
2 |
3 | class Link extends URL {
4 | /**
5 | * The response status of a link's fetch result. Defaults to success scenario until proven otherwise.
6 | */
7 | status = new LinkStatus(this.href)
8 |
9 | constructor(href: string) {
10 | super(href)
11 | }
12 |
13 | /**
14 | * Returns whether or not the protocol is http since links can be phone or address too.
15 | */
16 | get isHttp() {
17 | return this.protocol.includes('http')
18 | }
19 |
20 | /**
21 | * Returns hostname without 'www.'
22 | */
23 | get domain() {
24 | return this.hostname.replace('www.', '')
25 | }
26 |
27 | /**
28 | * Create a pretty version of the domain + pathname of a URL
29 | */
30 | get displayHref() {
31 | const href = this.domain + this.pathname + this.hash
32 | const lastCharacter = href.charAt(href.length - 1)
33 | return lastCharacter === '/' ? href.slice(0, -1) : href
34 | }
35 |
36 | /**
37 | * The URL class this is extending has a custom toJSON method that only returns the href of the object. This is a hack to clone the object for serializing it.
38 | */
39 | clone() {
40 | return {
41 | ...this,
42 | hash: this.hash,
43 | host: this.host,
44 | hostname: this.hostname,
45 | href: this.href,
46 | origin: this.origin,
47 | password: this.password,
48 | pathname: this.pathname,
49 | port: this.port,
50 | protocol: this.protocol,
51 | search: this.search,
52 | searchParams: this.searchParams,
53 | username: this.username,
54 | displayHref: this.displayHref,
55 | domain: this.domain,
56 | isHttp: this.isHttp,
57 | }
58 | }
59 | }
60 |
61 | export default Link
62 |
--------------------------------------------------------------------------------
/src/components/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Heading, Stack, Text } from '@chakra-ui/react'
2 | import React, { useEffect, useState } from 'react'
3 | import { useData } from '../../providers/DataProvider'
4 | import c from '../../scripts/Chrome'
5 | import { getDomain } from '../../scripts/execute-scripts'
6 | import lp from '../../scripts/LinksHandler'
7 | import Favicon from '../Favicon'
8 | import FetchLoader from './FetchLoader'
9 | import ThemeToggle from './ThemeToggle'
10 |
11 | const Header = () => {
12 | const [domain, setDomain] = useState('')
13 | const { data } = useData()
14 |
15 | useEffect(() => {
16 | const fetchData = async () => {
17 | const { id } = await c.getActiveTab()
18 |
19 | if (id) {
20 | const domainName = await c.executeScript(id, getDomain)
21 | setDomain(domainName)
22 | }
23 | }
24 |
25 | fetchData().catch(() => setDomain('Website'))
26 | }, [data])
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 | {domain}
36 |
37 |
38 |
39 | Found {data.links.length} links across{' '}
40 | {Object.keys(lp.categorizeByDomain(data.links)).length} different
41 | domains
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | export default Header
54 |
--------------------------------------------------------------------------------
/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import { http } from '@google-cloud/functions-framework'
2 | import fetch, { AbortError, RequestInit } from 'node-fetch'
3 | import UserAgent from 'user-agents'
4 | import LinkStatus from './LinkStatus'
5 |
6 | const getStatus = (link: string) => {
7 | async function fetchStatus(retries: number, method = 'HEAD') {
8 | const userAgent = new UserAgent()
9 | const controller = new globalThis.AbortController()
10 |
11 | const timeout = setTimeout(() => {
12 | controller.abort()
13 | }, 12000)
14 |
15 | const options: RequestInit = {
16 | headers: { 'User-Agent': userAgent.toString() },
17 | method,
18 | signal: controller.signal,
19 | size: 0,
20 | }
21 |
22 | try {
23 | const response = await fetch(link, options)
24 |
25 | clearTimeout(timeout)
26 |
27 | if (response.status === 405 && retries >= 0) {
28 | fetchStatus(retries - 1, 'GET')
29 | }
30 |
31 | if (response.status === 404 && retries >= 0) {
32 | fetchStatus(retries - 1, method)
33 | }
34 |
35 | return new LinkStatus(link, response)
36 | } catch (error) {
37 | if (error instanceof AbortError) {
38 | return new LinkStatus(link)
39 | }
40 |
41 | if (retries >= 0) {
42 | fetchStatus(retries - 1, method)
43 | }
44 |
45 | return new LinkStatus(link)
46 | }
47 | }
48 |
49 | return fetchStatus(5)
50 | }
51 |
52 | const resolveSettledPromises = (promise: PromiseSettledResult) => {
53 | switch (promise.status) {
54 | case 'fulfilled':
55 | return promise.value
56 |
57 | case 'rejected':
58 | return new LinkStatus('')
59 | }
60 | }
61 |
62 | http('fetchStatuses', async (request, response) => {
63 | const links: string[] = JSON.parse(request.body)
64 | const results = (await Promise.allSettled(links.map(getStatus))).map(
65 | resolveSettledPromises
66 | )
67 | response.send(JSON.stringify(results))
68 | })
69 |
--------------------------------------------------------------------------------
/src/components/ActionsBar/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ButtonGroup,
4 | Flex,
5 | SlideFade,
6 | Tag,
7 | useColorModeValue,
8 | } from '@chakra-ui/react'
9 | import React from 'react'
10 | import { useCheckedItems } from '../../providers/CheckedItems'
11 | import c from '../../scripts/Chrome'
12 | import { ExternalLinkIcon } from '../icons'
13 | import OverflowActions from './OverflowActions'
14 | import TabGroupPopover from './TabGroupPopover'
15 |
16 | const ActionsBar = () => {
17 | const { checkedItems } = useCheckedItems()
18 | const checkedItemsQty = checkedItems.length
19 | const showFooter = checkedItemsQty > 0
20 |
21 | const bg = useColorModeValue('white', 'gray.800')
22 | const border = useColorModeValue('gray.200', 'gray.600')
23 |
24 | async function createNewWindowTabs() {
25 | await c.createTabsInNewWindow(checkedItems)
26 | }
27 |
28 | return (
29 |
30 |
31 |
43 |
44 | {checkedItemsQty} selected
45 |
46 |
47 |
48 | }
51 | onClick={createNewWindowTabs}
52 | >
53 | Open in new window
54 |
55 | {'isV3Manifest' && }
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | export default ActionsBar
64 |
--------------------------------------------------------------------------------
/src/scripts/LinksHandler.ts:
--------------------------------------------------------------------------------
1 | import { CategorizedLinks } from '../types'
2 | import Link from './Link'
3 |
4 | class LinksHandler {
5 | public links: Link[]
6 |
7 | constructor(links: Link[]) {
8 | this.links = links.sort((linkA, linkB) =>
9 | linkA.href.length > linkB.href.length ? 0 : -1
10 | )
11 | }
12 |
13 | /**
14 | * Creates a Record of Links categorized by available domain names. Returns
15 | * a Record of Links with keys as domains.
16 | */
17 | static categorizeByDomain(links: Link[]) {
18 | return links.reduce((accum, link: Link) => {
19 | const { domain } = link
20 | if (!accum[domain]) accum[domain] = []
21 | accum[domain].push(link)
22 | return accum
23 | }, {} as CategorizedLinks)
24 | }
25 |
26 | /**
27 | * Creates a Record of class instance's hrefs categorized by available domain names.
28 | */
29 | get categorizedByDomain() {
30 | return LinksHandler.categorizeByDomain(this.links)
31 | }
32 |
33 | /**
34 | * Filter links to include only those with fragments as a Link[]
35 | */
36 | get fragmentLinks() {
37 | return this.links.filter((link) => Boolean(link.href.includes('#')))
38 | }
39 |
40 | /**
41 | * Return all the links that aren't status ok as a Link[]
42 | */
43 | get notOkLinks() {
44 | return this.links.filter(
45 | (link) => !link.status.ok && link.status.status !== 999
46 | )
47 | }
48 |
49 | /**
50 | * Return all the links that aren't redirected as a Link[]
51 | */
52 | get redirectedLinks() {
53 | return this.links.filter((link) => link.status.redirected)
54 | }
55 |
56 | /**
57 | * Return all the links that aren't redirected as a Link[]
58 | */
59 | get httpLinks() {
60 | return this.links.filter((link) => link.protocol === 'http:')
61 | }
62 |
63 | /**
64 | * Return all the links that aren't redirected as a Link[]
65 | */
66 | get fourOhFourLinks() {
67 | return this.links.filter((link) => link.status.status === 404)
68 | }
69 | }
70 |
71 | export default LinksHandler
72 |
--------------------------------------------------------------------------------
/src/components/icons/ExportIcon.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | export const ExportIcon = createIcon({
5 | defaultProps: {
6 | width: '16px',
7 | height: '16px',
8 | fill: 'currentcolor',
9 | 'aria-hidden': true,
10 | },
11 | viewBox: '0 0 24 24',
12 | path: (
13 | <>
14 |
15 | >
16 | ),
17 | })
18 |
--------------------------------------------------------------------------------
/src/components/LinkList/LinkItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Checkbox,
3 | Flex,
4 | Link as ChakraLink,
5 | ListItem,
6 | Text,
7 | } from '@chakra-ui/react'
8 | import React, { useState } from 'react'
9 | import { useCheckedItems } from '../../providers/CheckedItems'
10 | import Link from '../../scripts/Link'
11 | import LinkSuffix from './LinkSuffix'
12 | import RedirectedLink from './RedirectedLink'
13 |
14 | type Props = {
15 | link: Link
16 | }
17 |
18 | const LinkItem = ({ link }: Props) => {
19 | const [hover, setHover] = useState(false)
20 | const { checkedItems, setCheckedItems } = useCheckedItems()
21 |
22 | const handleChange = (event: React.ChangeEvent) => {
23 | const checked = event.target.checked
24 | const value = event.target.value
25 |
26 | checked
27 | ? setCheckedItems((prevChecked) => [...prevChecked, value])
28 | : setCheckedItems((prevChecked) =>
29 | prevChecked.filter((href) => href !== value)
30 | )
31 | }
32 |
33 | return (
34 | setHover(true)}
37 | onMouseLeave={() => setHover(false)}
38 | onFocus={() => setHover(true)}
39 | onBlur={() => setHover(false)}
40 | >
41 |
42 |
43 |
51 |
52 |
58 |
59 | {link.href}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | )
69 | }
70 |
71 | export default LinkItem
72 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs'
2 | import json from '@rollup/plugin-json'
3 | import resolve from '@rollup/plugin-node-resolve'
4 | import replace from '@rollup/plugin-replace'
5 | import typescript from '@rollup/plugin-typescript'
6 | import path from 'path'
7 | import { chromeExtension, simpleReloader } from 'rollup-plugin-chrome-extension'
8 | import { emptyDir } from 'rollup-plugin-empty-dir'
9 | import zip from 'rollup-plugin-zip'
10 |
11 | const isProduction = process.env.NODE_ENV === 'production'
12 |
13 | const NODE_ENV = isProduction
14 | ? JSON.stringify('production')
15 | : JSON.stringify('development')
16 |
17 | const apiConfig = {
18 | input: 'src/api/index.ts',
19 | output: {
20 | dir: 'dist/api',
21 | format: 'esm',
22 | },
23 | plugins: [typescript()],
24 | external: ['node-fetch', '@google-cloud/functions-framework', 'user-agents'],
25 | }
26 |
27 | const v2Manifest = {
28 | input: 'src/v2-manifest.json',
29 | output: {
30 | dir: 'dist/v2-manifest',
31 | format: 'esm',
32 | chunkFileNames: path.join('chunks', '[name]-[hash].js'),
33 | },
34 | plugins: [
35 | replace({
36 | 'process.env.NODE_ENV': NODE_ENV,
37 | isV3Manifest: '',
38 | preventAssignment: true,
39 | }),
40 | chromeExtension(),
41 | simpleReloader(),
42 | json(),
43 | resolve(),
44 | commonjs(),
45 | typescript(),
46 | emptyDir(),
47 | isProduction && zip({ dir: 'releases/v2-manifest' }),
48 | ],
49 | }
50 |
51 | const v3Manifest = {
52 | input: isProduction
53 | ? 'src/v3-manifest-prod.json'
54 | : 'src/v3-manifest-dev.json',
55 | output: {
56 | dir: 'dist/v3-manifest',
57 | format: 'esm',
58 | chunkFileNames: path.join('chunks', '[name]-[hash].js'),
59 | },
60 | plugins: [
61 | replace({
62 | 'process.env.NODE_ENV': NODE_ENV,
63 | isV3Manifest: 'true',
64 | preventAssignment: true,
65 | }),
66 | chromeExtension(),
67 | simpleReloader(),
68 | json(),
69 | resolve(),
70 | commonjs(),
71 | typescript(),
72 | emptyDir(),
73 | isProduction && zip({ dir: 'releases/v3-manifest' }),
74 | ],
75 | }
76 |
77 | const exports = isProduction
78 | ? [v2Manifest, v3Manifest]
79 | : [apiConfig, v3Manifest]
80 |
81 | export default exports
82 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Chrome extension release zip files
2 | releases
3 | .idea
4 | Link Roamer
5 |
6 | # Logs
7 | .DS_Store
8 | src/.DS_Store
9 | logs
10 | *.log
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | lerna-debug.log*
15 |
16 | # Diagnostic reports (https://nodejs.org/api/report.html)
17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 | *.lcov
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (https://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | node_modules/
49 | jspm_packages/
50 |
51 | # TypeScript v1 declaration files
52 | typings/
53 |
54 | # TypeScript cache
55 | *.tsbuildinfo
56 |
57 | # Optional npm cache directory
58 | .npm
59 |
60 | # Optional eslint cache
61 | .eslintcache
62 |
63 | # Microbundle cache
64 | .rpt2_cache/
65 | .rts2_cache_cjs/
66 | .rts2_cache_es/
67 | .rts2_cache_umd/
68 |
69 | # Optional REPL history
70 | .node_repl_history
71 |
72 | # Output of 'npm pack'
73 | *.tgz
74 |
75 | # Yarn Integrity file
76 | .yarn-integrity
77 |
78 | # dotenv environment variables file
79 | .env
80 | .env.test
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 |
85 | # Next.js build output
86 | .next
87 |
88 | # Nuxt.js build / generate output
89 | .nuxt
90 | dist
91 |
92 | # Gatsby files
93 | .cache/
94 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
95 | # https://nextjs.org/blog/next-9-1#public-directory-support
96 | # public
97 |
98 | # vuepress build output
99 | .vuepress/dist
100 |
101 | # Serverless directories
102 | .serverless/
103 |
104 | # FuseBox cache
105 | .fusebox/
106 |
107 | # DynamoDB Local files
108 | .dynamodb/
109 |
110 | # TernJS port file
111 | .tern-port
112 | .DS_Store
113 | src/.DS_Store
114 |
--------------------------------------------------------------------------------
/src/api/LinkStatus.ts:
--------------------------------------------------------------------------------
1 | import { Response } from 'node-fetch'
2 | import Link from '../scripts/Link'
3 |
4 | class LinkStatus {
5 | /**
6 | * A boolean indicating whether the response was successful (status in the range 200–299) or not. Defaults to true in case links are timed out.
7 | */
8 | readonly ok: boolean
9 |
10 | /**
11 | * When an instance is created without a response, this defaults to false to check if the instance is valid.
12 | */
13 | readonly validResponse: boolean
14 |
15 | /**
16 | * The original url used to fetch. Important when links are redirected as the url returned from response is final destination.
17 | */
18 | readonly originUrl: Link['href']
19 |
20 | /**
21 | * Indicates whether or not the response is the result of a redirect (that is, its URL list has more than one entry).
22 | */
23 | readonly redirected: boolean
24 |
25 | /**
26 | * The Headers object associated with the response.
27 | */
28 | readonly headers?: Record
29 |
30 | /**
31 | * The status code of the response. (This will be 200 for a success).
32 | */
33 | readonly status?: number
34 |
35 | /**
36 | * The status message corresponding to the status code. (e.g., OK for 200).
37 | */
38 | readonly statusText?: string
39 |
40 | /**
41 | * The type of the response (e.g., basic, cors).
42 | * */
43 | readonly type?: ResponseType
44 |
45 | /**
46 | * The final URL of the response after redirects.
47 | * */
48 | readonly url?: string
49 |
50 | constructor(originUrl: string, response?: Response) {
51 | this.originUrl = originUrl
52 | this.ok = true
53 | this.validResponse = false
54 | this.redirected = false
55 |
56 | if (response) {
57 | this.validResponse = true
58 | this.ok = response.ok
59 | this.redirected = response.redirected
60 | this.status = response.status
61 | this.statusText = response.statusText
62 | this.type = response.type
63 | this.url = response.url
64 | this.headers = this.setHeaders(response)
65 | }
66 | }
67 |
68 | private setHeaders(response: Response) {
69 | const entries = Array.from(response.headers.entries())
70 | return entries.reduce((accumulator, [key, value]) => {
71 | accumulator[key] = JSON.stringify(value)
72 | return accumulator
73 | }, {} as Record)
74 | }
75 | }
76 |
77 | export default LinkStatus
78 |
--------------------------------------------------------------------------------
/src/components/ActionsBar/OverflowActions.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconButton,
3 | Menu,
4 | MenuButton,
5 | MenuDivider,
6 | MenuItem,
7 | MenuList,
8 | } from '@chakra-ui/react'
9 | import React from 'react'
10 | import { useCheckedItems } from '../../providers/CheckedItems'
11 | import { useData } from '../../providers/DataProvider'
12 | import LinkActions from '../../scripts/LinkActions'
13 | import {
14 | BookmarkIcon,
15 | CsvCopyIcon,
16 | CsvFileIcon,
17 | ExportIcon,
18 | JsonCopyIcon,
19 | JsonFileIcon,
20 | MoreIcon,
21 | } from '../icons'
22 | import BookmarkModal from './BookmarkModal'
23 |
24 | const OverflowActions = () => {
25 | const [showBookmark, setShowBookmark] = React.useState(false)
26 | const { data } = useData()
27 | const { checkedItems } = useCheckedItems()
28 |
29 | return (
30 |
31 |
32 | }
37 | />
38 |
39 |
40 | setShowBookmark(true)}
42 | icon={ }
43 | >
44 | Bookmark links
45 |
46 |
47 | LinkActions.jsonToFile(checkedItems)}
49 | icon={ }
50 | >
51 | Export as JSON
52 |
53 | LinkActions.csvToFile(checkedItems)}
55 | icon={ }
56 | >
57 | Export as CSV
58 |
59 |
60 | LinkActions.jsonToClipboard(checkedItems)}
62 | icon={ }
63 | >
64 | Copy JSON
65 |
66 | LinkActions.csvToClipboard(checkedItems)}
68 | icon={ }
69 | >
70 | Copy CSV
71 |
72 |
73 | LinkActions.exportAllData(data.links, checkedItems)}
75 | icon={ }
76 | >
77 | Export detailed JSON
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
85 |
86 | export default OverflowActions
87 |
--------------------------------------------------------------------------------
/src/providers/DataProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useState } from 'react'
2 | import LinkStatus from '../api/LinkStatus'
3 | import c from '../scripts/Chrome'
4 | import dataDebugger from '../scripts/data-debugger'
5 | import { gatherHrefs } from '../scripts/execute-scripts'
6 | import Link from '../scripts/Link'
7 | import { default as LinksHandler } from '../scripts/LinksHandler'
8 | import { Children, LinkData } from '../types'
9 |
10 | interface DataContextProps {
11 | data: LinkData
12 | setData: React.Dispatch>
13 | }
14 |
15 | const DataContext = React.createContext({} as DataContextProps)
16 |
17 | export const DataProvider = ({ children }: Children) => {
18 | const [data, setData] = useState({
19 | links: [],
20 | loading: true,
21 | })
22 |
23 | const dataMemo = useMemo(
24 | () => ({
25 | data,
26 | setData,
27 | }),
28 | [data]
29 | )
30 |
31 | useEffect(() => {
32 | const fetchData = async () => {
33 | const { id } = await c.getActiveTab()
34 |
35 | if (id) {
36 | const { links } = new LinksHandler(
37 | (await c.executeScript(id, gatherHrefs))
38 | .map((link) => new Link(link))
39 | .filter((link) => link.isHttp)
40 | )
41 |
42 | links.length > 0
43 | ? setData({ links, loading: true })
44 | : setData({ links: [], loading: false })
45 | }
46 | }
47 |
48 | fetchData().catch(console.error)
49 | }, [])
50 |
51 | useEffect(() => {
52 | const fetchData = async () => {
53 | if (data.links.length > 0) {
54 | const result = await c.fetchLinks(data.links)
55 |
56 | /**
57 | * A completely failed attempt returns an empty Array.
58 | */
59 | if (result.length < 1)
60 | return setData((prevData) => ({ ...prevData, loading: false }))
61 |
62 | const links = data.links.map((link) => {
63 | link.status =
64 | result.find(({ originUrl }) => originUrl === link.href) ??
65 | new LinkStatus(link.href)
66 | return link
67 | })
68 |
69 | if (process.env.NODE_ENV === 'development') {
70 | dataDebugger(links)
71 | }
72 |
73 | setData({ links, loading: false })
74 | }
75 | }
76 |
77 | fetchData().catch(console.error)
78 | }, [data.links.length])
79 |
80 | return (
81 | {children}
82 | )
83 | }
84 |
85 | export const useData = () => React.useContext(DataContext)
86 |
--------------------------------------------------------------------------------
/src/components/ActionsBar/BookmarkModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ButtonGroup,
4 | FormLabel,
5 | Input,
6 | Modal,
7 | ModalBody,
8 | ModalCloseButton,
9 | ModalContent,
10 | ModalFooter,
11 | ModalHeader,
12 | ModalOverlay,
13 | Text,
14 | VisuallyHidden,
15 | } from '@chakra-ui/react'
16 | import React from 'react'
17 | import ReactFocusLock from 'react-focus-lock'
18 | import { useCheckedItems } from '../../providers/CheckedItems'
19 | import c from '../../scripts/Chrome'
20 | import Link from '../../scripts/Link'
21 |
22 | type Props = {
23 | state: boolean
24 | setState: React.Dispatch>
25 | }
26 |
27 | const BookmarkModal = ({ state, setState }: Props) => {
28 | const { checkedItems } = useCheckedItems()
29 | const inputRef = React.useRef(null)
30 |
31 | async function createBookmark() {
32 | const { id } = await c.createBookmarkFolder(
33 | inputRef.current?.value ?? 'Link Roamer'
34 | )
35 |
36 | checkedItems.forEach((url) => {
37 | const prettyUrl = new Link(url).displayHref
38 | c.createBookmark(prettyUrl, id, url)
39 | })
40 |
41 | setState(false)
42 | }
43 |
44 | return (
45 | setState(false)}
48 | size="sm"
49 | initialFocusRef={inputRef}
50 | >
51 |
52 |
53 |
54 | Set a folder title
55 |
56 |
57 |
58 | The folder will be created initially inside the "Other
59 | Bookmarks" directory.
60 |
61 |
62 |
63 | Set a bookmark folder title
64 |
65 |
66 |
67 |
68 |
69 |
70 | setState(false)}>Cancel
71 |
76 | Create
77 |
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
85 |
86 | export default BookmarkModal
87 |
--------------------------------------------------------------------------------
/src/scripts/LinkActions.ts:
--------------------------------------------------------------------------------
1 | import Link from './Link'
2 |
3 | class LinkActions {
4 | /**
5 | * Transforms all link hrefs into a comma separated string
6 | */
7 | static linksToCsvString(links: string[]) {
8 | return links.reduce((prevValue, currValue) => {
9 | return prevValue.concat(currValue, ',')
10 | }, '')
11 | }
12 |
13 | /**
14 | * Returns a single string of hrefs separated by commas
15 | */
16 | static linksToJsonString(links: string[]) {
17 | const jsonified = links.reduce((prevValue, currValue) => {
18 | return prevValue.concat('"', currValue, '"', ',')
19 | }, '')
20 |
21 | return '[' + jsonified + ']'
22 | }
23 |
24 | static exportAllData(data: Link[], checkedItems: string[]) {
25 | const links = JSON.stringify(
26 | data
27 | .filter((item) => checkedItems.some((href) => href === item.href))
28 | .map((item) => item.clone())
29 | )
30 |
31 | const json =
32 | 'data:application/json;charset=utf-8,' + encodeURIComponent(links)
33 |
34 | const link = document.createElement('a')
35 | link.setAttribute('href', json)
36 | link.setAttribute('download', 'detailed-roamer-data.json')
37 | document.body.appendChild(link)
38 | link.click()
39 | }
40 |
41 | /**
42 | * Save given links to a csv file
43 | */
44 | static csvToFile(links: string[]) {
45 | const csv = 'data:text/csv;charset=utf-8,' + this.linksToCsvString(links)
46 | const encodedUri = encodeURI(csv)
47 | const link = document.createElement('a')
48 | link.setAttribute('href', encodedUri)
49 | link.setAttribute('download', 'link-roamer-data.csv')
50 | document.body.appendChild(link)
51 | link.click()
52 | }
53 |
54 | static jsonToFile(links: string[]) {
55 | const string = this.linksToJsonString(links)
56 | const json =
57 | 'data:application/json;charset=utf-8,' + encodeURIComponent(string)
58 | const link = document.createElement('a')
59 | link.setAttribute('href', json)
60 | link.setAttribute('download', 'link-roamer-data.json')
61 | document.body.appendChild(link)
62 | link.click()
63 | }
64 |
65 | static async jsonToClipboard(links: string[]) {
66 | const value = this.linksToJsonString(links)
67 | await this.copyToClipBoard(value)
68 | }
69 |
70 | static async csvToClipboard(links: string[]) {
71 | const value = this.linksToCsvString(links)
72 | await this.copyToClipBoard(value)
73 | }
74 |
75 | private static async copyToClipBoard(value: string) {
76 | await navigator.clipboard.writeText(value).catch(console.error)
77 | }
78 | }
79 |
80 | export default LinkActions
81 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "link-roamer",
3 | "version": "1.2.0",
4 | "description": "A browser extension for gathering, organizing, and inspecting all the links on a web page.",
5 | "scripts": {
6 | "release": "cross-env NODE_ENV=production rollup -c",
7 | "safari": "xcrun safari-web-extension-converter ./dist/v2-manifest",
8 | "server": "npx functions-framework --target fetchStatuses --source dist/api",
9 | "start": "rollup -c -w",
10 | "watch": "npm-watch server"
11 | },
12 | "watch": {
13 | "server": "dist/api/*.js"
14 | },
15 | "type": "module",
16 | "keywords": [
17 | "chrome-extension",
18 | "web-extension",
19 | "typescript",
20 | "react",
21 | "rollup"
22 | ],
23 | "engines": {
24 | "node": ">=16.0.0"
25 | },
26 | "author": "Ross Moody <@_rossmoody>",
27 | "license": "MIT",
28 | "bugs": {
29 | "url": "https://github.com/rossmoody/link-roamer/issues"
30 | },
31 | "homepage": "https://github.com/rossmoody/link-roamer#readme",
32 | "repository": {
33 | "type": "git",
34 | "url": "git+https://github.com/rossmoody/link-roamer.git"
35 | },
36 | "dependencies": {
37 | "@chakra-ui/react": "^1.8.7",
38 | "@emotion/react": "^11",
39 | "@emotion/styled": "^11",
40 | "framer-motion": "^6",
41 | "react": "^18.0.0",
42 | "react-dom": "^18.0.0",
43 | "react-focus-lock": "^2.8.1",
44 | "user-agents": "^1.0.996"
45 | },
46 | "devDependencies": {
47 | "@google-cloud/functions-framework": "^3.1.0",
48 | "@rollup/plugin-alias": "^3.1.1",
49 | "@rollup/plugin-commonjs": "^17.0.0",
50 | "@rollup/plugin-json": "^4.1.0",
51 | "@rollup/plugin-node-resolve": "^11.0.1",
52 | "@rollup/plugin-replace": "^3.0.0",
53 | "@rollup/plugin-typescript": "^8.1.0",
54 | "@types/chrome": "^0.0.180",
55 | "@types/express": "^4.17.13",
56 | "@types/firefox-webext-browser": "^94.0.0",
57 | "@types/react": "^18.0.0",
58 | "@types/react-dom": "^18.0.0",
59 | "@types/user-agents": "^1.0.2",
60 | "@typescript-eslint/eslint-plugin": "^4.10.0",
61 | "@typescript-eslint/parser": "^4.10.0",
62 | "cross-env": "^7.0.3",
63 | "eslint": "^7.16.0",
64 | "eslint-plugin-react": "^7.21.5",
65 | "node-fetch": "^3.2.3",
66 | "npm-watch": "^0.11.0",
67 | "rollup": "^2.56.3",
68 | "rollup-plugin-chrome-extension": "^3.6.1",
69 | "rollup-plugin-empty-dir": "^1.0.5",
70 | "rollup-plugin-zip": "^1.0.3",
71 | "tslib": "^2.0.3",
72 | "typescript": "^4.5.0",
73 | "webextension-polyfill": "^0.7.0"
74 | },
75 | "prettier": {
76 | "semi": false,
77 | "tabWidth": 2,
78 | "singleQuote": true,
79 | "printWidth": 80
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/pages/Onboarding.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Center,
4 | Code,
5 | Flex,
6 | Heading,
7 | Kbd,
8 | Link,
9 | ListItem,
10 | OrderedList,
11 | Text,
12 | } from '@chakra-ui/react'
13 | import React from 'react'
14 | import ReactDOMClient from 'react-dom/client'
15 | import LinkRoamerLogo from '../components/LinkRoamerLogo'
16 | import ThemeProvider from '../providers/ThemeProvider'
17 |
18 | const Onboarding = () => (
19 |
20 |
21 |
27 |
28 |
29 |
30 |
38 | Welcome to{' '}
39 |
40 | Link Roamer
41 |
42 |
43 |
44 | A few things to know before you get to roamin'.
45 |
46 |
47 |
48 | Link Roamer only works on http and https{' '}
49 | pages.
50 |
51 |
52 | The default keyboard shortcut to open Link Roamer is{' '}
53 | Cmd + U . Sometimes that shortcut is taken by another
54 | extension and you will need to pick a new one on the{' '}
55 | chrome://extensions/shortcuts settings page.
56 |
57 |
58 | This extension is open-source. If you encounter an issue, please{' '}
59 |
64 | submit an issue on GitHub
65 | {' '}
66 | or{' '}
67 |
72 | send me a message on Twitter
73 |
74 | .
75 |
76 |
77 |
78 |
79 |
80 | )
81 |
82 | const root = document.getElementById('root')!
83 | ReactDOMClient.createRoot(root).render( )
84 |
--------------------------------------------------------------------------------
/src/components/LinkList/Domain.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AccordionButton,
3 | AccordionIcon,
4 | AccordionItem,
5 | AccordionPanel,
6 | Badge,
7 | Checkbox,
8 | Heading,
9 | HStack,
10 | List,
11 | Stack,
12 | } from '@chakra-ui/react'
13 | import React from 'react'
14 | import { useCheckedItems } from '../../providers/CheckedItems'
15 | import Link from '../../scripts/Link'
16 | import LinksHandler from '../../scripts/LinksHandler'
17 | import Favicon from '../Favicon'
18 | import QuantityTag from '../ResponseTag'
19 | import LinkItem from './LinkItem'
20 |
21 | type Props = {
22 | domain: string
23 | links: Link[]
24 | }
25 |
26 | const Domain = ({ domain, links }: Props) => {
27 | const { checkedItems, setCheckedItems } = useCheckedItems()
28 | const allChecked = links.every((link) => checkedItems.includes(link.href))
29 | const isIndeterminate =
30 | links.some((link) => checkedItems.includes(link.href)) && !allChecked
31 |
32 | const handleChange = () => {
33 | const hrefs = links.map((link) => link.href)
34 |
35 | allChecked
36 | ? setCheckedItems((prev) => prev.filter((item) => !hrefs.includes(item)))
37 | : setCheckedItems((prev) => [...prev, ...hrefs])
38 | }
39 |
40 | const lp = new LinksHandler(links)
41 | const httpQty = lp.httpLinks.length
42 | const brokenQty = lp.fourOhFourLinks.length
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
56 |
57 |
58 | {domain}
59 |
60 | {links.length}
61 |
62 |
63 | {httpQty && (
64 |
65 | HTTP
66 |
67 | )}
68 | {brokenQty && (
69 |
70 | 404
71 |
72 | )}
73 |
74 |
75 |
76 |
77 |
78 |
79 | {links.map((link, index) => (
80 |
81 | ))}
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | export default Domain
89 |
--------------------------------------------------------------------------------
/src/components/Tabs/index.tsx:
--------------------------------------------------------------------------------
1 | import { TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'
2 | import React, { useEffect, useState } from 'react'
3 | import { useData } from '../../providers/DataProvider'
4 | import Link from '../../scripts/Link'
5 | import LinksHandler from '../../scripts/LinksHandler'
6 | import EmptyState from '../EmptyState'
7 | import LinkList from '../LinkList'
8 | import SearchFilter from './SearchFilter'
9 | import SelectAll from './SelectAll'
10 | import Tab from './Tab'
11 |
12 | const CategoryTabs = () => {
13 | const [links, setLinks] = useState ([])
14 | const [filter, setFilter] = useState('')
15 | const { data } = useData()
16 |
17 | useEffect(() => {
18 | setLinks(data.links)
19 | }, [data.links.length])
20 |
21 | useEffect(() => {
22 | const filteredLinks = data.links.filter((link) =>
23 | link.href.includes(filter)
24 | )
25 | setLinks(filteredLinks)
26 | }, [filter])
27 |
28 | const lp = new LinksHandler(links)
29 |
30 | const all = {
31 | links: LinksHandler.categorizeByDomain(links),
32 | quantity: links.length,
33 | }
34 |
35 | const fragments = {
36 | links: LinksHandler.categorizeByDomain(lp.fragmentLinks),
37 | quantity: lp.fragmentLinks.length,
38 | }
39 |
40 | const notOk = {
41 | links: LinksHandler.categorizeByDomain(lp.notOkLinks),
42 | quantity: lp.notOkLinks.length,
43 | }
44 |
45 | const redirected = {
46 | links: LinksHandler.categorizeByDomain(lp.redirectedLinks),
47 | quantity: lp.redirectedLinks.length,
48 | }
49 |
50 | return (
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | {all.quantity < 1 ? (
63 |
64 | ) : (
65 |
66 |
67 |
68 |
69 | {notOk.quantity && (
70 |
71 |
72 |
73 | )}
74 | {redirected.quantity && (
75 |
76 |
77 |
78 | )}
79 | {fragments.quantity && (
80 |
81 |
82 |
83 | )}
84 |
85 | )}
86 |
87 |
88 | )
89 | }
90 |
91 | export default CategoryTabs
92 |
--------------------------------------------------------------------------------
/src/components/ActionsBar/TabGroupPopover.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ButtonGroup,
4 | FormLabel,
5 | Heading,
6 | Input,
7 | Popover,
8 | PopoverArrow,
9 | PopoverBody,
10 | PopoverCloseButton,
11 | PopoverContent,
12 | PopoverFooter,
13 | PopoverHeader,
14 | PopoverTrigger,
15 | Text,
16 | useDisclosure,
17 | VisuallyHidden,
18 | } from '@chakra-ui/react'
19 | import React from 'react'
20 | import ReactFocusLock from 'react-focus-lock'
21 | import { useCheckedItems } from '../../providers/CheckedItems'
22 | import c from '../../scripts/Chrome'
23 | import { NewTabIcon } from '../icons'
24 |
25 | const TabGroupPopover = () => {
26 | const { onOpen, onClose, isOpen } = useDisclosure()
27 | const { checkedItems } = useCheckedItems()
28 | const inputRef = React.useRef(null)
29 |
30 | async function createTabGroup() {
31 | const tabIds = await Promise.all(
32 | checkedItems.map(async (href) => {
33 | const tab = await c.createBackgroundTab(href)
34 | return tab.id as number
35 | })
36 | )
37 |
38 | const title = inputRef.current?.value ?? ''
39 | const groupId = await c.createTabGroup(tabIds)
40 | await c.updateTabGroup(groupId, title)
41 | onClose()
42 | }
43 |
44 | return (
45 |
52 | {/*// @ts-ignore*/}
53 |
54 | } variant="solid">
55 | Open in tab group
56 |
57 |
58 |
59 |
60 |
61 | Set a tab group title
62 |
63 | The title or tab group color can be changed later.
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Set a tab group title
72 |
73 |
74 |
75 |
76 |
83 |
84 | Cancel
85 |
90 | Create
91 |
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
100 | export default TabGroupPopover
101 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 👋 Link Roamer
2 |
3 | 
4 |
5 | ---
6 |
7 | ## 🚀 Where to install
8 |
9 | This extension is available on:
10 |
11 | - [Chrome Web store](https://chrome.google.com/webstore/detail/link-roamer/lgcgflalbmeodapiohjepkjlgipmhofe)
12 | - [Firefox Addon Marketplace](https://addons.mozilla.org/en-US/firefox/addon/link-roamer/)
13 | - [Microsoft Edge Add-ons Marketplace](https://microsoftedge.microsoft.com/addons/detail/link-roamer/bigambbapbnineapeagbdpdpkaildjdd)
14 |
15 | ---
16 |
17 | A browser extension for gathering, organizing, and inspecting all the links on a
18 | web page. It's also pretty good at finding broken links (404). This extension
19 | allows you to:
20 |
21 | ### Inspect links
22 |
23 | - Highlight broken links on a page (i.e. 404)
24 | - Highlight non-secure links on a page (i.e. http)
25 | - Quickly find, view, organize, and navigate links on a page
26 | - See the status and reasoning for requests that don't succed
27 | - View links and where they redirect to before navigating
28 |
29 | ### Organize links
30 |
31 | - Group links by their primary domain name
32 | - Create a tab group from a set of selected links (_\*Chrome only_)
33 | - Save a set of selected links as bookmarks
34 |
35 | ### Interact with links
36 |
37 | - Copy links to clipboard
38 | - Open links in a new window or tab
39 | - Open individual links in a background tab
40 | - Export links as `json`, `text`, or `csv`
41 | - Export detailed URL and fetch request data
42 |
43 | ---
44 |
45 | 
46 |
47 | ## Local development
48 |
49 | Before making edits you will need to build the extension locally and side load
50 | it as a developer extension to test any changes.
51 |
52 | > At the moment, v3 and v2 manifest API conflicts are making things difficult.
53 | > The Rollup config programmatically compiles two different versions depending
54 | > on the manifest. For Chrome, the v3 manifest in the `dist` folder is the one
55 | > to load. For Firefox, you will need to build and load the zipped release
56 | > version with `yarn release`.
57 |
58 | ### 1. Clone the repo
59 |
60 | Clone the repo to your local machine and navigate into the root directory.
61 |
62 | ```shell
63 | cd link-roamer
64 | ```
65 |
66 | ### 2. Install dependencies
67 |
68 | Link Roamer uses yarn to build the necessary dependencies.
69 |
70 | ```shell
71 | yarn
72 | ```
73 |
74 | ### 3. Start and watch a build
75 |
76 | For development with automatic reloading:
77 |
78 | ```bash
79 | yarn start
80 | ```
81 |
82 | This will build to the `dist` folder. To load the extension, open the Extensions
83 | Dashboard, enable "Developer mode", click "Load unpacked", and choose the
84 | `dist/v3-manifest` folder.
85 |
86 | When you make changes in src the background script and any content script will
87 | reload automatically.
88 |
89 | ### 4. Start the server
90 |
91 | You'll need to start up the server to make fetch calls and check statuses. To build content from the `src/api` folder, run:
92 |
93 | ```bash
94 | yarn watch
95 | ```
96 |
97 | This will startup the server and restart it any time a change is recompiled from rollup.
98 |
99 | ---
100 |
101 | ## More apps by me
102 |
103 | I like making things. [Check out what I'm up to lately](https://rossmoody.com).
104 |
105 | ---
106 |
107 | ## Open source
108 |
109 | This extension is open source and doesn't collect any information from users.
110 | It's free, and made available because I enjoy making useful things for the web.
111 |
112 | Please consider contributing with an idea, bug fix, or feature request.
113 |
114 | ---
115 |
116 | ## Contribute
117 |
118 | Feel free to submit a pull request if you've made an improvement of some kind.
119 | This is an open source project and any help is very appreciated.
120 |
--------------------------------------------------------------------------------
/src/scripts/Chrome.ts:
--------------------------------------------------------------------------------
1 | import CreateData = chrome.windows.CreateData
2 | import GroupOptions = chrome.tabs.GroupOptions
3 | import LinkStatus from '../api/LinkStatus'
4 | import { Message } from '../types'
5 | import Link from './Link'
6 |
7 | class Chrome {
8 | /**
9 | * Inject scripts into a given tabId.
10 | * The function returns the result of whatever function is passed in.
11 | */
12 | async executeScript(tabId: number, func: () => Type) {
13 | if ('isV3Manifest') {
14 | return (
15 | await chrome.scripting.executeScript({
16 | target: { tabId },
17 | func,
18 | })
19 | )[0].result as Type
20 | }
21 |
22 | return (
23 | await browser.tabs.executeScript(tabId, {
24 | code: `(${func})()`,
25 | })
26 | )[0] as Type
27 | }
28 |
29 | async getActiveTab() {
30 | const config: chrome.tabs.QueryInfo = { active: true, currentWindow: true }
31 |
32 | if ('isV3Manifest') {
33 | return (await chrome.tabs.query(config))[0]
34 | }
35 |
36 | return (await browser.tabs.query(config))[0]
37 | }
38 |
39 | async createBackgroundTab(url: string) {
40 | const config: chrome.tabs.CreateProperties = { active: false, url }
41 | return await chrome.tabs.create(config)
42 | }
43 |
44 | async createTabsInNewWindow(url: string[]) {
45 | const config: CreateData = { url }
46 | return await chrome.windows.create(config)
47 | }
48 |
49 | /**
50 | * Creates a tab group from the list of given tabIds.
51 | * If no windowId, the tab group opens in currently active window.
52 | * Returns the id of the newly created tab group.
53 | */
54 | async createTabGroup(tabIds: number[], windowId?: number) {
55 | const config: GroupOptions = {
56 | tabIds,
57 | createProperties: { windowId },
58 | }
59 |
60 | return await chrome.tabs.group(config)
61 | }
62 |
63 | /**
64 | * Collapses a newly created tab group and sets the given title.
65 | */
66 | async updateTabGroup(groupId: number, title: string) {
67 | const updateProperties: chrome.tabGroups.UpdateProperties = {
68 | collapsed: true,
69 | title,
70 | }
71 | return await chrome.tabGroups.update(groupId, updateProperties)
72 | }
73 |
74 | async createBookmarkFolder(title: string) {
75 | const config: chrome.bookmarks.BookmarkCreateArg = {
76 | title,
77 | }
78 |
79 | if ('isV3Manifest') return await chrome.bookmarks.create(config)
80 | return browser.bookmarks.create(config)
81 | }
82 |
83 | /**
84 | * Creates a bookmark and puts it inside the given folder using the parentId.
85 | */
86 | async createBookmark(title: string, parentId: string, url: string) {
87 | const config: chrome.bookmarks.BookmarkCreateArg = {
88 | parentId,
89 | title,
90 | url,
91 | }
92 | if ('isV3Manifest') return await chrome.bookmarks.create(config)
93 | return browser.bookmarks.create(config)
94 | }
95 |
96 | /**
97 | * Sends a message to the background script to process all the given hrefs
98 | * and return the response status objects for each.
99 | */
100 | async fetchLinks(links: Link[]): Promise {
101 | const data = JSON.stringify(links)
102 |
103 | const message: Message = {
104 | action: 'fetchLinks',
105 | data,
106 | }
107 |
108 | return new Promise((resolve) => {
109 | chrome.runtime.sendMessage(message, resolve)
110 | })
111 | }
112 |
113 | async getStorage(key: string): Promise {
114 | const result = await chrome.storage.local.get(key)
115 | return result[key]
116 | }
117 |
118 | setStorage(key: string, value: unknown) {
119 | chrome.storage.local.set({ [key]: value })
120 | }
121 | }
122 |
123 | export default new Chrome()
124 |
--------------------------------------------------------------------------------
/src/components/LinkRoamerLogo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type Props = {
4 | size: number
5 | }
6 |
7 | const LinkRoamerLogo = (props: Props) => (
8 |
15 |
19 |
25 |
31 |
37 |
38 |
46 |
47 |
48 |
49 |
57 |
58 |
59 |
60 |
61 |
62 | )
63 |
64 | export default LinkRoamerLogo
65 |
--------------------------------------------------------------------------------
/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Center, Heading, Text } from '@chakra-ui/react'
2 | import React from 'react'
3 |
4 | const EmptyState = () => (
5 |
6 |
7 |
8 |
9 |
10 |
11 | No links to show
12 |
13 |
14 | Try different keywords in the search filter or on the off-chance you
15 | found a site with no links; congratulations, that doesn't happen
16 | very often.
17 |
18 |
19 |
20 | )
21 |
22 | const EmptyStateGraphic = () => (
23 |
30 |
31 |
35 |
36 | )
37 |
38 | export default EmptyState
39 |
--------------------------------------------------------------------------------
/src/status-codes.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | 300: {
3 | title: 'Multiple Choice',
4 | description:
5 | 'The request has more than one possible response. The user agent or user should choose one of them.',
6 | },
7 | 301: {
8 | title: 'Moved Permanently',
9 | description:
10 | 'The URL of the requested resource has been changed permanently.',
11 | },
12 | 302: {
13 | title: 'Found',
14 | description:
15 | 'This response code means that the URI of requested resource has been changed temporarily.',
16 | },
17 | 303: {
18 | title: 'See Other',
19 | description:
20 | 'The server sent this response to direct the client to get the requested resource at another URI with a GET request.',
21 | },
22 | 307: {
23 | title: 'Use Proxy Deprecated',
24 | description:
25 | 'The server sends this response to direct the client to get the requested resource at another URI with same method that was used in the prior request.',
26 | },
27 | 308: {
28 | title: 'Permanent Redirect',
29 | description:
30 | 'This means that the resource is now permanently located at another URI, specified by the Location: HTTP Response header.',
31 | },
32 | 400: {
33 | title: 'Bad Request',
34 | description:
35 | 'The server cannot or will not process the request due to something that is perceived to be a client error.',
36 | },
37 | 401: {
38 | title: 'Unauthorized',
39 | description:
40 | 'The client must authenticate itself to get the requested response.',
41 | },
42 | 403: {
43 | title: 'Forbidden',
44 | description:
45 | 'The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource.',
46 | },
47 | 404: {
48 | title: 'Not found',
49 | description:
50 | 'This URL may be broken. Link Roamer sent 5 requess and could not get a response. It is best to double check, as sometimes valid sites block requests.',
51 | },
52 | 405: {
53 | title: 'Method Not Allowed',
54 | description:
55 | 'This is a valid URL but the request method used is not supported.',
56 | },
57 | 406: {
58 | title: 'Not Acceptable',
59 | description:
60 | "This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent.",
61 | },
62 | 407: {
63 | title: 'Proxy Authentication Required',
64 | description:
65 | 'This is similar to 401 Unauthorized but authentication is needed to be done by a proxy.',
66 | },
67 | 408: {
68 | title: 'Request Timeout',
69 | description:
70 | 'This response is sent on an idle connection by some servers, even without any previous request by the client.',
71 | },
72 | 409: {
73 | title: 'Conflict',
74 | description: 'A request conflicts with the current state of the server.',
75 | },
76 | 410: {
77 | title: 'Gone',
78 | description:
79 | 'This response is sent when the requested content has been permanently deleted from server, with no forwarding address.',
80 | },
81 | 411: {
82 | title: 'Length Required',
83 | description:
84 | 'Server rejected the request because the Content-Length header field is not defined and the server requires it.',
85 | },
86 | 412: {
87 | title: 'Precondition Failed',
88 | description:
89 | 'The client has indicated preconditions in its headers which the server does not meet.',
90 | },
91 | 413: {
92 | title: 'Payload Too Large',
93 | description:
94 | 'Request entity is larger than limits defined by server. The server might close the connection or return an Retry-After header field.',
95 | },
96 | 414: {
97 | title: 'URI Too Long',
98 | description:
99 | 'The URI requested by the client is longer than the server is willing to interpret.',
100 | },
101 | 415: {
102 | title: 'Unsupported Media Type',
103 | description:
104 | 'The media format of the requested data is not supported by the server, so the server is rejecting the request.',
105 | },
106 | 416: {
107 | title: 'Range Not Satisfiable',
108 | description:
109 | "The range specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target URI's data.",
110 | },
111 | 417: {
112 | title: 'Expectation Failed',
113 | description:
114 | 'This response code means the expectation indicated by the Expect request header field cannot be met by the server.',
115 | },
116 | 418: {
117 | title: "I'm a teapot",
118 | description: 'The server refuses the attempt to brew coffee with a teapot.',
119 | },
120 | 421: {
121 | title: 'Misdirected Request',
122 | description:
123 | 'The request was directed at a server that is not able to produce a response.',
124 | },
125 | 422: {
126 | title: 'Unprocessable Entity (WebDAV)',
127 | description:
128 | 'The request was well-formed but was unable to be followed due to semantic errors.',
129 | },
130 | 423: {
131 | title: '423 Locked (WebDAV)',
132 | description: 'The resource that is being accessed is locked.',
133 | },
134 | 424: {
135 | title: 'Failed Dependency (WebDAV)',
136 | description: 'The request failed due to failure of a previous request.',
137 | },
138 | 425: {
139 | title: 'Too Early Experimental',
140 | description:
141 | 'Indicates that the server is unwilling to risk processing a request that might be replayed.',
142 | },
143 | 426: {
144 | title: 'Upgrade Required',
145 | description:
146 | 'The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol.',
147 | },
148 | 428: {
149 | title: 'Precondition Required',
150 | description: 'The origin server requires the request to be conditional.',
151 | },
152 | 429: {
153 | title: 'Too Many Requests',
154 | description:
155 | 'The user has sent too many requests in a given amount of time ("rate limiting").',
156 | },
157 | 431: {
158 | title: 'Request Header Fields Too Large',
159 | description:
160 | 'The server is unwilling to process the request because its header fields are too large.',
161 | },
162 | 451: {
163 | title: 'Unavailable For Legal Reasons',
164 | description:
165 | 'The user agent requested a resource that cannot legally be provided, such as a web page censored by a government.',
166 | },
167 | 500: {
168 | title: 'Internal Server Error',
169 | description:
170 | 'The server has encountered a situation it does not know how to handle.',
171 | },
172 | 501: {
173 | title: 'Not Implemented',
174 | description:
175 | 'The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that must not return this code) are GET and HEAD.',
176 | },
177 | 502: {
178 | title: 'Bad Gateway',
179 | description:
180 | 'This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.',
181 | },
182 | 503: {
183 | title: 'Service Unavailable',
184 | description:
185 | 'The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded.',
186 | },
187 | 504: {
188 | title: 'Gateway Timeout',
189 | description:
190 | 'This error response is given when the server is acting as a gateway and cannot get a response in time.',
191 | },
192 | 505: {
193 | title: 'HTTP Version Not Supported',
194 | description:
195 | 'The HTTP version used in the request is not supported by the server.',
196 | },
197 | 506: {
198 | title: 'Variant Also Negotiates',
199 | description:
200 | 'The server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process.',
201 | },
202 | 507: {
203 | title: 'Insufficient Storage (WebDAV)',
204 | description:
205 | 'The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.',
206 | },
207 | 508: {
208 | title: 'Loop Detected (WebDAV)',
209 | description:
210 | 'The server detected an infinite loop while processing the request.',
211 | },
212 | 510: {
213 | title: 'Not Extended',
214 | description:
215 | 'Further extensions to the request are required for the server to fulfill it.',
216 | },
217 | 511: {
218 | title: 'Network Authentication Required',
219 | description:
220 | 'Indicates that the client needs to authenticate to gain network access.',
221 | },
222 | 999: {
223 | title: 'Custom Status',
224 | description:
225 | 'A non-standard code is returned by some sites (e.g. LinkedIn) which do not permit scanning.',
226 | },
227 | }
228 |
--------------------------------------------------------------------------------