add --inline

refactor css.js and html.js
This commit is contained in:
Talon Poole 2021-02-18 16:43:25 +00:00
parent 232dbe13eb
commit b172d16ca9
7 changed files with 188 additions and 128 deletions

View file

@ -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
View file

@ -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
View file

@ -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}.`);
}
}

View file

@ -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
View file

@ -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
View file

@ -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
View file

@ -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": {