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> ↔ >
|
<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
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) => {
|
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
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"
|
// 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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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
27
html.js
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue