Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/css/_html.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
@import "autocomplete.css";
@import "tooltips.css";
@import "copy-button.css";
@import "copy-markdown.css";
@import "settings.css";
@import "toast.css";
@import "screen-reader.css";
Expand Down
49 changes: 49 additions & 0 deletions assets/css/copy-markdown.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* Actions Group Container */
.actions-group {
float: right;
display: flex;
align-items: center;
gap: 4px;
margin-top: 12px;
}

.actions-group .icon-action {
float: none;
margin-top: 0;
}

/* Copy Markdown Button Styling */
.copy-markdown-btn {
color: var(--iconAction);
text-decoration: none;
border: none;
transition: color 0.3s ease-in-out;
background-color: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
font-weight: 300;
padding: 2px 4px;
height: fit-content;
border-radius: 3px;
opacity: 0.7;
}

.copy-markdown-btn:hover {
color: var(--iconActionHover);
opacity: 1;
background-color: rgba(128, 128, 128, 0.1);
}

.copy-markdown-btn i {
font-size: 0.7rem;
}

.copy-markdown-btn span {
white-space: nowrap;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
132 changes: 132 additions & 0 deletions assets/js/copy-markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Initializes markdown copy functionality.
*/
export function initialize () {
console.log('Initializing copy-markdown functionality')

if ('clipboard' in navigator) {
// Make the copyMarkdown function globally available
window.copyMarkdown = copyMarkdown
console.log('copyMarkdown function attached to window')
} else {
console.warn('Clipboard API not available')
}
}

/**
* Copies the markdown version of the current page to clipboard.
* @param {string} markdownPath - The path to the markdown file
*/
async function copyMarkdown (markdownPath) {
console.log('copyMarkdown called with path:', markdownPath)

try {
// Check if clipboard API is available
if (!navigator.clipboard) {
throw new Error('Clipboard API not available')
}

// Construct the URL for the markdown file
// We need to replace the current filename with the markdown version
const currentUrl = new URL(window.location.href)
const baseUrl = currentUrl.origin + currentUrl.pathname.replace(/\/[^/]*$/, '')
const markdownUrl = `${baseUrl}/markdown/${markdownPath}`

console.log('Fetching markdown from:', markdownUrl)

// Fetch the markdown content
const response = await fetch(markdownUrl)

if (!response.ok) {
throw new Error(`Failed to fetch markdown: ${response.status}`)
}

const markdownContent = await response.text()
console.log('Markdown content length:', markdownContent.length)

// Copy to clipboard with fallback
await copyToClipboard(markdownContent)

// Show success feedback
showCopyFeedback('Markdown copied!')
console.log('Markdown copied successfully')

} catch (error) {
console.error('Failed to copy markdown:', error)
showCopyFeedback('Failed to copy markdown: ' + error.message, true)
}
}

/**
* Copies text to clipboard with fallback for older browsers.
* @param {string} text - The text to copy
*/
async function copyToClipboard(text) {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()

return new Promise((resolve, reject) => {
const successful = document.execCommand('copy')
document.body.removeChild(textArea)

if (successful) {
resolve()
} else {
reject(new Error('Unable to copy to clipboard'))
}
})
}
}

/**
* Shows feedback when copying markdown.
* @param {string} message - The message to show
* @param {boolean} isError - Whether this is an error message
*/
function showCopyFeedback (message, isError = false) {
// Create or update a feedback element
let feedback = document.getElementById('markdown-copy-feedback')

if (!feedback) {
feedback = document.createElement('div')
feedback.id = 'markdown-copy-feedback'
feedback.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 10px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
color: white;
z-index: 10000;
transition: opacity 0.3s ease;
`
document.body.appendChild(feedback)
}

feedback.textContent = message
feedback.style.backgroundColor = isError ? '#dc2626' : '#059669'
feedback.style.opacity = '1'

// Hide after 3 seconds
setTimeout(() => {
feedback.style.opacity = '0'
setTimeout(() => {
if (feedback.parentNode) {
feedback.parentNode.removeChild(feedback)
}
}, 300)
}, 3000)
}
1 change: 1 addition & 0 deletions assets/js/entry/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import '../makeup'
import '../search-bar'
import '../tooltips/tooltips'
import '../copy-button'
import '../copy-markdown'
import '../search-page'
import '../settings'
import '../keyboard-shortcuts'
Expand Down
83 changes: 83 additions & 0 deletions formatters/html/dist/html-55LLUM6Q.js

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion lib/ex_doc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,34 @@ defmodule ExDoc do
end

{module_nodes, filtered_nodes} = config.retriever.docs_from_dir(config.source_beam, config)
find_formatter(config.formatter).run(module_nodes, filtered_nodes, config)

# Check if we should use the new ExtraNode architecture
if use_extra_node_architecture?(config.formatter) do
generate_docs_with_extra_nodes(module_nodes, filtered_nodes, config)
else
# Legacy path for backwards compatibility
find_formatter(config.formatter).run(module_nodes, filtered_nodes, config)
end
end

@doc """
Generates documentation using the new ExtraNode architecture.

This builds extras once and passes pre-built ExtraNode structures to formatters,
eliminating duplicate work when multiple formatters are used.
"""
def generate_docs_with_extra_nodes(module_nodes, filtered_nodes, config) do
# Build extras once for all formats
extra_nodes = ExDoc.ExtraNode.build_extras(config)

# Pass pre-built extras to the formatter
find_formatter(config.formatter).run_with_extra_nodes(module_nodes, filtered_nodes, extra_nodes, config)
end

# For now, we'll enable the new architecture for all formatters
# In the future, this could be more selective based on config or formatter capabilities
defp use_extra_node_architecture?(_formatter), do: true

# Short path for programmatic interface
defp find_formatter(modname) when is_atom(modname), do: modname

Expand Down
5 changes: 4 additions & 1 deletion lib/ex_doc/autolink.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,13 @@ defmodule ExDoc.Autolink do
if app in config.apps do
path <> ext <> suffix
else
#  TODO: remove this if/when hexdocs.pm starts including .md files
ext = ".html"

config.deps
|> Keyword.get_lazy(app, fn -> base_url <> "#{app}" end)
|> String.trim_trailing("/")
|> Kernel.<>("/" <> path <> ".html" <> suffix)
|> Kernel.<>("/" <> path <> ext <> suffix)
end
else
path <> ext <> suffix
Expand Down
4 changes: 2 additions & 2 deletions lib/ex_doc/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ defmodule ExDoc.CLI do
defp normalize_formatters(opts) do
formatters =
case Keyword.get_values(opts, :formatter) do
[] -> opts[:formatters] || ["html", "epub"]
[] -> opts[:formatters] || ["html", "epub", "markdown"]
values -> values
end

Expand Down Expand Up @@ -199,7 +199,7 @@ defmodule ExDoc.CLI do
See "Custom config" section below for more information.
--favicon Path to a favicon image for the project. Must be PNG, JPEG or SVG. The image
will be placed in the output "assets" directory.
-f, --formatter Docs formatter to use (html or epub), default: html and epub
-f, --formatter Docs formatter to use (html, epub, or markdown), default: html, epub, and markdown
--homepage-url URL to link to for the site name
--language Identify the primary language of the documents, its value must be
a valid [BCP 47](https://tools.ietf.org/html/bcp47) language tag, default: "en"
Expand Down
66 changes: 65 additions & 1 deletion lib/ex_doc/doc_ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ defmodule ExDoc.DocAST do
meta param source track wbr)a

@doc """
Transform AST into string.
Transform AST into an HTML string.
"""
def to_string(binary) do
IO.iodata_to_binary(to_iodata(binary))
Expand Down Expand Up @@ -65,6 +65,70 @@ defmodule ExDoc.DocAST do
Enum.map(attrs, fn {key, val} -> " #{key}=\"#{ExDoc.Utils.h(val)}\"" end)
end

@doc """
Transform AST into a markdown string.
"""
def to_markdown_string(ast, fun \\ fn _ast, string -> string end)

def to_markdown_string(binary, _fun) when is_binary(binary) do
ExDoc.Utils.h(binary)
end

def to_markdown_string(list, fun) when is_list(list) do
result = Enum.map_join(list, "", &to_markdown_string(&1, fun))
fun.(list, result)
end

def to_markdown_string({:comment, _attrs, inner, _meta} = ast, fun) do
fun.(ast, "<!--#{inner}-->")
end

def to_markdown_string({:code, _attrs, inner, _meta} = ast, fun) do
result = """
```
#{inner}
```
"""

fun.(ast, result)
end

def to_markdown_string({:a, attrs, inner, _meta} = ast, fun) do
result = "[#{inner}](#{attrs[:href]})"
fun.(ast, result)
end

def to_markdown_string({:hr, _attrs, _inner, _meta} = ast, fun) do
result = "\n\n---\n\n"
fun.(ast, result)
end

def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in [:p, :br] do
result = "\n\n"
fun.(ast, result)
end

def to_markdown_string({:img, attrs, _inner, _meta} = ast, fun) do
result = "![#{attrs[:alt]}](#{attrs[:src]} \"#{attrs[:title]}\")"
fun.(ast, result)
end

# ignoring these: area base col command embed input keygen link meta param source track wbr
def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in @void_elements do
result = ""
fun.(ast, result)
end

def to_markdown_string({_tag, _attrs, inner, %{verbatim: true}} = ast, fun) do
result = Enum.join(inner, "")
fun.(ast, result)
end

def to_markdown_string({_tag, _attrs, inner, _meta} = ast, fun) do
result = to_string(inner, fun)
fun.(ast, result)
end

## parse markdown

defp parse_markdown(markdown, opts) do
Expand Down
Loading
Loading