diff --git a/integrations/github-files/gitbook-manifest.yaml b/integrations/github-files/gitbook-manifest.yaml
index 1937af070..5ebfe765f 100644
--- a/integrations/github-files/gitbook-manifest.yaml
+++ b/integrations/github-files/gitbook-manifest.yaml
@@ -12,13 +12,17 @@ externalLinks:
summary: |
# Overview
- The GitHub Files integration allows you to take a link to a GitHub file or a permalink to lines of code and display them into code blocks in GitBook.
+ The GitHub Files integration allows you to take a link to a GitHub file or a permalink to lines of code and display them into code blocks in GitBook. It also supports snippet tags for extracting specific code sections.
# How it works
After installing the GitHub Files integration, you're able to insert it into a GitBook file in the (CMD + /) menu.
- Insert the integration, paste your link, and the integration will display the code in a formatted code block.
+ **GitHub Files**: Insert the integration, paste your link, and the integration will display the code in a formatted code block.
+
+ **GitHub Snippet**: Insert the snippet block, provide a GitHub URL and a snippet tag (e.g., "BaseOAuthExample"), and the integration will extract and display only the code between the `--8<-- [start:tag]` and `--8<-- [end:tag]` markers. You can also add multiple snippets to combine them into a single code block.
+
+ **Simple GitHub Reference**: Insert the simple GitHub block and provide repository details (owner, repo, branch, file path) for an easier way to reference files without full URLs.
# Configure
@@ -31,6 +35,12 @@ blocks:
description: Insert a GitHub file as a code block
urlUnfurl:
- https://github.com/**
+ - id: github-snippet-block
+ title: GitHub Snippet
+ description: Insert a GitHub file snippet using tags (supports multiple snippets)
+ - id: github-simple-block
+ title: Simple GitHub Reference
+ description: Reference GitHub files using owner/repo/branch/file format
configurations:
account:
properties:
diff --git a/integrations/github-files/src/github.ts b/integrations/github-files/src/github.ts
index c0f81edef..c6ffec3a2 100644
--- a/integrations/github-files/src/github.ts
+++ b/integrations/github-files/src/github.ts
@@ -1,14 +1,17 @@
import { ExposableError } from '@gitbook/runtime';
-import { GithubInstallationConfiguration, GithubRuntimeContext } from './types';
+import { GithubInstallationConfiguration, GithubRuntimeContext, GithubSnippetProps } from './types';
export interface GithubProps {
url: string;
}
+const constructGithubUrl = (owner: string, repo: string, branch: string, filePath: string) => {
+ return `https://github.com/${owner}/${repo}/blob/${branch}/${filePath}`;
+};
+
const splitGithubUrl = (url: string) => {
- const permalinkRegex =
- /^https?:\/\/github\.com\/([\w-]+)\/([\w-]+)\/blob\/([a-f0-9]+)\/(.+?)#(.+)$/;
- const wholeFileRegex = /^https?:\/\/github\.com\/([\w-]+)\/([\w-]+)\/blob\/([\w.-]+)\/(.+)$/;
+ // Enhanced patterns to handle more GitHub URL formats including branches, tags, and commits
+ const generalBlobRegex = /^https?:\/\/github\.com\/([\w-]+)\/([\w-]+)\/blob\/([^\/]+)\/(.+?)(?:#(.+))?$/;
const multipleLineRegex = /^L\d+-L\d+$/;
let orgName = '';
@@ -17,41 +20,27 @@ const splitGithubUrl = (url: string) => {
let fileName = '';
let lines: number[] = [];
- if (url.match(permalinkRegex)) {
- const match = url.match(permalinkRegex);
- if (!match) {
- return;
- }
-
- orgName = match[1];
- repoName = match[2];
- ref = match[3];
- fileName = match[4];
- const hash = match[5];
-
- if (hash !== '') {
- if (url.match(permalinkRegex)) {
- if (hash.match(multipleLineRegex)) {
- lines = hash.replace(/L/g, '').split('-').map(Number);
- } else {
- const singleLineNumberArray: number[] = [];
- const parsedInt = parseInt(hash.replace(/L/g, ''), 10);
- singleLineNumberArray.push(parsedInt);
- singleLineNumberArray.push(parsedInt);
- lines = singleLineNumberArray;
- }
+ // Try to match general blob pattern (handles branches, tags, commits)
+ const generalMatch = url.match(generalBlobRegex);
+ if (generalMatch) {
+ orgName = generalMatch[1];
+ repoName = generalMatch[2];
+ ref = generalMatch[3];
+ fileName = generalMatch[4];
+ const hash = generalMatch[5];
+
+ // Handle line numbers if present
+ if (hash && hash !== '') {
+ if (hash.match(multipleLineRegex)) {
+ lines = hash.replace(/L/g, '').split('-').map(Number);
+ } else if (hash.startsWith('L')) {
+ const singleLineNumberArray: number[] = [];
+ const parsedInt = parseInt(hash.replace(/L/g, ''), 10);
+ singleLineNumberArray.push(parsedInt);
+ singleLineNumberArray.push(parsedInt);
+ lines = singleLineNumberArray;
}
}
- } else if (url.match(wholeFileRegex)) {
- const match = url.match(wholeFileRegex);
- if (!match) {
- return;
- }
-
- orgName = match[1];
- repoName = match[2];
- ref = match[3];
- fileName = match[4];
}
return {
orgName,
@@ -66,6 +55,31 @@ const getLinesFromGithubFile = (content: string[], lines: number[]) => {
return content.slice(lines[0] - 1, lines[1]);
};
+const extractSnippetSection = (content: string, snippetTag: string) => {
+ const lines = content.split('\n');
+ const startMarker = `--8<-- [start:${snippetTag}]`;
+ const endMarker = `--8<-- [end:${snippetTag}]`;
+
+ let startIndex = -1;
+ let endIndex = -1;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (line.includes(startMarker)) {
+ startIndex = i + 1; // Start from the line after the marker
+ } else if (line.includes(endMarker) && startIndex !== -1) {
+ endIndex = i; // End at the line before the marker
+ break;
+ }
+ }
+
+ if (startIndex === -1 || endIndex === -1) {
+ return null; // Snippet tag not found
+ }
+
+ return lines.slice(startIndex, endIndex).join('\n');
+};
+
const getHeaders = (authorise: boolean, accessToken = '') => {
const headers: { 'User-Agent': string; Authorization?: string } = {
'User-Agent': 'request',
@@ -144,3 +158,63 @@ export const getGithubContent = async (url: string, context: GithubRuntimeContex
return { content, fileName: urlObject.fileName };
};
+
+export const getGithubSnippetContent = async (
+ url: string,
+ snippetTag: string,
+ context: GithubRuntimeContext,
+) => {
+ const urlObject = splitGithubUrl(url);
+ if (!urlObject) {
+ return;
+ }
+
+ let content: string | boolean = '';
+ const configuration = context.environment.installation
+ ?.configuration as GithubInstallationConfiguration;
+ const accessToken = configuration.oauth_credentials?.access_token;
+ if (!accessToken) {
+ throw new ExposableError('Integration is not authenticated with GitHub');
+ }
+
+ content = await fetchGithubFile(
+ urlObject.orgName,
+ urlObject.repoName,
+ urlObject.fileName,
+ urlObject.ref,
+ accessToken,
+ );
+
+ if (content && snippetTag) {
+ const snippetContent = extractSnippetSection(content, snippetTag);
+ if (snippetContent === null) {
+ throw new ExposableError(`Snippet tag '${snippetTag}' not found in file`);
+ }
+ content = snippetContent;
+ }
+
+ return { content, fileName: urlObject.fileName };
+};
+
+export const getGithubContentByParams = async (
+ owner: string,
+ repo: string,
+ branch: string,
+ filePath: string,
+ context: GithubRuntimeContext,
+) => {
+ const url = constructGithubUrl(owner, repo, branch, filePath);
+ return await getGithubContent(url, context);
+};
+
+export const getGithubSnippetContentByParams = async (
+ owner: string,
+ repo: string,
+ branch: string,
+ filePath: string,
+ snippetTag: string,
+ context: GithubRuntimeContext,
+) => {
+ const url = constructGithubUrl(owner, repo, branch, filePath);
+ return await getGithubSnippetContent(url, snippetTag, context);
+};
diff --git a/integrations/github-files/src/index.tsx b/integrations/github-files/src/index.tsx
index 36d4bf1d3..f4a752469 100644
--- a/integrations/github-files/src/index.tsx
+++ b/integrations/github-files/src/index.tsx
@@ -9,22 +9,20 @@ import {
FetchEventCallback,
} from '@gitbook/runtime';
-import { getGithubContent, GithubProps } from './github';
-import { GithubRuntimeContext } from './types';
+import { getGithubContent, getGithubSnippetContent, getGithubContentByParams, getGithubSnippetContentByParams, GithubProps } from './github';
+import { GithubRuntimeContext, GithubSnippetProps, GithubSimpleProps } from './types';
import { getFileExtension } from './utils';
const embedBlock = createComponent<
- { url?: string },
- { visible: boolean },
+ { url?: string; visible?: boolean },
+ {},
{
action: 'show' | 'hide';
},
GithubRuntimeContext
>({
componentId: 'github-code-block',
- initialState: {
- visible: true,
- },
+ initialState: {},
async action(element, action) {
switch (action.action) {
@@ -33,15 +31,27 @@ const embedBlock = createComponent<
return {
props: {
+ ...element.props,
url,
+ visible: element.props.visible ?? true,
},
};
}
case 'show': {
- return { state: { visible: true } };
+ return {
+ props: {
+ ...element.props,
+ visible: true,
+ },
+ };
}
case 'hide': {
- return { state: { visible: false } };
+ return {
+ props: {
+ ...element.props,
+ visible: false,
+ },
+ };
}
}
@@ -49,7 +59,7 @@ const embedBlock = createComponent<
},
async render(element, context) {
- const { url } = element.props as GithubProps;
+ const { url, visible = true } = element.props;
const found = await getGithubContent(url, context);
if (!found) {
@@ -97,9 +107,9 @@ const embedBlock = createComponent<
]}
>
({
+ componentId: 'github-snippet-block',
+ initialState: {},
+
+ async action(element, action) {
+ switch (action.action) {
+ case 'updateUrl': {
+ // Handle single snippet mode
+ return {
+ props: {
+ ...element.props,
+ url: action.url,
+ snippetTag: action.snippetTag,
+ },
+ };
+ }
+ case 'addSnippet': {
+ // Convert to multi-snippet mode or add to existing
+ const currentSnippets = element.props.snippets || [];
+ const newSnippet = {
+ id: `snippet-${Date.now()}`,
+ url: '',
+ snippetTag: '',
+ };
+ return {
+ props: {
+ ...element.props,
+ snippets: [...currentSnippets, newSnippet],
+ },
+ };
+ }
+ case 'removeSnippet': {
+ const updatedSnippets = element.props.snippets?.filter(s => s.id !== action.snippetId) || [];
+ return {
+ props: {
+ ...element.props,
+ snippets: updatedSnippets.length > 0 ? updatedSnippets : undefined,
+ },
+ };
+ }
+ case 'updateSnippet': {
+ const updatedSnippets = element.props.snippets?.map(snippet =>
+ snippet.id === action.snippetId
+ ? { ...snippet, url: action.url || snippet.url, snippetTag: action.snippetTag || snippet.snippetTag }
+ : snippet
+ ) || [];
+ return {
+ props: {
+ ...element.props,
+ snippets: updatedSnippets,
+ },
+ };
+ }
+ case 'show': {
+ return {
+ props: {
+ ...element.props,
+ visible: true,
+ },
+ };
+ }
+ case 'hide': {
+ return {
+ props: {
+ ...element.props,
+ visible: false,
+ },
+ };
+ }
+ }
+
+ return element;
+ },
+
+ async render(element, context) {
+ const { url, snippetTag, snippets, visible = true } = element.props;
+
+ // Multi-snippet mode
+ if (snippets && snippets.length > 0) {
+ const snippetContents = await Promise.all(
+ snippets.map(async (snippet) => {
+ if (!snippet.url || !snippet.snippetTag) {
+ return { snippet, content: null, fileName: '' };
+ }
+ const found = await getGithubSnippetContent(snippet.url, snippet.snippetTag, context);
+ return { snippet, content: found?.content || null, fileName: found?.fileName || '' };
+ })
+ );
+
+ const validSnippets = snippetContents.filter(s => s.content);
+ const combinedContent = validSnippets.map(s => s.content).join('\n\n');
+ const fileExtension = validSnippets.length > 0 ? await getFileExtension(validSnippets[0].fileName) : '';
+
+ return (
+
+
+ {snippets.map((snippet, index) => (
+
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+ ))}
+
+ {combinedContent ? (
+
+ ) : null}
+
+
+ );
+ }
+
+ // Single snippet mode
+ if (!url || !snippetTag) {
+ return (
+
+
+ GitHub Code Snippet
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+
+ );
+ }
+
+ const found = await getGithubSnippetContent(url, snippetTag, context);
+
+ if (!found) {
+ return (
+
+
+ ) : undefined
+ }
+ />
+
+ );
+ }
+
+ const { content, fileName } = found;
+ const fileExtension = await getFileExtension(fileName);
+
+ return (
+
+
+ ) : undefined
+ }
+ >
+ {content ? (
+
+ ) : null}
+
+
+ );
+ },
+});
+
+const simpleGithubBlock = createComponent<
+ GithubSimpleProps,
+ {},
+ {
+ action: 'show' | 'hide' | 'updateParams' | 'addFile' | 'removeFile' | 'updateFile';
+ owner?: string;
+ repo?: string;
+ branch?: string;
+ filePath?: string;
+ snippetTag?: string;
+ fileId?: string;
+ },
+ GithubRuntimeContext
+>({
+ componentId: 'github-simple-block',
+ initialState: {},
+
+ async action(element, action) {
+ switch (action.action) {
+ case 'updateParams': {
+ return {
+ props: {
+ ...element.props,
+ owner: action.owner || element.props.owner,
+ repo: action.repo || element.props.repo,
+ branch: action.branch || element.props.branch,
+ filePath: action.filePath || element.props.filePath,
+ snippetTag: action.snippetTag || element.props.snippetTag,
+ },
+ };
+ }
+ case 'addFile': {
+ const currentFiles = element.props.files || [];
+ const newFile = {
+ id: `file-${Date.now()}`,
+ filePath: '',
+ snippetTag: '',
+ };
+ return {
+ props: {
+ ...element.props,
+ files: [...currentFiles, newFile],
+ },
+ };
+ }
+ case 'removeFile': {
+ const updatedFiles = element.props.files?.filter(f => f.id !== action.fileId) || [];
+ return {
+ props: {
+ ...element.props,
+ files: updatedFiles.length > 0 ? updatedFiles : undefined,
+ },
+ };
+ }
+ case 'updateFile': {
+ const updatedFiles = element.props.files?.map(file =>
+ file.id === action.fileId
+ ? { ...file, filePath: action.filePath || file.filePath, snippetTag: action.snippetTag || file.snippetTag }
+ : file
+ ) || [];
+ return {
+ props: {
+ ...element.props,
+ files: updatedFiles,
+ },
+ };
+ }
+ case 'show': {
+ return {
+ props: {
+ ...element.props,
+ visible: true,
+ },
+ };
+ }
+ case 'hide': {
+ return {
+ props: {
+ ...element.props,
+ visible: false,
+ },
+ };
+ }
+ }
+
+ return element;
+ },
+
+ async render(element, context) {
+ const { owner = '', repo = '', branch = 'main', filePath = '', snippetTag = '', files, visible = true } = element.props;
+
+ // Multi-file mode
+ if (files && files.length > 0) {
+ const fileContents = await Promise.all(
+ files.map(async (file) => {
+ if (!owner || !repo || !file.filePath) {
+ return { file, content: null, fileName: '' };
+ }
+ let found;
+ if (file.snippetTag) {
+ found = await getGithubSnippetContentByParams(owner, repo, branch, file.filePath, file.snippetTag, context);
+ } else {
+ found = await getGithubContentByParams(owner, repo, branch, file.filePath, context);
+ }
+ return { file, content: found?.content || null, fileName: found?.fileName || '' };
+ })
+ );
+
+ const validFiles = fileContents.filter(f => f.content);
+ const combinedContent = validFiles.map(f => {
+ const label = f.file.snippetTag ? `// ${f.file.filePath} (${f.file.snippetTag})` : `// ${f.file.filePath}`;
+ return `${label}\n${f.content}`;
+ }).join('\n\n');
+ const fileExtension = validFiles.length > 0 ? await getFileExtension(validFiles[0].fileName) : '';
+
+ return (
+
+
+ {files.map((file, index) => (
+
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+ ))}
+
+ {combinedContent ? (
+
+ ) : null}
+
+
+ );
+ }
+
+ // Single file mode or initial setup
+ if (!owner || !repo || !filePath) {
+ return (
+
+
+ Simple GitHub Reference
+ Enter repository details to reference a file or code snippet.
+
+
+ }
+ />
+
+
+ }
+ />
+
+
+ }
+ />
+
+
+ }
+ />
+
+
+ }
+ />
+
+
+ );
+ }
+
+ // Single file loaded mode
+ let found;
+ if (snippetTag) {
+ found = await getGithubSnippetContentByParams(owner, repo, branch, filePath, snippetTag, context);
+ } else {
+ found = await getGithubContentByParams(owner, repo, branch, filePath, context);
+ }
+
+ if (!found) {
+ return (
+
+
+ ) : undefined
+ }
+ />
+
+ );
+ }
+
+ const { content, fileName } = found;
+ const fileExtension = await getFileExtension(fileName);
+ const displayTitle = snippetTag ? `${owner}/${repo}/${filePath} (${snippetTag})` : `${owner}/${repo}/${filePath}`;
+
+ return (
+
+
+ ) : undefined
+ }
+ >
+ {content ? (
+
+ ) : null}
+
+
+ );
+ },
+});
+
const handleFetchEvent: FetchEventCallback = async (request, context) => {
const { environment } = context;
@@ -187,5 +901,5 @@ const extractCredentials = async (
export default createIntegration({
fetch: handleFetchEvent,
- components: [embedBlock],
+ components: [embedBlock, snippetBlock, simpleGithubBlock],
});
diff --git a/integrations/github-files/src/types.ts b/integrations/github-files/src/types.ts
index 5e194eb27..a4fd51e2d 100644
--- a/integrations/github-files/src/types.ts
+++ b/integrations/github-files/src/types.ts
@@ -6,5 +6,30 @@ export interface GithubInstallationConfiguration {
};
}
-export type GithubRuntimeEnvironment = RuntimeEnvironment;
+export interface GithubSnippetProps {
+ url?: string;
+ snippetTag?: string;
+ snippets?: Array<{
+ url: string;
+ snippetTag?: string;
+ id: string;
+ }>;
+ visible?: boolean;
+}
+
+export interface GithubSimpleProps {
+ owner?: string;
+ repo?: string;
+ branch?: string;
+ filePath?: string;
+ snippetTag?: string;
+ visible?: boolean;
+ files?: Array<{
+ filePath: string;
+ snippetTag?: string;
+ id: string;
+ }>;
+}
+
+export type GithubRuntimeEnvironment = RuntimeEnvironment;
export type GithubRuntimeContext = RuntimeContext;