css wizardry

This commit is contained in:
Talon Poole 2021-02-20 19:28:43 +00:00
parent e5c4b6016d
commit 0794a35bd9
5 changed files with 107 additions and 92 deletions

View file

@ -13,22 +13,21 @@ The converted Gemini document should exist inside the `<body>`. Consider if shar
<blockquote> ↔ > <blockquote> ↔ >
```` ````
`<li>` must be wrapped in `<ul>`. Take care to render `<pre>` blocks with their original formatting, _do not_ indent the generated `<li>` must be wrapped in `<ul>`. Take care to render `<pre>` blocks with their original formatting, _do not_ indent the generated HTML for these tags.
HTML for these tags.
`<a>` tags are categorized as inline which CSS [Normal Flow](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Normal_Flow) presents vertically—Gemini only deals with horizontally flowing content, this can be addressed by using [`display: block;` at the CSS level.](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Flow_Layout/Block_and_Inline_Layout_in_Normal_Flow#changing_the_formatting_context_an_element_participates_in) `<a>` tags are categorized as inline which CSS [Normal Flow](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Normal_Flow) presents vertically—Gemini only deals with horizontally flowing content, this can be addressed by using [`display: block;` at the CSS level.](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Flow_Layout/Block_and_Inline_Layout_in_Normal_Flow#changing_the_formatting_context_an_element_participates_in)
## optional: inline media ## optional: inline media
If a link is consumable by `<img>`, `<audio>` or `<video>` you may insert the respective tag inline, instead of an `<a>`. It's a good idea to also include the `controls` property. If a URL is consumable by `<img>`, `<audio>` or `<video>` you may insert the respective tag inline, instead of an `<a>`. It's a good idea to also include the `controls` property.
These are categorized as inline just like `<a>` and will need `display: block;` styling. Images and video should also have `max-width: 100%;` so they don't overflow the body. These are categorized as inline just like `<a>` and will need `display: block;` styling. Images and video should also have `max-width: 100%;` so they don't overflow the body.
## `<html>` and `<head>` ## `<html>` and `<head>`
When producing a complete and valid HTML5 document the first declaration is the required `<!DOCTYPE html>`. At the root of a document is the `<html>` tag which should have a [`lang` attribute which declares the overall language of the page](https://www.w3.org/International/questions/qa-html-language-declarations) and if necessary should also include `dir="rtl"`. When producing a complete and valid HTML5 document the first declaration is the required `<!DOCTYPE html>`. At the root of a document is the `<html>` tag which should have a [`lang` attribute declaring the overall language of the page](https://www.w3.org/International/questions/qa-html-language-declarations) as well as `dir="rtl"` if necessary.
Additionally a `<head>` tag with at least the following must also be included: A `<head>` tag with at least the following must be included:
```html ```html
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -66,7 +65,7 @@ The `--foreground` and `--background` variables will be inverted when
# gmi-web(1) # gmi-web(1)
A command-line utility that pulls together all of the above into a unix-like API. A command-line utility that pulls all of the above together into a unix-like API for creating HTML from Gemini documents.
```sh ```sh
npm install --global gmi-web-cli && gmi-web --help npm install --global gmi-web-cli && gmi-web --help

2
cli.js
View file

@ -81,7 +81,7 @@ const cli = yargs(process.argv.slice(2))
}, },
}); });
const CSS_VARS = CSS.rootVariables(CSS.FULL); const CSS_VARS = CSS.rootVariables(CSS.load({ css: "web" }));
Object.keys(CSS_VARS).map((key) => { Object.keys(CSS_VARS).map((key) => {
cli.option(key, { default: CSS_VARS[key] }); cli.option(key, { default: CSS_VARS[key] });
cli.conflicts(key, "core"); cli.conflicts(key, "core");

150
css.js
View file

@ -1,85 +1,101 @@
import { readFileSync } from "fs"; import { readFileSync, existsSync } from "fs";
// TODO import.meta.resolve is supposed to accomplish this without "path" // TODO import.meta.resolve is supposed to accomplish this without "path"
import path from "path"; import path from "path";
import { stringify, parse } from "css"; import { stringify, parse } from "css";
export const CORE = parse( export function style(options) {
readFileSync( if (options.inline || options.css === "none") return "";
path.resolve(path.dirname(new URL(import.meta.url).pathname), "./core.css"), const rules = load(options);
"utf-8" const schemes = rules.find(({ media }) => media === "preferes-color-scheme")
) ? `<meta name="color-scheme" content="dark light">\n`
); : "";
return `<style>${stringify(
export const FULL = parse( {
readFileSync( stylesheet: { rules: reduceVariables(rules, options) },
path.resolve(path.dirname(new URL(import.meta.url).pathname), "./gmi.css"), },
"utf-8" { compress: true }
) )}</style>`;
);
export function rootVariables({ stylesheet }) {
return stylesheet.rules
.find(
({ type, selectors }) => type === "rule" && selectors.includes(":root")
)
.declarations.reduce(
(obj, { property, value }) =>
!/^\-\-/.test(property)
? obj
: Object.assign(obj, { [property.replace("--", "")]: value }),
{}
);
} }
export function override(options) { export function inline(options, tag) {
if (options.css !== "full") return ""; if (!options.inline || options.css === "none") return "";
const vars = rootVariables(FULL); const styles = reduceVariables(load(options), options)
.filter(({ selectors }) => selectors && selectors.includes(tag))
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({ inline, css }) {
if (css === "full")
return `
<meta name="color-scheme" content="dark light">
<style>${stringify(FULL, { compress: true })}</style>`;
if (css === "core")
return `<style>${stringify(CORE, { compress: true })}</style>`;
if (inline || css === "none") return "";
return `<style>${stringify(load(css, { compress: true }))}</style>`;
}
export function inline(tag, { inline, css }) {
if (!inline || css === "full") return "";
const { stylesheet } = css === "core" ? CORE : load(css);
const styles = stylesheet.rules
.filter(({ type, selectors }) => type === "rule" && selectors.includes(tag))
.reduce((style, { declarations }) => { .reduce((style, { declarations }) => {
declarations.forEach(({ property, value }) => { declarations.forEach(({ property, value }) => {
style = `${style}${property}:${value};`; style = `${style}${property}:${value};`;
}); });
return style; return style;
}, ""); }, "");
return styles !== "" ? ` style="${styles}"` : ""; return styles !== "" ? ` style="${styles}"` : "";
} }
export function load(file, options) { export function load(options) {
if (fs.existsSync(file)) { console.log("load:", options.css);
return parse(readFileSync(path.resolve(file), "utf-8")); options.css =
options.css || (!options.body || !options.inline ? "web" : "core");
if (
["gmi", "web", "gmi.css", "gmi-web.css"].includes(options.css) ||
existsSync(options.css)
) {
const packageRoot = (file) =>
path.resolve(path.dirname(new URL(import.meta.url).pathname), file);
return resolveImports(
parse(
readFileSync(
path.resolve(
["gmi-web.css", "web"].includes(options.css)
? packageRoot("gmi-web.css")
: ["gmi.css", "gmi"].includes(options.css)
? packageRoot("gmi.css")
: resolve(options.css)
),
"utf-8"
)
).stylesheet.rules
);
} else { } else {
throw new Error(`Cannot find file ${file}.`); throw new Error(`Cannot find file ${options.css}.`);
} }
} }
export function rootVariables(rules) {
const root = rules.find(
({ selectors }) => selectors && selectors.includes(":root")
);
if (!root) return {};
return root.declarations.reduce(
(obj, { property, value }) =>
!/^\-\-/.test(property) ? obj : Object.assign(obj, { [property]: value }),
{}
);
}
function resolveImports(rules) {
const imports = rules
.filter(({ type }) => type === "import")
.map((rule) => load({ css: rule.import.replace(/\"/g, "") }));
return []
.concat(...imports)
.concat(rules.filter(({ type }) => type !== "import"));
}
function reduceVariables(rules, options) {
const defaultVariables = rootVariables(rules);
const CSS_VAR = /(^var\((?<key>.+)\)|(?<val>.+))/;
return rules
.filter(({ selectors }) => selectors && !selectors.includes(":root"))
.map((rule) => {
return Object.assign(rule, {
declarations: rule.declarations.map((declaration) => {
let { key, val } = CSS_VAR.exec(declaration.value).groups;
// only one level of variable referencing is supported
key = CSS_VAR.exec(options[key] || defaultVariables[key]).groups.key || key;
return Object.assign(declaration, {
value: key ? options[key] || defaultVariables[key] : declaration.value,
});
}),
});
});
}

View file

@ -1,4 +1,4 @@
@import ("gmi.css"); @import "gmi.css";
img, img,
audio, audio,
@ -48,6 +48,7 @@ video {
--ul-family: var(--serif); --ul-family: var(--serif);
--ul-size: var(--p-size); --ul-size: var(--p-size);
--ul-height: 1.25; --ul-height: 1.25;
--ul-style: circle;
--quote-family: var(--serif); --quote-family: var(--serif);
--quote-size: var(--p-size); --quote-size: var(--p-size);
@ -73,7 +74,7 @@ a {
font-size: var(--a-size); font-size: var(--a-size);
font-style: var(--a-style); font-style: var(--a-style);
font-family: var(--serif); font-family: var(--serif);
line-height: var(--a-line-height); line-height: var(--a-height);
text-decoration: var(--a-decoration); text-decoration: var(--a-decoration);
} }
@ -136,7 +137,7 @@ pre::selection {
} }
blockquote { blockquote {
border-left: 0.5rem solid var(--foreground); border-color: var(--foreground);
} }
pre, pre,
@ -162,7 +163,7 @@ a:hover {
} }
blockquote { blockquote {
border-left: 0.5rem solid var(--background); border-color: var(--background);
} }
pre, pre,

27
html.js
View file

@ -42,21 +42,21 @@ export function block(
exts.some((ext) => new RegExp(`\.${ext}$`).test(url)); exts.some((ext) => new RegExp(`\.${ext}$`).test(url));
if (options.image && matchesExt(href, options.image)) { if (options.image && matchesExt(href, options.image)) {
type = "img"; type = "img";
props += ` src=${href}` + alt ? ` title=${alt}` : ""; props += ` src="${href}"` + alt ? ` title="${alt}"` : "";
} else if (options.audio && matchesExt(href, options.audio)) { } else if (options.audio && matchesExt(href, options.audio)) {
type = "audio"; type = "audio";
props += ` controls src=${href}` + alt ? ` title=${alt}` : ""; props += ` controls src="${href}"` + alt ? ` title="${alt}"` : "";
} else if (options.video && matchesExt(href, options.video)) { } else if (options.video && matchesExt(href, options.video)) {
type = "video"; type = "video";
props += ` controls src=${href}` + alt ? ` title=${alt}` : ""; props += ` controls src="${href}"` + alt ? ` title="${alt}"` : "";
} else { } else {
type = "a"; type = "a";
content = title || href; content = title || href;
props += ` href=${href}`; props += ` href="${href}"`;
} }
} }
if (options.body || options.inline) if (options.body || options.inline)
content !== "" ? (props += CSS.inline(type, options)) : ""; content !== "" ? (props += CSS.inline(options, type)) : "";
return `<${type}${props}>${escape(content)}</${type}>`; return `<${type}${props}>${escape(content)}</${type}>`;
} }
@ -68,7 +68,7 @@ export function body(tokens, options) {
if (cursor.pre) { if (cursor.pre) {
blocks.push( blocks.push(
`<pre${cursor.alt ? ` title="${cursor.alt}"` : ""}${ `<pre${cursor.alt ? ` title="${cursor.alt}"` : ""}${
options.body || options.inline ? CSS.inline("ul", options.css) : "" options.body || options.inline ? CSS.inline(options, "pre") : ""
}>` }>`
); );
const closing = tokens.findIndex((token) => token.pre); const closing = tokens.findIndex((token) => token.pre);
@ -77,9 +77,7 @@ export function body(tokens, options) {
tokens = tokens.slice(closing + 1); tokens = tokens.slice(closing + 1);
} else if (cursor.li) { } else if (cursor.li) {
blocks.push( blocks.push(
`<ul${ `<ul${options.body || options.inline ? CSS.inline(options, "ul") : ""}>`
options.body || options.inline ? CSS.inline("ul", options.css) : ""
}>`
); );
const closing = tokens.findIndex((token) => !token.li); const closing = tokens.findIndex((token) => !token.li);
blocks = blocks blocks = blocks
@ -100,17 +98,19 @@ export function toHTML(gemtext, options) {
if (options.body) return body(tokens, options); if (options.body) return body(tokens, options);
console.log(options)
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="${options.language}" dir="${options.dir}" style='${ <html lang="${options.language}" dir="${options.dir}" style='${CSS.inline(
CSS.inline("html", options) + CSS.override(options) options,
}'> "html"
)}'>
<head>${head( <head>${head(
Object.assign(options, { Object.assign(options, {
title: tokens[0].h1, title: tokens[0].h1,
description: description(tokens, options), description: description(tokens, options),
}) })
)}</head> )}</head>
<body${CSS.inline("body", options)}> <body${CSS.inline(options, "body")}>
${body(tokens, options)} ${body(tokens, options)}
</body> </body>
</html> </html>
@ -160,7 +160,6 @@ export function streamHTML(options) {
toHTML(file.contents.toString("utf-8"), options) toHTML(file.contents.toString("utf-8"), options)
); );
file.path = file.path.replace(GMI_EXT, ".html"); file.path = file.path.replace(GMI_EXT, ".html");
if (options.verbose) console.log(file.path);
return cb(null, file); return cb(null, file);
}); });
} }