Files
herolib/lib/data/atlas/link.v
Mahmoud-Emad 04e1e2375f refactor: Remove docusaurus dev server and path_meta flag
- Remove 'dev' flag from run command
- Remove 'path_meta' flag from run command
- Remove docusaurus integration from playcmds
- Add `validate_links` and `fix_links` to Atlas
- Refactor page link processing for clarity and export mode
2025-11-05 15:25:50 +02:00

293 lines
8.1 KiB
V

module atlas
import incubaid.herolib.core.texttools
// Link represents a markdown link found in content
pub struct Link {
pub mut:
src string // Source content where link was found (what to replace)
text string // Link text [text]
target string // Original link target (the source text)
line int // Line number where link was found (1-based)
pos int // Character position in line where link starts (0-based)
target_collection_name string
target_item_name string
status LinkStatus
is_file_link bool // is the link pointing to a file
is_image_link bool // is the link pointing to an image
page &Page @[skip; str: skip] // Reference to page where this link is found
}
pub enum LinkStatus {
init
external
found
not_found
anchor
error
}
fn (mut self Link) key() string {
return '${self.target_collection_name}:${self.target_item_name}'
}
// is the link in the same collection as the page containing the link
fn (mut self Link) is_local_in_collection() bool {
return self.target_collection_name == self.page.collection.name
}
// is the link pointing to an external resource e.g. http, git, mailto, ftp
pub fn (mut self Link) is_external() bool {
return self.status == .external
}
pub fn (mut self Link) target_page() !&Page {
if self.status == .external {
return error('External links do not have a target page')
}
return self.page.collection.atlas.page_get(self.key())
}
pub fn (mut self Link) target_file() !&File {
if self.status == .external {
return error('External links do not have a target file')
}
return self.page.collection.atlas.file_or_image_get(self.key())
}
// Find all markdown links in content
fn (mut p Page) find_links(content string) ![]Link {
mut links := []Link{}
mut lines := content.split_into_lines()
for line_idx, line in lines {
// println('Processing line ${line_idx + 1}: ${line}')
mut pos := 0
for {
mut image_open := line.index_after('!', pos) or { -1 }
// Find next [
open_bracket := line.index_after('[', pos) or { break }
// Find matching ]
close_bracket := line.index_after(']', open_bracket) or { break }
// Check for (
if close_bracket + 1 >= line.len || line[close_bracket + 1] != `(` {
pos = close_bracket + 1
// println('no ( after ]: skipping, ${line}')
continue
}
if image_open + 1 != open_bracket {
image_open = -1
}
// Find matching )
open_paren := close_bracket + 1
close_paren := line.index_after(')', open_paren) or { break }
// Extract link components
text := line[open_bracket + 1..close_bracket]
target := line[open_paren + 1..close_paren]
mut is_image_link := (image_open != -1)
mut is_file_link := false
// if no . in file then it means it's a page link (binaries with . are not supported in other words)
if target.contains('.') && (!target.trim_space().to_lower().ends_with('.md')) {
is_file_link = true
is_image_link = false // means it's a file link, not an image link
}
// Store position - use image_open if it's an image, otherwise open_bracket
link_start_pos := if is_image_link { image_open } else { open_bracket }
mut link := Link{
src: line[open_bracket..close_paren + 1]
text: text
target: target.trim_space()
line: line_idx + 1
pos: link_start_pos
is_file_link: is_file_link
is_image_link: is_image_link
page: &p
}
p.parse_link_target(mut link)
if link.status == .external {
link.is_file_link = false
link.is_image_link = false
}
links << link
pos = close_paren + 1
}
}
return links
}
// Parse link target to extract collection and page
fn (mut p Page) parse_link_target(mut link Link) {
mut target := link.target.to_lower().trim_space()
// Skip external links
if target.starts_with('http://') || target.starts_with('https://')
|| target.starts_with('mailto:') || target.starts_with('ftp://') {
link.status = .external
return
}
// Skip anchors
if target.starts_with('#') {
link.status = .anchor
return
}
if target.contains('/') {
parts9 := target.split('/')
if parts9.len >= 1 {
target = parts9[1]
}
}
// Format: $collection:$pagename or $collection:$pagename.md
if target.contains(':') {
parts := target.split(':')
if parts.len >= 2 {
link.target_collection_name = texttools.name_fix(parts[0])
link.target_item_name = normalize_page_name(parts[1])
}
} else {
link.target_item_name = normalize_page_name(target).trim_space()
link.target_collection_name = p.collection.name
}
if link.is_file_link == false && !p.collection.atlas.page_exists(link.key()) {
p.collection.error(
category: .invalid_page_reference
page_key: p.key()
message: 'Broken link to `${link.key()}` at line ${link.line}: `${link.src}`'
show_console: true
)
link.status = .not_found
} else if link.is_file_link && !p.collection.atlas.file_or_image_exists(link.key()) {
p.collection.error(
category: .invalid_file_reference
page_key: p.key()
message: 'Broken file link to `${link.key()}` at line ${link.line}: `${link.src}`'
show_console: true
)
link.status = .not_found
} else {
link.status = .found
}
}
////////////////FIX PAGES FOR THE LINKS///////////////////////
@[params]
pub struct FixLinksArgs {
include bool // Process includes before fixing links
cross_collection bool // Process cross-collection links (for export)
export_mode bool // Use export-style simple paths instead of filesystem paths
}
// Fix links in page content - rewrites links with proper relative paths
fn (mut p Page) content_with_fixed_links(args FixLinksArgs) !string {
mut content := p.content(include: args.include)!
// Get links - either re-find them (if includes processed) or use cached
mut links := if args.include {
p.find_links(content)! // Re-find links in processed content
} else {
p.links // Use cached links from validation
}
// Filter and transform links
for mut link in links {
// Skip invalid links
if link.status != .found {
continue
}
// Skip cross-collection links unless enabled
if !args.cross_collection && !link.is_local_in_collection() {
continue
}
// Calculate new link path
new_link := p.calculate_link_path(mut link, args) or { continue }
// Build the complete link markdown
prefix := if link.is_file_link { '!' } else { '' }
new_link_md := '${prefix}[${link.text}](${new_link})'
// Replace in content
content = content.replace(link.src, new_link_md)
}
return content
}
// calculate_link_path returns the relative path for a link
fn (mut p Page) calculate_link_path(mut link Link, args FixLinksArgs) !string {
if args.export_mode {
// Export mode: simple flat structure
return p.export_link_path(mut link)!
}
// Fix mode: filesystem paths
return p.filesystem_link_path(mut link)!
}
// export_link_path calculates path for export (flat structure: collection/file.md)
fn (mut p Page) export_link_path(mut link Link) !string {
mut target_collection := ''
mut target_filename := ''
if link.is_file_link {
mut tf := link.target_file()!
target_collection = tf.collection.name
target_filename = tf.name
} else {
mut tp := link.target_page()!
target_collection = tp.collection.name
target_filename = '${tp.name}.md'
}
// Same collection: just filename, different collection: ../collection/filename
return if link.is_local_in_collection() {
target_filename
} else {
'../${target_collection}/${target_filename}'
}
}
// filesystem_link_path calculates path using actual filesystem paths
fn (mut p Page) filesystem_link_path(mut link Link) !string {
source_path := p.path()!
mut target_path := if link.is_file_link {
mut tf := link.target_file()!
tf.path()!
} else {
mut tp := link.target_page()!
tp.path()!
}
return target_path.path_relative(source_path.path)!
}
/////////////TOOLS//////////////////////////////////
// Normalize page name (remove .md, apply name_fix)
fn normalize_page_name(name string) string {
mut clean := name
if clean.ends_with('.md') {
clean = clean[0..clean.len - 3]
}
return texttools.name_fix(clean)
}