@docusaurus/utils 3.3.2 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/lib/cliUtils.js +1 -2
  2. package/lib/cliUtils.js.map +1 -1
  3. package/lib/contentVisibilityUtils.js +2 -3
  4. package/lib/contentVisibilityUtils.js.map +1 -1
  5. package/lib/dataFileUtils.d.ts +6 -7
  6. package/lib/dataFileUtils.d.ts.map +1 -1
  7. package/lib/dataFileUtils.js +14 -14
  8. package/lib/dataFileUtils.js.map +1 -1
  9. package/lib/emitUtils.d.ts +0 -1
  10. package/lib/emitUtils.d.ts.map +1 -1
  11. package/lib/emitUtils.js +2 -3
  12. package/lib/emitUtils.js.map +1 -1
  13. package/lib/gitUtils.js +2 -2
  14. package/lib/gitUtils.js.map +1 -1
  15. package/lib/globUtils.js +3 -3
  16. package/lib/globUtils.js.map +1 -1
  17. package/lib/hashUtils.d.ts +4 -1
  18. package/lib/hashUtils.d.ts.map +1 -1
  19. package/lib/hashUtils.js +12 -7
  20. package/lib/hashUtils.js.map +1 -1
  21. package/lib/i18nUtils.js +4 -5
  22. package/lib/i18nUtils.js.map +1 -1
  23. package/lib/index.d.ts +5 -4
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +10 -5
  26. package/lib/index.js.map +1 -1
  27. package/lib/jsUtils.js +2 -3
  28. package/lib/jsUtils.js.map +1 -1
  29. package/lib/lastUpdateUtils.js +4 -4
  30. package/lib/lastUpdateUtils.js.map +1 -1
  31. package/lib/markdownLinks.d.ts +7 -34
  32. package/lib/markdownLinks.d.ts.map +1 -1
  33. package/lib/markdownLinks.js +21 -104
  34. package/lib/markdownLinks.js.map +1 -1
  35. package/lib/markdownUtils.js +12 -12
  36. package/lib/markdownUtils.js.map +1 -1
  37. package/lib/moduleUtils.js +1 -2
  38. package/lib/moduleUtils.js.map +1 -1
  39. package/lib/pathUtils.js +8 -8
  40. package/lib/pathUtils.js.map +1 -1
  41. package/lib/regExpUtils.js +1 -2
  42. package/lib/regExpUtils.js.map +1 -1
  43. package/lib/routeUtils.js +1 -2
  44. package/lib/routeUtils.js.map +1 -1
  45. package/lib/shellUtils.js +1 -2
  46. package/lib/shellUtils.js.map +1 -1
  47. package/lib/slugger.js +1 -2
  48. package/lib/slugger.js.map +1 -1
  49. package/lib/tags.d.ts +35 -18
  50. package/lib/tags.d.ts.map +1 -1
  51. package/lib/tags.js +58 -30
  52. package/lib/tags.js.map +1 -1
  53. package/lib/urlUtils.d.ts +14 -0
  54. package/lib/urlUtils.d.ts.map +1 -1
  55. package/lib/urlUtils.js +76 -30
  56. package/lib/urlUtils.js.map +1 -1
  57. package/lib/webpackUtils.js +2 -3
  58. package/lib/webpackUtils.js.map +1 -1
  59. package/package.json +6 -5
  60. package/src/dataFileUtils.ts +12 -14
  61. package/src/hashUtils.ts +20 -3
  62. package/src/index.ts +12 -4
  63. package/src/markdownLinks.ts +32 -151
  64. package/src/markdownUtils.ts +2 -2
  65. package/src/tags.ts +122 -36
  66. package/src/urlUtils.ts +76 -22
@@ -40,159 +40,40 @@ export type BrokenMarkdownLink<T extends ContentPaths> = {
40
40
  link: string;
41
41
  };
42
42
 
43
- type CodeFence = {
44
- type: '`' | '~';
45
- definitelyOpen: boolean;
46
- count: number;
47
- };
43
+ export type SourceToPermalink = Map<
44
+ string, // Aliased source path: "@site/docs/content.mdx"
45
+ string // Permalink: "/docs/content"
46
+ >;
48
47
 
