From 8af95dea49b4d7a2c1f3e0621d77fff1a36caca2 Mon Sep 17 00:00:00 2001 From: Marty Winkler Date: Sun, 5 Mar 2023 03:11:01 +0100 Subject: [PATCH] feat: convert relative urls to absolute (#125) * feat: convert relative urls to absolute * test: add tests for conversion of relative urls --- README.md | 35 +++ __test__/readme-helper.unit.test.ts | 335 ++++++++++++++++++++++++++++ action.yml | 8 + dist/index.js | 192 +++++++++++++++- jest.config.js | 5 + package.json | 2 +- src/input-helper.ts | 28 ++- src/main.ts | 12 +- src/readme-helper.ts | 175 +++++++++++++++ 9 files changed, 774 insertions(+), 18 deletions(-) create mode 100644 __test__/readme-helper.unit.test.ts create mode 100644 src/readme-helper.ts diff --git a/README.md b/README.md index d5ec57f..491a9ec 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ This is useful if you `docker push` your images to Docker Hub. It provides an ea | `repository` | Docker Hub repository in the format `/`. | `github.repository` | | `short-description` | Docker Hub repository short description. | | | `readme-filepath` | Path to the repository readme. | `./README.md` | +| `enable-url-completion` | Enables completion of relative URLs to absolute ones. See also [Known Issues](#known-issues). | `false` | +| `image-extensions` | File extensions that will be treated as images. | `bmp,gif,jpg,jpeg,png,svg,webp` | #### Content limits @@ -86,6 +88,7 @@ jobs: password: ${{ secrets.DOCKERHUB_PASSWORD }} repository: peterevans/dockerhub-description short-description: ${{ github.event.repository.description }} + enable-url-completion: true ``` Updates the Docker Hub repository description whenever a new release is created. @@ -122,6 +125,38 @@ docker run -v $PWD:/workspace \ peterevans/dockerhub-description:3 ``` +## Known Issues + +The completion of relative urls has some known issues: + +1. Relative markdown links in inline-code and code blocks **are also converted**: + + ```markdown + [link in inline code](#table-of-content) + ``` + + will be converted into + + ```markdown + [link in inline code](https://github.com/peter-evans/dockerhub-description/blob/main/./README.md#table-of-content) + ``` + +2. Links containing square brackets (`]`) in the text fragment **are not converted**: + + ```markdown + [[link text with square brackets]](#table-of-content) + ``` + +3. [Reference-style links/images](https://www.markdownguide.org/basic-syntax/#reference-style-links) **are not converted**. + + ```markdown + [table-of-content][toc] + + ... + + [toc]: #table-of-content "Table of content" + ``` + ## License [MIT](LICENSE) diff --git a/__test__/readme-helper.unit.test.ts b/__test__/readme-helper.unit.test.ts new file mode 100644 index 0000000..5b22616 --- /dev/null +++ b/__test__/readme-helper.unit.test.ts @@ -0,0 +1,335 @@ +import {completeRelativeUrls} from '../src/readme-helper' + +describe('complete relative urls tests', () => { + const GITHUB_SERVER_URL = process.env['GITHUB_SERVER_URL'] + const GITHUB_REPOSITORY = process.env['GITHUB_REPOSITORY'] + const GITHUB_REF_NAME = process.env['GITHUB_REF_NAME'] + + const README_FILEPATH = './README.md' + const EXPECTED_REPOSITORY_URL = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}` + const EXPECTED_BLOB_URL = `${EXPECTED_REPOSITORY_URL}/blob/${GITHUB_REF_NAME}` + const EXPECTED_RAW_URL = `${EXPECTED_REPOSITORY_URL}/raw/${GITHUB_REF_NAME}` + + // known issues + test('reference-style links/image sources are not converted', async () => { + const content = [ + 'table-of-content][toc]', + '', + '[toc]: #table-of-content "Table of content"' + ].join('\n') + expect(completeRelativeUrls(content, README_FILEPATH, true, '')).toEqual( + content + ) + }) + + test('links containing square brackets in the text fragment are not converted', async () => { + expect( + completeRelativeUrls( + '[[text with square brackets]](README.md)', + README_FILEPATH, + true, + '' + ) + ).toEqual('[[text with square brackets]](README.md)') + }) + + test('links containing square brackets in the text fragment are not converted', async () => { + expect( + completeRelativeUrls('`[text](README.md)`', README_FILEPATH, true, '') + ).toEqual(`\`[text](${EXPECTED_BLOB_URL}/README.md)\``) + }) + + // misc + test('do not change content when disabled', async () => { + expect( + completeRelativeUrls('[text](README.md)', README_FILEPATH, false, '') + ).toEqual('[text](README.md)') + }) + + test('do not change link with mailto protocol', async () => { + expect( + completeRelativeUrls( + '[text](mailto:mail@example.com)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](mailto:mail@example.com)`) + }) + + test('do not change link with ftp protocol', async () => { + expect( + completeRelativeUrls( + '[text](ftp://example.com)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](ftp://example.com)`) + }) + + test('do not change link with http protocol', async () => { + expect( + completeRelativeUrls( + '[text](http://example.com)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](http://example.com)`) + }) + + test('do not change link with https protocol', async () => { + expect( + completeRelativeUrls( + '[text](https://example.com)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](https://example.com)`) + }) + + test('do not change link with protocol-like beginning', async () => { + expect( + completeRelativeUrls( + '[text](abc://example.com)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](abc://example.com)`) + }) + + test('do not change image from absolute source with absolute link', async () => { + expect( + completeRelativeUrls( + '[![alttext](https://example.com/image.svg)](https://example.com/image.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](https://example.com/image.svg)](https://example.com/image.svg)` + ) + }) + + // anchors + test('anchor referencing the current document', async () => { + expect( + completeRelativeUrls( + '[text](#relative-anchor)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](${EXPECTED_BLOB_URL}/README.md#relative-anchor)`) + }) + + test('anchor referencing the current document with a title', async () => { + expect( + completeRelativeUrls( + '[text](#relative-anchor "the anchor (a title)")', + README_FILEPATH, + true, + '' + ) + ).toEqual( + `[text](${EXPECTED_BLOB_URL}/README.md#relative-anchor "the anchor (a title)")` + ) + }) + + test('anchor referencing the current document with a title and unicode', async () => { + expect( + completeRelativeUrls( + '[text with 🌬](#relative-anchor "the anchor (a title with 🌬)")', + README_FILEPATH, + true, + '' + ) + ).toEqual( + `[text with 🌬](${EXPECTED_BLOB_URL}/README.md#relative-anchor "the anchor (a title with 🌬)")` + ) + }) + + test('anchor referencing another document', async () => { + expect( + completeRelativeUrls( + '[text](OTHER.md#absolute-anchor)', + README_FILEPATH, + true, + '' + ) + ).toEqual(`[text](${EXPECTED_BLOB_URL}/OTHER.md#absolute-anchor)`) + }) + + test('anchor referencing another document with a title', async () => { + expect( + completeRelativeUrls( + '[text](OTHER.md#absolute-anchor "the anchor (a title)")', + README_FILEPATH, + true, + '' + ) + ).toEqual( + `[text](${EXPECTED_BLOB_URL}/OTHER.md#absolute-anchor "the anchor (a title)")` + ) + }) + + test('anchor with image referencing the current document', async () => { + expect( + completeRelativeUrls( + '[![alttext](image.svg)](#absolute-anchor "the anchor (a title)")', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](${EXPECTED_RAW_URL}/image.svg)](${EXPECTED_BLOB_URL}/README.md#absolute-anchor "the anchor (a title)")` + ) + }) + + test('anchor with image referencing another document', async () => { + expect( + completeRelativeUrls( + '[![alttext](image.svg)](OTHER.md#absolute-anchor "the anchor (a title)")', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](${EXPECTED_RAW_URL}/image.svg)](${EXPECTED_BLOB_URL}/OTHER.md#absolute-anchor "the anchor (a title)")` + ) + }) + + // documents + test('text document', async () => { + expect( + completeRelativeUrls('[text](document.yaml)', README_FILEPATH, true, '') + ).toEqual(`[text](${EXPECTED_BLOB_URL}/document.yaml)`) + }) + + test('pdf document', async () => { + expect( + completeRelativeUrls('[text](document.pdf)', README_FILEPATH, true, '') + ).toEqual(`[text](${EXPECTED_BLOB_URL}/document.pdf)`) + }) + + test('document with a title', async () => { + expect( + completeRelativeUrls( + '[text](document.pdf "the document (a title)")', + README_FILEPATH, + true, + '' + ) + ).toEqual( + `[text](${EXPECTED_BLOB_URL}/document.pdf "the document (a title)")` + ) + }) + + test('document with a title and unicode', async () => { + expect( + completeRelativeUrls( + '[text with 🌬](document.pdf "the document (a title with 🌬)")', + README_FILEPATH, + true, + '' + ) + ).toEqual( + `[text with 🌬](${EXPECTED_BLOB_URL}/document.pdf "the document (a title with 🌬)")` + ) + }) + + // images + test('image with supported file extension', async () => { + expect( + completeRelativeUrls( + '![alttext](image.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual(`![alttext](${EXPECTED_RAW_URL}/image.svg)`) + }) + + test('image with unsupported file extension', async () => { + expect( + completeRelativeUrls( + '![alttext](image.svg)', + README_FILEPATH, + true, + 'jpeg' + ) + ).toEqual(`![alttext](${EXPECTED_BLOB_URL}/image.svg)`) + }) + + test('image without alternate text', async () => { + expect( + completeRelativeUrls('![](image.svg)', README_FILEPATH, true, 'svg') + ).toEqual(`![](${EXPECTED_RAW_URL}/image.svg)`) + }) + + test('image with a title', async () => { + expect( + completeRelativeUrls( + '![alttext](image.svg "the image (a title)")', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual(`![alttext](${EXPECTED_RAW_URL}/image.svg "the image (a title)")`) + }) + + test('image with relative link', async () => { + expect( + completeRelativeUrls( + '[![alttext](image.svg)](image.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](${EXPECTED_RAW_URL}/image.svg)](${EXPECTED_BLOB_URL}/image.svg)` + ) + }) + + test('image with a title, unicode and relative link', async () => { + expect( + completeRelativeUrls( + '[![alttext with 🌬](image.🌬.svg "the image.🌬.svg (a title)")](image.🌬.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext with 🌬](${EXPECTED_RAW_URL}/image.🌬.svg "the image.🌬.svg (a title)")](${EXPECTED_BLOB_URL}/image.🌬.svg)` + ) + }) + + test('image from absolute source with relative link', async () => { + expect( + completeRelativeUrls( + '[![alttext](https://example.com/image.svg)](image.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](https://example.com/image.svg)](${EXPECTED_BLOB_URL}/image.svg)` + ) + }) + + test('image with absolute link', async () => { + expect( + completeRelativeUrls( + '[![alttext](image.svg)](https://example.com/image.svg)', + README_FILEPATH, + true, + 'svg' + ) + ).toEqual( + `[![alttext](${EXPECTED_RAW_URL}/image.svg)](https://example.com/image.svg)` + ) + }) +}) diff --git a/action.yml b/action.yml index 24f56b1..070353f 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,14 @@ inputs: description: > Path to the repository readme Default: `./README.md` + enable-url-completion: + description: > + Enables completion of relative urls to absolute ones + Default: `false` + image-extensions: + description: > + Extensions of files that you be treated as images + Default: `bmp,gif,jpg,jpeg,png,svg,webp` runs: using: 'node16' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index a097b95..7099be6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -121,14 +121,16 @@ var __importStar = (this && this.__importStar) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.validateInputs = exports.getInputs = void 0; const core = __importStar(__nccwpck_require__(2186)); -const README_FILEPATH_DEFAULT = './README.md'; +const readmeHelper = __importStar(__nccwpck_require__(3367)); function getInputs() { const inputs = { username: core.getInput('username'), password: core.getInput('password'), repository: core.getInput('repository'), shortDescription: core.getInput('short-description'), - readmeFilepath: core.getInput('readme-filepath') + readmeFilepath: core.getInput('readme-filepath'), + enableUrlCompletion: Boolean(core.getInput('enable-url-completion')), + imageExtensions: core.getInput('image-extensions') }; // Environment variable input alternatives and their aliases if (!inputs.username && process.env['DOCKERHUB_USERNAME']) { @@ -155,15 +157,28 @@ function getInputs() { if (!inputs.readmeFilepath && process.env['README_FILEPATH']) { inputs.readmeFilepath = process.env['README_FILEPATH']; } + if (!inputs.enableUrlCompletion && process.env['ENABLE_URL_COMPLETION']) { + inputs.enableUrlCompletion = Boolean(process.env['ENABLE_URL_COMPLETION']); + } + if (!inputs.imageExtensions && process.env['IMAGE_EXTENSIONS']) { + inputs.imageExtensions = process.env['IMAGE_EXTENSIONS']; + } // Set defaults if (!inputs.readmeFilepath) { - inputs.readmeFilepath = README_FILEPATH_DEFAULT; + inputs.readmeFilepath = readmeHelper.README_FILEPATH_DEFAULT; + } + if (!inputs.enableUrlCompletion) { + inputs.enableUrlCompletion = readmeHelper.ENABLE_URL_COMPLETION_DEFAULT; + } + if (!inputs.imageExtensions) { + inputs.imageExtensions = readmeHelper.IMAGE_EXTENSIONS_DEFAULT; } if (!inputs.repository && process.env['GITHUB_REPOSITORY']) { inputs.repository = process.env['GITHUB_REPOSITORY']; } - // Docker repositories must be all lower case + // Enforce lower case, where needed inputs.repository = inputs.repository.toLowerCase(); + inputs.imageExtensions = inputs.imageExtensions.toLowerCase(); return inputs; } exports.getInputs = getInputs; @@ -222,7 +237,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(2186)); const inputHelper = __importStar(__nccwpck_require__(5480)); const dockerhubHelper = __importStar(__nccwpck_require__(1812)); -const fs = __importStar(__nccwpck_require__(7147)); +const readmeHelper = __importStar(__nccwpck_require__(3367)); const util_1 = __nccwpck_require__(3837); function getErrorMessage(error) { if (error instanceof Error) @@ -236,9 +251,9 @@ function run() { core.debug(`Inputs: ${(0, util_1.inspect)(inputs)}`); inputHelper.validateInputs(inputs); // Fetch the readme content - const readmeContent = yield fs.promises.readFile(inputs.readmeFilepath, { - encoding: 'utf8' - }); + core.info('Reading description source file'); + const readmeContent = yield readmeHelper.getReadmeContent(inputs.readmeFilepath, inputs.enableUrlCompletion, inputs.imageExtensions); + core.debug(readmeContent); // Acquire a token for the Docker Hub API core.info('Acquiring token'); const token = yield dockerhubHelper.getToken(inputs.username, inputs.password); @@ -256,6 +271,167 @@ function run() { run(); +/***/ }), + +/***/ 3367: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.completeRelativeUrls = exports.getReadmeContent = exports.ENABLE_URL_COMPLETION_DEFAULT = exports.IMAGE_EXTENSIONS_DEFAULT = exports.README_FILEPATH_DEFAULT = void 0; +const core = __importStar(__nccwpck_require__(2186)); +const fs = __importStar(__nccwpck_require__(7147)); +exports.README_FILEPATH_DEFAULT = './README.md'; +exports.IMAGE_EXTENSIONS_DEFAULT = 'bmp,gif,jpg,jpeg,png,svg,webp'; +exports.ENABLE_URL_COMPLETION_DEFAULT = false; +const TITLE_REGEX = `(?: +"[^"]+")?`; +const REPOSITORY_URL = `${process.env['GITHUB_SERVER_URL']}/${process.env['GITHUB_REPOSITORY']}`; +const BLOB_PREFIX = `${REPOSITORY_URL}/blob/${process.env['GITHUB_REF_NAME']}/`; +const RAW_PREFIX = `${REPOSITORY_URL}/raw/${process.env['GITHUB_REF_NAME']}/`; +function getReadmeContent(readmeFilepath, enableUrlCompletion, imageExtensions) { + return __awaiter(this, void 0, void 0, function* () { + // Fetch the readme content + let readmeContent = yield fs.promises.readFile(readmeFilepath, { + encoding: 'utf8' + }); + readmeContent = completeRelativeUrls(readmeContent, readmeFilepath, enableUrlCompletion, imageExtensions); + return readmeContent; + }); +} +exports.getReadmeContent = getReadmeContent; +function completeRelativeUrls(readmeContent, readmeFilepath, enableUrlCompletion, imageExtensions) { + if (enableUrlCompletion) { + readmeFilepath = readmeFilepath.replace(/^[.][/]/, ''); + // Make relative urls absolute + const rules = [ + ...getRelativeReadmeAnchorsRules(readmeFilepath), + ...getRelativeImageUrlRules(imageExtensions), + ...getRelativeUrlRules() + ]; + readmeContent = applyRules(rules, readmeContent); + } + return readmeContent; +} +exports.completeRelativeUrls = completeRelativeUrls; +function applyRules(rules, readmeContent) { + rules.forEach(rule => { + const combinedRegex = `${rule.left.source}[(]${rule.url.source}[)]`; + core.debug(`rule: ${combinedRegex}`); + const replacement = `$(${rule.absUrlPrefix}$)`; + core.debug(`replacement: ${replacement}`); + readmeContent = readmeContent.replace(new RegExp(combinedRegex, 'giu'), replacement); + }); + return readmeContent; +} +// has to be applied first to avoid wrong results +function getRelativeReadmeAnchorsRules(readmeFilepath) { + const prefix = `${BLOB_PREFIX}${readmeFilepath}`; + // matches e.g.: + // #table-of-content + // #table-of-content "the anchor (a title)" + const url = new RegExp(`(?#[^)]+${TITLE_REGEX})`); + const rules = [ + // matches e.g.: + // [#table-of-content](#table-of-content) + // [#table-of-content](#table-of-content "the anchor (a title)") + { + left: /(?\[[^\]]+\])/, + url: url, + absUrlPrefix: prefix + }, + // matches e.g.: + // [![media/image.svg](media/image.svg)](#table-of-content) + // [![media/image.svg](media/image.svg "title a")](#table-of-content "title b") + { + left: /(?\[!\[[^\]]*\]\([^)]+\)\])/, + url: url, + absUrlPrefix: prefix + } + ]; + return rules; +} +function getRelativeImageUrlRules(imageExtensions) { + const extensionsRegex = imageExtensions.replace(/,/g, '|'); + // matches e.g.: + // media/image.svg + // media/image.svg "with title" + const url = new RegExp(`(?[^:)]+[.](?:${extensionsRegex})${TITLE_REGEX})`); + const rules = [ + // matches e.g.: + // ![media/image.svg](media/image.svg) + // ![media/image.svg](media/image.svg "with title") + { + left: /(?!\[[^\]]*\])/, + url: url, + absUrlPrefix: RAW_PREFIX + } + ]; + return rules; +} +function getRelativeUrlRules() { + // matches e.g.: + // .releaserc.yaml + // README.md#table-of-content "title b" + // .releaserc.yaml "the .releaserc.yaml file (a title)" + const url = new RegExp(`(?[^:)]+${TITLE_REGEX})`); + const rules = [ + // matches e.g.: + // [.releaserc.yaml](.releaserc.yaml) + // [.releaserc.yaml](.releaserc.yaml "the .releaserc.yaml file (a title)") + { + left: /(?\[[^\]]+\])/, + url: url, + absUrlPrefix: BLOB_PREFIX + }, + // matches e.g.: + // [![media/image.svg](media/image.svg)](media/image.svg) + // [![media/image.svg](media/image.svg)](README.md#table-of-content "title b") + // [![media/image.svg](media/image.svg "title a")](media/image.svg) + // [![media/image.svg](media/image.svg "title a")](media/image.svg "title b") + // [![media/image.svg](media/image.svg "title a")](README.md#table-of-content "title b") + { + left: new RegExp(`(?\\[!\\[[^\\]]*\\]\\([^)]+${TITLE_REGEX}\\)\\])`), + url: url, + absUrlPrefix: BLOB_PREFIX + } + ]; + return rules; +} + + /***/ }), /***/ 7351: diff --git a/jest.config.js b/jest.config.js index 14e44f9..a81d050 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,3 +9,8 @@ module.exports = { }, verbose: true } +process.env = Object.assign(process.env, { + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'peter-evans/dockerhub-description', + GITHUB_REF_NAME: 'main' +}) \ No newline at end of file diff --git a/package.json b/package.json index 823b18e..22a521f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "format": "prettier --write '**/*.ts'", "format-check": "prettier --check '**/*.ts'", "lint": "eslint src/**/*.ts", - "test": "jest --passWithNoTests" + "test": "jest" }, "repository": { "type": "git", diff --git a/src/input-helper.ts b/src/input-helper.ts index dd79412..7093ee3 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -1,6 +1,5 @@ import * as core from '@actions/core' - -const README_FILEPATH_DEFAULT = './README.md' +import * as readmeHelper from './readme-helper' interface Inputs { username: string @@ -8,6 +7,8 @@ interface Inputs { repository: string shortDescription: string readmeFilepath: string + enableUrlCompletion: boolean + imageExtensions: string } export function getInputs(): Inputs { @@ -16,7 +17,9 @@ export function getInputs(): Inputs { password: core.getInput('password'), repository: core.getInput('repository'), shortDescription: core.getInput('short-description'), - readmeFilepath: core.getInput('readme-filepath') + readmeFilepath: core.getInput('readme-filepath'), + enableUrlCompletion: Boolean(core.getInput('enable-url-completion')), + imageExtensions: core.getInput('image-extensions') } // Environment variable input alternatives and their aliases @@ -50,16 +53,31 @@ export function getInputs(): Inputs { inputs.readmeFilepath = process.env['README_FILEPATH'] } + if (!inputs.enableUrlCompletion && process.env['ENABLE_URL_COMPLETION']) { + inputs.enableUrlCompletion = Boolean(process.env['ENABLE_URL_COMPLETION']) + } + + if (!inputs.imageExtensions && process.env['IMAGE_EXTENSIONS']) { + inputs.imageExtensions = process.env['IMAGE_EXTENSIONS'] + } + // Set defaults if (!inputs.readmeFilepath) { - inputs.readmeFilepath = README_FILEPATH_DEFAULT + inputs.readmeFilepath = readmeHelper.README_FILEPATH_DEFAULT + } + if (!inputs.enableUrlCompletion) { + inputs.enableUrlCompletion = readmeHelper.ENABLE_URL_COMPLETION_DEFAULT + } + if (!inputs.imageExtensions) { + inputs.imageExtensions = readmeHelper.IMAGE_EXTENSIONS_DEFAULT } if (!inputs.repository && process.env['GITHUB_REPOSITORY']) { inputs.repository = process.env['GITHUB_REPOSITORY'] } - // Docker repositories must be all lower case + // Enforce lower case, where needed inputs.repository = inputs.repository.toLowerCase() + inputs.imageExtensions = inputs.imageExtensions.toLowerCase() return inputs } diff --git a/src/main.ts b/src/main.ts index 55e0aa9..17fc993 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import * as core from '@actions/core' import * as inputHelper from './input-helper' import * as dockerhubHelper from './dockerhub-helper' -import * as fs from 'fs' +import * as readmeHelper from './readme-helper' import {inspect} from 'util' function getErrorMessage(error: unknown) { @@ -17,9 +17,13 @@ async function run(): Promise { inputHelper.validateInputs(inputs) // Fetch the readme content - const readmeContent = await fs.promises.readFile(inputs.readmeFilepath, { - encoding: 'utf8' - }) + core.info('Reading description source file') + const readmeContent = await readmeHelper.getReadmeContent( + inputs.readmeFilepath, + inputs.enableUrlCompletion, + inputs.imageExtensions + ) + core.debug(readmeContent) // Acquire a token for the Docker Hub API core.info('Acquiring token') diff --git a/src/readme-helper.ts b/src/readme-helper.ts new file mode 100644 index 0000000..0ce6c6d --- /dev/null +++ b/src/readme-helper.ts @@ -0,0 +1,175 @@ +import * as core from '@actions/core' +import * as fs from 'fs' + +export const README_FILEPATH_DEFAULT = './README.md' +export const IMAGE_EXTENSIONS_DEFAULT = 'bmp,gif,jpg,jpeg,png,svg,webp' +export const ENABLE_URL_COMPLETION_DEFAULT = false + +const TITLE_REGEX = `(?: +"[^"]+")?` +const REPOSITORY_URL = `${process.env['GITHUB_SERVER_URL']}/${process.env['GITHUB_REPOSITORY']}` +const BLOB_PREFIX = `${REPOSITORY_URL}/blob/${process.env['GITHUB_REF_NAME']}/` +const RAW_PREFIX = `${REPOSITORY_URL}/raw/${process.env['GITHUB_REF_NAME']}/` + +type Rule = { + /** + * all left of the relative url belonging to the markdown image/link + */ + left: RegExp + /** + * relative url + */ + url: RegExp + /** + * part to prefix the relative url with (excluding github repository url) + */ + absUrlPrefix: string +} + +export async function getReadmeContent( + readmeFilepath: string, + enableUrlCompletion: boolean, + imageExtensions: string +): Promise { + // Fetch the readme content + let readmeContent = await fs.promises.readFile(readmeFilepath, { + encoding: 'utf8' + }) + + readmeContent = completeRelativeUrls( + readmeContent, + readmeFilepath, + enableUrlCompletion, + imageExtensions + ) + + return readmeContent +} + +export function completeRelativeUrls( + readmeContent: string, + readmeFilepath: string, + enableUrlCompletion: boolean, + imageExtensions: string +): string { + if (enableUrlCompletion) { + readmeFilepath = readmeFilepath.replace(/^[.][/]/, '') + + // Make relative urls absolute + const rules = [ + ...getRelativeReadmeAnchorsRules(readmeFilepath), + ...getRelativeImageUrlRules(imageExtensions), + ...getRelativeUrlRules() + ] + + readmeContent = applyRules(rules, readmeContent) + } + + return readmeContent +} + +function applyRules(rules: Rule[], readmeContent: string): string { + rules.forEach(rule => { + const combinedRegex = `${rule.left.source}[(]${rule.url.source}[)]` + core.debug(`rule: ${combinedRegex}`) + + const replacement = `$(${rule.absUrlPrefix}$)` + core.debug(`replacement: ${replacement}`) + + readmeContent = readmeContent.replace( + new RegExp(combinedRegex, 'giu'), + replacement + ) + }) + + return readmeContent +} + +// has to be applied first to avoid wrong results +function getRelativeReadmeAnchorsRules(readmeFilepath: string): Rule[] { + const prefix = `${BLOB_PREFIX}${readmeFilepath}` + + // matches e.g.: + // #table-of-content + // #table-of-content "the anchor (a title)" + const url = new RegExp(`(?#[^)]+${TITLE_REGEX})`) + + const rules: Rule[] = [ + // matches e.g.: + // [#table-of-content](#table-of-content) + // [#table-of-content](#table-of-content "the anchor (a title)") + { + left: /(?\[[^\]]+\])/, + url: url, + absUrlPrefix: prefix + }, + + // matches e.g.: + // [![media/image.svg](media/image.svg)](#table-of-content) + // [![media/image.svg](media/image.svg "title a")](#table-of-content "title b") + { + left: /(?\[!\[[^\]]*\]\([^)]+\)\])/, + url: url, + absUrlPrefix: prefix + } + ] + + return rules +} + +function getRelativeImageUrlRules(imageExtensions: string): Rule[] { + const extensionsRegex = imageExtensions.replace(/,/g, '|') + // matches e.g.: + // media/image.svg + // media/image.svg "with title" + const url = new RegExp( + `(?[^:)]+[.](?:${extensionsRegex})${TITLE_REGEX})` + ) + + const rules: Rule[] = [ + // matches e.g.: + // ![media/image.svg](media/image.svg) + // ![media/image.svg](media/image.svg "with title") + { + left: /(?!\[[^\]]*\])/, + url: url, + absUrlPrefix: RAW_PREFIX + } + ] + + return rules +} + +function getRelativeUrlRules(): Rule[] { + // matches e.g.: + // .releaserc.yaml + // README.md#table-of-content "title b" + // .releaserc.yaml "the .releaserc.yaml file (a title)" + const url = new RegExp(`(?[^:)]+${TITLE_REGEX})`) + + const rules: Rule[] = [ + // matches e.g.: + // [.releaserc.yaml](.releaserc.yaml) + // [.releaserc.yaml](.releaserc.yaml "the .releaserc.yaml file (a title)") + { + left: /(?\[[^\]]+\])/, + url: url, + absUrlPrefix: BLOB_PREFIX + }, + + // matches e.g.: + // [![media/image.svg](media/image.svg)](media/image.svg) + // [![media/image.svg](media/image.svg)](README.md#table-of-content "title b") + // [![media/image.svg](media/image.svg "title a")](media/image.svg) + // [![media/image.svg](media/image.svg "title a")](media/image.svg "title b") + // [![media/image.svg](media/image.svg "title a")](README.md#table-of-content "title b") + { + left: new RegExp( + `(?\\[!\\[[^\\]]*\\]\\([^)]+${TITLE_REGEX}\\)\\])` + ), + url: url, + absUrlPrefix: BLOB_PREFIX + } + ] + + return rules +}