feat: Add inline link display and vim-style quick navigation

Major improvements to link handling and navigation:

Features:
- Display links inline with numbered indicators [0], [1], etc.
- Quick navigation: type number + Enter to jump to link
- Fast follow: press 'f' + number to open link directly
- Visual improvements: links shown with underline and highlight
- Remove separate link list at bottom for better readability

Technical changes:
- Add InlineLink structure to track link positions in text
- Implement wrap_text_with_links() for intelligent text wrapping
- Add GOTO_LINK and FOLLOW_LINK_NUM actions
- Implement LINK input mode for 'f' command
- Character-by-character rendering for proper link highlighting
- Update help documentation with new navigation methods

Usage examples:
- 3<Enter>  : Jump to link 3
- f5 or 5f  : Open link 5 directly
- Tab/Enter : Traditional navigation still works

All comments converted to standard Unix style (English).
This commit is contained in:
m1ngsama 2025-12-08 17:07:40 +08:00
parent 354133b500
commit ea71b0ca02
9 changed files with 540 additions and 42 deletions

76
LINK_NAVIGATION.md Normal file
View file

@ -0,0 +1,76 @@
# Quick Link Navigation Guide
The browser now supports vim-style quick navigation to links!
## Features
### 1. Visual Link Numbers
All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc.
- Links are shown with yellow color and underline
- Active link (selected with Tab) has yellow background
### 2. Quick Navigation Methods
#### Method 1: Number + Enter
Type a number and press Enter to jump to that link:
```
3<Enter> - Jump to link [3]
10<Enter> - Jump to link [10]
```
#### Method 2: 'f' command (follow)
Press `f` followed by a number to immediately open that link:
```
f3 - Open link [3] directly
f10 - Open link [10] directly
```
Or type the number first:
```
3f - Open link [3] directly
10f - Open link [10] directly
```
#### Method 3: Traditional Tab navigation (still works)
```
Tab - Next link
Shift-Tab/T - Previous link
Enter - Follow current highlighted link
```
## Examples
Given a page with these links:
- "Google[0]"
- "GitHub[1]"
- "Wikipedia[2]"
You can:
- Press `1<Enter>` to select GitHub link
- Press `f2` to immediately open Wikipedia
- Press `Tab` twice then `Enter` to open Wikipedia
## Usage
```bash
# Test with a real website
./tut https://example.com
# View help
./tut
# Press ? for help
```
## Key Bindings Summary
| Command | Action |
|---------|--------|
| `[N]<Enter>` | Jump to link N |
| `f[N]` or `[N]f` | Open link N directly |
| `Tab` | Next link |
| `Shift-Tab` / `T` | Previous link |
| `Enter` | Follow current link |
| `h` | Go back |
| `l` | Go forward |
All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before!

View file

