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
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ $ mat README.md # render and open the browser
$ mat # render this directory's standard document
$ cat notes.md | mat - # read from stdin
$ mat README.md -f # also render the local Markdown files it links to
$ mat README.md -w # keep re-rendering and reload the tab on every change
$ mat README.md --output readme.html # write a self-contained file, open nothing
$ mat README.md --output - # write the HTML to stdout
$ mat README.md --theme dark # force a theme; default follows the OS
Expand All @@ -67,6 +68,19 @@ recursively, and points those links at the rendered previews instead of the raw
whose target does not exist or cannot be rendered keep pointing at the source and are reported
on stderr.

`--watch` (short: `-w`) keeps `mat` running: every change to a rendered file re-renders it and
reloads the open tab. The reload travels over a WebSocket on `127.0.0.1` that lives and dies with
the process, on a random path; the page itself stays a plain `file://` document. Watched are
exactly the Markdown files that were rendered, so with `--follow-links` the set follows the links
as they appear and disappear. Images and other assets are not watched.

A file that cannot be read or rendered is reported on stderr and leaves the last good preview in
place, so fixing it and saving again picks the session back up. `Ctrl+C` ends it with exit `0`. If
no browser can be started, the URL is printed and watching continues, because opening it by hand
is all that is missing. `--watch` cannot be combined with `--output` or with `-`, and on network
file systems changes may go unnoticed, because the `fs.watch` underneath does not reliably see
them. It can be made the [default](#configuration).

`--output` produces a file you can move or send: the diagram script and the fonts are embedded.
Images are not — they stay absolute `file://` links to wherever they are on your disk.

Expand All @@ -79,15 +93,20 @@ Images are not — they stay absolute `file://` links to wherever they are on yo
```json
{
"defaultDocuments": ["NOTES.md", "README.md"],
"followLinks": true
"followLinks": true,
"watch": true
}
```

Both keys are optional. `defaultDocuments` replaces the built-in list of documents `mat` tries
when called without a file, in the order given. `followLinks` makes `--follow-links` the default;
`--follow-links=false` turns it back off for one call. A flag on the command line always wins over
the file, and `--output` ignores a configured `followLinks`, because a single self-contained file
cannot hold the linked previews.
All three keys are optional. `defaultDocuments` replaces the built-in list of documents `mat` tries
when called without a file, in the order given. `followLinks` makes `--follow-links` the default,
and `watch` makes `--watch` the default, so plain `mat README.md` keeps running until `Ctrl+C`
instead of returning once the browser is open. `--follow-links=false` and `--watch=false` turn
either back off for one call, and a flag on the command line always wins over the file.

Where a configured default cannot apply, it is dropped rather than turned into an error: `--output`
ignores both, because a single self-contained file cannot hold the linked previews and there is no
tab to reload, and reading from `-` ignores `watch`, because a pipe has no path to watch.

A configuration file that is not valid JSON, or that contains unknown keys or wrong types, is an
error: `mat` names the file and the problem, and exits `2`. Having no configuration file is the
Expand Down
142 changes: 127 additions & 15 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { DEFAULT_DOCUMENTS, findDefaultDocument } from './cli/default-document.t
import { ConfigurationError, describeFileSystemError, RuntimeError } from './cli/errors.ts';
import { createLogger, type Logger } from './cli/logger.ts';
import { startUpdateCheck } from './cli/update-check.ts';
import { runWatch } from './cli/watch.ts';
import { MAT_VERSION } from './generated/assets.ts';
import { render } from './render/index.ts';
import { type RenderOptions, render } from './render/index.ts';
import type { LinkAdmission } from './render/pipeline.ts';

export const EXIT_SUCCESS = 0;
Expand Down Expand Up @@ -290,12 +291,27 @@ function writePreview(previewPath: string, html: string): void {
}
}

