css wizardry
This commit is contained in:
parent
e5c4b6016d
commit
0794a35bd9
11
README.md
11
README.md
|
@ -13,22 +13,21 @@ The converted Gemini document should exist inside the `<body>`. Consider if shar
|
|||
<blockquote> ↔ >
|
||||
````
|
||||
|
||||
`<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.
|
||||
`<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.
|
||||
|
||||
`<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
|
||||
|
||||
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.
|
||||
|
||||
## `<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
|
||||
<meta charset="utf-8" />
|
||||
|
@ -66,7 +65,7 @@ The `--foreground` and `--background` variables will be inverted when
|
|||
|
||||
# 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
|
||||
npm install --global gmi-web-cli && gmi-web --help
|
||||
|
|
2
cli.js
2
cli.js
|
@ -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) => {
|
||||
cli.option(key, { default: CSS_VARS[key] });
|
||||
cli.conflicts(key, "core");
|
||||
|
|
150
css.js
150
css.js
|
@ -1,85 +1,101 @@
|
|||
import { readFileSync } from "fs";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
// TODO import.meta.resolve is supposed to accomplish this without "path"
|
||||
import path from "path";
|
||||
import { stringify, parse } from "css";
|
||||
|
||||
export const CORE = parse(
|
||||
readFileSync(
|
||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "./core.css"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
.find(
|
||||
({ type, selectors }) => type === "rule" && selectors.includes(":root")
|
||||
)
|
||||
.declarations.reduce(
|
||||
(obj, { property, value }) =>
|
||||
!/^\-\-/.test(property)
|
||||
? obj
|
||||
: Object.assign(obj, { [property.replace("--", "")]: value }),
|
||||
{}
|
||||
);
|
||||
export function style(options) {
|
||||
if (options.inline || options.css === "none") return "";
|
||||
const rules = load(options);
|
||||
const schemes = rules.find(({ media }) => media === "preferes-color-scheme")
|
||||
? `<meta name="color-scheme" content="dark light">\n`
|
||||
: "";
|
||||
return `<style>${stringify(
|
||||
{
|
||||
stylesheet: { rules: reduceVariables(rules, options) },
|
||||
},
|
||||
{ compress: true }
|
||||
)}</style>`;
|
||||
}
|
||||
|
||||
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({ 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))
|
||||
export function inline(options, tag) {
|
||||
if (!options.inline || options.css === "none") return "";
|
||||
const styles = reduceVariables(load(options), options)
|
||||
.filter(({ selectors }) => selectors && 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(file)) {
|
||||
return parse(readFileSync(path.resolve(file), "utf-8"));
|
||||
export function load(options) {
|
||||
console.log("load:", options.css);
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import ("gmi.css");
|
||||
@import "gmi.css";
|
||||
|
||||
img,
|
||||
audio,
|
||||
|
@ -48,6 +48,7 @@ video {
|
|||
--ul-family: var(--serif);
|
||||
--ul-size: var(--p-size);
|
||||
--ul-height: 1.25;
|
||||
--ul-style: circle;
|
||||
|
||||
--quote-family: var(--serif);
|
||||
--quote-size: var(--p-size);
|
||||
|
@ -73,7 +74,7 @@ a {
|
|||
font-size: var(--a-size);
|
||||
font-style: var(--a-style);
|
||||
font-family: var(--serif);
|
||||
line-height: var(--a-line-height);
|
||||
line-height: var(--a-height);
|
||||
text-decoration: var(--a-decoration);
|
||||
}
|
||||
|
||||
|
@ -136,7 +137,7 @@ pre::selection {
|
|||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 0.5rem solid var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
pre,
|
||||
|
@ -162,7 +163,7 @@ a:hover {
|
|||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 0.5rem solid var(--background);
|
||||
border-color: var(--background);
|
||||
}
|
||||
|
||||
pre,
|
||||
|
|
27
html.js
27
html.js
|
@ -42,21 +42,21 @@ export function block(
|
|||
exts.some((ext) => new RegExp(`\.${ext}$`).test(url));
|
||||
if (options.image && matchesExt(href, options.image)) {
|
||||
type = "img";
|
||||
props += ` src=${href}` + alt ? ` title=${alt}` : "";
|
||||
props += ` src="${href}"` + alt ? ` title="${alt}"` : "";
|
||||
} else if (options.audio && matchesExt(href, options.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)) {
|
||||
type = "video";
|
||||
props += ` controls src=${href}` + alt ? ` title=${alt}` : "";
|
||||
props += ` controls src="${href}"` + alt ? ` title="${alt}"` : "";
|
||||
} else {
|
||||
type = "a";
|
||||
content = title || href;
|
||||
props += ` href=${href}`;
|
||||
props += ` href="${href}"`;
|
||||
}
|
||||
}
|
||||
if (options.body || options.inline)
|
||||
content !== "" ? (props += CSS.inline(type, options)) : "";
|
||||
content !== "" ? (props += CSS.inline(options, type)) : "";
|
||||
|
||||
return `<${type}${props}>${escape(content)}</${type}>`;
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ export function body(tokens, options) {
|
|||
if (cursor.pre) {
|
||||
blocks.push(
|
||||
`<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);
|
||||
|
@ -77,9 +77,7 @@ export function body(tokens, options) {
|
|||
tokens = tokens.slice(closing + 1);
|
||||
} else if (cursor.li) {
|
||||
blocks.push(
|
||||
`<ul${
|
||||
options.body || options.inline ? CSS.inline("ul", options.css) : ""
|
||||
}>`
|
||||
`<ul${options.body || options.inline ? CSS.inline(options, "ul") : ""}>`
|
||||
);
|
||||
const closing = tokens.findIndex((token) => !token.li);
|
||||
blocks = blocks
|
||||
|
@ -100,17 +98,19 @@ export function toHTML(gemtext, options) {
|
|||
|
||||
if (options.body) return body(tokens, options);
|
||||
|
||||
console.log(options)
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${options.language}" dir="${options.dir}" style='${
|
||||
CSS.inline("html", options) + CSS.override(options)
|
||||
}'>
|
||||
<html lang="${options.language}" dir="${options.dir}" style='${CSS.inline(
|
||||
options,
|
||||
"html"
|
||||
)}'>
|
||||
<head>${head(
|
||||
Object.assign(options, {
|
||||
title: tokens[0].h1,
|
||||
description: description(tokens, options),
|
||||
})
|
||||
)}</head>
|
||||
<body${CSS.inline("body", options)}>
|
||||
<body${CSS.inline(options, "body")}>
|
||||
${body(tokens, options)}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -160,7 +160,6 @@ export function streamHTML(options) {
|
|||
toHTML(file.contents.toString("utf-8"), options)
|
||||
);
|
||||
file.path = file.path.replace(GMI_EXT, ".html");
|
||||
if (options.verbose) console.log(file.path);
|
||||
return cb(null, file);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue