module core import incubaid.herolib.web.doctree import incubaid.herolib.ui.console pub enum LinkFileType { page // Default: link to another page file // Link to a non-image file image // Link to an image file } // 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 file_type LinkFileType // Type of the link target: file, image, or page (default) page &Page @[skip; str: skip] // Reference to page where this link is found } pub enum LinkStatus { init external found not_found anchor error } // Get the collection:item key for this link fn (mut self Link) key() string { return '${self.target_collection_name}:${self.target_item_name}' } // Get the target page this link points to 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.doctree.page_get(self.key()) } // Get the target file this link points to 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.doctree.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] // Determine link type based on content mut detected_file_type := LinkFileType.page // Check if it's an image link (starts with !) if image_open != -1 { detected_file_type = .image } else if target.contains('.') && !target.trim_space().to_lower().ends_with('.md') { // File link: has extension but not .md detected_file_type = .file } // console.print_debug('Found link: text="${text}", target="${target}", type=${detected_file_type}') // Store position - use image_open if it's an image, otherwise open_bracket link_start_pos := if detected_file_type == .image { image_open } else { open_bracket } // For image links, src should include the ! prefix link_src := if detected_file_type == .image { line[image_open..close_paren + 1] } else { line[open_bracket..close_paren + 1] } mut link := Link{ src: link_src text: text target: target.trim_space() line: line_idx + 1 pos: link_start_pos file_type: detected_file_type page: &p } p.parse_link_target(mut link)! // No need to set file_type to false for external links, as it's already .page by default 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() // Check for external links (http, https, mailto, ftp) if target.starts_with('http://') || target.starts_with('https://') || target.starts_with('mailto:') || target.starts_with('ftp://') { link.status = .external return } // Check for anchor links if target.starts_with('#') { link.status = .anchor return } // Handle relative paths - extract the last part after / if target.contains('/') { parts := target.split('/') if parts.len > 1 { target = parts[parts.len - 1] } } // Format: $collection:$pagename or $collection:$pagename.md if target.contains(':') { parts := target.split(':') if parts.len >= 2 { link.target_collection_name = doctree.name_fix(parts[0]) // For file links, use name without extension; for page links, normalize normally if link.file_type == .file { link.target_item_name = doctree.name_fix(parts[1]) } else { link.target_item_name = normalize_page_name(parts[1]) } } } else { // For file links, use name without extension; for page links, normalize normally if link.file_type == .file { link.target_item_name = doctree.name_fix(target).trim_space() } else { link.target_item_name = normalize_page_name(target).trim_space() } link.target_collection_name = p.collection.name } // console.print_debug('Parsed link target: collection="${link.target_collection_name}", item="${link.target_item_name}", type=${link.file_type}') // Validate link target exists mut target_exists := false mut error_category := CollectionErrorCategory.invalid_page_reference mut error_prefix := 'Broken link' if link.file_type == .file || link.file_type == .image { target_exists = p.collection.doctree.file_or_image_exists(link.key())! error_category = .invalid_file_reference error_prefix = if link.file_type == .file { 'Broken file link' } else { 'Broken image link' } } else { target_exists = p.collection.doctree.page_exists(link.key())! } // console.print_debug('Link target exists: ${target_exists} for key=${link.key()}') if target_exists { link.status = .found } else { p.collection.error( category: error_category page_key: p.key() message: '${error_prefix} to `${link.key()}` at line ${link.line}: `${link.src}`' show_console: true ) link.status = .not_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 is_local := link.target_collection_name == p.collection.name if !args.cross_collection && !is_local { continue } // Calculate new link path based on mode new_link := if args.export_mode { p.export_link_path(mut link) or { continue } } else { p.filesystem_link_path(mut link) or { continue } } // Build the complete link markdown // For image links, link.src already includes the !, so we build the same format prefix := if link.file_type == .image { '!' } else { '' } new_link_md := '${prefix}[${link.text}](${new_link})' // Replace in content content = content.replace(link.src, new_link_md) } return content } // export_link_path calculates path for export (self-contained: all references are local) fn (mut p Page) export_link_path(mut link Link) !string { match link.file_type { .image { mut tf := link.target_file()! return 'img/${tf.name}' } .file { mut tf := link.target_file()! return 'files/${tf.name}' } .page { mut tp := link.target_page()! return '${tp.name}.md' } } } // 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 := match link.file_type { .image, .file { mut tf := link.target_file()! tf.path()! } .page { 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 doctree.name_fix(clean) }