Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div align="center">

<img src="./markdown-code.png" alt="markdown-code" width="600" />
<img src="https://raw.githubusercontent.com/scalvert/markdown-code/main/markdown-code.png" alt="markdown-code" width="600" />

[![CI Build](https://github.com/scalvert/markdown-code/actions/workflows/ci.yml/badge.svg)](https://github.com/scalvert/markdown-code/actions/workflows/ci.yml)
[![npm version](https://badge.fury.io/js/markdown-code.svg)](https://badge.fury.io/js/markdown-code)
Expand Down Expand Up @@ -200,7 +200,8 @@ syntax as a local path:
````

HTTPS is allowed by default. Plain HTTP is rejected unless `allowInsecureHttp` is
set to `true`. Remote requests time out after 30 seconds by default; configure
set to `true`, including an HTTPS URL that redirects to HTTP. Remote files larger
than 5 MB are rejected. Remote requests time out after 30 seconds by default; configure
`remoteTimeout` in milliseconds when needed. Remote failures remain errors even
when `missingSnippetSeverity` is set to `warning`, because the source was
configured but could not be loaded.
Expand Down
10 changes: 1 addition & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@
"markdown-code": "dist/cli.js"
},
"files": [
"dist",
"markdown-code.png"
"dist"
],
"scripts": {
"build": "tsup",
Expand All @@ -40,10 +39,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/mdast": "^4.0.3",
"fast-glob": "^3.3.2",
"language-map": "^1.5.0",
"mdast": "^3.0.0",
"picocolors": "^1.1.1",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
Expand All @@ -56,6 +53,7 @@
"devDependencies": {
"@eslint/js": "^9.35.0",
"@release-it-plugins/lerna-changelog": "^9.0.0",
"@types/mdast": "^4.0.4",
"@types/node": "^25.6.0",
"@types/yargs": "^17.0.35",
"@typescript-eslint/eslint-plugin": "^8.58.1",
Expand Down
7 changes: 1 addition & 6 deletions scripts/smoke-test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ const repoRoot = resolve(import.meta.dirname, '..');
const { version } = JSON.parse(
readFileSync(join(repoRoot, 'package.json'), 'utf-8'),
);
const allowedFiles = new Set([
'LICENSE',
'README.md',
'package.json',
'markdown-code.png',
]);
const allowedFiles = new Set(['LICENSE', 'README.md', 'package.json']);
const npmCli = process.env.npm_execpath;

if (!npmCli) {
Expand Down
2 changes: 1 addition & 1 deletion src/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ export async function findMarkdownFiles({
cwd: workingDir,
absolute: true,
});
return files.map((file) => resolve(file));
return files.map((file) => resolve(file)).sort();
}
120 changes: 112 additions & 8 deletions src/remote.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
export interface FetchOptions {
timeout?: number | undefined;
allowInsecureHttp?: boolean | undefined;
maxBytes?: number | undefined;
}

const DEFAULT_TIMEOUT = 30000; // 30 seconds
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;

function tooLarge(url: string, maxBytes: number): Error {
return new Error(`Remote file is larger than ${maxBytes} bytes: ${url}`);
}

async function readBody(
response: Response,
url: string,
maxBytes: number,
): Promise<string> {
const declaredLength = Number(response.headers.get('content-length'));
if (declaredLength > maxBytes) {
await response.body?.cancel();
throw tooLarge(url, maxBytes);
}

if (!response.body) {
return '';
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let received = 0;
let text = '';

for (;;) {
const { done, value } = await reader.read();
if (done) {
return text + decoder.decode();
}
received += value.byteLength;
if (received > maxBytes) {
await reader.cancel();
throw tooLarge(url, maxBytes);
}
text += decoder.decode(value, { stream: true });
}
}

/**
* Check if a path is a remote URL (HTTP or HTTPS)
Expand Down Expand Up @@ -77,6 +117,70 @@ export function validateRemoteUrl(
/**
* Fetch content from a remote URL.
*/
const MAX_REDIRECTS = 10;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);

function redirectTarget(
url: string,
from: string,
location: string,
options: FetchOptions,
): string {
if (!URL.canParse(location, from)) {
throw new Error(`Remote file sent an invalid redirect: ${url}`);
}
const target = new URL(location, from);
target.hash = '';

if (target.username || target.password) {
throw new Error(`Remote file redirected to a URL with credentials: ${url}`);
}

if (target.protocol === 'http:' && !options.allowInsecureHttp) {
throw new Error(
`Remote file redirected to an insecure HTTP URL: ${url} -> ${target}. ` +
'Use HTTPS or set allowInsecureHttp: true in config.',
);
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error(
`Remote file redirected to an unsupported URL: ${url} -> ${target}`,
);
}
return target.toString();
}

async function fetchFollowingRedirects(
url: string,
fetchUrl: string,
options: FetchOptions,
signal: AbortSignal,
): Promise<Response> {
let current = fetchUrl;

for (let redirects = 0; ; redirects++) {
const response = await fetch(current, {
signal,
redirect: 'manual',
headers: {
'User-Agent': 'markdown-code',
Accept: 'text/plain, */*',
},
});

const location = response.headers.get('location');
if (!REDIRECT_STATUSES.has(response.status) || location === null) {
return response;
}

await response.body?.cancel();
if (redirects === MAX_REDIRECTS) {
throw new Error(`Too many redirects: ${url}`);
}
current = redirectTarget(url, current, location, options);
}
}

export async function fetchRemoteContent(
url: string,
options: FetchOptions = {},
Expand All @@ -94,15 +198,15 @@ export async function fetchRemoteContent(
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(fetchUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'markdown-code',
Accept: 'text/plain, */*',
},
});
const response = await fetchFollowingRedirects(
url,
fetchUrl,
options,
controller.signal,
);

if (!response.ok) {
await response.body?.cancel();
if (response.status === 404) {
throw new Error(`Remote file not found: ${url}`);
}
Expand All @@ -111,7 +215,7 @@ export async function fetchRemoteContent(
);
}

return await response.text();
return await readBody(response, url, options.maxBytes ?? DEFAULT_MAX_BYTES);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
Expand Down
44 changes: 44 additions & 0 deletions test/glob.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Project } from 'fixturify-project';
Expand Down Expand Up @@ -50,6 +51,49 @@ describe('findMarkdownFiles', () => {
);
});

it('includes the working directory itself in a ../ pattern', async () => {
const files = await findMarkdownFiles({
markdownGlob: '../**/*.md',
excludeGlob: [],
workingDir: join(project.baseDir, 'docs'),
});

expect(files).toEqual([
join(project.baseDir, 'README.md'),
join(project.baseDir, 'docs', 'draft.md'),
join(project.baseDir, 'docs', 'guide.md'),
]);
});

it('follows symlinked directories under a symlinked absolute root', async () => {
const outside = new Project();
await outside.write({ 'linked.md': '' });
try {
mkdirSync(join(project.baseDir, 'tree'));
writeFileSync(join(project.baseDir, 'tree', 'own.md'), '');
symlinkSync(
outside.baseDir,
join(project.baseDir, 'tree', 'link'),
'junction',
);
const alias = join(project.baseDir, 'alias');
symlinkSync(join(project.baseDir, 'tree'), alias, 'junction');

const files = await findMarkdownFiles({
markdownGlob: join(alias, '**', '*.md'),
excludeGlob: [],
workingDir: alias,
});

expect(files).toEqual([
join(alias, 'link', 'linked.md'),
join(alias, 'own.md'),
]);
} finally {
outside.dispose();
}
});

it('accepts an absolute pattern built with platform paths', async () => {
const files = await findMarkdownFiles({
markdownGlob: join(project.baseDir, 'docs', '*.md'),
Expand Down
Loading
Loading