@ -138,34 +138,93 @@ public:
int line_idx = scroll_pos + i; int line_idx = scroll_pos + i;
const auto& line = rendered_lines[line_idx]; const auto& line = rendered_lines[line_idx];
if (line.is_link && line.link_index == current_link) { // Check if this line contains the active link
attron(COLOR_PAIR(COLOR_LINK_ACTIVE)); bool has_active_link = (line.is_link && line.link_index == current_link);
} else {
attron(COLOR_PAIR(line.color_pair)); // Check if this line is in search results
if (line.is_bold) { bool in_search_results = !search_term.empty() &&
attron(A_BOLD); std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end();
// If line has link ranges, render character by character with proper highlighting
if (!line.link_ranges.empty()) {
int col = 0;
for (size_t char_idx = 0; char_idx < line.text.length(); ++char_idx) {
// Check if this character is within any link range
bool is_in_link = false;
for (const auto& [start, end] : line.link_ranges) {
if (char_idx >= start && char_idx < end) {
is_in_link = true;
break;
}
}
// Apply appropriate color
if (is_in_link && has_active_link) {
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else if (is_in_link) {
attron(COLOR_PAIR(COLOR_LINK));
attron(A_UNDERLINE);
} else {
attron(COLOR_PAIR(line.color_pair));
if (line.is_bold) {
attron(A_BOLD);
}
}
if (in_search_results) {
attron(A_REVERSE);
}
mvaddch(i, col, line.text[char_idx]);
if (in_search_results) {
attroff(A_REVERSE);
}
if (is_in_link && has_active_link) {
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else if (is_in_link) {
attroff(A_UNDERLINE);
attroff(COLOR_PAIR(COLOR_LINK));
} else {
if (line.is_bold) {
attroff(A_BOLD);
}
attroff(COLOR_PAIR(line.color_pair));
}
col++;
} }
}
if (!search_term.empty() &&
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
attron(A_REVERSE);
}
mvprintw(i, 0, "%s", line.text.c_str());
if (!search_term.empty() &&
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
attroff(A_REVERSE);
}
if (line.is_link && line.link_index == current_link) {
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else { } else {
if (line.is_bold) { // No inline links, render normally
attroff(A_BOLD); if (has_active_link) {
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else {
attron(COLOR_PAIR(line.color_pair));
if (line.is_bold) {
attron(A_BOLD);
}
}
if (in_search_results) {
attron(A_REVERSE);
}
mvprintw(i, 0, "%s", line.text.c_str());
if (in_search_results) {
attroff(A_REVERSE);
}
if (has_active_link) {
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else {
if (line.is_bold) {
attroff(A_BOLD);
}
attroff(COLOR_PAIR(line.color_pair));
} }
attroff(COLOR_PAIR(line.color_pair));
} }
} }
@ -229,6 +288,26 @@ public:
} }
break; break;
case Action::GOTO_LINK:
// Jump to specific link by number
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
current_link = result.number;
scroll_to_link(current_link);
status_message = "Link " + std::to_string(result.number);
} else {
status_message = "Invalid link number: " + std::to_string(result.number);
}
break;
case Action::FOLLOW_LINK_NUM:
// Follow specific link by number directly
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
load_page(current_doc.links[result.number].url);
} else {
status_message = "Invalid link number: " + std::to_string(result.number);
}
break;
case Action::GO_BACK: case Action::GO_BACK:
if (history_pos > 0) { if (history_pos > 0) {
history_pos--; history_pos--;
@ -332,9 +411,12 @@ public:
<< "<p>G: Go to bottom</p>" << "<p>G: Go to bottom</p>"
<< "<p>[number]G: Go to line number</p>" << "<p>[number]G: Go to line number</p>"
<< "<h2>Links</h2>" << "<h2>Links</h2>"
<< "<p>Links are displayed inline with numbers like [0], [1], etc.</p>"
<< "<p>Tab: Next link</p>" << "<p>Tab: Next link</p>"
<< "<p>Shift-Tab or T: Previous link</p>" << "<p>Shift-Tab or T: Previous link</p>"
<< "<p>Enter: Follow link</p>" << "<p>Enter: Follow current link</p>"
<< "<p>[number]Enter: Jump to link number N</p>"
<< "<p>f[number]: Follow link number N directly</p>"
<< "<p>h: Go back</p>" << "<p>h: Go back</p>"
<< "<p>l: Go forward</p>" << "<p>l: Go forward</p>"
<< "<h2>Search</h2>" << "<h2>Search</h2>"

View file

@ -139,6 +139,70 @@ public:
return links; return links;
} }
// 从HTML中提取文本同时保留内联链接位置信息
std::string extract_text_with_links(const std::string& html,
std::vector<Link>& all_links,
std::vector<InlineLink>& inline_links) {
std::string result;
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
std::regex::icase);
size_t last_pos = 0;
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
auto end = std::sregex_iterator();
// 处理所有链接
for (std::sregex_iterator i = begin; i != end; ++i) {
std::smatch match = *i;
// 添加链接前的文本
std::string before_link = html.substr(last_pos, match.position() - last_pos);
std::string before_text = decode_html_entities(remove_tags(before_link));
result += before_text;
// 提取链接信息
std::string link_url = match[1].str();
std::string link_text = decode_html_entities(remove_tags(match[2].str()));
// 跳过空链接或锚点链接
if (link_url.empty() || link_url[0] == '#' || link_text.empty()) {
result += link_text;
last_pos = match.position() + match.length();
continue;
}
// 找到这个链接在全局链接列表中的索引
int link_index = -1;
for (size_t j = 0; j < all_links.size(); ++j) {
if (all_links[j].url == link_url && all_links[j].text == link_text) {
link_index = j;
break;
}
}
if (link_index != -1) {
// 记录内联链接位置
InlineLink inline_link;
inline_link.text = link_text;
inline_link.url = link_url;
inline_link.start_pos = result.length();
inline_link.end_pos = result.length() + link_text.length();
inline_link.link_index = link_index;
inline_links.push_back(inline_link);
}
// 添加链接文本
result += link_text;
last_pos = match.position() + match.length();
}
// 添加最后一段文本
std::string remaining = html.substr(last_pos);
result += decode_html_entities(remove_tags(remaining));
return trim(result);
}
// 清理空白字符 // 清理空白字符
std::string trim(const std::string& str) { std::string trim(const std::string& str) {
auto start = str.begin(); auto start = str.begin();
@ -237,14 +301,13 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
} }
} }
// 解析段落 // 解析段落 (保留内联链接)
auto paragraphs = pImpl->extract_all_tags(main_content, "p"); auto paragraphs = pImpl->extract_all_tags(main_content, "p");
for (const auto& para : paragraphs) { for (const auto& para : paragraphs) {
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para))); ContentElement elem;
if (!text.empty() && text.length() > 1) { elem.type = ElementType::PARAGRAPH;
ContentElement elem; elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links);
elem.type = ElementType::PARAGRAPH; if (!elem.text.empty() && elem.text.length() > 1) {
elem.text = text;
doc.elements.push_back(elem); doc.elements.push_back(elem);
} }
} }

View file

@ -24,11 +24,20 @@ struct Link {
int position; int position;
}; };
struct InlineLink {
std::string text;
std::string url;
size_t start_pos; // Position in the text where link starts
size_t end_pos; // Position in the text where link ends
int link_index; // Index in the document's links array
};
struct ContentElement { struct ContentElement {
ElementType type; ElementType type;
std::string text; std::string text;
std::string url; std::string url;
int level; int level;
std::vector<InlineLink> inline_links; // Links within this element's text
}; };
struct ParsedDocument { struct ParsedDocument {

View file

@ -23,6 +23,7 @@ public:
result.has_count = false; result.has_count = false;
result.count = 1; result.count = 1;
// Handle digit input for count or 'f' command
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) { if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
count_buffer += static_cast<char>(ch); count_buffer += static_cast<char>(ch);
return result; return result;
@ -31,33 +32,38 @@ public:
if (!count_buffer.empty()) { if (!count_buffer.empty()) {
result.has_count = true; result.has_count = true;
result.count = std::stoi(count_buffer); result.count = std::stoi(count_buffer);
count_buffer.clear();
} }
switch (ch) { switch (ch) {
case 'j': case 'j':
case KEY_DOWN: case KEY_DOWN:
result.action = Action::SCROLL_DOWN; result.action = Action::SCROLL_DOWN;
count_buffer.clear();
break; break;
case 'k': case 'k':
case KEY_UP: case KEY_UP:
result.action = Action::SCROLL_UP; result.action = Action::SCROLL_UP;
count_buffer.clear();
break; break;
case 'h': case 'h':
case KEY_LEFT: case KEY_LEFT:
result.action = Action::GO_BACK; result.action = Action::GO_BACK;
count_buffer.clear();
break; break;
case 'l': case 'l':
case KEY_RIGHT: case KEY_RIGHT:
result.action = Action::GO_FORWARD; result.action = Action::GO_FORWARD;
count_buffer.clear();
break; break;
case 4: case 4:
case ' ': case ' ':
result.action = Action::SCROLL_PAGE_DOWN; result.action = Action::SCROLL_PAGE_DOWN;
count_buffer.clear();
break; break;
case 21: case 21:
case 'b': case 'b':
result.action = Action::SCROLL_PAGE_UP; result.action = Action::SCROLL_PAGE_UP;
count_buffer.clear();
break; break;
case 'g': case 'g':
buffer += 'g'; buffer += 'g';
@ -65,6 +71,7 @@ public:
result.action = Action::GOTO_TOP; result.action = Action::GOTO_TOP;
buffer.clear(); buffer.clear();
} }
count_buffer.clear();
break; break;
case 'G': case 'G':
if (result.has_count) { if (result.has_count) {
@ -73,27 +80,52 @@ public:
} else { } else {
result.action = Action::GOTO_BOTTOM; result.action = Action::GOTO_BOTTOM;
} }
count_buffer.clear();
break; break;
case '/': case '/':
mode = InputMode::SEARCH; mode = InputMode::SEARCH;
buffer = "/"; buffer = "/";
count_buffer.clear();
break; break;
case 'n': case 'n':
result.action = Action::SEARCH_NEXT; result.action = Action::SEARCH_NEXT;
count_buffer.clear();
break; break;
case 'N': case 'N':
result.action = Action::SEARCH_PREV; result.action = Action::SEARCH_PREV;
count_buffer.clear();
break; break;
case '\t': case '\t':
result.action = Action::NEXT_LINK; result.action = Action::NEXT_LINK;
count_buffer.clear();
break; break;
case KEY_BTAB: case KEY_BTAB:
case 'T': case 'T':
result.action = Action::PREV_LINK; result.action = Action::PREV_LINK;
count_buffer.clear();
break; break;
case '\n': case '\n':
case '\r': case '\r':
result.action = Action::FOLLOW_LINK; // If count buffer has a number, jump to that link
if (result.has_count) {
result.action = Action::GOTO_LINK;
result.number = result.count;
} else {
result.action = Action::FOLLOW_LINK;
}
count_buffer.clear();
break;
case 'f':
// 'f' command: follow link by number
if (result.has_count) {
result.action = Action::FOLLOW_LINK_NUM;
result.number = result.count;
count_buffer.clear();
} else {
// Enter link follow mode, wait for number
mode = InputMode::LINK;
buffer = "f";
}
break; break;
case ':': case ':':
mode = InputMode::COMMAND; mode = InputMode::COMMAND;
@ -190,6 +222,41 @@ public:
return result; return result;
} }
InputResult process_link_mode(int ch) {
InputResult result;
result.action = Action::NONE;
if (std::isdigit(ch)) {
buffer += static_cast<char>(ch);
} else if (ch == '\n' || ch == '\r') {
// Follow the link number entered
if (buffer.length() > 1) {
try {
int link_num = std::stoi(buffer.substr(1));
result.action = Action::FOLLOW_LINK_NUM;
result.number = link_num;
} catch (...) {
set_status("Invalid link number");
}
}
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == 27) {
// ESC cancels
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
if (buffer.length() > 1) {
buffer.pop_back();
} else {
mode = InputMode::NORMAL;
buffer.clear();
}
}
return result;
}
}; };
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {} InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
@ -204,6 +271,8 @@ InputResult InputHandler::handle_key(int ch) {
return pImpl->process_command_mode(ch); return pImpl->process_command_mode(ch);
case InputMode::SEARCH: case InputMode::SEARCH:
return pImpl->process_search_mode(ch); return pImpl->process_search_mode(ch);
case InputMode::LINK:
return pImpl->process_link_mode(ch);
default: default:
break; break;
} }

