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 # 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 ```html
<head> <head>

9
cli.js
View file

@ -47,6 +47,9 @@ const cli = yargs(process.argv.slice(2))
default: "full", default: "full",
requiresArg: true, requiresArg: true,
}, },
inline: {
type: "boolean",
},
image: { image: {
type: "array", type: "array",
requiresArg: true, requiresArg: true,
@ -92,6 +95,12 @@ const argv = cli
.showHelpOnFail(true) .showHelpOnFail(true)
.help().argv; .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) { if (!argv.html && !argv.body) {
cli.showHelp(); cli.showHelp();
console.error(`\nMissing required argument: --html or --body`); 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"; import { stringify, parse } from "css";
export { stringify, parse }; export { stringify, parse };
// TODO import.meta.resolve is supposed to accomplish this without "path" export const CORE = parse(`p, pre, ul, blockquote, h1, h2, h3 {
const GMI_CSS = path.resolve( margin-top: 0;
path.dirname(new URL(import.meta.url).pathname), margin-bottom: 0;
"./gmi.css" overflow-wrap: break-word;
); hyphens: auto;
}
export const CORE = parse(`p, a { display: block; }
a,
pre, img, audio, video {
h1,
h2,
h3,
ul,
blockquote,
img,
audio,
video {
display: block; display: block;
margin: 0;
padding: 0;
overflow-wrap: anywhere;
max-width: 100%; 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 }) { export function rootVariables({ stylesheet }) {
return stylesheet.rules return stylesheet.rules
@ -43,6 +40,51 @@ export function rootVariables({ stylesheet }) {
); );
} }
export function resolve(arg, options) { export function override(options) {
return stringify(parse(readFileSync(path.resolve(arg), "utf-8")), 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 # DESCRIPTION
Convert Gemtext to semantic HTML styled in a readable, predictable and Convert Gemtext to semantic HTML styled in a readable and mobile-friendly
mobile-friendly fashion! fashion!
# OPTIONS # OPTIONS
@ -24,12 +24,12 @@ mobile-friendly fashion!
Generate a full HTML5 document with the provided _LANG_. *--dir* can be used Generate a full HTML5 document with the provided _LANG_. *--dir* can be used
to adjust the document text direction from "ltr" to "rtl". 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 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 file as the description <meta> tag. _LIMIT_ will be used to truncate the text
with an ellipsis at that number of characters. with an ellipsis at that number of characters.
Use *--author* _NAME_ to set the author <meta> tag on every file.
*--css* [_MODE_|_FILE_] *--css* [_MODE_|_FILE_]
By default this will be set to *full* enabling a handful of customizable By default this will be set to *full* enabling a handful of customizable
variables. See *--help* for the complete list. 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 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_ with CSS 2.1's Normal Flow and inline elements. Choosing *none* will not
will use those styles. include any style information.
Choosing *none* will not include any style information including when paired *--inline* will insert the declarations as "style" properties on their
with *--body* where it will not apply the core inline styles. 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_ *[--image|--audio|--video]* _EXTENSIONS_
Include media extensions inline. You can provide multiple extensions per flag 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 { :root {
--body-width: 48rem; --body-width: 48rem;
--serif: georgia, times, serif; --serif: georgia, times, serif;
@ -36,24 +63,6 @@ body {
margin: 0 auto; 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, h1,
h2, h2,
h3 { 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) { export function toHTML(gemtext, options) {
const tokens = gemtext.split("\n").map((line) => GMI_REGEX.exec(line).groups); 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 = let description =
options.descriptions > 0 options.descriptions > 0
? tokens.find((token) => { ? tokens.find((token) => {
@ -15,31 +20,10 @@ export function toHTML(gemtext, options) {
}) })
: false; : 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> return `<!DOCTYPE html>
<html lang="${options.language}" dir="${options.dir}" style='${overrideStyles( <html lang="${options.language}" dir="${options.dir}" style='${
options options.inline ? CSS.inline("html", options.css) : CSS.override(options)
)}'> }'>
<head>${head( <head>${head(
Object.assign(options, { Object.assign(options, {
title: tokens[0].h1, title: tokens[0].h1,
@ -47,7 +31,7 @@ export function toHTML(gemtext, options) {
description && truncate(description.text, options.descriptions), description && truncate(description.text, options.descriptions),
}) })
)}</head> )}</head>
<body> <body${options.inline ? CSS.inline("body", options.css) : ""}>
${body(tokens, options)} ${body(tokens, options)}
</body> </body>
</html> </html>
@ -62,7 +46,7 @@ ${
options.schemes || options.css === "full" options.schemes || options.css === "full"
? `<meta name="color-scheme" content="dark light">\n` ? `<meta name="color-scheme" content="dark light">\n`
: "" : ""
}${style(options.css)} }${!options.inline ? CSS.style(options) : ""}
<title>${options.title}</title>${ <title>${options.title}</title>${
!options.author ? "" : `\n<meta name="author" content="${options.author}">` !options.author ? "" : `\n<meta name="author" content="${options.author}">`
}${ }${
@ -77,83 +61,95 @@ ${
`; `;
} }
export function style(mode) { function block(
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(
{ text, href, title, pre, alt, h1, h2, h3, li, quote }, { 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) { if (href) {
const titleProp = title ? ` title="${title}"` : "";
const FIX_NORMAL_FLOW =
body && css !== "none" ? ` style="display: block; max-width: 100%;"` : "";
const matchesExt = (url, exts) => const matchesExt = (url, exts) =>
exts.some((ext) => new RegExp(`\.${ext}$`).test(url)); exts.some((ext) => new RegExp(`\.${ext}$`).test(url));
if (image && matchesExt(href, image)) { 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>`; return `<${type}${props}>${
if (h2) return `<h2>${escape(h2)}</h2>`; content !== "<br>" ? escape(content) : content
if (h3) return `<h3>${escape(h3)}</h3>`; }</${type}>`;
if (li) return `<li>${escape(li)}</li>`;
if (quote) return `<blockquote>${escape(quote)}</blockquote>`;
return `<p><br></p>`;
} }
export function body(tokens, options) { export function body(tokens, options) {
let lines = []; let blocks = [];
let cursor = tokens.shift(); let cursor = tokens.shift();
while (tokens.length) { while (tokens.length) {
if (cursor.pre) { 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); const closing = tokens.findIndex((token) => token.pre);
lines = lines.concat(tokens.slice(0, closing).map(({ text }) => text)); blocks = blocks.concat(tokens.slice(0, closing).map(({ text }) => text));
lines.push("</pre>"); blocks.push("</pre>");
tokens = tokens.slice(closing + 1); tokens = tokens.slice(closing + 1);
} else if (cursor.li) { } 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); const closing = tokens.findIndex((token) => !token.li);
lines = lines blocks = blocks
.concat([line(cursor)]) .concat([block(cursor)])
.concat(tokens.slice(0, closing).map(line)); .concat(tokens.slice(0, closing).map(block));
lines.push("</ul>"); blocks.push("</ul>");
tokens = tokens.slice(closing); tokens = tokens.slice(closing);
} else { } else {
lines.push(line(cursor, options)); blocks.push(block(cursor, options));
} }
cursor = tokens.shift(); cursor = tokens.shift();
} }
return lines.join("\n"); return blocks.join("\n");
} }
export const GMI_EXT = /\.gmi$/; export const GMI_EXT = /\.gmi$/;

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "gmi-web-cli", "name": "gmi-web-cli",
"version": "1.0.8-rc.2", "version": "1.0.10-rc.2",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {