Skip to content

Commit 8137c9a

Browse files
committed
feat: text-fragments implementation
1 parent 51736d5 commit 8137c9a

File tree

6 files changed

+125
-10
lines changed

6 files changed

+125
-10
lines changed

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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useBasename, useHref, useNavigation } from "../brouther/brouther";
44
import type { QueryString } from "../types/query-string";
55
import { AnyJson } from "../types";
66
import { Paths } from "../types/paths";
7+
import { TextFragment } from "../utils/text-fragment";
78

89
const isLeftClick = (e: React.MouseEvent) => e.button === 0;
910

@@ -18,20 +19,21 @@ export type LinkProps<Path extends string> = Omit<AnchorProps, "href" | "onClick
1819
href: Path;
1920
state?: AnyJson;
2021
replace?: boolean;
22+
fragments?: TextFragment[];
2123
onClick?: (event: Parameters<NonNullable<AnchorProps["onClick"]>>[0], pathAndQuery: QueryAndPaths<Path>) => void;
2224
} & QueryAndPaths<Path>;
2325

2426
const httpRegex = /^https?:\/\//;
2527

2628
export const Link: <TPath extends string>(props: LinkProps<TPath>) => React.ReactElement = forwardRef(
2729
<TPath extends string>(
28-
{ href, state, replace = false, onClick, query, paths, ...props }: LinkProps<TPath>,
30+
{ href, state, replace = false, onClick, query, paths, fragments, ...props }: LinkProps<TPath>,
2931
ref: React.Ref<HTMLAnchorElement>
3032
) => {
3133
const { push, replace: _replace } = useNavigation();
3234
const contextHref = useHref();
3335
const basename = useBasename();
34-
const _href = httpRegex.test(href) ? href : join(basename, mergeUrlEntities(href, paths, query));
36+
const _href = httpRegex.test(href) ? href : join(basename, mergeUrlEntities(href, paths, query, undefined, fragments));
3537
const _onClick: NonNullable<AnchorProps["onClick"]> = (event) => {
3638
if (props.target === undefined && props.target !== "_self") event.preventDefault();
3739
if (_href === contextHref) return;

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/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)