async function runRender(
export interface RenderedDocuments {
html: string;
/** Where the root document's preview belongs; a `--output` render has no use for it. */
previewPath: string;
/** The root document's real path, or undefined when it was read from stdin. */
realPath: string | undefined;
/** Every rendered document that has a path on disk: the root first, then the followed links. */
renderedRealPaths: string[];
}

/**
* One render pass over the source and everything it links to, with the linked previews already
* written. Separate from `runRender` so that `--watch` can repeat exactly this step: every call
* builds a fresh follow queue, so a link added or removed since the last pass is picked up.
*/
async function renderDocuments(
invocation: Invocation,
configuration: Configuration,
out: OutputStreams,
logger: Logger,
): Promise<number> {
reload?: RenderOptions['reload'],
): Promise<RenderedDocuments> {
const { bytes, label } = await readSource(invocation, configuration, logger);

if (bytes.byteLength > WARN_BYTES) {
Expand All @@ -304,8 +320,6 @@ async function runRender(

const markdown = decodeMarkdown(bytes, label);
const realPath = invocation.source === '-' ? undefined : label;
const toStdout = invocation.output === '-';
const toFile = invocation.output !== undefined && !toStdout;
// The configured default is suppressed by `--output` rather than rejected like the explicit
// flag: a single self-contained file cannot hold the linked previews, and a configuration
// meant as a default must not make `--output` unusable.
Expand All @@ -324,9 +338,11 @@ async function runRender(
// the shared cache.
embedMode: invocation.output === undefined ? 'cache' : 'inline',
followLinks: followQueue,
reload,
});

const collectedMessages = [...messages];
const renderedRealPaths = realPath === undefined ? [] : [realPath];

if (followQueue !== undefined) {
for (
Expand All @@ -342,9 +358,11 @@ async function runRender(
linkMode: 'absolute',
embedMode: 'cache',
followLinks: followQueue,
reload,
});

writePreview(previewPathFor(queued.realPath), linked.html);
renderedRealPaths.push(queued.realPath);

const documentLabel = relative(process.cwd(), queued.realPath);
collectedMessages.push(...linked.messages.map((message) => `${documentLabel}: ${message}`));
Expand All @@ -353,13 +371,34 @@ async function runRender(

reportMessages(collectedMessages, logger);

if (toStdout) {
return {
html,
// stdin has no path to key the preview on, so its content decides: two different documents
// piped in must not overwrite each other's tab.
previewPath:
realPath === undefined
? join(previewDirectory(), `${createHash('sha256').update(markdown).digest('hex')}.html`)
: previewPathFor(realPath),
realPath,
renderedRealPaths,
};
}

async function runRender(
invocation: Invocation,
configuration: Configuration,
out: OutputStreams,
logger: Logger,
): Promise<number> {
const { html, previewPath } = await renderDocuments(invocation, configuration, logger);

if (invocation.output === '-') {
out.stdout(html);

return EXIT_SUCCESS;
}

if (toFile && invocation.output !== undefined) {
if (invocation.output !== undefined) {
const target = isAbsolute(invocation.output) ? invocation.output : resolve(invocation.output);

try {
Expand All @@ -373,11 +412,6 @@ async function runRender(
return EXIT_SUCCESS;
}

const previewPath =
realPath === undefined
? join(previewDirectory(), `${createHash('sha256').update(markdown).digest('hex')}.html`)
: previewPathFor(realPath);

writePreview(previewPath, html);

// Never string concatenation: it breaks on Windows paths, spaces and umlauts.
Expand All @@ -394,6 +428,39 @@ async function runRender(
return EXIT_SUCCESS;
}

async function runWatchSession(
invocation: Invocation,
configuration: Configuration,
logger: Logger,
signal: AbortSignal,
onFirstRender: () => void,
): Promise<number> {
let pinned = invocation;

await runWatch({
logger,
signal,
onFirstRender,
async render(reloadUrl) {
const rendered = await renderDocuments(pinned, configuration, logger, { url: reloadUrl });

writePreview(rendered.previewPath, rendered.html);
// Pinned to the path the first render resolved: called without a file, a session that
// started on `README.md` must not silently switch to an `index.md` created later. `--watch`
// is rejected for stdin, so there is always a path to pin. Retargeting the symlink a
// session was started through is knowingly not followed.
pinned = { ...pinned, source: rendered.realPath ?? pinned.source };

return {
previewUrl: pathToFileURL(rendered.previewPath).href,
renderedRealPaths: rendered.renderedRealPaths,
};
},
});

return EXIT_SUCCESS;
}

/**
* `checkForUpdate` is injectable for the same reason `out` is: the real one talks to GitHub
* and to the shared temp directory, neither of which a test may touch.
Expand Down Expand Up @@ -436,10 +503,55 @@ export async function main(
logger,
});

// A watch session reports right after its first render instead of at shutdown, where the hint
// would scroll past minutes later; the wrapper keeps the `finally` below from repeating it.
let reported = false;
const reportUpdateOnce = (): void => {
if (reported) {
return;
}

reported = true;
updateCheck.report();
};

// The configured default is suppressed by `--output` and by stdin rather than rejected like the
// explicit flag: neither can carry a session, and a default must not make them unusable.
const watch =
parsed.value.watch ??
(configuration.watch === true &&
parsed.value.output === undefined &&
parsed.value.source !== '-');

try {
return await runRender(parsed.value, configuration, out, logger);
if (!watch) {
return await runRender(parsed.value, configuration, out, logger);
}

const controller = new AbortController();
const abort = (): void => {
controller.abort();
};

// `once` on both, so a second Ctrl+C during shutdown reaches the default handler and kills
// the process rather than being swallowed.
process.once('SIGINT', abort);
process.once('SIGTERM', abort);

try {
return await runWatchSession(
parsed.value,
configuration,
logger,
controller.signal,
reportUpdateOnce,
);
} finally {
process.off('SIGINT', abort);
process.off('SIGTERM', abort);
}
} finally {
updateCheck.report();
reportUpdateOnce();
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
Expand Down
80 changes: 53 additions & 27 deletions src/cli/commands/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { boolean, command, flag, optional, positional } from 'cmd-ts';
import type { ParseContext, ParsingResult } from 'cmd-ts/dist/cjs/argparser';
import type { AstNode } from 'cmd-ts/dist/cjs/newparser/parser';
import { DEFAULT_FLAVOR_NAME } from '../../flavors/index.ts';
import { MAT_VERSION, type ThemeName } from '../../generated/assets.ts';
import { directoryType, flavorType, pathType, themeType } from '../argument-types.ts';
Expand All @@ -13,6 +14,7 @@ export interface Invocation {
flavor: string;
baseDir: string | undefined;
followLinks: boolean | undefined;
watch: boolean | undefined;
}

const renderArguments = command({
Expand All @@ -27,6 +29,10 @@ const renderArguments = command({
description: 'Also render every local Markdown file the document links to',
command: 'mat README.md -f',
},
{
description: 'Keep re-rendering and reload the browser on every change',
command: 'mat README.md -w',
},
{
description: 'Write a self-contained file and open nothing',
command: 'mat README.md --output readme.html',
Expand Down Expand Up @@ -70,49 +76,69 @@ const renderArguments = command({
description:
'Also render linked local Markdown files and point their links at the previews. =false overrides the configuration file.',
}),
watch: flag({
long: 'watch',
short: 'w',
// `optional` for the same reason as `--follow-links` above.
type: optional(boolean),
description:
'Re-render whenever a rendered file changes and reload the browser tab, until Ctrl+C. =false overrides the configuration file.',
}),
},
handler: (invocation): Invocation => invocation,
});

function usageError(nodes: AstNode[], message: string): ParsingResult<never> {
return { _tag: 'error', error: { errors: [{ nodes, message }] } };
}

/**
* The command, plus the one rule that no single argument can check on its own. Reporting it from
* `parse` rather than from the handler puts it in the same error box as every other usage error.
* The command, plus the rules that no single argument can check on its own. Reporting them from
* `parse` rather than from the handler puts them in the same error box as every other usage error.
*/
export const renderCommand = {
...renderArguments,
async parse(context: ParseContext): Promise<ParsingResult<Invocation>> {
const parsed = await renderArguments.parse(context);

if (parsed._tag === 'ok' && parsed.value.baseDir !== undefined && parsed.value.source !== '-') {
// Whenever a file is rendered — named or defaulted to — the base is that file's directory;
if (parsed._tag !== 'ok') {
return parsed;
}

if (parsed.value.baseDir !== undefined && parsed.value.source !== '-') {
// Whenever a file is rendered, named or defaulted to, the base is that file's directory;
// an override would silently resolve images against a directory it knows nothing about.
return {
_tag: 'error',
error: {
errors: [
{
nodes: longOptionsNamed(context.nodes, 'base-dir'),
message: '--base-dir is only valid together with -',
},
],
},
};
return usageError(
longOptionsNamed(context.nodes, 'base-dir'),
'--base-dir is only valid together with -',
);
}

if (parsed._tag === 'ok' && parsed.value.followLinks && parsed.value.output !== undefined) {
if (parsed.value.followLinks && parsed.value.output !== undefined) {
// `--output` produces a single self-contained file; following links needs one preview file
// per document, which that contract cannot hold.
return {
_tag: 'error',
error: {
errors: [
{
nodes: optionsNamed(context.nodes, 'follow-links', 'f'),
message: '--follow-links is only valid without --output',
},
],
},
};
return usageError(
optionsNamed(context.nodes, 'follow-links', 'f'),
'--follow-links is only valid without --output',
);
}

if (parsed.value.watch && parsed.value.output !== undefined) {
// `--output` writes once and opens nothing, so there is neither a tab to reload nor a reason
// to keep the process alive.
return usageError(
optionsNamed(context.nodes, 'watch', 'w'),
'--watch is only valid without --output',
);
}

if (parsed.value.watch && parsed.value.source === '-') {
// A pipe is read once and has no path to watch. This rules out `--base-dir` along with it,
// since that one only exists together with `-`.
return usageError(
optionsNamed(context.nodes, 'watch', 'w'),
'--watch is only valid without -',
);
}

return parsed;
Expand Down
Loading