add --inline
refactor css.js and html.js
This commit is contained in:
parent
232dbe13eb
commit
b172d16ca9
|
@ -141,7 +141,7 @@ gmi-web --config web.json $(find ~/gmi/dst -name '*.gmi')
|
|||
|
||||
# gmi.css
|
||||
|
||||
The CSS variables exposed by gmi-web(1) are derived from [gmi.css](./gmi.css), which could be used on its own. The default values are optimized for readability and mobile-friendliness and may be customized by adding a style property to `<html>`.
|
||||
The CSS variables exposed by gmi-web(1) are derived from [gmi.css](./gmi.css), which could be used independently. The default values are optimized for readability and mobile-friendliness and may be customized by adding a style property to `<html>`.
|
||||
|
||||
```html
|
||||
<head>
|
||||
|
|
9
cli.js
9
cli.js
|
@ -47,6 +47,9 @@ const cli = yargs(process.argv.slice(2))
|
|||
default: "full",
|
||||
requiresArg: true,
|
||||
},
|
||||
inline: {
|
||||
type: "boolean",
|
||||
},
|
||||
image: {
|
||||
type: "array",
|
||||
requiresArg: true,
|
||||
|
@ -92,6 +95,12 @@ const argv = cli
|
|||
.showHelpOnFail(true)
|
||||
.help().argv;
|
||||
|
||||
if (argv.inline && argv.css === "full") {
|
||||
cli.showHelp();
|
||||
console.error(`\n--inline is not compatible with --css full`);
|
||||
cli.exit(1);
|
||||
}
|
||||
|
||||
if (!argv.html && !argv.body) {
|
||||
cli.showHelp();
|
||||
console.error(`\nMissing required argument: --html or --body`);
|
||||
|
|
88
css.js
88
css.js
|
@ -3,31 +3,28 @@ import path from "path";
|
|||
import { stringify, parse } from "css";
|
||||
export { stringify, parse };
|
||||
|
||||
// TODO import.meta.resolve is supposed to accomplish this without "path"
|
||||
const GMI_CSS = path.resolve(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
"./gmi.css"
|
||||
);
|
||||
export const CORE = parse(`p, pre, ul, blockquote, h1, h2, h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
export const CORE = parse(`p,
|
||||
a,
|
||||
pre,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
ul,
|
||||
blockquote,
|
||||
img,
|
||||
audio,
|
||||
video {
|
||||
a { display: block; }
|
||||
|
||||
img, audio, video {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}`);
|
||||
}
|
||||
`);
|
||||
|
||||
export const FULL = parse(readFileSync(GMI_CSS, "utf-8"));
|
||||
// TODO import.meta.resolve is supposed to accomplish this without "path"
|
||||
export const FULL = parse(
|
||||
readFileSync(
|
||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "./gmi.css"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
|
||||
export function rootVariables({ stylesheet }) {
|
||||
return stylesheet.rules
|
||||
|
@ -43,6 +40,51 @@ export function rootVariables({ stylesheet }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function resolve(arg, options) {
|
||||
return stringify(parse(readFileSync(path.resolve(arg), "utf-8")), options);
|
||||
export function override(options) {
|
||||
if (options.css !== "full") return "";
|
||||
const vars = rootVariables(FULL);
|
||||
|
||||
return Object.keys(vars).reduce((styles, key) => {
|
||||
if (typeof options[key] !== undefined && options[key] !== vars[key]) {
|
||||
let value = options[key];
|
||||
if (["a-prefix", "ul-bullet"].includes(key) && value !== "none") {
|
||||
value = `"${value}"`;
|
||||
}
|
||||
styles += `--${key}:${value};`;
|
||||
}
|
||||
return styles;
|
||||
}, "");
|
||||
}
|
||||
|
||||
export function style({ css }) {
|
||||
if (css === "none") return "";
|
||||
if (css === "core")
|
||||
return `<style>${stringify(CORE, { compress: true })}</style>`;
|
||||
if (css === "full")
|
||||
return `<style>${stringify(FULL, { compress: true })}</style>`;
|
||||
|
||||
return `<style>${stringify(resolve(css, { compress: true }))}</style>`;
|
||||
}
|
||||
|
||||
export function inline(tag, css) {
|
||||
const { stylesheet } =
|
||||
css === "full" ? FULL : css === "core" ? CORE : resolve(css);
|
||||
const styles = stylesheet.rules
|
||||
.filter(({ type, selectors }) => type === "rule" && selectors.includes(tag))
|
||||
.reduce((style, { declarations }) => {
|
||||
declarations.forEach(({ property, value }) => {
|
||||
style = `${style}${property}:${value};`;
|
||||
});
|
||||
return style;
|
||||
}, "");
|
||||
|
||||
return styles !== "" ? ` style="${styles}"` : "";
|
||||
}
|
||||
|
||||
export function load(file, options) {
|
||||
if (fs.existsSync(mode)) {
|
||||
return parse(readFileSync(path.resolve(file), "utf-8"));
|
||||
} else {
|
||||
throw new Error(`Cannot find file ${mode}.`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@ gmi-web - A bridge between Gemini and HTML
|
|||
|
||||
# DESCRIPTION
|
||||
|
||||
Convert Gemtext to semantic HTML styled in a readable, predictable and
|
||||
mobile-friendly fashion!
|
||||
Convert Gemtext to semantic HTML styled in a readable and mobile-friendly
|
||||
fashion!
|
||||
|
||||
# OPTIONS
|
||||
|
||||
|
@ -24,12 +24,12 @@ mobile-friendly fashion!
|
|||
Generate a full HTML5 document with the provided _LANG_. *--dir* can be used
|
||||
to adjust the document text direction from "ltr" to "rtl".
|
||||
|
||||
Use *--author* _NAME_ to set the author <meta> tag on every file.
|
||||
|
||||
Use *--descriptions* _LIMIT_ to apply the first non-empty text line of each
|
||||
file as the description <meta> tag. _LIMIT_ will be used to truncate the text
|
||||
with an ellipsis at that number of characters.
|
||||
|
||||
Use *--author* _NAME_ to set the author <meta> tag on every file.
|
||||
|
||||
*--css* [_MODE_|_FILE_]
|
||||
By default this will be set to *full* enabling a handful of customizable
|
||||
variables. See *--help* for the complete list.
|
||||
|
@ -41,11 +41,15 @@ gmi-web --html en \\
|
|||
```
|
||||
|
||||
Choosing *core* will use just what is needed to fix vertical layout issues
|
||||
with CSS 2.1's Normal Flow and inline elements. Pointing to a .css _FILE_
|
||||
will use those styles.
|
||||
with CSS 2.1's Normal Flow and inline elements. Choosing *none* will not
|
||||
include any style information.
|
||||
|
||||
Choosing *none* will not include any style information including when paired
|
||||
with *--body* where it will not apply the core inline styles.
|
||||
*--inline* will insert the declarations as "style" properties on their
|
||||
respective tags. This is the sole behavior when using *--body* and can be
|
||||
turned off by using --css *none*. When using --css *full* this feature is
|
||||
unavailable.
|
||||
|
||||
Pointing to a .css _FILE_ will use those styles and also works with *--inline*
|
||||
|
||||
*[--image|--audio|--video]* _EXTENSIONS_
|
||||
Include media extensions inline. You can provide multiple extensions per flag
|
||||
|
|
45
gmi.css
45
gmi.css
|
@ -1,3 +1,30 @@
|
|||
p,
|
||||
pre,
|
||||
ul,
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
img,
|
||||
audio,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--body-width: 48rem;
|
||||
--serif: georgia, times, serif;
|
||||
|
@ -36,24 +63,6 @@ body {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
p,
|
||||
a,
|
||||
pre,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
ul,
|
||||
blockquote,
|
||||
img,
|
||||
audio,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
|
|
150
html.js
150
html.js
|
@ -8,6 +8,11 @@ export const GMI_REGEX = /^((=>\s?(?<href>[^\s]+)(\s(?<title>.+))?)|(?<pre>```\s
|
|||
export function toHTML(gemtext, options) {
|
||||
const tokens = gemtext.split("\n").map((line) => GMI_REGEX.exec(line).groups);
|
||||
|
||||
if (options.body) return body(tokens, options);
|
||||
|
||||
const truncate = (text, limit) =>
|
||||
text.length > limit ? `${text.substring(0, limit)}...` : text;
|
||||
|
||||
let description =
|
||||
options.descriptions > 0
|
||||
? tokens.find((token) => {
|
||||
|
@ -15,31 +20,10 @@ export function toHTML(gemtext, options) {
|
|||
})
|
||||
: false;
|
||||
|
||||
if (options.body) return body(tokens, options);
|
||||
|
||||
const truncate = (text, limit) =>
|
||||
text.length > limit ? `${text.substring(0, limit)}...` : text;
|
||||
|
||||
function overrideStyles(options) {
|
||||
if (options.css !== "full") return "";
|
||||
const vars = CSS.rootVariables(CSS.FULL);
|
||||
|
||||
return Object.keys(vars).reduce((styles, key) => {
|
||||
if (typeof options[key] !== undefined && options[key] !== vars[key]) {
|
||||
let value = options[key];
|
||||
if (["a-prefix", "ul-bullet"].includes(key) && value !== "none") {
|
||||
value = `"${value}"`;
|
||||
}
|
||||
styles += `--${key}:${value};`;
|
||||
}
|
||||
return styles;
|
||||
}, "");
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${options.language}" dir="${options.dir}" style='${overrideStyles(
|
||||
options
|
||||
)}'>
|
||||
<html lang="${options.language}" dir="${options.dir}" style='${
|
||||
options.inline ? CSS.inline("html", options.css) : CSS.override(options)
|
||||
}'>
|
||||
<head>${head(
|
||||
Object.assign(options, {
|
||||
title: tokens[0].h1,
|
||||
|
@ -47,7 +31,7 @@ export function toHTML(gemtext, options) {
|
|||
description && truncate(description.text, options.descriptions),
|
||||
})
|
||||
)}</head>
|
||||
<body>
|
||||
<body${options.inline ? CSS.inline("body", options.css) : ""}>
|
||||
${body(tokens, options)}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -62,7 +46,7 @@ ${
|
|||
options.schemes || options.css === "full"
|
||||
? `<meta name="color-scheme" content="dark light">\n`
|
||||
: ""
|
||||
}${style(options.css)}
|
||||
}${!options.inline ? CSS.style(options) : ""}
|
||||
<title>${options.title}</title>${
|
||||
!options.author ? "" : `\n<meta name="author" content="${options.author}">`
|
||||
}${
|
||||
|
@ -77,83 +61,95 @@ ${
|
|||
`;
|
||||
}
|
||||
|
||||
export function style(mode) {
|
||||
if (mode === "none") return "";
|
||||
if (mode === "core")
|
||||
return `<style>${CSS.stringify(CSS.CORE, { compress: true })}</style>`;
|
||||
if (mode === "full")
|
||||
return `<style>${CSS.stringify(CSS.FULL, { compress: true })}</style>`;
|
||||
if (fs.existsSync(mode)) {
|
||||
return `<style>${CSS.resolve(mode, { compress: true })}</style>`;
|
||||
} else {
|
||||
throw new Error(`Cannot find file ${mode}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function line(
|
||||
function block(
|
||||
{ text, href, title, pre, alt, h1, h2, h3, li, quote },
|
||||
{ image, audio, video, css, body } = {}
|
||||
{ image, audio, video, css, body, inline } = {}
|
||||
) {
|
||||
if (text) return `<p>${escape(text)}</p>`;
|
||||
|
||||
let type = "p";
|
||||
let props = "";
|
||||
let content = "<br>";
|
||||
if (text && text !== "") {
|
||||
content = text;
|
||||
}
|
||||
if (h1) {
|
||||
type = "h1";
|
||||
content = h1;
|
||||
}
|
||||
if (h2) {
|
||||
type = "h2";
|
||||
content = h2;
|
||||
}
|
||||
if (h3) {
|
||||
type = "h3";
|
||||
content = h3;
|
||||
}
|
||||
if (li) {
|
||||
type = "li";
|
||||
content = li;
|
||||
}
|
||||
if (quote) {
|
||||
type = "blockquote";
|
||||
content = quote;
|
||||
}
|
||||
if (href) {
|
||||
const titleProp = title ? ` title="${title}"` : "";
|
||||
const FIX_NORMAL_FLOW =
|
||||
body && css !== "none" ? ` style="display: block; max-width: 100%;"` : "";
|
||||
const matchesExt = (url, exts) =>
|
||||
exts.some((ext) => new RegExp(`\.${ext}$`).test(url));
|
||||
|
||||
if (image && matchesExt(href, image)) {
|
||||
return `<img src="${href}"${titleProp}${FIX_NORMAL_FLOW}/>`;
|
||||
type = "img";
|
||||
props += ` src=${href}` + alt ? ` title=${alt}` : "";
|
||||
} else if (audio && matchesExt(href, audio)) {
|
||||
type = "audio";
|
||||
props += ` src=${href}` + alt ? ` title=${alt}` : "";
|
||||
} else if (video && matchesExt(href, video)) {
|
||||
type = "video";
|
||||
props += ` src=${href}` + alt ? ` title=${alt}` : "";
|
||||
} else {
|
||||
type = "a";
|
||||
content = title || href;
|
||||
props += ` href=${href}`;
|
||||
}
|
||||
if (audio && matchesExt(href, audio)) {
|
||||
return `<audio controls src="${href}"${titleProp}${FIX_NORMAL_FLOW}></audio>`;
|
||||
}
|
||||
if (video && matchesExt(href, video)) {
|
||||
return `<video controls src="${href}"${titleProp}${FIX_NORMAL_FLOW}/></video>`;
|
||||
}
|
||||
|
||||
return `<a href="${href}"${FIX_NORMAL_FLOW}>${
|
||||
title ? escape(title) : href
|
||||
}</a>`;
|
||||
}
|
||||
if (body || inline) props += CSS.inline(type, css);
|
||||
|
||||
if (h1) return `<h1>${escape(h1)}</h1>`;
|
||||
if (h2) return `<h2>${escape(h2)}</h2>`;
|
||||
if (h3) return `<h3>${escape(h3)}</h3>`;
|
||||
if (li) return `<li>${escape(li)}</li>`;
|
||||
|
||||
if (quote) return `<blockquote>${escape(quote)}</blockquote>`;
|
||||
|
||||
return `<p><br></p>`;
|
||||
return `<${type}${props}>${
|
||||
content !== "<br>" ? escape(content) : content
|
||||
}</${type}>`;
|
||||
}
|
||||
|
||||
export function body(tokens, options) {
|
||||
let lines = [];
|
||||
let blocks = [];
|
||||
|
||||
let cursor = tokens.shift();
|
||||
while (tokens.length) {
|
||||
if (cursor.pre) {
|
||||
lines.push(`<pre${cursor.alt ? ` title="${cursor.alt}"` : ""}>`);
|
||||
blocks.push(
|
||||
`<pre${cursor.alt ? ` title="${cursor.alt}"` : ""}${
|
||||
options.body || options.inline ? CSS.inline("ul", options.css) : ""
|
||||
}>`
|
||||
);
|
||||
const closing = tokens.findIndex((token) => token.pre);
|
||||
lines = lines.concat(tokens.slice(0, closing).map(({ text }) => text));
|
||||
lines.push("</pre>");
|
||||
blocks = blocks.concat(tokens.slice(0, closing).map(({ text }) => text));
|
||||
blocks.push("</pre>");
|
||||
tokens = tokens.slice(closing + 1);
|
||||
} else if (cursor.li) {
|
||||
lines.push(`<ul>`);
|
||||
blocks.push(
|
||||
`<ul${
|
||||
options.body || options.inline ? CSS.inline("ul", options.css) : ""
|
||||
}>`
|
||||
);
|
||||
const closing = tokens.findIndex((token) => !token.li);
|
||||
lines = lines
|
||||
.concat([line(cursor)])
|
||||
.concat(tokens.slice(0, closing).map(line));
|
||||
lines.push("</ul>");
|
||||
blocks = blocks
|
||||
.concat([block(cursor)])
|
||||
.concat(tokens.slice(0, closing).map(block));
|
||||
blocks.push("</ul>");
|
||||
tokens = tokens.slice(closing);
|
||||
} else {
|
||||
lines.push(line(cursor, options));
|
||||
blocks.push(block(cursor, options));
|
||||
}
|
||||
cursor = tokens.shift();
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
export const GMI_EXT = /\.gmi$/;
|
||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gmi-web-cli",
|
||||
"version": "1.0.8-rc.2",
|
||||
"version": "1.0.10-rc.2",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
Loading…
Reference in a new issue