View file

@ -26,6 +26,8 @@ enum class Action {
NEXT_LINK, NEXT_LINK,
PREV_LINK, PREV_LINK,
FOLLOW_LINK, FOLLOW_LINK,
GOTO_LINK, // Jump to specific link by number
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
GO_BACK, GO_BACK,
GO_FORWARD, GO_FORWARD,
OPEN_URL, OPEN_URL,

View file

@ -7,6 +7,12 @@ class TextRenderer::Impl {
public: public:
RenderConfig config; RenderConfig config;
struct LinkPosition {
int link_index;
size_t start;
size_t end;
};
std::vector<std::string> wrap_text(const std::string& text, int width) { std::vector<std::string> wrap_text(const std::string& text, int width) {
std::vector<std::string> lines; std::vector<std::string> lines;
if (text.empty()) { if (text.empty()) {
@ -50,6 +56,151 @@ public:
return lines; return lines;
} }
// Wrap text with links, tracking link positions and adding link numbers
std::vector<std::pair<std::string, std::vector<LinkPosition>>>
wrap_text_with_links(const std::string& original_text, int width,
const std::vector<InlineLink>& inline_links) {
std::vector<std::pair<std::string, std::vector<LinkPosition>>> result;
// If no links, use simple wrapping
if (inline_links.empty()) {
auto wrapped = wrap_text(original_text, width);
for (const auto& line : wrapped) {
result.push_back({line, {}});
}
return result;
}
// Build modified text with link numbers inserted
std::string text;
std::vector<InlineLink> modified_links;
size_t text_pos = 0;
for (const auto& link : inline_links) {
// Add text before link
if (link.start_pos > text_pos) {
text += original_text.substr(text_pos, link.start_pos - text_pos);
}
// Add link text with number indicator
size_t link_start_in_modified = text.length();
std::string link_text = original_text.substr(link.start_pos, link.end_pos - link.start_pos);
std::string link_indicator = "[" + std::to_string(link.link_index) + "]";
text += link_text + link_indicator;
// Store modified link position (including the indicator)
InlineLink mod_link = link;
mod_link.start_pos = link_start_in_modified;
mod_link.end_pos = text.length();
modified_links.push_back(mod_link);
text_pos = link.end_pos;
}
// Add remaining text after last link
if (text_pos < original_text.length()) {
text += original_text.substr(text_pos);
}
// Split text into words
std::vector<std::string> words;
std::vector<size_t> word_positions;
size_t pos = 0;
while (pos < text.length()) {
// Skip whitespace
while (pos < text.length() && std::isspace(text[pos])) {
pos++;
}
if (pos >= text.length()) break;
// Extract word
size_t word_start = pos;
while (pos < text.length() && !std::isspace(text[pos])) {
pos++;
}
words.push_back(text.substr(word_start, pos - word_start));
word_positions.push_back(word_start);
}
// Build lines
std::string current_line;
std::vector<LinkPosition> current_links;
for (size_t i = 0; i < words.size(); ++i) {
const auto& word = words[i];
size_t word_pos = word_positions[i];
bool can_fit = current_line.empty()
? word.length() <= static_cast<size_t>(width)
: current_line.length() + 1 + word.length() <= static_cast<size_t>(width);
if (!can_fit && !current_line.empty()) {
// Save current line
result.push_back({current_line, current_links});
current_line.clear();
current_links.clear();
}
// Add word to current line
if (!current_line.empty()) {
current_line += " ";
}
size_t word_start_in_line = current_line.length();
current_line += word;
// Check if this word overlaps with any links
for (const auto& link : modified_links) {
size_t word_end = word_pos + word.length();
// Check for overlap
if (word_pos < link.end_pos && word_end > link.start_pos) {
// Calculate link position in current line
size_t link_start_in_line = word_start_in_line;
if (link.start_pos > word_pos) {
link_start_in_line += (link.start_pos - word_pos);
}
size_t link_end_in_line = word_start_in_line + word.length();
if (link.end_pos < word_end) {
link_end_in_line -= (word_end - link.end_pos);
}
// Check if link already added
bool already_added = false;
for (auto& existing : current_links) {
if (existing.link_index == link.link_index) {
// Extend existing link range
existing.end = link_end_in_line;
already_added = true;
break;
}
}
if (!already_added) {
LinkPosition lp;
lp.link_index = link.link_index;
lp.start = link_start_in_line;
lp.end = link_end_in_line;
current_links.push_back(lp);
}
}
}
}
// Add last line
if (!current_line.empty()) {
result.push_back({current_line, current_links});
}
if (result.empty()) {
result.push_back({"", {}});
}
return result;
}
std::string add_indent(const std::string& text, int indent) { std::string add_indent(const std::string& text, int indent) {
return std::string(indent, ' ') + text; return std::string(indent, ' ') + text;
} }
@ -167,18 +318,38 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
break; break;
} }
auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length()); auto wrapped_with_links = pImpl->wrap_text_with_links(elem.text,
for (size_t i = 0; i < wrapped_lines.size(); ++i) { content_width - prefix.length(),
elem.inline_links);
for (size_t i = 0; i < wrapped_with_links.size(); ++i) {
const auto& [line_text, link_positions] = wrapped_with_links[i];
RenderedLine line; RenderedLine line;
if (i == 0) { if (i == 0) {
line.text = std::string(margin, ' ') + prefix + wrapped_lines[i]; line.text = std::string(margin, ' ') + prefix + line_text;
} else { } else {
line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i]; line.text = std::string(margin + prefix.length(), ' ') + line_text;
} }
line.color_pair = color; line.color_pair = color;
line.is_bold = bold; line.is_bold = bold;
line.is_link = false;
line.link_index = -1; // Store link information
if (!link_positions.empty()) {
line.is_link = true;
line.link_index = link_positions[0].link_index; // Primary link for Tab navigation
// Adjust link positions for margin and prefix
size_t offset = (i == 0) ? (margin + prefix.length()) : (margin + prefix.length());
for (const auto& lp : link_positions) {
line.link_ranges.push_back({lp.start + offset, lp.end + offset});
}
} else {
line.is_link = false;
line.link_index = -1;
}
lines.push_back(line); lines.push_back(line);
} }
@ -198,7 +369,8 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
} }
} }
if (!doc.links.empty() && pImpl->config.show_link_indicators) { // Don't show separate links section if inline links are displayed
if (!doc.links.empty() && !pImpl->config.show_link_indicators) {
RenderedLine separator; RenderedLine separator;
std::string sepline(content_width, '-'); std::string sepline(content_width, '-');
separator.text = std::string(margin, ' ') + sepline; separator.text = std::string(margin, ' ') + sepline;

View file

@ -12,6 +12,7 @@ struct RenderedLine {
bool is_bold; bool is_bold;
bool is_link; bool is_link;
int link_index; int link_index;
std::vector<std::pair<size_t, size_t>> link_ranges; // (start, end) positions of links in this line
}; };
struct RenderConfig { struct RenderConfig {
@ -19,7 +20,7 @@ struct RenderConfig {
int margin_left = 0; int margin_left = 0;
bool center_content = true; bool center_content = true;
int paragraph_spacing = 1; int paragraph_spacing = 1;
bool show_link_indicators = true; bool show_link_indicators = false; // Set to false to show inline links by default
}; };
class TextRenderer { class TextRenderer {

24
test_inline_links.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Inline Links</title>
</head>
<body>
<h1>Test Page for Inline Links</h1>
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
<h2>More Examples</h2>
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
<ul>
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
</ul>
</body>
</html>