This commit is contained in:
parent
320af54b3d
commit
4666c1995f
14 changed files with 876 additions and 346 deletions
|
|
@ -18,9 +18,9 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else:
|
||||
//
|
||||
// blocks ATX headings, fenced code, blockquotes (nested), ordered and
|
||||
// unordered lists, thematic breaks, paragraphs
|
||||
// inline links, images, code spans, ** strong **, * emphasis *, and bare
|
||||
// URLs via Html::Autolink
|
||||
// unordered lists, pipe tables, thematic breaks, paragraphs
|
||||
// inline links, images, code spans, ** strong **, * emphasis *,
|
||||
// ~~ strikethrough ~~, and bare URLs via Html::Autolink
|
||||
//
|
||||
// WHAT IT DELIBERATELY DOES NOT SUPPORT:
|
||||
//
|
||||
|
|
@ -34,8 +34,15 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a
|
||||
// symbol name into italics is a worse failure than not italicising a word
|
||||
// that used the underscore form. Asterisks are unambiguous here.
|
||||
// * Setext headings, reference links, tables, footnotes, HTML entities.
|
||||
// None appear; adding them speculatively is parser surface with no reader.
|
||||
// * Single-tilde strikethrough, for the same reason. GFM accepts one tilde or
|
||||
// two; a lone `~` in these posts is a home directory (`~/.local`) or an
|
||||
// approximation (`~5 minutes`), so only the doubled form strikes anything.
|
||||
// * Tilde-fenced code. `~~~` would be ambiguous with the above and no body
|
||||
// uses it; a fence here is written with backticks.
|
||||
// * Setext headings, reference links, footnotes, HTML entities. None appear;
|
||||
// adding them speculatively is parser surface with no reader. (Tables did
|
||||
// appear — the carrier compatibility list and the GPU price comparison are
|
||||
// written as pipe tables — which is why they are in the list above now.)
|
||||
// * Trailing-double-space hard breaks. An invisible two-character difference
|
||||
// is not something a reader can see in the source or a writer can rely on
|
||||
// having typed; the lines of a paragraph join with a space, and a break
|
||||
|
|
@ -264,6 +271,30 @@ SafeHtml RenderInline(std::string_view text, std::span<const PostMedia> media, i
|
|||
}
|
||||
}
|
||||
|
||||
// ~~struck~~, structurally the same as **strong** and sharing its
|
||||
// open/close tests, so "a ~~ b ~~ c" stays literal the same way
|
||||
// "a ** b ** c" does.
|
||||
//
|
||||
// <s> rather than GFM's <del>: nothing was removed from this document.
|
||||
// <del> is a claim that an edit happened, and the one body that uses
|
||||
// this is striking a joke through for effect — which is exactly what
|
||||
// <s> is for ("no longer accurate or no longer relevant").
|
||||
if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') {
|
||||
const std::size_t inner = i + 2;
|
||||
if (depth < kMaxDepth && OpensEmphasis(text, inner)) {
|
||||
const std::size_t close = FindEmphasisClose(text, inner, "~~");
|
||||
if (close != std::string_view::npos) {
|
||||
flush(i);
|
||||
out.push_back(Format(
|
||||
R"(<s>{}</s>)",
|
||||
RenderInline(text.substr(inner, close - inner), media, depth + 1)));
|
||||
i = close + 2;
|
||||
run = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
++i;
|
||||
}
|
||||
|
||||
|
|
@ -338,9 +369,123 @@ ListMarker ParseListMarker(std::string_view line) {
|
|||
return m;
|
||||
}
|
||||
|
||||
bool StartsBlock(std::string_view line) {
|
||||
// ── tables ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// GFM pipe tables. These arrived after the parser did: the carrier
|
||||
// compatibility list in the camera post and the GPU price comparison in
|
||||
// another are written as tables, and with no table support a table is the
|
||||
// worst-degrading construct there is — the rows join into one run-on
|
||||
// paragraph of pipes and dashes, which is neither the data nor prose.
|
||||
//
|
||||
// Recognition deliberately needs TWO lines: a row, and under it an alignment
|
||||
// row agreeing about the number of columns. That is what keeps ordinary prose
|
||||
// safe, because a pipe by itself is common in these posts (`dmesg | grep`, an
|
||||
// or-list, a pasted command) and nothing under those lines is `|---|---|`.
|
||||
|
||||
// A column's alignment, as the delimiter row asked for it. Left is absent
|
||||
// because it is what the CSS already does — `:---` and a bare `---` produce
|
||||
// identical markup, so only the two that change something carry a class.
|
||||
enum class Align { Default, Center, Right };
|
||||
|
||||
// A row split into cells on unescaped pipes, with the optional outer pipes
|
||||
// dropped. Both are optional independently, which is not pedantry: the real
|
||||
// carrier table is written `| Device | OS | Carrier` — leading pipe, no
|
||||
// trailing one — and requiring both would leave it as prose.
|
||||
//
|
||||
// `\|` is left in the cell text for RenderInline to turn into a literal pipe;
|
||||
// that is how a cell contains one.
|
||||
std::vector<std::string_view> SplitRow(std::string_view line) {
|
||||
std::string_view s = Trim(Undent(line));
|
||||
if (s.starts_with('|')) s.remove_prefix(1);
|
||||
if (s.size() > 1 && s.ends_with('|') && !s.ends_with("\\|")) s.remove_suffix(1);
|
||||
|
||||
std::vector<std::string_view> cells;
|
||||
std::size_t start = 0;
|
||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '\\') { ++i; continue; }
|
||||
if (s[i] != '|') continue;
|
||||
cells.push_back(Trim(s.substr(start, i - start)));
|
||||
start = i + 1;
|
||||
}
|
||||
cells.push_back(Trim(s.substr(start)));
|
||||
return cells;
|
||||
}
|
||||
|
||||
// One cell of the delimiter row: `---`, `:---`, `---:`, `:---:` and nothing
|
||||
// else. A single dash is enough — `| - |` is a table people write.
|
||||
bool IsDelimiterCell(std::string_view cell, Align& align) {
|
||||
const bool left = cell.starts_with(':');
|
||||
if (left) cell.remove_prefix(1);
|
||||
const bool right = cell.ends_with(':');
|
||||
if (right) cell.remove_suffix(1);
|
||||
if (cell.empty()) return false;
|
||||
for (const char c : cell) {
|
||||
if (c != '-') return false;
|
||||
}
|
||||
align = (left && right) ? Align::Center : right ? Align::Right : Align::Default;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The alignment of each column, or an empty vector when `line` is not the
|
||||
// delimiter row of a table whose header had `columns` cells.
|
||||
std::vector<Align> ParseDelimiterRow(std::string_view line, std::size_t columns) {
|
||||
// A pipe is required, so `---` under a one-cell row stays the thematic
|
||||
// break it looks like — the same call this file already makes for `- - -`
|
||||
// over a one-item list.
|
||||
if (Trim(line).find('|') == std::string_view::npos) return {};
|
||||
|
||||
std::vector<Align> aligns;
|
||||
for (const std::string_view cell : SplitRow(line)) {
|
||||
Align align = Align::Default;
|
||||
if (!IsDelimiterCell(cell, align)) return {};
|
||||
aligns.push_back(align);
|
||||
}
|
||||
// GFM's rule, and a useful one: a mismatched count is far more likely to be
|
||||
// prose that happens to contain pipes than a table its author miscounted.
|
||||
return aligns.size() == columns ? aligns : std::vector<Align>{};
|
||||
}
|
||||
|
||||
bool IsTableStart(std::span<const std::string_view> lines, std::size_t at) {
|
||||
if (at + 1 >= lines.size()) return false;
|
||||
if (Trim(Undent(lines[at])).find('|') == std::string_view::npos) return false;
|
||||
return !ParseDelimiterRow(lines[at + 1], SplitRow(lines[at]).size()).empty();
|
||||
}
|
||||
|
||||
// A row's cells, as <th> or <td>. Rendered per cell rather than per line, so a
|
||||
// cell holds links, code spans and emphasis like any other prose.
|
||||
//
|
||||
// A row shorter than the header is padded with empty cells so the grid stays
|
||||
// rectangular; a row LONGER than it keeps its extras rather than having them
|
||||
// dropped, which follows the rule the rest of this file follows — content that
|
||||
// confuses the parser shows up looking odd instead of disappearing.
|
||||
SafeHtml RenderRow(std::span<const std::string_view> cells, std::span<const Align> aligns,
|
||||
bool header, std::span<const PostMedia> media, int depth) {
|
||||
std::vector<SafeHtml> out;
|
||||
const std::size_t columns = std::max(cells.size(), aligns.size());
|
||||
for (std::size_t c = 0; c < columns; ++c) {
|
||||
const Align align = c < aligns.size() ? aligns[c] : Align::Default;
|
||||
const SafeHtml cls =
|
||||
align == Align::Center ? Attr("class", "post-body__cell--center")
|
||||
: align == Align::Right ? Attr("class", "post-body__cell--right")
|
||||
: SafeHtml{};
|
||||
const SafeHtml inner =
|
||||
RenderInline(c < cells.size() ? cells[c] : std::string_view{}, media, depth);
|
||||
out.push_back(header ? Format("<th{}>{}</th>", cls, inner)
|
||||
: Format("<td{}>{}</td>", cls, inner));
|
||||
}
|
||||
return Format("<tr>{}</tr>", Join(out));
|
||||
}
|
||||
|
||||
// ── block dispatch ────────────────────────────────────────────────────
|
||||
|
||||
// Whether a new block begins at `at`, so a paragraph is interrupted by one
|
||||
// rather than swallowing it as text. Takes the whole span because a table is
|
||||
// the one construct that cannot be recognised from a single line.
|
||||
bool StartsBlock(std::span<const std::string_view> lines, std::size_t at) {
|
||||
const std::string_view line = lines[at];
|
||||
return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line)
|
||||
|| Undent(line).starts_with('>') || ParseListMarker(line).ok;
|
||||
|| Undent(line).starts_with('>') || ParseListMarker(line).ok
|
||||
|| IsTableStart(lines, at);
|
||||
}
|
||||
|
||||
SafeHtml RenderBlocks(std::span<const std::string_view> lines,
|
||||
|
|
@ -524,7 +669,7 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
|
|||
// A non-marker line that would start some other block ends the
|
||||
// list; anything else is this item's text continuing onto the
|
||||
// next line.
|
||||
if (StartsBlock(lines[j])) break;
|
||||
if (StartsBlock(lines, j)) break;
|
||||
current.push_back(lines[j]);
|
||||
++j;
|
||||
}
|
||||
|
|
@ -545,13 +690,48 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
|
|||
continue;
|
||||
}
|
||||
|
||||
// ── table ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Below the list branch on purpose: `- a | b` over a `|---|---|`
|
||||
// satisfies both tests, and a line that opens with a list marker is a
|
||||
// list item. Ordinary tables are unaffected — a pipe is never a list
|
||||
// marker, so they reach here either way.
|
||||
if (IsTableStart(lines, i)) {
|
||||
const std::vector<std::string_view> header = SplitRow(line);
|
||||
const std::vector<Align> aligns = ParseDelimiterRow(lines[i + 1], header.size());
|
||||
|
||||
std::vector<SafeHtml> rows;
|
||||
std::size_t j = i + 2;
|
||||
while (j < lines.size() && !Blank(lines[j])) {
|
||||
// A row has to have a pipe in it. A line without one directly
|
||||
// under a table is prose whose author forgot the blank line —
|
||||
// rendering it as a lone one-column row would be worse than
|
||||
// ending the table and letting it be the paragraph it is.
|
||||
if (Trim(Undent(lines[j])).find('|') == std::string_view::npos) break;
|
||||
if (StartsBlock(lines, j)) break;
|
||||
rows.push_back(RenderRow(SplitRow(lines[j]), aligns, false, media, depth));
|
||||
++j;
|
||||
}
|
||||
|
||||
// The wrapper is what scrolls. A table cannot be narrowed below its
|
||||
// content, so without a container around it a nine-column price
|
||||
// comparison scrolls the PAGE sideways on a phone — the one thing
|
||||
// wide content must never do (same reasoning as the code block).
|
||||
out.push_back(Format(
|
||||
R"(<div class="post-body__table"><table>)"
|
||||
R"(<thead>{}</thead><tbody>{}</tbody></table></div>)",
|
||||
RenderRow(header, aligns, true, media, depth), Join(rows)));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── paragraph ─────────────────────────────────────────────────
|
||||
{
|
||||
std::size_t j = i;
|
||||
while (j < lines.size() && !Blank(lines[j])) {
|
||||
// A block marker on a later line interrupts the paragraph
|
||||
// rather than being absorbed into it as text.
|
||||
if (j > i && StartsBlock(lines[j])) break;
|
||||
if (j > i && StartsBlock(lines, j)) break;
|
||||
++j;
|
||||
}
|
||||
out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth));
|
||||
|
|
|
|||
Loading…
Reference in a new issue