Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/2.generators/src-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# src-link

The `src-link` generator automatically creates links to specific lines in the GitHub file, or relative to the markdown file, by looking for patterns.

## Example

<!-- automd:example generator=src-link src="gh:nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts" pattern="export interface NuxtHooks" label="schema source code" -->

### Input

<!-- automd:src-link src="gh:nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts" pattern="\"export" interface NuxtHooks" label="\"schema" source code" -->
<!-- /automd -->

### Output

<!-- automd:src-link src="gh:nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts" pattern="\"export" interface NuxtHooks" label="\"schema" source code" -->

<!-- ⚠️ Unknown generator:`src-link`. -->
Copy link
Contributor Author

@onmax onmax Oct 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how to register the generator in the config/automd


<!-- /automd -->

<!-- /automd -->

## Arguments

::field-group

::field{name="src" type="string"}
The relative path of the file or the GitHub URL where the file is located. If it is a GitHub URL, it should start with `gh:`.
::

::field{name="pattern" type="string"}
The pattern to search for in the file. This can be a string or a regular expression.
::

::field{name="label" type="string"}
The text for the link to appear in the markdown output.
::

::

## Usage

Instead of manually maintaining line numbers in your documentation, you can use the `src-link` generator to automatically create links to specific lines in GitHub files or relative to the markdown file. For example:

```markdown
Check the <!-- automd:src-link src="gh:nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts" pattern="export interface NuxtHooks" label="schema source code" --> for all available hooks.
```

This will generate a link to the correct line in the file, even if the line number changes in the future.
2 changes: 2 additions & 0 deletions src/generators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pmX, pmInstall } from "./pm";
import { fetch as _fetch } from "./fetch";
import { jsimport } from "./jsimport";
import { withAutomd } from "./with-automd";
import { srcLink } from "./src-link";
import { file } from "./file";
import { contributors } from "./contributors";

Expand All @@ -19,4 +20,5 @@ export default {
jsimport,
"with-automd": withAutomd,
contributors,
"src-link": srcLink,
} as Record<string, Generator>;
118 changes: 118 additions & 0 deletions src/generators/src-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { defineGenerator } from "../generator";
import { resolve } from "pathe";
import { readFile } from "node:fs/promises";

export const srcLink = defineGenerator({
name: "src-link",
async generate({ args, url }) {
const { pattern, label } = args;
let src = args.src;

if (!src || !pattern || !label) {
throw new Error("src, pattern, and label are required arguments");
}

let originalSrc = src;
let contents = "";

if (src.startsWith("gh:") || src.startsWith("https://github.com/")) {
const url = new URL(
src.startsWith("gh:") ? `https://github.com/${src.slice(3)}` : src,
);
originalSrc = url.toString();
url.host = "raw.githubusercontent.com";
src = url.toString();
} else if (src.startsWith("http")) {
// If it's a URL, we can't fetch the raw content, then we just use the Highlighted URL
const url = `${src}#:~:text=${encodeURIComponent(pattern)}`;
return {
contents: `[${label}](${url})`,
};
} else {
// Handle local file paths
try {
const localFilePath = resolve(url || "", src);
contents = await readFile(localFilePath, "utf8");
} catch (error) {
return {
contents: `[${label}](${originalSrc})`,
issues: [`Failed to read local file: ${originalSrc}. ${error}`],
};
}
}

if (!contents) {
// If contents weren't set by reading a local file, fetch from the network
try {
const { $fetch } = await import("ofetch");
contents = await $fetch(src);
} catch (error) {
return {
contents: `[${label}](${originalSrc})`,
issues: [`Failed to fetch file: ${originalSrc}. ${error}`],
};
}

Check warning on line 54 in src/generators/src-link.ts

View check run for this annotation

Codecov / codecov/patch

src/generators/src-link.ts#L50-L54

Added lines #L50 - L54 were not covered by tests
}

const re = parsePattern(pattern);
const matches = [...contents.matchAll(re)];
const matchedLines = matches.map(
(match) => contents.slice(0, match.index).split("\n").length,
);

if (matchedLines.length === 0) {
return {
contents: `[${label}](${originalSrc})`,
issues: [`Pattern "${pattern}" not found in the file: ${originalSrc}`],
};
}

if (matchedLines.length > 1) {
return {
contents: `[${label}](${originalSrc})`,
issues: [
`Multiple matches found for pattern "${pattern}" in the file: ${originalSrc}. Matches found at lines: ${matchedLines.join(", ")}`,
],
};
}

let linkUrl;
if (originalSrc.startsWith("https://github.com")) {
const firstMatch = [...matches][0];
const matchStartLine = matchedLines[0];
const matchEndLine = contents
.slice(0, firstMatch.index + firstMatch[0].length)
.split("\n").length;
const matchHasMultipleLines = matchStartLine !== matchEndLine;
const lines = matchHasMultipleLines
? `L${matchStartLine}-L${matchEndLine}`
: `L${matchStartLine}`;
linkUrl = `${originalSrc}#${lines}`;
} else {
linkUrl = `${originalSrc}:${matchedLines[0]}`;
}

return {
contents: `[${label}](${linkUrl})`,
};
},
});

function parsePattern(pattern: string): RegExp {
let regex;
let flags = "g"; // Default to global search

if (pattern.startsWith("/") && pattern.endsWith("/")) {
// If the pattern is wrapped in slashes, extract it as a regex
const parts = pattern.split("/");
regex = parts[1]; // The actual pattern between the slashes
flags = parts[2] || "g"; // Any flags provided after the last slash
} else {
// Treat as a literal string, escape special regex characters
regex = pattern.replace(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
if (pattern.includes("\n")) {
flags += "m"; // Enable multi-line mode if the pattern contains newlines
}
}
return new RegExp(regex, flags);
}
199 changes: 199 additions & 0 deletions test/generators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { describe, it, expect, vi } from "vitest";
import { srcLink } from "../src/generators/src-link";


describe("src-link generator", () => {
vi.mock("ofetch", () => ({
$fetch: vi.fn().mockResolvedValue(`{
"name": "nuxt-framework",
"version": "1.0.0",
"dependencies": {}
}`),
}));

vi.mock('node:fs/promises', () => ({
readFile: vi.fn().mockImplementation(() => ''),
}));

vi.mock('pathe', () => ({
resolve: vi.fn().mockImplementation(() => ''),
}));

it("generates correct link for GitHub URL", async () => {
const result = await srcLink.generate({
args: {
src: "gh:nuxt/framework/blob/main/package.json",
pattern: "\"name\": \"nuxt-framework\"",
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(result.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json#L2)");
});

it("generates correct link for GitHub URL with a Regex", async () => {
const re = /name/;
const result = await srcLink.generate({
args: {
src: "gh:nuxt/framework/blob/main/package.json",
pattern: re.toString(),
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(result.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json#L2)");
});

it("generates correct link for GitHub URL with multiple lines and Regex", async () => {
const re = /"name"[^}]*"dependencies"/;
const result = await srcLink.generate({
args: {
src: "gh:nuxt/framework/blob/main/package.json",
pattern: re.toString(),
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(result.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json#L2-L4)");
});

it("generates correct link for full GitHub URL", async () => {
const result = await srcLink.generate({
args: {
src: "https://github.com/nuxt/framework/blob/main/package.json",
pattern: "\"name\": \"nuxt-framework\"",
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(result.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json#L2)");
});

it("appends link to file but not the line number if no pattern or multiple patterns are found", async () => {
const resultNonExistent = await srcLink.generate({
args: {
src: "gh:nuxt/framework/blob/main/package.json",
pattern: "NonexistentPattern",
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(resultNonExistent.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json)");

const resultMultipleResults = await srcLink.generate({
args: {
src: "gh:nuxt/framework/blob/main/package.json",
pattern: "\n",
label: "long live nuxt",
},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

expect(resultMultipleResults.contents).toBe("[long live nuxt](https://github.com/nuxt/framework/blob/main/package.json)");
});

it("throws error when required arguments are missing", async () => {
await expect(srcLink.generate({
args: {},
config: {} as any,
block: {} as any,
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
})).rejects.toThrow("src, pattern, and label are required arguments");
});

it("generates correct link using highlight API for non-GitHub URL", async () => {
const result = await srcLink.generate({
args: {
src: "https://example.com/some-page",
pattern: "highlighted text",
label: "Check this out",
},
config: {} as any,
block: {} as any,
url: "",
transform: async () => ({ contents: "", hasChanged: false, hasIssues: false, updates: [], time: 0 }),
});

const expectedUrl = `https://example.com/some-page#:~:text=${encodeURIComponent(
"highlighted text"
)}`;
expect(result.contents).toBe(`[Check this out](${expectedUrl})`);
});

it("generates correct link for local file", async () => {
const mockReadFile = vi
.spyOn(await import("node:fs/promises"), "readFile")
.mockResolvedValue("line1\nline2\nline3\npattern line\nline5");

const mockResolve = vi.spyOn(await import("pathe"), "resolve").mockImplementation(() => "/mocked/path/to/local/file");

const result = await srcLink.generate({
args: {
src: "/path/to/local/file",
pattern: "pattern line",
label: "Local File Label",
},
url: "/current/directory", // Simulate current directory
config: {} as any,
block: {} as any,
transform: async () => ({
contents: "",
hasChanged: false,
hasIssues: false,
updates: [],
time: 0,
}),
});

expect(mockReadFile).toHaveBeenCalledWith("/mocked/path/to/local/file", "utf8");
expect(result.contents).toBe("[Local File Label](/path/to/local/file:4)");

mockReadFile.mockRestore();
mockResolve.mockRestore();
});

it("handles local file read error gracefully", async () => {
const mockReadFile = vi
.spyOn(await import("node:fs/promises"), "readFile")
.mockRejectedValue(new Error("File not found"));

const result = await srcLink.generate({
args: {
src: "/non/existent/file",
pattern: "pattern",
label: "Missing File",
},
url: "/current/directory",
config: {} as any,
block: {} as any,
transform: async () => ({
contents: "",
hasChanged: false,
hasIssues: false,
updates: [],
time: 0,
}),
});

expect(result.contents).toBe("[Missing File](/non/existent/file)");

mockReadFile.mockRestore();
});
});