Skip to content

Commit c5b6aac

Browse files
committed
Merge pull request #42 from g4rcez/link-features
2 parents 86c3f98 + 98247b1 commit c5b6aac

File tree

11 files changed

+837
-2147
lines changed

11 files changed

+837
-2147
lines changed

docs/pnpm-lock.yaml

Lines changed: 0 additions & 1427 deletions
This file was deleted.

package-lock.json

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

package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "brouther",
33
"type": "module",
4-
"version": "4.4.1",
4+
"version": "4.4.2",
55
"source": "./src/index.ts",
66
"types": "./dist/index.d.ts",
77
"main": "./dist/index.js",
@@ -48,17 +48,17 @@
4848
"react": ">=16.8.3"
4949
},
5050
"devDependencies": {
51-
"@types/node": "18.11.19",
52-
"@types/qs": "6.9.7",
53-
"@types/react": "18.0.27",
54-
"cypress": "12.10.0",
55-
"prettier": "2.8.8",
51+
"@types/node": "^20.5.9",
52+
"@types/qs": "^6.9.8",
53+
"@types/react": "^18.2.21",
54+
"cypress": "^13.1.0",
55+
"prettier": "^3.0.3",
5656
"start-server-and-test": "2.0.0",
5757
"ts-node": "10.9.1",
58-
"tslib": "2.5.0",
59-
"typescript": "5.0.4",
60-
"vite": "4.3.9",
61-
"vitest": "0.30.1"
58+
"tslib": "^2.6.2",
59+
"typescript": "^5.2.2",
60+
"vite": "^4.4.9",
61+
"vitest": "^0.34.3"
6262
},
6363
"browserslist": {
6464
"production": [

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ export { createFormPath } from "./form/form-path";
5050
export { redirectResponse, jsonResponse, type InferLoader, type CustomResponse } from "./brouther/brouther-response";
5151
export { waitFor, Scroll, useScroll } from "./brouther/scroll";
5252
export { Await } from "./brouther/await";
53+
export { parseTextFragment } from "./utils/text-fragment";

src/router/link.tsx

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,47 @@
11
import React, { forwardRef } from "react";
22
import { join, mergeUrlEntities } from "../utils/utils";
33
import { useBasename, useHref, useNavigation } from "../brouther/brouther";
4-
import type { Paths } from "../types/paths";
54
import type { QueryString } from "../types/query-string";
65
import { AnyJson } from "../types";
6+
import { Paths } from "../types/paths";
7+
import { TextFragment } from "../utils/text-fragment";
8+
import { QueryStringMapper } from "../utils/mappers";
79

810
const isLeftClick = (e: React.MouseEvent) => e.button === 0;
911

1012
const isMod = (event: React.MouseEvent): boolean => event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
1113

1214
type AnchorProps = React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
1315

14-
export type LinkProps<Path extends string> = AnchorProps & {
16+
type QueryAndPaths<Path extends string> = (Paths.Has<Path> extends true ? { paths: Paths.Parse<Path> } : { paths?: never }) &
17+
(QueryString.Has<Path> extends true ? { query: NonNullable<QueryString.Parse<Path>> } : { query?: never });
18+
19+
export type LinkProps<Path extends string> = Omit<AnchorProps, "href" | "onClick"> & {
20+
fragments?: TextFragment[];
1521
href: Path;
16-
state?: AnyJson;
22+
onClick?: (event: Parameters<NonNullable<AnchorProps["onClick"]>>[0], pathAndQuery: QueryAndPaths<Path>) => void;
23+
parsers?: Partial<QueryStringMapper<string>>;
1724
replace?: boolean;
18-
} & (Paths.Parse<Paths.Pathname<Path>> extends null
19-
? { paths?: undefined }
20-
: Omit<Path, string> extends string
21-
? { paths: Paths.Parse<Paths.Pathname<Path>> }
22-
: { paths?: Paths.Parse<Paths.Pathname<Path>> }) &
23-
(QueryString.Has<Path> extends false
24-
? { query?: undefined }
25-
: QueryString.HasRequired<Path> extends true
26-
? { query: QueryString.Parse<Path> }
27-
: { query?: QueryString.Parse<Path> });
25+
state?: AnyJson;
26+
} & QueryAndPaths<Path>;
2827

2928
const httpRegex = /^https?:\/\//;
3029

31-
export const Link: <TPath extends string>(props: LinkProps<TPath>, ref: React.MutableRefObject<HTMLAnchorElement>) => JSX.Element = forwardRef(
32-
<TPath extends string>({ href, state, replace = false, onClick, query, paths, ...props }: LinkProps<TPath>, ref: any) => {
30+
export const Link: <TPath extends string>(props: LinkProps<TPath>) => React.ReactElement = forwardRef(
31+
<TPath extends string>(
32+
{ href, state, replace = false, onClick, parsers, query, paths, fragments, ...props }: LinkProps<TPath>,
33+
ref: React.Ref<HTMLAnchorElement>
34+
) => {
3335
const { push, replace: _replace } = useNavigation();
3436
const contextHref = useHref();
3537
const basename = useBasename();
36-
const _href = httpRegex.test(href) ? href : join(basename, mergeUrlEntities(href, paths, query));
37-
const _onClick: NonNullable<typeof onClick> = (event) => {
38+
const _href = httpRegex.test(href) ? href : join(basename, mergeUrlEntities(href, paths, query, parsers, fragments));
39+
const _onClick: NonNullable<AnchorProps["onClick"]> = (event) => {
3840
if (props.target === undefined && props.target !== "_self") event.preventDefault();
3941
if (_href === contextHref) return;
4042
if (!isLeftClick(event)) return;
4143
if (isMod(event)) return;
42-
onClick?.(event);
44+
onClick?.(event, { query, paths } as QueryAndPaths<TPath>);
4345
return replace ? _replace(_href, state) : push(_href, state);
4446
};
4547
return <a {...props} href={_href} onClick={_onClick} ref={ref} />;

src/types/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Paths } from "./paths";
66
import { BrowserHistory } from "./history";
77
import { X } from "./x";
88
import { CustomResponse } from "../brouther/brouther-response";
9+
import { TextFragment } from "../utils/text-fragment";
910

1011
export type RouteData = { [k in string]: unknown } | {};
1112

@@ -62,18 +63,19 @@ export type CreateHref<T extends readonly Route[]> = <
6263
const Path extends FetchPaths<T>,
6364
const Qs extends QueryString.Parse<Path>,
6465
const Params extends Paths.Parse<Path>,
65-
const QueryStringParsers extends QueryString.ParseURL<Path>
66+
const QueryStringParsers extends QueryString.ParseURL<Path>,
67+
const TextFragments extends TextFragment[],
6668
>(
6769
...args: Paths.Parse<Path> extends null
6870
? QueryString.Has<Path> extends true
6971
? QueryString.HasRequired<Path> extends true
70-
? readonly [path: Path, qs: Qs, parsers?: QueryStringParsers]
71-
: readonly [path: Path, qs?: Qs, parsers?: QueryStringParsers]
72+
? readonly [path: Path, qs: Qs, parsers?: QueryStringParsers, textFragments?: TextFragments]
73+
: readonly [path: Path, qs?: Qs, parsers?: QueryStringParsers, textFragments?: TextFragments]
7274
: readonly [path: Path]
7375
: QueryString.Has<Path> extends true
7476
? QueryString.HasRequired<Path> extends true
75-
? readonly [path: Path, params: Params, qs: Qs, parsers?: QueryStringParsers]
76-
: readonly [path: Path, params: Params, qs?: Qs, parsers?: QueryStringParsers]
77+
? readonly [path: Path, params: Params, qs: Qs, parsers?: QueryStringParsers, textFragments?: TextFragments]
78+
: readonly [path: Path, params: Params, qs?: Qs, parsers?: QueryStringParsers, textFragments?: TextFragments]
7779
: readonly [path: Path, params: Paths.Parse<Path>]
7880
) => Paths.Parse<Path> extends null
7981
? QueryString.Assign<Path, NonNullable<Qs>>

src/types/paths.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ export namespace Paths {
5757
Params,
5858
Number.Add<I, 1>
5959
>;
60+
61+
export type Has<T extends string> = T extends `${string}/:${string}` ? true : T extends `${string}/<:${string}` ? true : false;
6062
}

src/utils/text-fragment.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type TextFragment = {
2+
text: string;
3+
prefix?: string;
4+
suffix?: string;
5+
};
6+
7+
const regex = /(?<prefix>\w+-,)?.*?(?<suffix>,-\w+)?/;
8+
9+
const specialText = (str: string): TextFragment => {
10+
const groups = (regex.exec(str) as any)?.groups;
11+
if (!groups) return { text: str };
12+
const text = str.replace(groups.prefix || "", "").replace(groups.suffix || "", "");
13+
const prefix = groups.prefix?.replace(/-$/, "");
14+
const suffix = groups.suffix?.replace(/^-/, "");
15+
return { text, prefix, suffix };
16+
};
17+
18+
export const TEXT_FRAGMENT_ID = ":~:";
19+
20+
const hasTextIdentifier = (url: string) => url.includes(TEXT_FRAGMENT_ID);
21+
22+
export const stringifyTextFragment = (textFragments: TextFragment[]) =>
23+
textFragments
24+
.map((x) => {
25+
let text = "text=";
26+
if (x.prefix) text += `${x.prefix}-,`;
27+
text += `${x.text}`;
28+
if (x.suffix) text += `,-${x.suffix}`;
29+
return text;
30+
})
31+
.join("&");
32+
33+
export const parseTextFragment = (url: string): TextFragment[] | null => {
34+
if (!("fragmentDirective" in document)) {
35+
return null;
36+
}
37+
const u = new URL(url);
38+
const hash = u.hash;
39+
const textFragment = hash.split(TEXT_FRAGMENT_ID)[1];
40+
if (!textFragment) return null;
41+
return textFragment.split("&").reduce<TextFragment[]>((acc, el) => {
42+
const v = el.split("=")[1] || "";
43+
const text = decodeURIComponent(v);
44+
return [...acc, v.includes(",") ? specialText(text) : { text }];
45+
}, []);
46+
};

src/utils/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fromValueToString, QueryStringMapper } from "./mappers";
33
import type { Paths } from "../types/paths";
44
import type { QueryString } from "../types/query-string";
55
import { X } from "../types/x";
6+
import { stringifyTextFragment, TEXT_FRAGMENT_ID, TextFragment } from "./text-fragment";
67

78
export const has = <T extends {}, K extends X.AnyString<keyof T>>(o: T, k: K): k is K => Object.prototype.hasOwnProperty.call(o, k as any);
89

@@ -13,13 +14,22 @@ const replaceUrlParams = <Path extends string, Keys extends Paths.Parse<Path>>(p
1314
.replace(/:(\w+)/g, (_, b) => `${(keys as any)[b]}`);
1415
};
1516

16-
export const mergeUrlEntities = (url: string, params: any | undefined, qs: any | undefined, parsers?: Partial<QueryStringMapper<string>>) => {
17+
export const mergeUrlEntities = (
18+
url: string,
19+
params: any | undefined,
20+
qs: any | undefined,
21+
parsers: Partial<QueryStringMapper<string>> | undefined,
22+
textFragment: TextFragment[] | undefined
23+
) => {
1724
const u = urlEntity(url);
1825
const path = u.pathname;
1926
const withParams = replaceUrlParams(path, params);
2027
const queryString = qs === undefined ? "" : qsToString(url, qs, parsers);
2128
const href = queryString === "" ? withParams : `${withParams}?${queryString}`;
22-
return u.hash ? `${href}#${u.hash}` : href;
29+
const hasFragments = textFragment !== undefined && textFragment.length >= 1;
30+
return u.hash || hasFragments
31+
? `${href}${u.hash || "#"}${hasFragments ? `${TEXT_FRAGMENT_ID}${stringifyTextFragment(textFragment!)}` : ""}`
32+
: href;
2333
};
2434

2535
export const trailingOptionalPath = (str: string) => str.replace(/\/+$/g, "/?");
@@ -103,7 +113,7 @@ export const qsToString = <Path extends string, T extends QueryString.Map>(
103113
export const createLink =
104114
<T extends readonly Route[]>(_routes: T): CreateHref<T> =>
105115
(...args: any): any =>
106-
mergeUrlEntities(args[0], args[1], args[2], args[3]) as never;
116+
mergeUrlEntities(args[0], args[1], args[2], args[3], args[4]) as never;
107117

108118
const rankBinds = (path: string) => path.split(":").length * 5;
109119

tests/create-url.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "vitest";
2+
import { mergeUrlEntities } from "../src";
3+
4+
describe("Should test mergeUrlEntities utility", () => {
5+
test("Should create a simple url", () => {
6+
const url = mergeUrlEntities("/", {}, {}, undefined, []);
7+
expect(url).toBe("/");
8+
});
9+
10+
test("Should create a params url", () => {
11+
const url = mergeUrlEntities("/:id", { id: "1" }, {}, undefined, []);
12+
expect(url).toBe("/1");
13+
});
14+
15+
test("Should create a query string url", () => {
16+
const url = mergeUrlEntities("/?name=string", {}, { name: "name" }, undefined, []);
17+
expect(url).toBe("/?name=name");
18+
});
19+
20+
test("Should create a query string url", () => {
21+
const url = mergeUrlEntities("/?name=string", {}, { name: "name" }, undefined, []);
22+
expect(url).toBe("/?name=name");
23+
});
24+
25+
test("Should create a text-fragment url with hash", () => {
26+
const url = mergeUrlEntities("/#hash", {}, {}, undefined, [{ text: "test" }]);
27+
expect(url).toBe("/#hash:~:text=test");
28+
});
29+
30+
test("Should create a text-fragment url without hash", () => {
31+
const url = mergeUrlEntities("/", {}, {}, undefined, [{ text: "test" }]);
32+
expect(url).toBe("/#:~:text=test");
33+
});
34+
35+
test("Should create multiple text-fragment url", () => {
36+
const url = mergeUrlEntities("/", {}, {}, undefined, [{ text: "test" }, { text: "fragments" }]);
37+
expect(url).toBe("/#:~:text=test&text=fragments");
38+
});
39+
40+
test("Should create a text-fragment using prefix", () => {
41+
const url = mergeUrlEntities("/", {}, {}, undefined, [{ text: "test", prefix: "property" }]);
42+
expect(url).toBe("/#:~:text=property-,test");
43+
});
44+
45+
test("Should create a text-fragment using suffix", () => {
46+
const url = mergeUrlEntities("/", {}, {}, undefined, [{ text: "test", suffix: "property" }]);
47+
expect(url).toBe("/#:~:text=test,-property");
48+
});
49+
50+
test("Should create a text-fragment using both prefix and suffix", () => {
51+
const url = mergeUrlEntities("/", {}, {}, undefined, [{ text: "test", prefix: "prefix", suffix: "suffix" }]);
52+
expect(url).toBe("/#:~:text=prefix-,test,-suffix");
53+
});
54+
});

0 commit comments

Comments
 (0)