49
- function parseCodeFence(line: string): CodeFence | null {
50
- const match = line.trim().match(/^(?<fence>`{3,}|~{3,})(?<rest>.*)/);
51
- if (!match) {
52
- return null;
48
+ // Note this is historical logic extracted during a 2024 refactor
49
+ // The algo has been kept exactly as before for retro compatibility
50
+ // See also https://github.com/facebook/docusaurus/pull/10168
51
+ export function resolveMarkdownLinkPathname(
52
+ linkPathname: string,
53
+ context: {
54
+ sourceFilePath: string;
55
+ sourceToPermalink: SourceToPermalink;
56
+ contentPaths: ContentPaths;
57
+ siteDir: string;
58
+ },
59
+ ): string | null {
60
+ const {sourceFilePath, sourceToPermalink, contentPaths, siteDir} = context;
61
+ const sourceDirsToTry: string[] = [];
62
+ // ./file.md and ../file.md are always relative to the current file
63
+ if (!linkPathname.startsWith('./') && !linkPathname.startsWith('../')) {
64
+ sourceDirsToTry.push(...getContentPathList(contentPaths), siteDir);
65
+ }
66
+ // /file.md is never relative to the source file path
67
+ if (!linkPathname.startsWith('/')) {
68
+ sourceDirsToTry.push(path.dirname(sourceFilePath));
53
69
  }
54
- return {
55
- type: match.groups!.fence![0]! as '`' | '~',
56
- definitelyOpen: !!match.groups!.rest!,
57
- count: match.groups!.fence!.length,
58
- };
59
- }
60
-
61
- /**
62
- * Takes a Markdown file and replaces relative file references with their URL
63
- * counterparts, e.g. `[link](./intro.md)` => `[link](/docs/intro)`, preserving
64
- * everything else.
65
- *
66
- * This method uses best effort to find a matching file. The file reference can
67
- * be relative to the directory of the current file (most likely) or any of the
68
- * content paths (so `/tutorials/intro.md` can be resolved as
69
- * `<siteDir>/docs/tutorials/intro.md`). Links that contain the `http(s):` or
70
- * `@site/` prefix will always be ignored.
71
- */
72
- export function replaceMarkdownLinks<T extends ContentPaths>({
73
- siteDir,
74
- fileString,
75
- filePath,
76
- contentPaths,
77
- sourceToPermalink,
78
- }: {
79
- /** Absolute path to the site directory, used to resolve aliased paths. */
80
- siteDir: string;
81
- /** The Markdown file content to be processed. */
82
- fileString: string;
83
- /** Absolute path to the current file containing `fileString`. */
84
- filePath: string;
85
- /** The content paths which the file reference may live in. */
86
- contentPaths: T;
87
- /**
88
- * A map from source paths to their URLs. Source paths are `@site` aliased.
89
- */
90
- sourceToPermalink: {[aliasedPath: string]: string};
91
- }): {
92
- /**
93
- * The content with all Markdown file references replaced with their URLs.
94
- * Unresolved links are left as-is.
95
- */
96
- newContent: string;
97
- /** The list of broken links, */
98
- brokenMarkdownLinks: BrokenMarkdownLink<T>[];
99
- } {
100
- const brokenMarkdownLinks: BrokenMarkdownLink<T>[] = [];
101
-
102
- // Replace internal markdown linking (except in fenced blocks).
103
- let lastOpenCodeFence: CodeFence | null = null;
104
- const lines = fileString.split('\n').map((line) => {
105
- const codeFence = parseCodeFence(line);
106
- if (codeFence) {
107
- if (!lastOpenCodeFence) {
108
- lastOpenCodeFence = codeFence;
109
- } else if (
110
- !codeFence.definitelyOpen &&
111
- lastOpenCodeFence.type === codeFence.type &&
112
- lastOpenCodeFence.count <= codeFence.count
113
- ) {
114
- // All three conditions must be met in order for this to be considered
115
- // a closing fence.
116
- lastOpenCodeFence = null;
117
- }
118
- }
119
- if (lastOpenCodeFence) {
120
- return line;
121
- }
122
-
123
- let modifiedLine = line;
124
- // Replace inline-style links or reference-style links e.g:
125
- // This is [Document 1](doc1.md)
126
- // [doc1]: doc1.md
127
- const linkTitlePattern = '(?:\\s+(?:\'.*?\'|".*?"|\\(.*?\\)))?';
128
- const linkSuffixPattern = '(?:\\?[^#>\\s]+)?(?:#[^>\\s]+)?';
129
- const linkCapture = (forbidden: string) =>
130
- `((?!https?://|@site/)[^${forbidden}#?]+)`;
131
- const linkURLPattern = `(?:(?!<)${linkCapture(
132
- '()\\s',
133
- )}${linkSuffixPattern}|<${linkCapture('>')}${linkSuffixPattern}>)`;
134
- const linkPattern = new RegExp(
135
- `\\[(?:(?!\\]\\().)*\\]\\(\\s*${linkURLPattern}${linkTitlePattern}\\s*\\)|^\\s*\\[[^[\\]]*[^[\\]\\s][^[\\]]*\\]:\\s*${linkURLPattern}${linkTitlePattern}$`,
136
- 'dgm',
137
- );
138
- let mdMatch = linkPattern.exec(modifiedLine);
139
- while (mdMatch !== null) {
140
- // Replace it to correct html link.
141
- const mdLink = mdMatch.slice(1, 5).find(Boolean)!;
142
- const mdLinkRange = mdMatch.indices!.slice(1, 5).find(Boolean)!;
143
- if (!/\.mdx?$/.test(mdLink)) {
144
- mdMatch = linkPattern.exec(modifiedLine);
145
- continue;
146
- }
147
-
148
- const sourcesToTry: string[] = [];
149
- // ./file.md and ../file.md are always relative to the current file
150
- if (!mdLink.startsWith('./') && !mdLink.startsWith('../')) {
151
- sourcesToTry.push(...getContentPathList(contentPaths), siteDir);
152
- }
153
- // /file.md is always relative to the content path
154
- if (!mdLink.startsWith('/')) {
155
- sourcesToTry.push(path.dirname(filePath));
156
- }
157
-
158
- const aliasedSourceMatch = sourcesToTry
159
- .map((p) => path.join(p, decodeURIComponent(mdLink)))
160
- .map((source) => aliasedSitePath(source, siteDir))
161
- .find((source) => sourceToPermalink[source]);
162
-
163
- const permalink: string | undefined = aliasedSourceMatch
164
- ? sourceToPermalink[aliasedSourceMatch]
165
- : undefined;
166
-
167
- if (permalink) {
168
- // MDX won't be happy if the permalink contains a space, we need to
169
- // convert it to %20
170
- const encodedPermalink = permalink
171
- .split('/')
172
- .map((part) => part.replace(/\s/g, '%20'))
173
- .join('/');
174
- modifiedLine = `${modifiedLine.slice(
175
- 0,
176
- mdLinkRange[0],
177
- )}${encodedPermalink}${modifiedLine.slice(mdLinkRange[1])}`;
178
- // Adjust the lastIndex to avoid passing over the next link if the
179
- // newly replaced URL is shorter.
180
- linkPattern.lastIndex += encodedPermalink.length - mdLink.length;
181
- } else {
182
- const brokenMarkdownLink: BrokenMarkdownLink<T> = {
183
- contentPaths,
184
- filePath,
185
- link: mdLink,
186
- };
187
-
188
- brokenMarkdownLinks.push(brokenMarkdownLink);
189
- }
190
- mdMatch = linkPattern.exec(modifiedLine);
191
- }
192
- return modifiedLine;
193
- });
194
70
 
195
- const newContent = lines.join('\n');
71
+ const aliasedSourceMatch = sourceDirsToTry
72
+ .map((sourceDir) => path.join(sourceDir, decodeURIComponent(linkPathname)))
73
+ .map((source) => aliasedSitePath(source, siteDir))
74
+ .find((source) => sourceToPermalink.has(source));
196
75
 
197
- return {newContent, brokenMarkdownLinks};
76
+ return aliasedSourceMatch
77
+ ? sourceToPermalink.get(aliasedSourceMatch) ?? null
78
+ : null;
198
79
  }
@@ -70,9 +70,9 @@ export function escapeMarkdownHeadingIds(content: string): string {
70
70
  export function unwrapMdxCodeBlocks(content: string): string {
71
71
  // We only support 3/4 backticks on purpose, should be good enough
72
72
  const regexp3 =
73
- /(?<begin>^|\r?\n)```(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n```(?<end>\r?\n|$)/gs;
73
+ /(?<begin>^|\r?\n)(?<indentStart>\x20*)```(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n(?<indentEnd>\x20*)```(?<end>\r?\n|$)/gs;
74
74
  const regexp4 =
75
- /(?<begin>^|\r?\n)````(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n````(?<end>\r?\n|$)/gs;
75
+ /(?<begin>^|\r?\n)(?<indentStart>\x20*)````(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n(?<indentEnd>\x20*)````(?<end>\r?\n|$)/gs;
76
76
 
77
77
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
78
  const replacer = (substring: string, ...args: any[]) => {
package/src/tags.ts CHANGED
@@ -6,13 +6,34 @@
6
6
  */
7
7
 
8
8
  import _ from 'lodash';
9
+ import logger from '@docusaurus/logger';
9
10
  import {normalizeUrl} from './urlUtils';
11
+ import type {Optional} from 'utility-types';
10
12
 
11
- /** What the user configures. */
12
13
  export type Tag = {
14
+ /** The display label of a tag */
13
15
  label: string;
14
16
  /** Permalink to this tag's page, without the `/tags/` base path. */
15
17
  permalink: string;
18
+ /** An optional description of the tag */
19
+ description: string | undefined;
20
+ };
21
+
22
+ export type TagsFileInput = Record<string, Partial<Tag> | null>;
23
+
24
+ export type TagsFile = Record<string, Tag>;
25
+
26
+ // Tags plugins options shared between docs/blog
27
+ export type TagsPluginOptions = {
28
+ // TODO allow option tags later? | TagsFile;
29
+ /** Path to the tags file. */
30
+ tags: string | false | null | undefined;
31
+ /** The behavior of Docusaurus when it finds inline tags. */
32
+ onInlineTags: 'ignore' | 'log' | 'warn' | 'throw';
33
+ };
34
+
35
+ export type TagMetadata = Tag & {
36
+ inline: boolean;
16
37
  };
17
38
 
18
39
  /** What the tags list page should know about each tag. */
@@ -24,67 +45,132 @@ export type TagsListItem = Tag & {
24
45
  /** What the tag's own page should know about the tag. */
25
46
  export type TagModule = TagsListItem & {
26
47
  /** The tags list page's permalink. */
48
+ // TODO move this global value to a shared docs/blog bundle
27
49
  allTagsPath: string;
28
50
  /** Is this tag unlisted? (when it only contains unlisted items) */
29
51
  unlisted: boolean;
30
52
  };
31
53
 
32
- export type FrontMatterTag = string | Tag;
54
+ export type FrontMatterTag = string | Optional<Tag, 'description'>;
33
55
 
34
- function normalizeFrontMatterTag(
35
- tagsPath: string,
56
+ // We always apply tagsBaseRoutePath on purpose. For versioned docs, v1/doc.md
57
+ // and v2/doc.md tags with custom permalinks don't lead to the same created
58
+ // page. tagsBaseRoutePath is different for each doc version
59
+ function normalizeTagPermalink({
60
+ tagsBaseRoutePath,
61
+ permalink,
62
+ }: {
63
+ tagsBaseRoutePath: string;
64
+ permalink: string;
65
+ }): string {
66
+ return normalizeUrl([tagsBaseRoutePath, permalink]);
67
+ }
68
+
69
+ function normalizeInlineTag(
70
+ tagsBaseRoutePath: string,
36
71
  frontMatterTag: FrontMatterTag,
37
- ): Tag {
38
- function toTagObject(tagString: string): Tag {
72
+ ): TagMetadata {
73
+ function toTagObject(tagString: string): TagMetadata {
39
74
  return {
75
+ inline: true,
40
76
  label: tagString,
41
77
  permalink: _.kebabCase(tagString),
78
+ description: undefined,
42
79
  };
43
80
  }
44
81
 
45
- // TODO maybe make ensure the permalink is valid url path?
46
- function normalizeTagPermalink(permalink: string): string {
47
- // Note: we always apply tagsPath on purpose. For versioned docs, v1/doc.md
48
- // and v2/doc.md tags with custom permalinks don't lead to the same created
49
- // page. tagsPath is different for each doc version
50
- return normalizeUrl([tagsPath, permalink]);
51
- }
52
-
53
82
  const tag: Tag =
54
83
  typeof frontMatterTag === 'string'
55
84
  ? toTagObject(frontMatterTag)
56
- : frontMatterTag;
85
+ : {...frontMatterTag, description: frontMatterTag.description};
57
86
 
58
87
  return {
88
+ inline: true,
59
89
  label: tag.label,
60
- permalink: normalizeTagPermalink(tag.permalink),
90
+ permalink: normalizeTagPermalink({
91
+ permalink: tag.permalink,
92
+ tagsBaseRoutePath,
93
+ }),
94
+ description: tag.description,
61
95
  };
62
96
  }
63
97
 
64
- /**
65
- * Takes tag objects as they are defined in front matter, and normalizes each
66
- * into a standard tag object. The permalink is created by appending the
67
- * sluggified label to `tagsPath`. Front matter tags already containing
68
- * permalinks would still have `tagsPath` prepended.
69
- *
70
- * The result will always be unique by permalinks. The behavior with colliding
71
- * permalinks is undetermined.
72
- */
73
- export function normalizeFrontMatterTags(
74
- /** Base path to append the tag permalinks to. */
75
- tagsPath: string,
76
- /** Can be `undefined`, so that we can directly pipe in `frontMatter.tags`. */
77
- frontMatterTags: FrontMatterTag[] | undefined = [],
78
- ): Tag[] {
79
- const tags = frontMatterTags.map((tag) =>
80
- normalizeFrontMatterTag(tagsPath, tag),
98
+ export function normalizeTag({
99
+ tag,
100
+ tagsFile,
101
+ tagsBaseRoutePath,
102
+ }: {
103
+ tag: FrontMatterTag;
104
+ tagsBaseRoutePath: string;
105
+ tagsFile: TagsFile | null;
106
+ }): TagMetadata {
107
+ if (typeof tag === 'string') {
108
+ const tagDescription = tagsFile?.[tag];
109
+ if (tagDescription) {
110
+ // pre-defined tag from tags.yml
111
+ return {
112
+ inline: false,
113
+ label: tagDescription.label,
114
+ permalink: normalizeTagPermalink({
115
+ permalink: tagDescription.permalink,
116
+ tagsBaseRoutePath,
117
+ }),
118
+ description: tagDescription.description,
119
+ };
120
+ }
121
+ }
122
+ // legacy inline tag object, always inline, unknown because isn't a string
123
+ return normalizeInlineTag(tagsBaseRoutePath, tag);
124
+ }
125
+
126
+ export function normalizeTags({
127
+ options,
128
+ source,
129
+ frontMatterTags,
130
+ tagsBaseRoutePath,
131
+ tagsFile,
132
+ }: {
133
+ options: TagsPluginOptions;
134
+ source: string;
135
+ frontMatterTags: FrontMatterTag[] | undefined;
136
+ tagsBaseRoutePath: string;
137
+ tagsFile: TagsFile | null;
138
+ }): TagMetadata[] {
139
+ const tags = (frontMatterTags ?? []).map((tag) =>
140
+ normalizeTag({tag, tagsBaseRoutePath, tagsFile}),
81
141
  );
142
+ if (tagsFile !== null) {
143
+ reportInlineTags({tags, source, options});
144
+ }
145
+ return tags;
146
+ }
82
147
 
83
- return _.uniqBy(tags, (tag) => tag.permalink);
148
+ export function reportInlineTags({
149
+ tags,
150
+ source,
151
+ options,
152
+ }: {
153
+ tags: TagMetadata[];
154
+ source: string;
155
+ options: TagsPluginOptions;
156
+ }): void {
157
+ if (options.onInlineTags === 'ignore') {
158
+ return;
159
+ }
160
+ const inlineTags = tags.filter((tag) => tag.inline);
161
+ if (inlineTags.length > 0) {
162
+ const uniqueUnknownTags = [...new Set(inlineTags.map((tag) => tag.label))];
163
+ const tagListString = uniqueUnknownTags.join(', ');
164
+ logger.report(options.onInlineTags)(
165
+ `Tags [${tagListString}] used in ${source} are not defined in ${
166
+ options.tags ?? 'tags.yml'
167
+ }`,
168
+ );
169
+ }
84
170
  }
85
171
 
86
172
  type TaggedItemGroup<Item> = {
87
- tag: Tag;
173
+ tag: TagMetadata;
88
174
  items: Item[];
89
175
  };
90
176
 
@@ -102,7 +188,7 @@ export function groupTaggedItems<Item>(
102
188
  * A callback telling me how to get the tags list of the current item. Usually
103
189
  * simply getting it from some metadata of the current item.
104
190
  */
105
- getItemTags: (item: Item) => readonly Tag[],
191
+ getItemTags: (item: Item) => readonly TagMetadata[],
106
192
  ): {[permalink: string]: TaggedItemGroup<Item>} {
107
193
  const result: {[permalink: string]: TaggedItemGroup<Item>} = {};
108
194
 
package/src/urlUtils.ts CHANGED
@@ -90,7 +90,7 @@ export function normalizeUrl(rawUrls: string[]): string {
90
90
  // first plain protocol part.
91
91
 
92
92
  // Remove trailing slash before parameters or hash.
93
- str = str.replace(/\/(?<search>\?|&|#[^!])/g, '$1');
93
+ str = str.replace(/\/(?<search>\?|&|#[^!/])/g, '$1');
94
94
 
95
95
  // Replace ? in parameters with &.
96
96
  const parts = str.split('?');
@@ -164,27 +164,22 @@ export function isValidPathname(str: string): boolean {
164
164
  }
165
165
  }
166
166
 
167
- export type URLPath = {pathname: string; search?: string; hash?: string};
168
-
169
- // Let's name the concept of (pathname + search + hash) as URLPath
170
- // See also https://twitter.com/kettanaito/status/1741768992866308120
171
- // Note: this function also resolves relative pathnames while parsing!
172
- export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
173
- function parseURL(url: string, base?: string | URL): URL {
174
- try {
175
- // A possible alternative? https://github.com/unjs/ufo#url
176
- return new URL(url, base ?? 'https://example.com');
177
- } catch (e) {
178
- throw new Error(
179
- `Can't parse URL ${url}${base ? ` with base ${base}` : ''}`,
180
- {cause: e},
181
- );
182
- }
167
+ export function parseURLOrPath(url: string, base?: string | URL): URL {
168
+ try {
169
+ // TODO when Node supports it, use URL.parse could be faster?
170
+ // see https://kilianvalkhof.com/2024/javascript/the-problem-with-new-url-and-how-url-parse-fixes-that/
171
+ return new URL(url, base ?? 'https://example.com');
172
+ } catch (e) {
173
+ throw new Error(
174
+ `Can't parse URL ${url}${base ? ` with base ${base}` : ''}`,
175
+ {cause: e},
176
+ );
183
177
  }
178
+ }
184
179
 
185
- const base = fromPath ? parseURL(fromPath) : undefined;
186
- const url = parseURL(urlPath, base);
180
+ export type URLPath = {pathname: string; search?: string; hash?: string};
187
181
 
182
+ export function toURLPath(url: URL): URLPath {
188
183
  const {pathname} = url;
189
184
 
190
185
  // Fixes annoying url.search behavior
@@ -193,17 +188,17 @@ export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
193
188
  // "?param => "param"
194
189
  const search = url.search
195
190
  ? url.search.slice(1)
196
- : urlPath.includes('?')
191
+ : url.href.includes('?')
197
192
  ? ''
198
193
  : undefined;
199
194
 
200
195
  // Fixes annoying url.hash behavior
201
196
  // "" => undefined
202
197
  // "#" => ""
203
- // "?param => "param"
198
+ // "#param => "param"
204
199
  const hash = url.hash
205
200
  ? url.hash.slice(1)
206
- : urlPath.includes('#')
201
+ : url.href.includes('#')
207
202
  ? ''
208
203
  : undefined;
209
204
 
@@ -214,6 +209,65 @@ export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
214
209
  };
215
210
  }
216
211
 
212
+ /**
213
+ * Let's name the concept of (pathname + search + hash) as URLPath
214
+ * See also https://twitter.com/kettanaito/status/1741768992866308120
215
+ * Note: this function also resolves relative pathnames while parsing!
216
+ */
217
+ export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
218
+ const base = fromPath ? parseURLOrPath(fromPath) : undefined;
219
+ const url = parseURLOrPath(urlPath, base);
220
+ return toURLPath(url);
221
+ }
222
+
223
+ /**
224
+ * This returns results for strings like "foo", "../foo", "./foo.mdx?qs#hash"
225
+ * Unlike "parseURLPath()" above, this will not resolve the pathnames
226
+ * Te returned pathname of "../../foo.mdx" will be "../../foo.mdx", not "/foo"
227
+ * This returns null if the url is not "local" (contains domain/protocol etc)
228
+ */
229
+ export function parseLocalURLPath(urlPath: string): URLPath | null {
230
+ // Workaround because URL("") requires a protocol
231
+ const unspecifiedProtocol = 'unspecified:';
232
+
233
+ const url = parseURLOrPath(urlPath, `${unspecifiedProtocol}//`);
234
+ // Ignore links with specified protocol / host
235
+ // (usually fully qualified links starting with https://)
236
+ if (
237
+ url.protocol !== unspecifiedProtocol ||
238
+ url.host !== '' ||
239
+ url.username !== '' ||
240
+ url.password !== ''
241
+ ) {
242
+ return null;
243
+ }
244
+
245
+ // We can't use "new URL()" result because it always tries to resolve urls
246
+ // IE it will remove any "./" or "../" in the pathname, which we don't want
247
+ // We have to parse it manually...
248
+ let localUrlPath = urlPath;
249
+
250
+ // Extract and remove the #hash part
251
+ const hashIndex = localUrlPath.indexOf('#');
252
+ const hash =
253
+ hashIndex !== -1 ? localUrlPath.substring(hashIndex + 1) : undefined;
254
+ localUrlPath =
255
+ hashIndex !== -1 ? localUrlPath.substring(0, hashIndex) : localUrlPath;
256
+
257
+ // Extract and remove ?search part
258
+ const searchIndex = localUrlPath.indexOf('?');
259
+ const search =
260
+ searchIndex !== -1 ? localUrlPath.substring(searchIndex + 1) : undefined;
261
+ localUrlPath =
262
+ searchIndex !== -1 ? localUrlPath.substring(0, searchIndex) : localUrlPath;
263
+
264
+ return {
265
+ pathname: localUrlPath,
266
+ search,
267
+ hash,
268
+ };
269
+ }
270
+
217
271
  export function serializeURLPath(urlPath: URLPath): string {
218
272
  const search = urlPath.search === undefined ? '' : `?${urlPath.search}`;
219
273
  const hash = urlPath.hash === undefined ? '' : `#${urlPath.hash}`;