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) => ( + + + } + /> + + } + /> + +