@link :section links to a section by its heading, with an alone value (from dev 831455f36fcd)

A section link names the TEXT of a heading: :section alone means the current
file, a value names the file of the same @document that has it. Resolved by
@document at assembly in html and tex; errors for a missing file, a missing
title, a doubled title and a link outside a @document. The Markdown
converter reads a Markdown anchor as a section link to the heading its slug
names. Also from dev: the converter's four silent defects fixed and --chapter
(9ae2c4cd), and the engine's alone-value error text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-12 20:55:39 +02:00
1 parent 32aa47d41c
commit f4dcdf3ffd
15 files changed
+594 -114

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ DOCUMENT := document$(DSO)
DOC_LDFLAGS := $(SHARED) $(EXPORT_DYNAMIC) -Wl,-rpath,'$(ORIGIN)/../../lib' -L$(LIBDIR)
# Local object files
LOCAL_OBJECTS := document_class.o document_html.o document_latex.o heading.o reference.o
LOCAL_OBJECTS := document_class.o document_html.o document_latex.o heading.o reference.o section_link.o
LOCAL_DEPFILES := document_class.d document_html.d document_latex.d heading.d reference.d document.d
# External object files from sks/ (mac/*.o now in the shared library)
+3
View File
@@ -72,6 +72,9 @@ public:
std::map<std::string, std::string> id_to_caption_number();
std::map<std::string, std::vector<std::pair<std::string, std::string>>> html_references();
void resolve_html_references();
void resolve_section_links(); // html, @link :section
std::string resolve_tex_section_links(
const std::vector<std::pair<std::string, std::string>>& pieces); // (basename, tex)
std::string single_page_toc();
elements_t page(const std::string& body, const std::string& output_dir, int max_level);
+51
View File
@@ -1,3 +1,4 @@
#include "section_link.h"
#include <iomanip>
#include "util.h"
#include "error.h"
@@ -315,6 +316,7 @@ void Document_class::process_input_files()
//if (true || !in_modification_order(inpath, cached_filename, m_no_cache)) {
if (m_no_cache || cache_requires_update(m_cache_dir, inpath.string(), basename)) {
Machine M = m_machine;
M.m_state.set("K_document_file", basename); // see document_latex.cpp
M.read(inpath);
html_text = trim(M.apply("html"));
html_text = trim(html::make_paragraphs(html_text)) + "\n";
@@ -686,6 +688,54 @@ elements_t Document_class::page(const std::string& body_text, const std::string&
return page;
}
// @link :section (section_link.h). Every heading element that carries an
// id -- numbered or not, so @h1..@h7 and @part are linkable too, which is
// why this reads the elements rather than the headings table that
// insert_section_numbers() builds from the numbered ones -- is a target;
// the Link klammer's href marker is resolved against them. A single page
// gets href="#id". A book's pages are embedded in index.html and shown by
// go_to_section(), so a link there is the "pagelink" anchor that
// sks/link/js/link.js binds: data-page and data-id, with the app's own
// "#page#id" hash as the href so the location survives without the script.
// Before 2026-10-12 html had no resolver and the marker was the href.
void Document_class::resolve_section_links()
{
static const std::regex heading_rgx(R"(<(h[1-7]|kt-chapter|kt-part)\b([^>]*)>(.*?)</\1>)");
static const std::regex link_rgx(R"(href="__seclink__(.*?)__KTSL__(.*?)__")");
std::vector<Section_target> table {};
strings_t files {};
for (const auto& [basename, text] : m_file_components) {
files.push_back(basename);
for (std::sregex_iterator it(text.cbegin(), text.cend(), heading_rgx), end; it != end; ++it) {
const std::smatch& m = *it;
std::string attrs = m[2];
std::smatch id_match {};
if (std::regex_search(attrs, id_match, id_attr_rgx)) {
table.push_back({normalize_section_title(m[3]), basename, id_match[1]});
}
}
}
const bool book = m_structure == docstruct_t::book;
for (auto& [basename, text] : m_file_components) {
std::string result {};
auto begin = text.cbegin();
for (std::sregex_iterator it(text.cbegin(), text.cend(), link_rgx), end; it != end; ++it) {
const std::smatch& m = *it;
result.append(begin, m[0].first);
const Section_target t = resolve_section_target(table, m[1], m[2], basename, files);
if (book) {
result += "href=\"#" + t.basename + "#" + t.id + "\" class=\"pagelink\" data-page=\""
+ t.basename + "\" data-id=\"" + t.id + "\"";
} else {
result += "href=\"#" + t.id + "\"";
}
begin = m[0].second;
}
result.append(begin, text.cend());
text = result;
}
}
std::string Document_class::make_single_html_page(const std::string& output_directory)
{
(void)K::log(3);
@@ -787,6 +837,7 @@ std::string Document_class::html()
}
auto headings = insert_section_numbers();
insert_section_numbers("___NUM2___", false);
resolve_section_links();
insert_html_caption_numbers();
resolve_html_references();
if (m_structure != docstruct_t::book) {
+51 -24
View File
@@ -1,3 +1,4 @@
#include "section_link.h"
#include "util.h"
#include "kutil.h"
#include "log.h"
@@ -20,15 +21,54 @@ std::string Document_class::create_tex_output_directories()
return output_directory;
}
// @link :section (section_link.h). Section.tex() announces every heading
// in a comment line; the links are resolved against them, every link on a
// line. The resolver this replaces (2026-10-12) read ONE link per line --
// std::regex_match once per line -- so a second @link ... :section @ on a
// line reached the pdf as a dead \hyperlink{__seclink__...}; it ignored
// :basename, and it wrote \hyperlink{} for an unknown title.
std::string Document_class::resolve_tex_section_links(
const std::vector<std::pair<std::string, std::string>>& pieces)
{
static const std::regex announce_rgx(
R"(%__sectionlink__(.*?)__KTSL__(.*?)__KTSL__(.*)__\n?)");
static const std::regex link_rgx(R"(__seclink__(.*?)__KTSL__(.*?)__)");
std::vector<Section_target> table {};
strings_t files {};
for (const auto& [basename, text] : pieces) {
if (!basename.empty()) files.push_back(basename);
for (const std::string& line : find_all(text, announce_rgx, 0)) {
std::smatch m {};
std::regex_match(line, m, announce_rgx);
table.push_back({normalize_section_title(m[3]), m[2], m[1]});
}
}
std::string result {};
for (const auto& [basename, text] : pieces) {
std::string stripped = std::regex_replace(text, announce_rgx, "");
auto begin = stripped.cbegin();
for (std::sregex_iterator it(stripped.cbegin(), stripped.cend(), link_rgx), end; it != end; ++it) {
const std::smatch& m = *it;
result.append(begin, m[0].first);
result += resolve_section_target(table, m[1], m[2], basename, files).id;
begin = m[0].second;
}
result.append(begin, stripped.cend());
}
return result;
}
std::string Document_class::tex()
{
(void)K::log(3);
create_tex_output_directories();
// :text content is already processed (klammers applied by the outer Machine).
// Use it directly; don't re-process through a sub-Machine.
std::string latex_text {};
// The pieces stay apart until the :section links are resolved, so an
// error can name the chapter that holds the link; "" is the :text.
std::vector<std::pair<std::string, std::string>> pieces {};
if (!m_text.empty()) {
latex_text = add_latex_caption_numbers(m_text);
pieces.push_back({"", add_latex_caption_numbers(m_text)});
}
if (m_files.size() > 0) {
for (auto input_filename : m_files) {
@@ -39,38 +79,25 @@ std::string Document_class::tex()
if (m_no_cache || cache_requires_update(m_cache_dir, inpath.string(), basename)) {
Machine M = m_machine;
M.m_state.set("document_landscape", landscape ? "true" : "false"); // , "Landscape mode");
// The chapter's basename: Section.tex() builds stable
// \hypertarget names from it (a counter alone collided
// when the cache served some chapters and not others), and
// phases.check_section_links reads its presence as "inside
// a @document". Same in the html path.
M.m_state.set("K_document_file", basename);
M.read(inpath);
std::string processed = trim(M.apply("tex", false));
processed = add_latex_caption_numbers(processed);
write_to_cache(m_cache_dir, basename, processed);
latex_text += processed;
pieces.push_back({basename, processed});
} else {
latex_text += read_from_cache(m_cache_dir, basename);
pieces.push_back({basename, read_from_cache(m_cache_dir, basename)});
}
}
}
std::string latex_text = resolve_tex_section_links(pieces);
auto split_lines = regex_split(latex_text, std::regex(R"(\n)"), false);
latex_text = resolve_caption_references("latex", split_lines, latex_line_states(split_lines));
std::map<std::string, std::string> section_to_id {};
std::regex section_id_rgx(R"(%__sectionlink__(.*?)__(.*?)__)");
for (std::string id_section : find_all(latex_text, section_id_rgx, 0)) {
std::smatch match {};
std::regex_match(id_section, match, section_id_rgx);
section_to_id[match[2]] = match[1];
}
latex_text = std::regex_replace(latex_text, section_id_rgx, "");
std::regex seclink_rgx("(.*?)__seclink__(.*?)__(.*)");
auto lines = regex_split(latex_text, std::regex(R"(\n)"), false);
for (auto i = 0ul; i < lines.size(); i++) {
if (lines[i].find("__seclink__") != std::string::npos) {
std::smatch match {};
std::regex_match(lines[i], match, seclink_rgx);
std::stringstream ss {};
ss << match[1] << section_to_id[match[2]] << match[3];
lines[i] = ss.str();
}
}
latex_text = join(lines, "\n");
latex_text = string_replace(latex_text, "___AT___", "^@");
// @typeface: collect what the document asked for, declare each as a
// \newfontfamily switch command in the preamble, and replace every
+55
View File
@@ -0,0 +1,55 @@
#include "section_link.h"
#include "error.h"
#include "locator.h"
#include "util.h"
#include <regex>
#include <sstream>
std::string normalize_section_title(const std::string& s)
{
static const std::regex number_rgx(R"(<span class="sectionnumber">.*?</span>)");
static const std::regex tag_rgx("<[^>]*>");
static const std::regex tex_command_rgx(R"(\\[A-Za-z@]+\*?)");
static const std::regex space_rgx(R"(\s+)");
std::string t = std::regex_replace(s, number_rgx, "");
t = std::regex_replace(t, tag_rgx, "");
t = std::regex_replace(t, tex_command_rgx, "");
t = string_replace(string_replace(t, "{", ""), "}", "");
t = std::regex_replace(t, space_rgx, " ");
return trim(t);
}
Section_target resolve_section_target(
const std::vector<Section_target>& table, const std::string& title,
const std::string& file, const std::string& where,
const std::vector<std::string>& files)
{
const std::string wanted = normalize_section_title(title);
const std::string in = file.empty() ? where : file; // the one file searched
const std::string in_name = in.empty() ? std::string("the document's :text") : in;
std::stringstream ss {};
ss << "In " << (where.empty() ? std::string("the document's :text") : where)
<< ", a :section link names the heading " << q_(title);
if (!file.empty()) {
bool known = false;
for (const auto& f : files) known = known || f == file;
if (!known) {
ss << " in " << file << ", but the document has no file " << file
<< " (its files are " << join(files, ", ") << ").";
throw Argument_error(ss.str(), Locator::none());
}
}
std::vector<const Section_target*> hits {};
for (const auto& t : table) {
if (t.basename == in && t.title == wanted) hits.push_back(&t);
}
if (hits.size() == 1) return *hits[0];
if (hits.empty()) {
ss << ", which " << in_name << " does not have"
<< (file.empty() ? " (a section in another file needs its basename as the value of :section)." : ".");
} else {
ss << ", which " << in_name << " has " << hits.size()
<< " times: give one of them an :id and link to that.";
}
throw Argument_error(ss.str(), Locator::none());
}
+49
View File
@@ -0,0 +1,49 @@
// @link :section -- a link to a section by the TEXT of its heading.
//
// Decided by Andy, 2026-10-12. :section (argtype section_link) written
// alone says the heading is in the CURRENT file; written with a value, the
// value is the basename (no directory, no extension) of the file of the
// same @document that has it. So a link names exactly one file, and a
// title shared between files is never ambiguous. Titles match with
// klammer markup removed and whitespace collapsed to one space,
// case-sensitive.
//
// Resolution happens here, when @document assembles its files -- the only
// place every heading is known -- from two markers the klammers emit:
// Link __seclink__<title>__KTSL__<file>__ (in the href / \hyperlink;
// <file> empty for the current one)
// Section %__sectionlink__<id>__KTSL__<basename>__KTSL__<title>__ (tex only;
// the html pass reads the heading elements themselves, which
// carry their ids after insert_ids())
// KTSL separates the fields because a title or a basename may contain a
// single underscore (md_to_sks_test). A file the document does not have, a
// title the file does not have, and a title it has more than once are
// errors, never fallbacks (the commands do not warn and continue).
#ifndef SECTION_LINK_H
#define SECTION_LINK_H
#include <string>
#include <vector>
struct Section_target {
std::string title {}; // normalized
std::string basename {}; // the file's basename, "" for :text content
std::string id {}; // the anchor: html id, or tex \hypertarget name
};
// The comparison form of a heading's text: tags and TeX control sequences
// removed, braces dropped, whitespace collapsed, trimmed.
std::string normalize_section_title(const std::string& s);
// The target a :section link names, or an Argument_error that says why
// there is none. FILE is the :section value ("" for the current file),
// WHERE the basename of the file holding the link ("" for the document's
// :text) and FILES the document's basenames. At assembly the link's own
// position is gone, so the file is the location an error can give
// (Locator::none(), never the C++ site).
Section_target resolve_section_target(
const std::vector<Section_target>& table, const std::string& title,
const std::string& file, const std::string& where,
const std::vector<std::string>& files);
#endif