Skip to content

Commit

Permalink
Merge pull request #78 from jannis-baum/issue/35-new-config-options
Browse files Browse the repository at this point in the history
  • Loading branch information
jannis-baum authored Jul 19, 2024
2 parents 696537f + ee05606 commit 9dc79ac
Show file tree
Hide file tree
Showing 10 changed files with 293 additions and 72 deletions.
43 changes: 1 addition & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ features!
support](#editor-support))
- Vivify server starts lazily and automatically shuts down when no more viewers
are connected
- various [config options](#config)
- various [customization options](docs/customization.md)

If you need any additional features, feel free to [open an
issue](https://github.com/jannis-baum/vivify/issues/new/choose) or
Expand Down Expand Up @@ -54,47 +54,6 @@ list!

- for Vim and Neovim: [vivify.vim](https://github.com/jannis-baum/vivify.vim)

### Config

Vivify will look for an optional config file at `~/.vivify/config.json` and
`~/.vivify.json`. This file should contain a JSON object that can have the
following optional keys:

- **`"styles"`**\
a path to a custom style sheet, see [the default
styles](./static/) for examples
- **`"port"`**\
the port Vivify's server should run on; this will be overwritten by
the environment variable `VIV_PORT` (default is 31622)
- **`"timeout"`**\
how long the server should wait in ms before shutting down after
the last client disconnected; this will be overwritten by the environment
variable `VIV_TIMEOUT` (default is 10000)
- **`"katexOptions"`**\
[available KaTeX options](https://katex.org/docs/options.html), such as

```json
{
"errorColor": "#cc0000",
"macros": {
"\\RR": "\\mathbb{R}"
}
}
```

- **`"pageTitle"`**\
JavaScript code that will be evaluated to determine the viewer's page title.
Here, the variable `components` is set to a string array of path components
for the current file, e.g. `['/', 'Users', 'you', 'file.txt']`. If this
evaluation fails, the title will be *custom title error* and you will see the
error message on the page. The default title are the last two components
joined with the path separator, e.g. `you/file.txt`
- **`"mdExtensions"`**\
An array of file extensions that Vivify will parse as Markdown. All other
files will be displayed as monospaced text with code highlighting if
available. Default Markdown extensions are `['markdown', 'md', 'mdown',
'mdwn', 'mkd', 'mkdn']`

## Installation

Once you have Vivify installed, use it by running `viv` with any text file or
Expand Down
File renamed without changes.
55 changes: 55 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Customizing Vivify

Vivify offers various configuration options. It aims to have sensible defaults
while being built for maximal customizability.

Vivify will look for an optional config file at `~/.vivify/config.json` and
`~/.vivify.json`. This file should contain a JSON object that can have the
following optional keys:

- **`"styles"`**\
A path to a single custom style sheet, or an array of multiple style sheets
applied in order. These will be applied after Vivify's [default
styles](./static/) are applied so that there are always sensible fallbacks but
you can override everything.
- **`"scripts"`**\
A path to a single custom JavaScript to inject into the viewing pages, or an
array of multiple custom scripts.
- **`"dirListIgnore"`**\
A path to a file with globs to ignore in Vivify's directory viewer, or an
array of multiple paths to ignore files. The syntax here is the same as in
`.gitignore` files.
- **`"port"`**\
The port Vivify's server should run on; this will be overwritten by
the environment variable `VIV_PORT` (default is 31622)
- **`"timeout"`**\
How long the server should wait in milliseconds before shutting down after the
last client disconnected; this will be overwritten by the environment variable
`VIV_TIMEOUT` (default is 10000)
- **`"katexOptions"`**\
[Available KaTeX options](https://katex.org/docs/options.html), such as

```json
{
"errorColor": "#cc0000",
"macros": {
"\\RR": "\\mathbb{R}"
}
}
```

- **`"pageTitle"`**\
JavaScript code that will be evaluated to determine the viewer's page title.
Here, the variable `components` is set to a string array of path components
for the current file, e.g. `['~', 'some', 'path', 'file.txt']`. If this
evaluation fails, the title will be *custom title error* and you will see the
error message on the page. The default title are the last two components
joined with the path separator, e.g. `path/file.txt`
- **`"mdExtensions"`**\
An array of file extensions that Vivify will render as Markdown. All other
files (except for Jupyter Notebooks) will be displayed as monospaced text with
code highlighting if available. The default Markdown extensions are
`['markdown', 'md', 'mdown', 'mdwn', 'mkd', 'mkdn']`
- **`"preferHomeTilde"`**\
Prefer using `~` as a placeholder for your home directory in URLs as well as
the `components` for `"pageTitle"` (default is `true`)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@viz-js/viz": "^3.7.0",
"ansi_up": "^6.0.2",
"express": "^4.19.2",
"glob": "10.4.5",
"highlight.js": "^11.10.0",
"katex": "^0.16.11",
"markdown-it": "^14.1.0",
Expand Down
6 changes: 3 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { router as healthRouter } from './routes/health.js';
import { router as staticRouter } from './routes/static.js';
import { router as viewerRouter } from './routes/viewer.js';
import { setupSockets } from './sockets.js';
import { pathToURL, urlToPath } from './utils/path.js';
import { pathToURL, preferredPath, urlToPath } from './utils/path.js';
import { existsSync } from 'fs';

const app = express();
Expand Down Expand Up @@ -48,8 +48,8 @@ const openArgs = async () => {
console.log(`File not found: ${path}`);
return;
}
const absolute = presolve(path);
const url = `${address}${pathToURL(absolute)}`;
const target = preferredPath(presolve(path));
const url = `${address}${pathToURL(target)}`;
await open(url);
}),
);
Expand Down
35 changes: 27 additions & 8 deletions src/parser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ import path from 'path';

type Config = {
styles?: string;
scripts?: string;
dirListIgnore?: string[];
port: number;
timeout: number;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
katexOptions?: any;
pageTitle?: string;
mdExtensions: string[];
timeout: number;
preferHomeTilde: boolean;
};

const defaultConfig: Config = {
port: 31622,
mdExtensions: ['markdown', 'md', 'mdown', 'mdwn', 'mkd', 'mkdn'],
timeout: 10000,
preferHomeTilde: true,
};

const envConfigs: [string, keyof Config][] = [
Expand All @@ -28,9 +32,23 @@ const configPaths = [
path.join(homedir(), '.vivify.json'),
];

// read contents of file at paths or files at paths
const getFileContents = (paths: string[] | string | undefined): string => {
if (paths === undefined) return '';
const getFileContent = (p: string): string => {
const resolved = p[0] === '~' ? path.join(homedir(), p.slice(1)) : p;
return fs.existsSync(resolved) ? fs.readFileSync(resolved, 'utf8') : '';
};

if (Array.isArray(paths)) {
return paths.map(getFileContent).join('\n');
}
return getFileContent(paths);
};

const getConfig = (): Config => {
let config = undefined;
// greedily get config
// greedily find config
for (const cp of configPaths) {
if (!fs.existsSync(cp)) continue;
try {
Expand All @@ -41,12 +59,13 @@ const getConfig = (): Config => {

if (config === undefined) return defaultConfig;

// get styles
if (config.styles && config.styles.length > 0) {
const stylePath =
config.styles[0] === '~' ? path.join(homedir(), config.styles.slice(1)) : config.styles;
config.styles = fs.existsSync(stylePath) ? fs.readFileSync(stylePath, 'utf8') : '';
}
// get styles, scripts and ignore files
config.styles = getFileContents(config.styles);
config.scripts = getFileContents(config.scripts);
config.dirListIgnore = getFileContents(config.dirListIgnore)
.split('\n')
.filter((pattern) => pattern !== '' && pattern[0] !== '#');

// fill missing values from default config
for (const [key, value] of Object.entries(defaultConfig)) {
if (config[key] === undefined) config[key] = value;
Expand Down
16 changes: 12 additions & 4 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Dirent, readdirSync } from 'fs';
import { Dirent } from 'fs';
import { homedir } from 'os';
import { join as pjoin } from 'path';
import { pathToURL } from '../utils/path.js';
import config from './config.js';
import renderNotebook from './ipynb.js';
import renderMarkdown from './markdown.js';
import { globSync } from 'glob';

export type Renderer = (content: string) => string;

Expand Down Expand Up @@ -38,13 +39,20 @@ export function renderTextFile(content: string, path: string): string {
}

const dirListItem = (item: Dirent, path: string) =>
`<li class="dir-list-${item.isDirectory() ? 'directory' : 'file'}"><a href="${pathToURL(
`<li class="dir-list-${item.isDirectory() ? 'directory' : 'file'}" name="${item.name}"><a href="${pathToURL(
pjoin(path, item.name),
)}">${item.name}</a></li>`;

export function renderDirectory(path: string): string {
const list = readdirSync(path, { withFileTypes: true })
.sort((a, b) => +b.isDirectory() - +a.isDirectory())
const list = globSync('*', {
cwd: path,
withFileTypes: true,
ignore: config.dirListIgnore,
dot: true,
maxDepth: 1,
})
// sort directories first and alphabetically in one combined smart step
.sort((a, b) => +b.isDirectory() - +a.isDirectory() || a.name.localeCompare(b.name))
.map((item) => dirListItem(item, path))
.join('\n');
return wrap(
Expand Down
22 changes: 16 additions & 6 deletions src/routes/viewer.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { lstatSync, readFileSync } from 'fs';
import { dirname as pdirname, join as pjoin } from 'path';
import { homedir } from 'os';

import { Request, Response, Router } from 'express';

import { messageClientsAt } from '../app.js';
import config from '../parser/config.js';
import { pathToURL, pcomponents, pmime } from '../utils/path.js';
import { absPath, pathToURL, pcomponents, pmime, preferredPath } from '../utils/path.js';
import { renderDirectory, renderTextFile } from '../parser/parser.js';

export const router = Router();

const liveContent = new Map<string, string>();

const pageTitle = (path: string) => {
const comps = pcomponents(path);
const comps = pcomponents(preferredPath(path));
if (config.pageTitle) {
return eval(`
const components = ${JSON.stringify(comps)};
Expand All @@ -22,6 +23,16 @@ const pageTitle = (path: string) => {
} else return pjoin(...comps.slice(-2));
};

if (config.preferHomeTilde) {
router.use((req, res, next) => {
if (req.method === 'GET' && req.path.startsWith(homedir())) {
res.redirect(req.baseUrl + req.path.replace(homedir(), '/~'));
} else {
next();
}
});
}

router.get(/.*/, async (req: Request, res: Response) => {
const path = res.locals.filepath;

Expand Down Expand Up @@ -63,9 +74,7 @@ router.get(/.*/, async (req: Request, res: Response) => {
<link rel="stylesheet" type="text/css" href="/static/highlight.css">
<link rel="stylesheet" type="text/css" href="/static/ipynb.css">
<link rel="stylesheet" type="text/css" href="/static/katex/katex.css">
<style>
${config.styles}
</style>
${config.styles ? `<style type="text/css">${config.styles}</style>` : ''}
<body>
<a id="parent-dir" href="${pathToURL(pdirname(path))}">↩</a>
<div id="body-content">
Expand All @@ -74,8 +83,9 @@ router.get(/.*/, async (req: Request, res: Response) => {
</body>
<script>
window.VIV_PORT = "${config.port}";
window.VIV_PATH = "${req.path}";
window.VIV_PATH = "${absPath(req.path)}";
</script>
${config.scripts ? `<script type="text/javascript">${config.scripts}</script>` : ''}
<script type="text/javascript" src="/static/client.js"></script>
</html>
`);
Expand Down
18 changes: 12 additions & 6 deletions src/utils/path.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execSync } from 'child_process';
import { homedir } from 'os';
import { basename as pbasename, dirname as pdirname, parse as pparse } from 'path';
import config from '../parser/config.js';

export const pmime = (path: string) => execSync(`file --mime-type -b '${path}'`).toString().trim();

Expand All @@ -9,7 +10,7 @@ export const pcomponents = (path: string) => {
const components = new Array<string>();
// directory
let dir = parsed.dir;
while (dir !== '/' && dir !== '') {
while (dir !== '/' && dir !== '.') {
components.unshift(pbasename(dir));
dir = pdirname(dir);
}
Expand All @@ -20,12 +21,17 @@ export const pcomponents = (path: string) => {
return components;
};

export const absPath = (path: string) => path.replace(/^\/~/, homedir()).replace(/\/+$/, '');

export const urlToPath = (url: string) => {
const path = decodeURIComponent(url.replace(/^\/(viewer|health)/, ''))
.replace(/^\/~/, homedir())
.replace(/\/+$/, '');
const path = absPath(decodeURIComponent(url.replace(/^\/(viewer|health)/, '')));
return path === '' ? '/' : path;
};

export const pathToURL = (path: string, route: string = 'viewer') =>
`/${route}${encodeURIComponent(path).replaceAll('%2F', '/')}`;
export const pathToURL = (path: string, route: string = 'viewer') => {
const withoutPrefix = path.startsWith('/') ? path.slice(1) : path;
return `/${route}/${encodeURIComponent(withoutPrefix).replaceAll('%2F', '/')}`;
};

export const preferredPath = (path: string): string =>
config.preferHomeTilde && path.startsWith(homedir()) ? path.replace(homedir(), '~') : path;
Loading

0 comments on commit 9dc79ac

Please sign in to comment.