diff --git a/assets/css/_html.css b/assets/css/_html.css index 329f74d40..f2a3f0c12 100644 --- a/assets/css/_html.css +++ b/assets/css/_html.css @@ -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"; diff --git a/assets/css/copy-markdown.css b/assets/css/copy-markdown.css new file mode 100644 index 000000000..f8fdb45fa --- /dev/null +++ b/assets/css/copy-markdown.css @@ -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; +} \ No newline at end of file diff --git a/assets/js/copy-markdown.js b/assets/js/copy-markdown.js new file mode 100644 index 000000000..fd7a5a0f0 --- /dev/null +++ b/assets/js/copy-markdown.js @@ -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) +} \ No newline at end of file diff --git a/assets/js/entry/html.js b/assets/js/entry/html.js index 6d44bef69..9b229c449 100644 --- a/assets/js/entry/html.js +++ b/assets/js/entry/html.js @@ -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' diff --git a/formatters/html/dist/html-55LLUM6Q.js b/formatters/html/dist/html-55LLUM6Q.js new file mode 100644 index 000000000..58755a1d6 --- /dev/null +++ b/formatters/html/dist/html-55LLUM6Q.js @@ -0,0 +1,83 @@ +(()=>{var Ui=Object.create;var Jt=Object.defineProperty;var qi=Object.getOwnPropertyDescriptor;var ji=Object.getOwnPropertyNames;var zi=Object.getPrototypeOf,Wi=Object.prototype.hasOwnProperty;var Xt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Gi=(e,t,n,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of ji(t))!Wi.call(e,r)&&r!==n&&Jt(e,r,{get:()=>t[r],enumerable:!(i=qi(t,r))||i.enumerable});return e};var Zt=(e,t,n)=>(n=e!=null?Ui(zi(e)):{},Gi(t||!e||!e.__esModule?Jt(n,"default",{value:e,enumerable:!0}):n,e));var pn=Xt((wa,fn)=>{var hn="Expected a function",un=NaN,pr="[object Symbol]",mr=/^\s+|\s+$/g,gr=/^[-+]0x[0-9a-f]+$/i,vr=/^0b[01]+$/i,yr=/^0o[0-7]+$/i,wr=parseInt,br=typeof global=="object"&&global&&global.Object===Object&&global,Sr=typeof self=="object"&&self&&self.Object===Object&&self,Er=br||Sr||Function("return this")(),xr=Object.prototype,Tr=xr.toString,Lr=Math.max,kr=Math.min,Ze=function(){return Er.Date.now()};function Cr(e,t,n){var i,r,s,o,a,c,l=0,u=!1,h=!1,f=!0;if(typeof e!="function")throw new TypeError(hn);t=dn(t)||0,Le(n)&&(u=!!n.leading,h="maxWait"in n,s=h?Lr(dn(n.maxWait)||0,t):s,f="trailing"in n?!!n.trailing:f);function y(E){var _=i,B=r;return i=r=void 0,l=E,o=e.apply(B,_),o}function w(E){return l=E,a=setTimeout(m,t),u?y(E):o}function b(E){var _=E-c,B=E-l,z=t-_;return h?kr(z,s-B):z}function g(E){var _=E-c,B=E-l;return c===void 0||_>=t||_<0||h&&B>=s}function m(){var E=Ze();if(g(E))return x(E);a=setTimeout(m,b(E))}function x(E){return a=void 0,f&&i?y(E):(i=r=void 0,o)}function A(){a!==void 0&&clearTimeout(a),l=0,i=c=r=a=void 0}function D(){return a===void 0?o:x(Ze())}function F(){var E=Ze(),_=g(E);if(i=arguments,r=this,c=E,_){if(a===void 0)return w(c);if(h)return a=setTimeout(m,t),y(c)}return a===void 0&&(a=setTimeout(m,t)),o}return F.cancel=A,F.flush=D,F}function Ar(e,t,n){var i=!0,r=!0;if(typeof e!="function")throw new TypeError(hn);return Le(n)&&(i="leading"in n?!!n.leading:i,r="trailing"in n?!!n.trailing:r),Cr(e,t,{leading:i,maxWait:t,trailing:r})}function Le(e){var t=typeof e;return!!e&&(t=="object"||t=="function")}function Or(e){return!!e&&typeof e=="object"}function Pr(e){return typeof e=="symbol"||Or(e)&&Tr.call(e)==pr}function dn(e){if(typeof e=="number")return e;if(Pr(e))return un;if(Le(e)){var t=typeof e.valueOf=="function"?e.valueOf():e;e=Le(t)?t+"":t}if(typeof e!="string")return e===0?e:+e;e=e.replace(mr,"");var n=vr.test(e);return n||yr.test(e)?wr(e.slice(2),n?2:8):gr.test(e)?un:+e}fn.exports=Ar});var Kn=Xt((Wn,Gn)=>{(function(){var e=function(t){var n=new e.Builder;return n.pipeline.add(e.trimmer,e.stopWordFilter,e.stemmer),n.searchPipeline.add(e.stemmer),t.call(n,n),n.build()};e.version="2.3.9";e.utils={},e.utils.warn=function(t){return function(n){t.console&&console.warn&&console.warn(n)}}(this),e.utils.asString=function(t){return t==null?"":t.toString()},e.utils.clone=function(t){if(t==null)return t;for(var n=Object.create(null),i=Object.keys(t),r=0;r0){var u=e.utils.clone(n)||{};u.position=[a,l],u.index=s.length,s.push(new e.Token(i.slice(a,o),u))}a=o+1}}return s},e.tokenizer.separator=/[\s\-]+/;e.Pipeline=function(){this._stack=[]},e.Pipeline.registeredFunctions=Object.create(null),e.Pipeline.registerFunction=function(t,n){n in this.registeredFunctions&&e.utils.warn("Overwriting existing registered function: "+n),t.label=n,e.Pipeline.registeredFunctions[t.label]=t},e.Pipeline.warnIfFunctionNotRegistered=function(t){var n=t.label&&t.label in this.registeredFunctions;n||e.utils.warn(`Function is not registered with pipeline. This may cause problems when serialising the index. +`,t)},e.Pipeline.load=function(t){var n=new e.Pipeline;return t.forEach(function(i){var r=e.Pipeline.registeredFunctions[i];if(r)n.add(r);else throw new Error("Cannot load unregistered function: "+i)}),n},e.Pipeline.prototype.add=function(){var t=Array.prototype.slice.call(arguments);t.forEach(function(n){e.Pipeline.warnIfFunctionNotRegistered(n),this._stack.push(n)},this)},e.Pipeline.prototype.after=function(t,n){e.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(t);if(i==-1)throw new Error("Cannot find existingFn");i=i+1,this._stack.splice(i,0,n)},e.Pipeline.prototype.before=function(t,n){e.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(t);if(i==-1)throw new Error("Cannot find existingFn");this._stack.splice(i,0,n)},e.Pipeline.prototype.remove=function(t){var n=this._stack.indexOf(t);n!=-1&&this._stack.splice(n,1)},e.Pipeline.prototype.run=function(t){for(var n=this._stack.length,i=0;i1&&(ot&&(i=s),o!=t);)r=i-n,s=n+Math.floor(r/2),o=this.elements[s*2];if(o==t||o>t)return s*2;if(oc?u+=2:a==c&&(n+=i[l+1]*r[u+1],l+=2,u+=2);return n},e.Vector.prototype.similarity=function(t){return this.dot(t)/this.magnitude()||0},e.Vector.prototype.toArray=function(){for(var t=new Array(this.elements.length/2),n=1,i=0;n0){var o=s.str.charAt(0),a;o in s.node.edges?a=s.node.edges[o]:(a=new e.TokenSet,s.node.edges[o]=a),s.str.length==1&&(a.final=!0),r.push({node:a,editsRemaining:s.editsRemaining,str:s.str.slice(1)})}if(s.editsRemaining!=0){if("*"in s.node.edges)var c=s.node.edges["*"];else{var c=new e.TokenSet;s.node.edges["*"]=c}if(s.str.length==0&&(c.final=!0),r.push({node:c,editsRemaining:s.editsRemaining-1,str:s.str}),s.str.length>1&&r.push({node:s.node,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)}),s.str.length==1&&(s.node.final=!0),s.str.length>=1){if("*"in s.node.edges)var l=s.node.edges["*"];else{var l=new e.TokenSet;s.node.edges["*"]=l}s.str.length==1&&(l.final=!0),r.push({node:l,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)})}if(s.str.length>1){var u=s.str.charAt(0),h=s.str.charAt(1),f;h in s.node.edges?f=s.node.edges[h]:(f=new e.TokenSet,s.node.edges[h]=f),s.str.length==1&&(f.final=!0),r.push({node:f,editsRemaining:s.editsRemaining-1,str:u+s.str.slice(2)})}}}return i},e.TokenSet.fromString=function(t){for(var n=new e.TokenSet,i=n,r=0,s=t.length;r=t;n--){var i=this.uncheckedNodes[n],r=i.child.toString();r in this.minimizedNodes?i.parent.edges[i.char]=this.minimizedNodes[r]:(i.child._str=r,this.minimizedNodes[r]=i.child),this.uncheckedNodes.pop()}};e.Index=function(t){this.invertedIndex=t.invertedIndex,this.fieldVectors=t.fieldVectors,this.tokenSet=t.tokenSet,this.fields=t.fields,this.pipeline=t.pipeline},e.Index.prototype.search=function(t){return this.query(function(n){var i=new e.QueryParser(t,n);i.parse()})},e.Index.prototype.query=function(t){for(var n=new e.Query(this.fields),i=Object.create(null),r=Object.create(null),s=Object.create(null),o=Object.create(null),a=Object.create(null),c=0;c1?this._b=1:this._b=t},e.Builder.prototype.k1=function(t){this._k1=t},e.Builder.prototype.add=function(t,n){var i=t[this._ref],r=Object.keys(this._fields);this._documents[i]=n||{},this.documentCount+=1;for(var s=0;s=this.length)return e.QueryLexer.EOS;var t=this.str.charAt(this.pos);return this.pos+=1,t},e.QueryLexer.prototype.width=function(){return this.pos-this.start},e.QueryLexer.prototype.ignore=function(){this.start==this.pos&&(this.pos+=1),this.start=this.pos},e.QueryLexer.prototype.backup=function(){this.pos-=1},e.QueryLexer.prototype.acceptDigitRun=function(){var t,n;do t=this.next(),n=t.charCodeAt(0);while(n>47&&n<58);t!=e.QueryLexer.EOS&&this.backup()},e.QueryLexer.prototype.more=function(){return this.pos1&&(t.backup(),t.emit(e.QueryLexer.TERM)),t.ignore(),t.more())return e.QueryLexer.lexText},e.QueryLexer.lexEditDistance=function(t){return t.ignore(),t.acceptDigitRun(),t.emit(e.QueryLexer.EDIT_DISTANCE),e.QueryLexer.lexText},e.QueryLexer.lexBoost=function(t){return t.ignore(),t.acceptDigitRun(),t.emit(e.QueryLexer.BOOST),e.QueryLexer.lexText},e.QueryLexer.lexEOS=function(t){t.width()>0&&t.emit(e.QueryLexer.TERM)},e.QueryLexer.termSeparator=e.tokenizer.separator,e.QueryLexer.lexText=function(t){for(;;){var n=t.next();if(n==e.QueryLexer.EOS)return e.QueryLexer.lexEOS;if(n.charCodeAt(0)==92){t.escapeCharacter();continue}if(n==":")return e.QueryLexer.lexField;if(n=="~")return t.backup(),t.width()>0&&t.emit(e.QueryLexer.TERM),e.QueryLexer.lexEditDistance;if(n=="^")return t.backup(),t.width()>0&&t.emit(e.QueryLexer.TERM),e.QueryLexer.lexBoost;if(n=="+"&&t.width()===1||n=="-"&&t.width()===1)return t.emit(e.QueryLexer.PRESENCE),e.QueryLexer.lexText;if(n.match(e.QueryLexer.termSeparator))return e.QueryLexer.lexTerm}},e.QueryParser=function(t,n){this.lexer=new e.QueryLexer(t),this.query=n,this.currentClause={},this.lexemeIdx=0},e.QueryParser.prototype.parse=function(){this.lexer.run(),this.lexemes=this.lexer.lexemes;for(var t=e.QueryParser.parseClause;t;)t=t(this);return this.query},e.QueryParser.prototype.peekLexeme=function(){return this.lexemes[this.lexemeIdx]},e.QueryParser.prototype.consumeLexeme=function(){var t=this.peekLexeme();return this.lexemeIdx+=1,t},e.QueryParser.prototype.nextClause=function(){var t=this.currentClause;this.query.clause(t),this.currentClause={}},e.QueryParser.parseClause=function(t){var n=t.peekLexeme();if(n!=null)switch(n.type){case e.QueryLexer.PRESENCE:return e.QueryParser.parsePresence;case e.QueryLexer.FIELD:return e.QueryParser.parseField;case e.QueryLexer.TERM:return e.QueryParser.parseTerm;default:var i="expected either a field or a term, found "+n.type;throw n.str.length>=1&&(i+=" with value '"+n.str+"'"),new e.QueryParseError(i,n.start,n.end)}},e.QueryParser.parsePresence=function(t){var n=t.consumeLexeme();if(n!=null){switch(n.str){case"-":t.currentClause.presence=e.Query.presence.PROHIBITED;break;case"+":t.currentClause.presence=e.Query.presence.REQUIRED;break;default:var i="unrecognised presence operator'"+n.str+"'";throw new e.QueryParseError(i,n.start,n.end)}var r=t.peekLexeme();if(r==null){var i="expecting term or field, found nothing";throw new e.QueryParseError(i,n.start,n.end)}switch(r.type){case e.QueryLexer.FIELD:return e.QueryParser.parseField;case e.QueryLexer.TERM:return e.QueryParser.parseTerm;default:var i="expecting term or field, found '"+r.type+"'";throw new e.QueryParseError(i,r.start,r.end)}}},e.QueryParser.parseField=function(t){var n=t.consumeLexeme();if(n!=null){if(t.query.allFields.indexOf(n.str)==-1){var i=t.query.allFields.map(function(o){return"'"+o+"'"}).join(", "),r="unrecognised field '"+n.str+"', possible fields: "+i;throw new e.QueryParseError(r,n.start,n.end)}t.currentClause.fields=[n.str];var s=t.peekLexeme();if(s==null){var r="expecting term, found nothing";throw new e.QueryParseError(r,n.start,n.end)}switch(s.type){case e.QueryLexer.TERM:return e.QueryParser.parseTerm;default:var r="expecting term, found '"+s.type+"'";throw new e.QueryParseError(r,s.start,s.end)}}},e.QueryParser.parseTerm=function(t){var n=t.consumeLexeme();if(n!=null){t.currentClause.term=n.str.toLowerCase(),n.str.indexOf("*")!=-1&&(t.currentClause.usePipeline=!1);var i=t.peekLexeme();if(i==null){t.nextClause();return}switch(i.type){case e.QueryLexer.TERM:return t.nextClause(),e.QueryParser.parseTerm;case e.QueryLexer.FIELD:return t.nextClause(),e.QueryParser.parseField;case e.QueryLexer.EDIT_DISTANCE:return e.QueryParser.parseEditDistance;case e.QueryLexer.BOOST:return e.QueryParser.parseBoost;case e.QueryLexer.PRESENCE:return t.nextClause(),e.QueryParser.parsePresence;default:var r="Unexpected lexeme type '"+i.type+"'";throw new e.QueryParseError(r,i.start,i.end)}}},e.QueryParser.parseEditDistance=function(t){var n=t.consumeLexeme();if(n!=null){var i=parseInt(n.str,10);if(isNaN(i)){var r="edit distance must be numeric";throw new e.QueryParseError(r,n.start,n.end)}t.currentClause.editDistance=i;var s=t.peekLexeme();if(s==null){t.nextClause();return}switch(s.type){case e.QueryLexer.TERM:return t.nextClause(),e.QueryParser.parseTerm;case e.QueryLexer.FIELD:return t.nextClause(),e.QueryParser.parseField;case e.QueryLexer.EDIT_DISTANCE:return e.QueryParser.parseEditDistance;case e.QueryLexer.BOOST:return e.QueryParser.parseBoost;case e.QueryLexer.PRESENCE:return t.nextClause(),e.QueryParser.parsePresence;default:var r="Unexpected lexeme type '"+s.type+"'";throw new e.QueryParseError(r,s.start,s.end)}}},e.QueryParser.parseBoost=function(t){var n=t.consumeLexeme();if(n!=null){var i=parseInt(n.str,10);if(isNaN(i)){var r="boost must be numeric";throw new e.QueryParseError(r,n.start,n.end)}t.currentClause.boost=i;var s=t.peekLexeme();if(s==null){t.nextClause();return}switch(s.type){case e.QueryLexer.TERM:return t.nextClause(),e.QueryParser.parseTerm;case e.QueryLexer.FIELD:return t.nextClause(),e.QueryParser.parseField;case e.QueryLexer.EDIT_DISTANCE:return e.QueryParser.parseEditDistance;case e.QueryLexer.BOOST:return e.QueryParser.parseBoost;case e.QueryLexer.PRESENCE:return t.nextClause(),e.QueryParser.parsePresence;default:var r="Unexpected lexeme type '"+s.type+"'";throw new e.QueryParseError(r,s.start,s.end)}}},function(t,n){typeof define=="function"&&define.amd?define(n):typeof Wn=="object"?Gn.exports=n():t.lunr=n()}(this,function(){return e})})()});Handlebars.registerHelper("groupChanged",function(e,t,n){let i=t||"";if(e.group!==i)return delete e.nestedContext,e.group=i,n.fn(this)});Handlebars.registerHelper("nestingChanged",function(e,t,n){if(t.nested_context&&t.nested_context!==e.nestedContext){if(e.nestedContext=t.nested_context,e.lastModuleSeenInGroup!==t.nested_context)return n.fn(this)}else e.lastModuleSeenInGroup=t.title});Handlebars.registerHelper("showSections",function(e,t){if(e.sections.length>0)return t.fn(this)});Handlebars.registerHelper("showSummary",function(e,t){if(e.nodeGroups)return t.fn(this)});Handlebars.registerHelper("isArray",function(e,t){return Array.isArray(e)?t.fn(this):t.inverse(this)});Handlebars.registerHelper("isNonEmptyArray",function(e,t){return Array.isArray(e)&&e.length>0?t.fn(this):t.inverse(this)});Handlebars.registerHelper("isEmptyArray",function(e,t){return Array.isArray(e)&&e.length===0?t.fn(this):t.inverse(this)});Handlebars.registerHelper("isLocal",function(e,t){let n=window.location.pathname.split("/").pop();return n===e+".html"||n===e?t.fn(this):t.inverse(this)});var d=document.querySelector.bind(document),O=document.querySelectorAll.bind(document);function en(e){return e.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&")}function Ee(e){return String(e).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function q(){return document.getElementById("main").dataset.type}function tn(e,t){if(e){for(let n of e){let i=n.nodeGroups&&n.nodeGroups.find(r=>r.nodes.some(s=>s.anchor===t));if(i)return i.key}return null}}function xe(e,t=!1){if(!e)return t?document.getElementById("top-content"):null;let n=document.getElementById(e);return n?n.matches(".detail")?n:["h1","h2","h3","h4","h5","h6"].includes(n.tagName.toLowerCase())?Ki(n):null:null}function Ki(e){let t=[e],n=e.nextElementSibling,i=e.tagName.toLowerCase();for(;n;){let s=n.tagName.toLowerCase();["h1","h2","h3","h4","h5","h6"].includes(s)&&s<=i?n=null:(t.push(n),n=n.nextElementSibling)}let r=document.createElement("div");return r.append(...t),r}function ie(){return window.location.hash.replace(/^#/,"")}function nn(e){return new URLSearchParams(window.location.search).get(e)}function rn(e){return fetch(e).then(t=>t.ok).catch(()=>!1)}function sn(e){document.readyState!=="loading"?e():document.addEventListener("DOMContentLoaded",e)}function re(e){return!e||e.trim()===""}function on(e,t){let n;return function(...r){clearTimeout(n),n=setTimeout(()=>{n=null,e(...r)},t)}}function Te(){return document.head.querySelector("meta[name=project][content]").content}function se(){return/(Macintosh|iPhone|iPad|iPod)/.test(window.navigator.userAgent)}var Yi="content",Ji="tabs-open",Xi="tabs-close",Zi="H3",er="tabset";function Ye(){tr().map(nr).forEach(n=>sr(n))}function tr(){let e=document.createNodeIterator(document.getElementById(Yi),NodeFilter.SHOW_COMMENT,{acceptNode(i){return i.nodeValue.trim()===Ji?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_REJECT}}),t=[],n;for(;n=e.nextNode();)t.push(n);return t}function nr(e,t,n){let i=[],r=[],s={label:"",content:[]};for(;e=e.nextSibling;){if(ir(e)){an(s,r,t);break}i.push(e),e.nodeName===Zi?(an(s,r,t),s.label=e.innerText,s.content=[]):s.content.push(e.outerHTML)}let o=document.createElement("div");return o.className=er,rr(i,o),o.innerHTML=Handlebars.templates.tabset({tabs:r}),o}function ir(e){return e.nodeName==="#comment"&&e.nodeValue.trim()===Xi}function an(e,t,n){if(e.label===""&&!e.content.length)return!1;let i=e.label,r=e.content;t.push({label:i,content:r,setIndex:n})}function rr(e,t){if(!e||!e.length)return!1;e[0].parentNode.insertBefore(t,e[0]),e.forEach(n=>t.appendChild(n))}function sr(e){let t={tabs:e.querySelectorAll(':scope [role="tab"]'),panels:e.querySelectorAll(':scope [role="tabpanel"]'),activeIndex:0};t.tabs.forEach((n,i)=>{n.addEventListener("click",r=>{Y(i,t)}),n.addEventListener("keydown",r=>{let s=t.tabs.length-1;r.code==="ArrowLeft"?(r.preventDefault(),t.activeIndex===0?Y(s,t):Y(t.activeIndex-1,t)):r.code==="ArrowRight"?(r.preventDefault(),t.activeIndex===s?Y(0,t):Y(t.activeIndex+1,t)):r.code==="Home"?(r.preventDefault(),Y(0,t)):r.code==="End"&&(r.preventDefault(),Y(s,t))})})}function Y(e,t){t.tabs[t.activeIndex].setAttribute("aria-selected","false"),t.tabs[t.activeIndex].tabIndex=-1,t.tabs[e].setAttribute("aria-selected","true"),t.tabs[e].tabIndex=0,t.tabs[e].focus(),t.panels[t.activeIndex].setAttribute("hidden",""),t.panels[t.activeIndex].tabIndex=-1,t.panels[e].removeAttribute("hidden"),t.panels[e].tabIndex=0,t.activeIndex=e}var cn="ex_doc:settings",or={tooltips:!0,theme:null,livebookUrl:null},Je=class{constructor(){this._subscribers=[],this._settings=or,this._loadSettings()}get(){return this._settings}update(t){let n=this._settings;this._settings={...this._settings,...t},this._subscribers.forEach(i=>i(this._settings,n)),this._storeSettings()}getAndSubscribe(t){this._subscribers.push(t),t(this._settings)}_loadSettings(){try{let t=localStorage.getItem(cn);if(t){let n=JSON.parse(t);this._settings={...this._settings,...n}}this._loadSettingsLegacy()}catch(t){console.error(`Failed to load settings: ${t}`)}}_storeSettings(){try{this._storeSettingsLegacy(),localStorage.setItem(cn,JSON.stringify(this._settings))}catch(t){console.error(`Failed to persist settings: ${t}`)}}_loadSettingsLegacy(){localStorage.getItem("tooltipsDisabled")!==null&&(this._settings={...this._settings,tooltips:!1}),localStorage.getItem("night-mode")==="true"&&(this._settings={...this._settings,nightMode:!0}),this._settings.nightMode===!0&&(this._settings={...this._settings,theme:"dark"})}_storeSettingsLegacy(){this._settings.tooltips?localStorage.removeItem("tooltipsDisabled"):localStorage.setItem("tooltipsDisabled","true"),this._settings.nightMode!==null?localStorage.setItem("night-mode",this._settings.nightMode===!0?"true":"false"):localStorage.removeItem("night-mode"),this._settings.theme!==null?(localStorage.setItem("night-mode",this._settings.theme==="dark"?"true":"false"),this._settings.nightMode=this._settings.theme==="dark"):(delete this._settings.nightMode,localStorage.removeItem("night-mode"))}},P=new Je;var ar=".content",ln=".content-inner",cr=".livebook-badge";function Xe(e){e||ur(),dr(),lr()}function lr(){d(ar).querySelectorAll("a").forEach(e=>{e.querySelector("code, img")&&e.classList.add("no-underline")})}function ur(){d(ln).setAttribute("tabindex",-1),d(ln).focus()}function dr(){let t=window.location.pathname.replace(/(\.html)?$/,".livemd"),n=new URL(t,window.location.href).toString();P.getAndSubscribe(i=>{let r=i.livebookUrl?fr(i.livebookUrl,n):hr(n);for(let s of O(cr))s.href=r})}function hr(e){return`https://livebook.dev/run?url=${encodeURIComponent(e)}`}function fr(e,t){return`${e}/import?url=${encodeURIComponent(t)}`}var gn=Zt(pn());var Ir=768,et=300,oe=".sidebar-toggle",_r=".content",$={CLOSED:"closed",OPEN:"open",NO_PREF:"no_pref"},M={opened:"sidebar-opened",openingStart:"sidebar-opening-start",opening:"sidebar-opening",closed:"sidebar-closed",closingStart:"sidebar-closing-start",closing:"sidebar-closing"},Nr=Object.values(M),N={togglingTimeout:null,lastWindowWidth:window.innerWidth,sidebarPreference:$.NO_PREF};function vn(){tt(),Rr(),Hr()}function yn(){tt()}function Rr(){let e=sessionStorage.getItem("sidebar_width");e&&mn(e),new ResizeObserver(n=>{for(let i of n)mn(i.contentRect.width)}).observe(document.getElementById("sidebar"))}function mn(e){sessionStorage.setItem("sidebar_width",e),document.body.style.setProperty("--sidebarWidth",`${e}px`)}function tt(){sessionStorage.getItem("sidebar_state")==="closed"||wn()?(j(M.closed),d(oe).setAttribute("aria-expanded","false")):(j(M.opened),d(oe).setAttribute("aria-expanded","true")),setTimeout(()=>d(oe).classList.add("sidebar-toggle--animated"),et)}function wn(){return window.matchMedia(`screen and (max-width: ${Ir}px)`).matches}function j(...e){document.body.classList.remove(...Nr),document.body.classList.add(...e)}function Hr(){d(oe).addEventListener("click",e=>{nt(),Qr()}),d(_r).addEventListener("click",e=>{Dr()}),window.addEventListener("resize",(0,gn.default)(e=>{Mr()},100))}function nt(){return it()?Sn():rt()}function it(){return document.body.classList.contains(M.opened)||document.body.classList.contains(M.opening)}function bn(){return document.body.classList.contains(M.opened)}function rt(){return En(),sessionStorage.setItem("sidebar_state","opened"),d(oe).setAttribute("aria-expanded","true"),new Promise((e,t)=>{requestAnimationFrame(()=>{j(M.openingStart),requestAnimationFrame(()=>{j(M.opening),N.togglingTimeout=setTimeout(()=>{j(M.opened),e()},et)})})})}function Sn(){return En(),sessionStorage.setItem("sidebar_state","closed"),d(oe).setAttribute("aria-expanded","false"),new Promise((e,t)=>{requestAnimationFrame(()=>{j(M.closingStart),requestAnimationFrame(()=>{j(M.closing),N.togglingTimeout=setTimeout(()=>{j(M.closed),e()},et)})})})}function En(){N.togglingTimeout&&(clearTimeout(N.togglingTimeout),N.togglingTimeout=null)}function Mr(){N.lastWindowWidth!==window.innerWidth&&(N.lastWindowWidth=window.innerWidth,(N.sidebarPreference===$.OPEN||N.sidebarPreference===$.NO_PREF)&&tt())}function Dr(){wn()&&it()&&Sn()}function Qr(){switch(N.sidebarPreference){case $.OPEN:N.sidebarPreference=$.CLOSED;break;case $.CLOSED:N.sidebarPreference=$.OPEN;break;case $.NO_PREF:it()?N.sidebarPreference=$.OPEN:N.sidebarPreference=$.CLOSED}}function fe(){return window.sidebarNodes||{}}function xn(){return window.versionNodes||[]}var st={search:"search",extras:"extras",modules:"modules",tasks:"tasks"},ot=[st.extras,st.modules,st.tasks],Ce=e=>`#${e}-full-list`;function Tn(){at(),zr()}function at(){ot.forEach(e=>{Fr(fe(),e)}),ke(q()),kn(),Ln()}function Fr(e,t){let n=e[t]||[],i=d(Ce(t));if(!i)return;let r=Handlebars.templates["sidebar-items"]({nodes:n,group:""});i.innerHTML=r,i.querySelectorAll("ul").forEach(s=>{if(s.innerHTML.trim()===""){let o=s.previousElementSibling;o.classList.contains("expand")&&o.classList.remove("expand"),s.remove()}}),i.querySelectorAll("li a + button").forEach(s=>{s.addEventListener("click",o=>{let c=o.target.closest("li");$r(c)})}),i.querySelectorAll("li a").forEach(s=>{s.addEventListener("click",o=>{let c=o.target.closest("li"),l=i.querySelector(".current-section");l&&Ur(l),s.matches(".expand")&&(s.pathname===window.location.pathname||s.pathname===window.location.pathname+".html")&&ct(c)})})}function ct(e){e.classList.add("open"),e.querySelector("button[aria-controls]").setAttribute("aria-expanded","true")}function Br(e){e.classList.remove("open"),e.querySelector("button[aria-controls]").setAttribute("aria-expanded","false")}function $r(e){e.classList.contains("open")?Br(e):ct(e)}function Vr(e){e.classList.add("current-section"),e.querySelector("a").setAttribute("aria-current","true")}function Ur(e){e.classList.remove("current-section"),e.querySelector("a").setAttribute("aria-current","false")}function qr(e){e.classList.add("current-hash"),e.querySelector("a").setAttribute("aria-current","true")}function jr(e){e.classList.remove("current-hash"),e.querySelector("a").setAttribute("aria-current","false")}function ke(e){ot.forEach(t=>{let n=d(`#${t}-list-tab-button`);if(n){let i=d(`#${n.getAttribute("aria-controls")}`);t===e?(n.parentElement.classList.add("selected"),n.setAttribute("aria-selected","true"),n.setAttribute("tabindex","0"),i.removeAttribute("hidden")):(n.parentElement.classList.remove("selected"),n.setAttribute("aria-selected","false"),n.setAttribute("tabindex","-1"),i.setAttribute("hidden","hidden"))}})}function Ln(){let e=d(Ce(q()));if(!e)return;let t=e.querySelector("li.current-page");t&&(t.scrollIntoView(),e.scrollTop-=40)}function kn(){let e=ie()||"content",n=fe()[q()]||[],i=tn(n,e),r=d(Ce(q()));if(!r)return;let s=r.querySelector(`li.current-page a.expand[href$="#${i}"]`);s&&ct(s.closest("li"));let o=r.querySelector(`li.current-page a[href$="#${e}"]`);if(o){let a=o.closest("ul");a.classList.contains("deflist")&&Vr(a.closest("li")),qr(o.closest("li"))}}function zr(){ot.forEach(t=>{let n=d(`#${t}-list-tab-button`);n&&n.addEventListener("click",i=>{ke(t),Ln()})});let e=d("#sidebar-list-nav");e.addEventListener("keydown",t=>{if(t.key!=="ArrowRight"&&t.key!=="ArrowLeft")return;let n=Array.from(e.querySelectorAll('[role="tab"]')).map(r=>r.dataset.type),i=e.querySelector('[role="tab"][aria-selected="true"]').dataset.type;if(t.key==="ArrowRight"){let r=n.indexOf(i)+1;r>=n.length&&(r=0);let s=n[r];ke(s),d(`#${s}-list-tab-button`).focus()}else if(t.key==="ArrowLeft"){let r=n.indexOf(i)-1;r<0&&(r=n.length-1);let s=n[r];ke(s),d(`#${s}-list-tab-button`).focus()}}),window.addEventListener("hashchange",t=>{let n=d(Ce(q()));if(!n)return;let i=n.querySelector("li.current-page li.current-hash");i&&jr(i),kn()})}var V={module:"module",moduleChild:"module-child",mixTask:"mix-task",extra:"extra",section:"section"};function An(e,t=8){if(re(e))return[];let n=fe(),i=[...lt(n.modules,e,V.module,"module"),...Wr(n.modules,e,V.moduleChild),...lt(n.tasks,e,V.mixTask,"mix task"),...lt(n.extras,e,V.extra,"page"),...ut(n.modules,e,V.section,"module"),...ut(n.tasks,e,V.section,"mix task"),...ut(n.extras,e,V.section,"page")].filter(r=>r!==null);return es(i).slice(0,t)}function lt(e,t,n,i){return e.map(r=>Kr(r,t,n,i))}function Wr(e,t,n){return e.filter(i=>i.nodeGroups).flatMap(i=>i.nodeGroups.flatMap(({key:r,nodes:s})=>{let o=Zr(r);return s.map(a=>Yr(a,i.id,t,n,o)||Xr(a,i.id,t,n,o))}))}function ut(e,t,n,i){return e.flatMap(r=>Gr(r).map(s=>Jr(r,s,t,n,i)))}function Gr(e){return(e.sections||[]).concat(e.headers||[])}function Kr(e,t,n,i){return Oe(e.title,t)?{link:`${e.id}.html`,title:_e(e.title,t),description:null,matchQuality:Pe(e.title,t),deprecated:e.deprecated,labels:[i],category:n}:null}function Yr(e,t,n,i,r){return Oe(e.id,n)?{link:`${t}.html#${e.anchor}`,title:_e(e.id,n),labels:[r],description:t,matchQuality:Pe(e.id,n),deprecated:e.deprecated,category:i}:null}function Jr(e,t,n,i,r){return On(t.id,n)?{link:`${e.id}.html#${t.anchor}`,title:_e(t.id,n),description:e.title,matchQuality:Pe(t.id,n),labels:[r,"section"],category:i}:null}function Xr(e,t,n,i,r){let s=`${t}.${e.id}`,o=`${t}:${e.id}`,a,c;if(Oe(s,n))a=s,c=/\./g;else if(Oe(o,n))a=o,c=/:/g;else return null;let l=n.replace(c," ");return On(e.id,l)?{link:`${t}.html#${e.anchor}`,title:_e(e.id,l),label:r,description:t,matchQuality:Pe(a,n),deprecated:e.deprecated,category:i}:null}function Zr(e){switch(e){case"callbacks":return"callback";case"types":return"type";default:return"function"}}function es(e){return e.slice().sort((t,n)=>t.matchQuality!==n.matchQuality?n.matchQuality-t.matchQuality:Cn(t.category)-Cn(n.category))}function Cn(e){switch(e){case V.module:return 1;case V.moduleChild:return 2;case V.mixTask:return 3;default:return 4}}function On(e,t){return Ie(t).some(i=>Pn(e,i))}function Oe(e,t){return Ie(t).every(i=>Pn(e,i))}function Pn(e,t){return e.toLowerCase().includes(t.toLowerCase())}function Pe(e,t){let n=Ie(t),r=n.map(o=>o.length).reduce((o,a)=>o+a,0)/e.length,s=ts(e,n[0])?1:0;return r+s}function ts(e,t){return e.toLowerCase().startsWith(t.toLowerCase())}function Ie(e){return e.trim().split(/\s+/)}function _e(e,t){let n=Ie(t).sort((i,r)=>r.length-i.length);return Ae(e,n)}function Ae(e,t){if(t.length===0)return e;let[n,...i]=t,r=e.match(new RegExp(`(.*)(${en(n)})(.*)`,"i"));if(r){let[,s,o,a]=r;return Ae(s,t)+""+Ee(o)+""+Ae(a,t)}else return Ae(e,i)}var Ne=null,J=null;function In(){J=document.getElementById("toast"),J.addEventListener("click",e=>{clearTimeout(Ne),e.target.classList.remove("show")})}function dt(e){J&&(clearTimeout(Ne),J.innerText=e,J.classList.add("show"),Ne=setTimeout(()=>{J.classList.remove("show"),Ne=setTimeout(function(){J.innerText=""},1e3)},5e3))}var _n="dark",ht=["system","dark","light"];function Nn(e){P.getAndSubscribe(t=>{document.body.classList.toggle(_n,Hn(e||t.theme))}),is()}function Rn(){let e=ht[ht.indexOf(ft())+1]||ht[0];P.update({theme:e}),dt(`Set theme to "${e}"`)}function ft(){return P.get().theme||"system"}function Hn(e){return e==="dark"||ns()&&(e==null||e==="system")}function ns(){return window.matchMedia("(prefers-color-scheme: dark)").matches}function is(){window.matchMedia("(prefers-color-scheme: dark)").addListener(e=>{let t=P.get().theme,n=Hn(t);(t==null||t==="system")&&(document.body.classList.toggle(_n,n),dt(`Browser changed theme to "${n?"dark":"light"}"`))})}var ae=".autocomplete",He=".autocomplete-suggestions",Re=".autocomplete-suggestion",I={autocompleteSuggestions:[],previewOpen:!1,selectedIdx:-1};function rs(){d(ae).classList.add("shown")}function pt(){d(ae).classList.remove("shown")}function Mn(){return d(ae).classList.contains("shown")}function mt(e){I.autocompleteSuggestions=An(e),I.selectedIdx=-1,re(e)?pt():(ss({term:e,suggestions:I.autocompleteSuggestions}),Me(0),rs())}function ss({term:e,suggestions:t}){let n=Handlebars.templates["autocomplete-suggestions"]({suggestions:t,term:e}),i=d(ae);i.innerHTML=n}function gt(){return I.selectedIdx===-1?null:I.autocompleteSuggestions[I.selectedIdx]}function Me(e){Qn(os(e))}function Dn(e){if(e.data.type==="preview"){let{contentHeight:t}=e.data,n=d(".autocomplete-preview");n&&(n.style.height=`${t+32}px`,n.classList.remove("loading"))}}function Qn(e){I.selectedIdx=e;let t=d(He),n=d(`${Re}.selected`),i=d(`${Re}[data-index="${I.selectedIdx}"]`);if(n&&n.classList.remove("selected"),i){if(I.previewOpen){Bn(),window.addEventListener("message",Dn),t.classList.add("previewing");let r=document.createElement("div");r.classList.add("autocomplete-preview"),r.classList.add("loading");let s=i.href.replace(".html",`.html?preview=true&theme=${ft()}`),o=document.createElement("iframe");o.setAttribute("src",s),r.appendChild(document.createElement("div")),r.appendChild(document.createElement("span")),r.appendChild(o),i.parentNode.insertBefore(r,i.nextSibling)}i.classList.add("selected"),i.scrollIntoView({block:"nearest"})}else t&&(t.scrollTop=0)}function Fn(){I.previewOpen?De():vt()}function De(){I.previewOpen=!1;let e=d(He);e&&e.classList.remove("previewing"),Bn()}function vt(e){I.previewOpen=!0,e?e=e.closest(Re):e=d(`${Re}[data-index="${I.selectedIdx}"]`),e&&Qn(parseInt(e.dataset.index))}function Bn(){let e=d(".autocomplete-preview");e&&(e.remove(),window.removeEventListener("message",Dn))}function os(e){let t=I.autocompleteSuggestions.length+1;return(I.selectedIdx+e+1+t)%t-1}var pe="form.search-bar input",as="form.search-bar .search-close-button";function wt(){cs(),window.onTogglePreviewClick=function(e,t){e.preventDefault(),e.stopImmediatePropagation(),bt(),t?vt(e.target):De()}}function Un(e){let t=d(pe);t.value=e}function bt(){let e=d(pe);document.body.classList.add("search-focused"),e.focus()}function cs(){let e=d(pe);if(document.querySelector('meta[name="exdoc:autocomplete"][content="off"]'))return e.addEventListener("keydown",t=>{t.key==="Enter"&&$n(t)}),!0;e.addEventListener("keydown",t=>{let n=se();t.key==="Escape"?(Qe(),e.blur()):t.key==="Enter"?$n(t):t.key==="ArrowUp"||n&&t.ctrlKey&&t.key==="p"?(Me(-1),t.preventDefault()):t.key==="ArrowDown"||n&&t.ctrlKey&&t.key==="n"?(Me(1),t.preventDefault()):t.key==="Tab"&>()!==null&&(Fn(),t.preventDefault())}),e.addEventListener("input",t=>{mt(t.target.value)}),e.addEventListener("focus",t=>{document.body.classList.contains("search-focused")||(document.body.classList.add("search-focused"),mt(t.target.value))}),e.addEventListener("blur",t=>{let n=t.relatedTarget,i=d(He);if(n&&i&&i.contains(n))return setTimeout(()=>{Mn()&&e.focus()},1e3),null;Fe()}),d(ae).addEventListener("click",t=>{t.shiftKey||t.ctrlKey?e.focus():(Qe(),Fe())}),d(as).addEventListener("click",t=>{Qe(),Fe()})}function $n(e){let t=d(pe),n=e.shiftKey||e.ctrlKey,i=gt();e.preventDefault();let r=n?"_blank":"_self",s=document.createElement("a");if(s.setAttribute("target",r),i)s.setAttribute("href",i.link);else{let o=document.querySelector('meta[name="exdoc:full-text-search-url"]'),a=o?o.getAttribute("content"):"search.html?q=";s.setAttribute("href",`${a}${encodeURIComponent(t.value)}`)}s.click(),n||(Qe(),Fe())}function Qe(){let e=d(pe);e.value=""}function Fe(){De(),document.body.classList.remove("search-focused"),pt()}var yt=window.scrollY,ls=70,Vn=-2;window.addEventListener("scroll",function(){let e=window.scrollY;e===0||yt-els?document.body.classList.remove("scroll-sticky"):yt-e>Math.abs(Vn)&&document.body.classList.add("scroll-sticky"),yt=e<=0?0:e},!1);var qn=".sidebar-projectVersion",jn=".sidebar-projectVersionsDropdown";function zn(){let e=xn();if(e.length>0){let n=d(qn).textContent.trim(),i=ds(e,n);us({nodes:i})}}function us({nodes:e}){let t=d(qn),n=Handlebars.templates["versions-dropdown"]({nodes:e});t.innerHTML=n,d(jn).addEventListener("change",fs)}function ds(e,t){return hs(e,t).map(i=>({...i,isCurrentVersion:i.version===t}))}function hs(e,t){return e.some(i=>i.version===t)?e:[{version:t,url:"#"},...e]}function fs(e){let t=e.target.value,n=window.location.pathname.split("/").pop()+window.location.hash,i=`${t}/${n}`;rn(i).then(r=>{r?window.location.href=i:window.location.href=t})}function St(){let e=d(jn);e&&(e.focus(),e.addEventListener("keydown",t=>{(t.key==="Escape"||t.key==="v")&&(t.preventDefault(),e.blur())}),navigator.userActivation.isActive&&"showPicker"in HTMLSelectElement.prototype&&e.showPicker())}var Q=Zt(Kn());var Be=80,ps="#search";Q.default.tokenizer.separator=/\s+/;Q.default.QueryLexer.termSeparator=/\s+/;Q.default.Pipeline.registerFunction(Xn,"docTokenSplitter");Q.default.Pipeline.registerFunction(Zn,"docTrimmer");function xt(){let e=window.location.pathname;if(e.endsWith("/search.html")||e.endsWith("/search")){let t=nn("q");ms(t)}}async function ms(e){if(re(e))Et({value:e});else{Un(e);let t=await gs();try{let n=e.replaceAll(/(\B|\\):/g,"\\:"),i=ks(t.search(n));Et({value:e,results:i})}catch(n){Et({value:e,errorMessage:n.message})}}}function Et({value:e,results:t,errorMessage:n}){let i=d(ps),r=Handlebars.templates["search-results"]({value:e,results:t,errorMessage:n});i.innerHTML=r}async function gs(){let e=await vs();if(e)return e;let t=xs();return ys(t),t}async function vs(){try{let e=sessionStorage.getItem(Jn());if(e){let t=await bs(e);return Q.default.Index.load(t)}else return null}catch(e){return console.error("Failed to load index: ",e),null}}async function ys(e){try{let t=await ws(e);sessionStorage.setItem(Jn(),t)}catch(t){console.error("Failed to save index: ",t)}}async function ws(e){let t=new Blob([JSON.stringify(e)],{type:"application/json"}).stream().pipeThrough(new window.CompressionStream("gzip")),i=await(await new Response(t).blob()).arrayBuffer();return Ss(i)}async function bs(e){let t=new Blob([Es(e)],{type:"application/json"}).stream().pipeThrough(new window.DecompressionStream("gzip")),n=await new Response(t).text();return JSON.parse(n)}function Ss(e){let t="",n=new Uint8Array(e),i=n.byteLength;for(let r=0;r{this.add(e)})})}function Ts(e){e.pipeline.before(Q.default.stemmer,Xn)}function Xn(e){let t=[e],n=/\/\d+$/,i=/\:|\./,r=e.toString();if(r.replace(/^[.,;?!]+|[.,;]+$/g,""),r.startsWith("`")&&r.endsWith("`")&&(r=r.slice(1,-1)),n.test(r)){let o=e.toString().replace(n,"");t.push(e.clone().update(()=>o));let a=o.split(i);if(a.length>1){for(let l of a)t.push(e.clone().update(()=>l));let c=e.toString().split(i);t.push(e.clone().update(()=>c[c.length-1]))}r=a[a.length-1]}else r.startsWith("@")?(r=r.substring(1),t.push(e.clone().update(()=>r))):r.startsWith(":")&&(r=r.substring(1),t.push(e.clone().update(()=>r)));let s=r.split(/\_|\-/);if(s.length>1)for(let o of s)t.push(e.clone().update(()=>o));return t}function Ls(e){e.pipeline.before(Q.default.stemmer,Zn)}function Zn(e){return e.update(function(t){return t.replace(/^[^@:\w]+/,"").replace(/[^\?\!\w]+$/,"")})}function ks(e){return e.filter(t=>Yn(t.ref)).map(t=>{let n=Yn(t.ref),i=t.matchData.metadata;return{...n,metadata:i,excerpts:Cs(n,i)}})}function Yn(e){return searchData.items.find(t=>t.ref===e)||null}function Cs(e,t){let{doc:n}=e,r=Object.keys(t).filter(s=>"doc"in t[s]).map(s=>t[s].doc.position.map(([o,a])=>As(n,o,a))).reduce((s,o)=>s.concat(o),[]);return r.length===0?[n.slice(0,Be*2)+(Be*20?"...":"",e.slice(i,t),""+Ee(e.slice(t,t+n))+"",e.slice(t+n,r),r{let n=t.getAttribute("data-group-id");t.addEventListener("mouseenter",i=>{ei(n,!0)}),t.addEventListener("mouseleave",i=>{ei(n,!1)})})}function ei(e,t){O(`[data-group-id="${e}"]`).forEach(i=>{i.classList.toggle(Os,t)})}var Z=".modal",Is=".modal .modal-close",_s=".modal .modal-title",Ns=".modal .modal-body",ti='button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',U={prevFocus:null,lastFocus:null,ignoreFocusChanges:!1};function ni(){Rs()}function Rs(){let e=Handlebars.templates["modal-layout"]();document.body.insertAdjacentHTML("beforeend",e),d(Z).addEventListener("keydown",t=>{t.key==="Escape"&&X()}),d(Is).addEventListener("click",t=>{X()}),d(Z).addEventListener("click",t=>{t.target.classList.contains("modal")&&X()})}function ii(e){if(U.ignoreFocusChanges)return;let t=d(Z);if(t.contains(e.target))U.lastFocus=e.target;else{U.ignoreFocusChanges=!0;let n=Hs(t);U.lastFocus===n?Ms(t).focus():n.focus(),U.ignoreFocusChanges=!1,U.lastFocus=document.activeElement}}function Hs(e){return e.querySelector(ti)}function Ms(e){let t=e.querySelectorAll(ti);return t[t.length-1]}function $e({title:e,body:t}){U.prevFocus=document.activeElement,document.addEventListener("focus",ii,!0),d(_s).innerHTML=e,d(Ns).innerHTML=t,d(Z).classList.add("shown"),d(Z).focus()}function X(){d(Z).classList.remove("shown"),document.addEventListener("focus",ii,!0),U.prevFocus&&U.prevFocus.focus(),U.prevFocus=null}function ri(){return d(Z).classList.contains("shown")}var Ds="https://hexdocs.pm/%%",Qs="https://www.erlang.org/doc/apps/%%",Fs="https://hex.pm/api/packages?search=name:%%*",Bs=".display-quick-switch",Lt="#quick-switch-input",oi="#quick-switch-results",$s=".quick-switch-result",Vs=300,Us=9,ai=["erts","asn1","common_test","compiler","crypto","debugger","dialyzer","diameter","edoc","eldap","erl_interface","et","eunit","ftp","inets","jinterface","kernel","megaco","mnesia","observer","odbc","os_mon","parsetools","public_key","reltool","runtime_tools","sasl","snmp","ssh","ssl","stdlib","syntax_tools","tftp","tools","wx","xmerl"],qs=["elixir","eex","ex_unit","hex","iex","logger","mix"].concat(ai).map(e=>({name:e})),ci=2,R={autocompleteResults:[],selectedIdx:null};function li(){js()}function js(){O(Bs).forEach(e=>{e.addEventListener("click",t=>{Ct()})})}function zs(e){if(e.key==="Enter"){let t=e.target.value;Gs(t),e.preventDefault()}else e.key==="ArrowUp"?(si(-1),e.preventDefault()):e.key==="ArrowDown"&&(si(1),e.preventDefault())}function Ws(e){let t=e.target.value;if(t.lengthn.json()).then(n=>{Array.isArray(n)&&(R.autocompleteResults=Xs(e,n),R.selectedIdx=null,d(Lt).value.length>=ci&&Js({results:R.autocompleteResults}))})}function Js({results:e}){let t=d(oi),n=Handlebars.templates["quick-switch-results"]({results:e});t.innerHTML=n,O($s).forEach(i=>{i.addEventListener("click",r=>{let s=i.getAttribute("data-index"),o=R.autocompleteResults[s];kt(o.name)})})}function Xs(e,t){return qs.concat(t).filter(n=>n.name.toLowerCase().includes(e.toLowerCase())).filter(n=>n.releases===void 0||n.releases[0].has_docs===!0).slice(0,Us)}function si(e){R.selectedIdx=Zs(e);let t=d(".quick-switch-result.selected"),n=d(`.quick-switch-result[data-index="${R.selectedIdx}"]`);t&&t.classList.remove("selected"),n&&n.classList.add("selected")}function Zs(e){let t=R.autocompleteResults.length;if(R.selectedIdx===null){if(e>=0)return 0;if(e<0)return t-1}return(R.selectedIdx+e+t)%t}var eo=".display-settings",to="#settings-modal-content",At="#modal-settings-tab",Ot="#modal-keyboard-shortcuts-tab",di="#settings-content",hi="#keyboard-shortcuts-content",no=[{title:"Settings",id:"modal-settings-tab"},{title:"Keyboard shortcuts",id:"modal-keyboard-shortcuts-tab"}];function Pt(){io()}function io(){O(eo).forEach(e=>{e.addEventListener("click",t=>{It()})})}function ui(){d(Ot).classList.remove("active"),d(At).classList.add("active"),d(di).classList.remove("hidden"),d(hi).classList.add("hidden")}function ro(){d(Ot).classList.add("active"),d(At).classList.remove("active"),d(hi).classList.remove("hidden"),d(di).classList.add("hidden")}function It(){$e({title:no.map(({id:s,title:o})=>``).join(""),body:Handlebars.templates["settings-modal-body"]({shortcuts:_t})});let e=d(to),t=e.querySelector('[name="theme"]'),n=e.querySelector('[name="tooltips"]'),i=e.querySelector('[name="direct_livebook_url"]'),r=e.querySelector('[name="livebook_url"]');P.getAndSubscribe(s=>{t.value=s.theme||"system",n.checked=s.tooltips,s.livebookUrl===null?(i.checked=!1,r.classList.add("hidden"),r.tabIndex=-1):(i.checked=!0,r.classList.remove("hidden"),r.tabIndex=0,r.value=s.livebookUrl)}),t.addEventListener("change",s=>{P.update({theme:s.target.value})}),n.addEventListener("change",s=>{P.update({tooltips:s.target.checked})}),i.addEventListener("change",s=>{let o=s.target.checked?r.value:null;P.update({livebookUrl:o})}),r.addEventListener("input",s=>{P.update({livebookUrl:s.target.value})}),d(At).addEventListener("click",s=>{ui()}),d(Ot).addEventListener("click",s=>{ro()}),ui()}var so="#settings-modal-content",_t=[{key:"c",description:"Toggle sidebar",action:nt},{key:"n",description:"Cycle themes",action:Rn},{key:"s",description:"Focus search bar",displayAs:"/ or s",action:Nt},{key:"/",action:Nt},{key:"k",hasModifier:!0,action:Nt},{key:"v",description:"Open/focus version select",action:lo},{key:"g",description:"Go to package docs",displayAs:"g",action:Ct},{key:"?",displayAs:"?",description:"Bring up this modal",action:uo}],Rt={shortcutBeingPressed:null};function fi(){oo()}function oo(){document.addEventListener("keydown",ao),document.addEventListener("keyup",co)}function ao(e){if(Rt.shortcutBeingPressed||e.target.matches("input, select, textarea"))return;let t=_t.find(n=>n.hasModifier?se()&&e.metaKey||e.ctrlKey?n.key===e.key:!1:e.ctrlKey||e.metaKey||e.altKey?!1:n.key===e.key);t&&(Rt.shortcutBeingPressed=t,e.preventDefault(),t.action(e))}function co(e){Rt.shortcutBeingPressed=null}function Nt(e){X(),bt()}function lo(){X(),bn()?St():rt().then(St)}function uo(){ho()?X():It()}function ho(){return ri()&&d(so)}var ee={plain:"plain",function:"function",module:"module"},fo=[{href:"typespecs.html#basic-types",hint:{kind:ee.plain,description:"Basic type"}},{href:"typespecs.html#literals",hint:{kind:ee.plain,description:"Literal"}},{href:"typespecs.html#built-in-types",hint:{kind:ee.plain,description:"Built-in type"}}],Ve={cancelHintFetching:null};function pi(e){if(gi(e))return!0;let t=/#.*\//;return e.includes("#")&&!t.test(e)?!1:e.includes(".html")}function mi(e){let t=gi(e);return t?Promise.resolve(t):po(e)}function gi(e){let t=fo.find(n=>e.includes(n.href));return t?t.hint:null}function po(e){let t=e.replace(".html",".html?hint=true");return new Promise((n,i)=>{let r=document.createElement("iframe");r.setAttribute("src",t),r.style.display="none";function s(a){let{href:c,hint:l}=a.data;t===c&&(o(),n(l))}Ve.cancelHintFetching=()=>{o(),i(new Error("cancelled"))};function o(){r.remove(),window.removeEventListener("message",s),Ve.cancelHintFetching=null}window.addEventListener("message",s),document.body.appendChild(r)})}function vi(){Ve.cancelHintFetching&&Ve.cancelHintFetching()}function yi(e){let n=e.querySelector("h1").textContent,i=e.querySelector(".docstring > p"),r=i?i.innerHTML:"";return{kind:ee.function,title:n.trim(),description:r.trim()}}function wi(e){let n=e.querySelector("h1 > span").textContent,i=e.querySelector("#moduledoc p"),r=i?i.innerHTML:"";return{kind:ee.module,title:n.trim(),description:r.trim()}}var mo=".content a",Ht=".tooltip",go=".tooltip .tooltip-body",Si="body .content-inner",vo="#content",Ei="tooltip-shown",me=10,yo=me*4,bi={height:450,width:768},wo=100,ce={currentLinkElement:null,hoverDelayTimeout:null};function Mt(){bo(),So()}function bo(){let e=Handlebars.templates["tooltip-layout"]();d(Si).insertAdjacentHTML("beforeend",e)}function So(){O(mo).forEach(e=>{Eo(e)&&(e.addEventListener("mouseenter",t=>{To(e)}),e.addEventListener("mouseleave",t=>{Ao(e)}))})}function Eo(e){return!(e.getAttribute("data-no-tooltip")!==null||xo(e.href)||!pi(e.href))}function xo(e){let t=e.replace(vo,"");return window.location.href.split("#")[0]===t}function To(e){Lo()&&(ce.currentLinkElement=e,ce.hoverDelayTimeout=setTimeout(()=>{mi(e.href).then(t=>{ko(t),Co()}).catch(()=>{})},wo))}function Lo(){let e=window.innerWidthe.firstElementChild&&e.firstElementChild.tagName==="CODE").forEach(e=>e.insertAdjacentHTML("beforeend",Mo)),Array.from(O(".copy-button")).forEach(e=>{let t;e.addEventListener("click",()=>{let n=e.querySelector("[aria-live]");t&&clearTimeout(t);let i=Array.from(e.parentElement.querySelector("code").childNodes).filter(r=>!(r.tagName==="SPAN"&&r.classList.contains("unselectable"))).map(r=>r.textContent).join("");navigator.clipboard.writeText(i),e.classList.add("clicked"),n.innerHTML="Copied! ✓",t=setTimeout(()=>{e.classList.remove("clicked"),n.innerHTML=""},3e3)})})}function Qt(){console.log("Initializing copy-markdown functionality"),"clipboard"in navigator?(window.copyMarkdown=Qo,console.log("copyMarkdown function attached to window")):console.warn("Clipboard API not available")}async function Qo(e){console.log("copyMarkdown called with path:",e);try{if(!navigator.clipboard)throw new Error("Clipboard API not available");let t=new URL(window.location.href),i=`${t.origin+t.pathname.replace(/\/[^/]*$/,"")}/markdown/${e}`;console.log("Fetching markdown from:",i);let r=await fetch(i);if(!r.ok)throw new Error(`Failed to fetch markdown: ${r.status}`);let s=await r.text();console.log("Markdown content length:",s.length),await Fo(s),ki("Markdown copied!"),console.log("Markdown copied successfully")}catch(t){console.error("Failed to copy markdown:",t),ki("Failed to copy markdown: "+t.message,!0)}}async function Fo(e){if(navigator.clipboard&&window.isSecureContext)await navigator.clipboard.writeText(e);else{let t=document.createElement("textarea");return t.value=e,t.style.position="fixed",t.style.left="-999999px",t.style.top="-999999px",document.body.appendChild(t),t.focus(),t.select(),new Promise((n,i)=>{let r=document.execCommand("copy");document.body.removeChild(t),r?n():i(new Error("Unable to copy to clipboard"))})}}function ki(e,t=!1){let n=document.getElementById("markdown-copy-feedback");n||(n=document.createElement("div"),n.id="markdown-copy-feedback",n.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(n)),n.textContent=e,n.style.backgroundColor=t?"#dc2626":"#059669",n.style.opacity="1",setTimeout(()=>{n.style.opacity="0",setTimeout(()=>{n.parentNode&&n.parentNode.removeChild(n)},300)},3e3)}function Ci(){let e=se()?"apple-os":"non-apple-os";document.documentElement.classList.add(e)}function Oi(){let e=xe(ie(),!0);e&&Bo(e)}function Bo(e){Uo(e),$o(),Vo(),Ai(),window.addEventListener("resize",t=>{Ai()})}function Ai(){let e=document.body.scrollHeight,t=document.getElementById("content").parentElement.offsetHeight,n={type:"preview",maxHeight:e,contentHeight:t};window.parent.postMessage(n,"*")}function $o(){let e=document.getElementsByTagName("a");for(let t of e)t.getAttribute("target")!=="_blank"&&t.setAttribute("target","_parent")}function Vo(){window.scrollTo(0,0)}function Uo(e){document.body.classList.add("preview");let t=document.getElementById("content");t.innerHTML=e.innerHTML}var Ft=new WeakMap;function Bt(e,t,n,i){if(!e&&!Ft.has(t))return!1;let r=Ft.get(t)??new WeakMap;Ft.set(t,r);let s=r.get(n)??new Set;r.set(n,s);let o=s.has(i);return e?s.add(i):s.delete(i),o&&e}function qo(e,t){let n=e.target;if(n instanceof Text&&(n=n.parentElement),n instanceof Element&&e.currentTarget instanceof Element){let i=n.closest(t);if(i&&e.currentTarget.contains(i))return i}}function jo(e,t,n,i={}){let{signal:r,base:s=document}=i;if(r?.aborted)return;let{once:o,...a}=i,c=s instanceof Document?s.documentElement:s,l=Boolean(typeof i=="object"?i.capture:i),u=y=>{let w=qo(y,String(e));if(w){let b=Object.assign(y,{delegateTarget:w});n.call(c,b),o&&(c.removeEventListener(t,u,a),Bt(!1,c,n,h))}},h=JSON.stringify({selector:e,type:t,capture:l});Bt(!0,c,n,h)||c.addEventListener(t,u,a),r?.addEventListener("abort",()=>{Bt(!1,c,n,h)})}var Ue=jo;function k(){return k=Object.assign?Object.assign.bind():function(e){for(var t=1;tString(e).toLowerCase().replace(/[\s/_.]+/g,"-").replace(/[^\w-]+/g,"").replace(/--+/g,"-").replace(/^-+|-+$/g,"")||t||"",ve=({hash:e}={})=>window.location.pathname+window.location.search+(e?window.location.hash:""),zo=(e,t={})=>{let n=k({url:e=e||ve({hash:!0}),random:Math.random(),source:"swup"},t);window.history.pushState(n,"",e)},ge=(e=null,t={})=>{e=e||ve({hash:!0});let n=k({},window.history.state||{},{url:e,random:Math.random(),source:"swup"},t);window.history.replaceState(n,"",e)},Wo=(e,t,n,i)=>{let r=new AbortController;return i=k({},i,{signal:r.signal}),Ue(e,t,n,i),{destroy:()=>r.abort()}},C=class extends URL{constructor(t,n=document.baseURI){super(t.toString(),n),Object.setPrototypeOf(this,C.prototype)}get url(){return this.pathname+this.search}static fromElement(t){let n=t.getAttribute("href")||t.getAttribute("xlink:href")||"";return new C(n)}static fromUrl(t){return new C(t)}};var le=class extends Error{constructor(t,n){super(t),this.url=void 0,this.status=void 0,this.aborted=void 0,this.timedOut=void 0,this.name="FetchError",this.url=n.url,this.status=n.status,this.aborted=n.aborted||!1,this.timedOut=n.timedOut||!1}};async function Go(e,t={}){var n;e=C.fromUrl(e).url;let{visit:i=this.visit}=t,r=k({},this.options.requestHeaders,t.headers),s=(n=t.timeout)!=null?n:this.options.timeout,o=new AbortController,{signal:a}=o;t=k({},t,{headers:r,signal:a});let c,l=!1,u=null;s&&s>0&&(u=setTimeout(()=>{l=!0,o.abort("timeout")},s));try{c=await this.hooks.call("fetch:request",i,{url:e,options:t},(g,{url:m,options:x})=>fetch(m,x)),u&&clearTimeout(u)}catch(g){throw l?(this.hooks.call("fetch:timeout",i,{url:e}),new le(`Request timed out: ${e}`,{url:e,timedOut:l})):g?.name==="AbortError"||a.aborted?new le(`Request aborted: ${e}`,{url:e,aborted:!0}):g}let{status:h,url:f}=c,y=await c.text();if(h===500)throw this.hooks.call("fetch:error",i,{status:h,response:c,url:f}),new le(`Server error: ${f}`,{status:h,url:f});if(!y)throw new le(`Empty response: ${f}`,{status:h,url:f});let{url:w}=C.fromUrl(f),b={url:w,html:y};return!i.cache.write||t.method&&t.method!=="GET"||e!==w||this.cache.set(b.url,b),b}var Vt=class{constructor(t){this.swup=void 0,this.pages=new Map,this.swup=t}get size(){return this.pages.size}get all(){let t=new Map;return this.pages.forEach((n,i)=>{t.set(i,k({},n))}),t}has(t){return this.pages.has(this.resolve(t))}get(t){let n=this.pages.get(this.resolve(t));return n&&k({},n)}set(t,n){n=k({},n,{url:t=this.resolve(t)}),this.pages.set(t,n),this.swup.hooks.callSync("cache:set",void 0,{page:n})}update(t,n){t=this.resolve(t);let i=k({},this.get(t),n,{url:t});this.pages.set(t,i)}delete(t){this.pages.delete(this.resolve(t))}clear(){this.pages.clear(),this.swup.hooks.callSync("cache:clear",void 0,void 0)}prune(t){this.pages.forEach((n,i)=>{t(i,n)&&this.delete(i)})}resolve(t){let{url:n}=C.fromUrl(t);return this.swup.resolveUrl(n)}},Ut=(e,t=document)=>t.querySelector(e),zt=(e,t=document)=>Array.from(t.querySelectorAll(e)),Ri=()=>new Promise(e=>{requestAnimationFrame(()=>{requestAnimationFrame(()=>{e()})})});function Hi(e){return!!e&&(typeof e=="object"||typeof e=="function")&&typeof e.then=="function"}function Ko(e,t=[]){return new Promise((n,i)=>{let r=e(...t);Hi(r)?r.then(n,i):n(r)})}function Pi(e,t){let n=e?.closest(`[${t}]`);return n!=null&&n.hasAttribute(t)?n?.getAttribute(t)||!0:void 0}var qt=class{constructor(t){this.swup=void 0,this.swupClasses=["to-","is-changing","is-rendering","is-popstate","is-animating","is-leaving"],this.swup=t}get selectors(){let{scope:t}=this.swup.visit.animation;return t==="containers"?this.swup.visit.containers:t==="html"?["html"]:Array.isArray(t)?t:[]}get selector(){return this.selectors.join(",")}get targets(){return this.selector.trim()?zt(this.selector):[]}add(...t){this.targets.forEach(n=>n.classList.add(...t))}remove(...t){this.targets.forEach(n=>n.classList.remove(...t))}clear(){this.targets.forEach(t=>{let n=t.className.split(" ").filter(i=>this.isSwupClass(i));t.classList.remove(...n)})}isSwupClass(t){return this.swupClasses.some(n=>t.startsWith(n))}},ze=class{constructor(t,n){this.id=void 0,this.state=void 0,this.from=void 0,this.to=void 0,this.containers=void 0,this.animation=void 0,this.trigger=void 0,this.cache=void 0,this.history=void 0,this.scroll=void 0,this.meta=void 0;let{to:i,from:r,hash:s,el:o,event:a}=n;this.id=Math.random(),this.state=1,this.from={url:r??t.location.url,hash:t.location.hash},this.to={url:i,hash:s},this.containers=t.options.containers,this.animation={animate:!0,wait:!1,name:void 0,native:t.options.native,scope:t.options.animationScope,selector:t.options.animationSelector},this.trigger={el:o,event:a},this.cache={read:t.options.cache,write:t.options.cache},this.history={action:"push",popstate:!1,direction:void 0},this.scroll={reset:!0,target:void 0},this.meta={}}advance(t){this.state=7}};function Yo(e){return new ze(this,e)}var jt=class{constructor(t){this.swup=void 0,this.registry=new Map,this.hooks=["animation:out:start","animation:out:await","animation:out:end","animation:in:start","animation:in:await","animation:in:end","animation:skip","cache:clear","cache:set","content:replace","content:scroll","enable","disable","fetch:request","fetch:error","fetch:timeout","history:popstate","link:click","link:self","link:anchor","link:newtab","page:load","page:view","scroll:top","scroll:anchor","visit:start","visit:transition","visit:abort","visit:end"],this.swup=t,this.init()}init(){this.hooks.forEach(t=>this.create(t))}create(t){this.registry.has(t)||this.registry.set(t,new Map)}exists(t){return this.registry.has(t)}get(t){let n=this.registry.get(t);if(n)return n;console.error(`Unknown hook '${t}'`)}clear(){this.registry.forEach(t=>t.clear())}on(t,n,i={}){let r=this.get(t);if(!r)return console.warn(`Hook '${t}' not found.`),()=>{};let s=k({},i,{id:r.size+1,hook:t,handler:n});return r.set(n,s),()=>this.off(t,n)}before(t,n,i={}){return this.on(t,n,k({},i,{before:!0}))}replace(t,n,i={}){return this.on(t,n,k({},i,{replace:!0}))}once(t,n,i={}){return this.on(t,n,k({},i,{once:!0}))}off(t,n){let i=this.get(t);i&&n?i.delete(n)||console.warn(`Handler for hook '${t}' not found.`):i&&i.clear()}async call(t,n,i,r){let[s,o,a]=this.parseCallArgs(t,n,i,r),{before:c,handler:l,after:u}=this.getHandlers(t,a);await this.run(c,s,o);let[h]=await this.run(l,s,o,!0);return await this.run(u,s,o),this.dispatchDomEvent(t,s,o),h}callSync(t,n,i,r){let[s,o,a]=this.parseCallArgs(t,n,i,r),{before:c,handler:l,after:u}=this.getHandlers(t,a);this.runSync(c,s,o);let[h]=this.runSync(l,s,o,!0);return this.runSync(u,s,o),this.dispatchDomEvent(t,s,o),h}parseCallArgs(t,n,i,r){return n instanceof ze||typeof n!="object"&&typeof i!="function"?[n,i,r]:[void 0,n,i]}async run(t,n=this.swup.visit,i,r=!1){let s=[];for(let{hook:o,handler:a,defaultHandler:c,once:l}of t)if(n==null||!n.done){l&&this.off(o,a);try{let u=await Ko(a,[n,i,c]);s.push(u)}catch(u){if(r)throw u;console.error(`Error in hook '${o}':`,u)}}return s}runSync(t,n=this.swup.visit,i,r=!1){let s=[];for(let{hook:o,handler:a,defaultHandler:c,once:l}of t)if(n==null||!n.done){l&&this.off(o,a);try{let u=a(n,i,c);s.push(u),Hi(u)&&console.warn(`Swup will not await Promises in handler for synchronous hook '${o}'.`)}catch(u){if(r)throw u;console.error(`Error in hook '${o}':`,u)}}return s}getHandlers(t,n){let i=this.get(t);if(!i)return{found:!1,before:[],handler:[],after:[],replaced:!1};let r=Array.from(i.values()),s=this.sortRegistrations,o=r.filter(({before:h,replace:f})=>h&&!f).sort(s),a=r.filter(({replace:h})=>h).filter(h=>!0).sort(s),c=r.filter(({before:h,replace:f})=>!h&&!f).sort(s),l=a.length>0,u=[];if(n&&(u=[{id:0,hook:t,handler:n}],l)){let h=a.length-1,{handler:f,once:y}=a[h],w=b=>{let g=a[b-1];return g?(m,x)=>g.handler(m,x,w(b-1)):n};u=[{id:0,hook:t,once:y,handler:f,defaultHandler:w(h)}]}return{found:!0,before:o,handler:u,after:c,replaced:l}}sortRegistrations(t,n){var i,r;return((i=t.priority)!=null?i:0)-((r=n.priority)!=null?r:0)||t.id-n.id||0}dispatchDomEvent(t,n,i){if(n!=null&&n.done)return;let r={hook:t,args:i,visit:n||this.swup.visit};document.dispatchEvent(new CustomEvent("swup:any",{detail:r,bubbles:!0})),document.dispatchEvent(new CustomEvent(`swup:${t}`,{detail:r,bubbles:!0}))}parseName(t){let[n,...i]=t.split(".");return[n,i.reduce((r,s)=>k({},r,{[s]:!0}),{})]}},Jo=e=>{if(e&&e.charAt(0)==="#"&&(e=e.substring(1)),!e)return null;let t=decodeURIComponent(e),n=document.getElementById(e)||document.getElementById(t)||Ut(`a[name='${CSS.escape(e)}']`)||Ut(`a[name='${CSS.escape(t)}']`);return n||e!=="top"||(n=document.body),n},qe="transition",$t="animation";async function Xo({selector:e,elements:t}){if(e===!1&&!t)return;let n=[];if(t)n=Array.from(t);else if(e&&(n=zt(e,document.body),!n.length))return void console.warn(`[swup] No elements found matching animationSelector \`${e}\``);let i=n.map(r=>function(s){let{type:o,timeout:a,propCount:c}=function(l){let u=window.getComputedStyle(l),h=je(u,`${qe}Delay`),f=je(u,`${qe}Duration`),y=Ii(h,f),w=je(u,`${$t}Delay`),b=je(u,`${$t}Duration`),g=Ii(w,b),m=Math.max(y,g),x=m>0?y>g?qe:$t:null;return{type:x,timeout:m,propCount:x?x===qe?f.length:b.length:0}}(s);return!(!o||!a)&&new Promise(l=>{let u=`${o}end`,h=performance.now(),f=0,y=()=>{s.removeEventListener(u,w),l()},w=b=>{b.target===s&&((performance.now()-h)/1e3=c&&y())};setTimeout(()=>{f0?await Promise.all(i):e&&console.warn(`[swup] No CSS animation duration defined on elements matching \`${e}\``)}function je(e,t){return(e[t]||"").split(", ")}function Ii(e,t){for(;e.length_i(n)+_i(e[i])))}function _i(e){return 1e3*parseFloat(e)}function Zo(e,t={},n={}){if(typeof e!="string")throw new Error("swup.navigate() requires a URL parameter");if(this.shouldIgnoreVisit(e,{el:n.el,event:n.event}))return void window.location.assign(e);let{url:i,hash:r}=C.fromUrl(e),s=this.createVisit(k({},n,{to:i,hash:r}));this.performNavigation(s,t)}async function ea(e,t={}){if(this.navigating){if(this.visit.state>=6)return e.state=2,void(this.onVisitEnd=()=>this.performNavigation(e,t));await this.hooks.call("visit:abort",this.visit,void 0),delete this.visit.to.document,this.visit.state=8}this.navigating=!0,this.visit=e;let{el:n}=e.trigger;t.referrer=t.referrer||this.location.url,t.animate===!1&&(e.animation.animate=!1),e.animation.animate||this.classes.clear();let i=t.history||Pi(n,"data-swup-history");typeof i=="string"&&["push","replace"].includes(i)&&(e.history.action=i);let r=t.animation||Pi(n,"data-swup-animation");var s,o;typeof r=="string"&&(e.animation.name=r),e.meta=t.meta||{},typeof t.cache=="object"?(e.cache.read=(s=t.cache.read)!=null?s:e.cache.read,e.cache.write=(o=t.cache.write)!=null?o:e.cache.write):t.cache!==void 0&&(e.cache={read:!!t.cache,write:!!t.cache}),delete t.cache;try{await this.hooks.call("visit:start",e,void 0),e.state=3;let a=this.hooks.call("page:load",e,{options:t},async(l,u)=>{let h;return l.cache.read&&(h=this.cache.get(l.to.url)),u.page=h||await this.fetchPage(l.to.url,u.options),u.cache=!!h,u.page});a.then(({html:l})=>{e.advance(5),e.to.html=l,e.to.document=new DOMParser().parseFromString(l,"text/html")});let c=e.to.url+e.to.hash;if(e.history.popstate||(e.history.action==="replace"||e.to.url===this.location.url?ge(c):(this.currentHistoryIndex++,zo(c,{index:this.currentHistoryIndex}))),this.location=C.fromUrl(c),e.history.popstate&&this.classes.add("is-popstate"),e.animation.name&&this.classes.add(`to-${Ni(e.animation.name)}`),e.animation.wait&&await a,e.done||(await this.hooks.call("visit:transition",e,void 0,async()=>{if(!e.animation.animate)return await this.hooks.call("animation:skip",void 0),void await this.renderPage(e,await a);e.advance(4),await this.animatePageOut(e),e.animation.native&&document.startViewTransition?await document.startViewTransition(async()=>await this.renderPage(e,await a)).finished:await this.renderPage(e,await a),await this.animatePageIn(e)}),e.done))return;await this.hooks.call("visit:end",e,void 0,()=>this.classes.clear()),e.state=7,this.navigating=!1,this.onVisitEnd&&(this.onVisitEnd(),this.onVisitEnd=void 0)}catch(a){if(!a||a!=null&&a.aborted)return void(e.state=8);e.state=9,console.error(a),this.options.skipPopStateHandling=()=>(window.location.assign(e.to.url+e.to.hash),!0),window.history.back()}finally{delete e.to.document}}var ta=async function(e){await this.hooks.call("animation:out:start",e,void 0,()=>{this.classes.add("is-changing","is-animating","is-leaving")}),await this.hooks.call("animation:out:await",e,{skip:!1},(t,{skip:n})=>{if(!n)return this.awaitAnimations({selector:t.animation.selector})}),await this.hooks.call("animation:out:end",e,void 0)},na=function(e){var t;let n=e.to.document;if(!n)return!1;let i=((t=n.querySelector("title"))==null?void 0:t.innerText)||"";document.title=i;let r=zt('[data-swup-persist]:not([data-swup-persist=""])'),s=e.containers.map(o=>{let a=document.querySelector(o),c=n.querySelector(o);return a&&c?(a.replaceWith(c.cloneNode(!0)),!0):(a||console.warn(`[swup] Container missing in current document: ${o}`),c||console.warn(`[swup] Container missing in incoming document: ${o}`),!1)}).filter(Boolean);return r.forEach(o=>{let a=o.getAttribute("data-swup-persist"),c=Ut(`[data-swup-persist="${a}"]`);c&&c!==o&&c.replaceWith(o)}),s.length===e.containers.length},ia=function(e){let t={behavior:"auto"},{target:n,reset:i}=e.scroll,r=n??e.to.hash,s=!1;return r&&(s=this.hooks.callSync("scroll:anchor",e,{hash:r,options:t},(o,{hash:a,options:c})=>{let l=this.getAnchorElement(a);return l&&l.scrollIntoView(c),!!l})),i&&!s&&(s=this.hooks.callSync("scroll:top",e,{options:t},(o,{options:a})=>(window.scrollTo(k({top:0,left:0},a)),!0))),s},ra=async function(e){if(e.done)return;let t=this.hooks.call("animation:in:await",e,{skip:!1},(n,{skip:i})=>{if(!i)return this.awaitAnimations({selector:n.animation.selector})});await Ri(),await this.hooks.call("animation:in:start",e,void 0,()=>{this.classes.remove("is-animating")}),await t,await this.hooks.call("animation:in:end",e,void 0)},sa=async function(e,t){if(e.done)return;e.advance(6);let{url:n}=t;this.isSameResolvedUrl(ve(),n)||(ge(n),this.location=C.fromUrl(n),e.to.url=this.location.url,e.to.hash=this.location.hash),await this.hooks.call("content:replace",e,{page:t},(i,{})=>{if(this.classes.remove("is-leaving"),i.animation.animate&&this.classes.add("is-rendering"),!this.replaceContent(i))throw new Error("[swup] Container mismatch, aborting");i.animation.animate&&(this.classes.add("is-changing","is-animating","is-rendering"),i.animation.name&&this.classes.add(`to-${Ni(i.animation.name)}`))}),await this.hooks.call("content:scroll",e,void 0,()=>this.scrollToContent(e)),await this.hooks.call("page:view",e,{url:this.location.url,title:document.title})},oa=function(e){var t;if(t=e,Boolean(t?.isSwupPlugin)){if(e.swup=this,!e._checkRequirements||e._checkRequirements())return e._beforeMount&&e._beforeMount(),e.mount(),this.plugins.push(e),this.plugins}else console.error("Not a swup plugin instance",e)};function aa(e){let t=this.findPlugin(e);if(t)return t.unmount(),t._afterUnmount&&t._afterUnmount(),this.plugins=this.plugins.filter(n=>n!==t),this.plugins;console.error("No such plugin",t)}function ca(e){return this.plugins.find(t=>t===e||t.name===e||t.name===`Swup${String(e)}`)}function la(e){if(typeof this.options.resolveUrl!="function")return console.warn("[swup] options.resolveUrl expects a callback function."),e;let t=this.options.resolveUrl(e);return t&&typeof t=="string"?t.startsWith("//")||t.startsWith("http")?(console.warn("[swup] options.resolveUrl needs to return a relative url"),e):t:(console.warn("[swup] options.resolveUrl needs to return a url"),e)}function ua(e,t){return this.resolveUrl(e)===this.resolveUrl(t)}var da={animateHistoryBrowsing:!1,animationSelector:'[class*="transition-"]',animationScope:"html",cache:!0,containers:["#swup"],hooks:{},ignoreVisit:(e,{el:t}={})=>!(t==null||!t.closest("[data-no-swup]")),linkSelector:"a[href]",linkToSelf:"scroll",native:!1,plugins:[],resolveUrl:e=>e,requestHeaders:{"X-Requested-With":"swup",Accept:"text/html, application/xhtml+xml"},skipPopStateHandling:e=>{var t;return((t=e.state)==null?void 0:t.source)!=="swup"},timeout:0},We=class{get currentPageUrl(){return this.location.url}constructor(t={}){var n,i;this.version="4.8.1",this.options=void 0,this.defaults=da,this.plugins=[],this.visit=void 0,this.cache=void 0,this.hooks=void 0,this.classes=void 0,this.location=C.fromUrl(window.location.href),this.currentHistoryIndex=void 0,this.clickDelegate=void 0,this.navigating=!1,this.onVisitEnd=void 0,this.use=oa,this.unuse=aa,this.findPlugin=ca,this.log=()=>{},this.navigate=Zo,this.performNavigation=ea,this.createVisit=Yo,this.delegateEvent=Wo,this.fetchPage=Go,this.awaitAnimations=Xo,this.renderPage=sa,this.replaceContent=na,this.animatePageIn=ra,this.animatePageOut=ta,this.scrollToContent=ia,this.getAnchorElement=Jo,this.getCurrentUrl=ve,this.resolveUrl=la,this.isSameResolvedUrl=ua,this.options=k({},this.defaults,t),this.handleLinkClick=this.handleLinkClick.bind(this),this.handlePopState=this.handlePopState.bind(this),this.cache=new Vt(this),this.classes=new qt(this),this.hooks=new jt(this),this.visit=this.createVisit({to:""}),this.currentHistoryIndex=(n=(i=window.history.state)==null?void 0:i.index)!=null?n:1,this.enable()}async enable(){var t;let{linkSelector:n}=this.options;this.clickDelegate=this.delegateEvent(n,"click",this.handleLinkClick),window.addEventListener("popstate",this.handlePopState),this.options.animateHistoryBrowsing&&(window.history.scrollRestoration="manual"),this.options.native=this.options.native&&!!document.startViewTransition,this.options.plugins.forEach(i=>this.use(i));for(let[i,r]of Object.entries(this.options.hooks)){let[s,o]=this.hooks.parseName(i);this.hooks.on(s,r,o)}((t=window.history.state)==null?void 0:t.source)!=="swup"&&ge(null,{index:this.currentHistoryIndex}),await Ri(),await this.hooks.call("enable",void 0,void 0,()=>{let i=document.documentElement;i.classList.add("swup-enabled"),i.classList.toggle("swup-native",this.options.native)})}async destroy(){this.clickDelegate.destroy(),window.removeEventListener("popstate",this.handlePopState),this.cache.clear(),this.options.plugins.forEach(t=>this.unuse(t)),await this.hooks.call("disable",void 0,void 0,()=>{let t=document.documentElement;t.classList.remove("swup-enabled"),t.classList.remove("swup-native")}),this.hooks.clear()}shouldIgnoreVisit(t,{el:n,event:i}={}){let{origin:r,url:s,hash:o}=C.fromUrl(t);return r!==window.location.origin||!(!n||!this.triggerWillOpenNewWindow(n))||!!this.options.ignoreVisit(s+o,{el:n,event:i})}handleLinkClick(t){let n=t.delegateTarget,{href:i,url:r,hash:s}=C.fromElement(n);if(this.shouldIgnoreVisit(i,{el:n,event:t}))return;if(this.navigating&&r===this.visit.to.url)return void t.preventDefault();let o=this.createVisit({to:r,hash:s,el:n,event:t});t.metaKey||t.ctrlKey||t.shiftKey||t.altKey?this.hooks.callSync("link:newtab",o,{href:i}):t.button===0&&this.hooks.callSync("link:click",o,{el:n,event:t},()=>{var a;let c=(a=o.from.url)!=null?a:"";t.preventDefault(),r&&r!==c?this.isSameResolvedUrl(r,c)||this.performNavigation(o):s?this.hooks.callSync("link:anchor",o,{hash:s},()=>{ge(r+s),this.scrollToContent(o)}):this.hooks.callSync("link:self",o,void 0,()=>{this.options.linkToSelf==="navigate"?this.performNavigation(o):(ge(r),this.scrollToContent(o))})})}handlePopState(t){var n,i,r,s;let o=(n=(i=t.state)==null?void 0:i.url)!=null?n:window.location.href;if(this.options.skipPopStateHandling(t)||this.isSameResolvedUrl(ve(),this.location.url))return;let{url:a,hash:c}=C.fromUrl(o),l=this.createVisit({to:a,hash:c,event:t});l.history.popstate=!0;let u=(r=(s=t.state)==null?void 0:s.index)!=null?r:0;u&&u!==this.currentHistoryIndex&&(l.history.direction=u-this.currentHistoryIndex>0?"forwards":"backwards",this.currentHistoryIndex=u),l.animation.animate=!1,l.scroll.reset=!1,l.scroll.target=!1,this.options.animateHistoryBrowsing&&(l.animation.animate=!0,l.scroll.reset=!0),this.hooks.callSync("history:popstate",l,{event:t},()=>{this.performNavigation(l)})}triggerWillOpenNewWindow(t){return!!t.matches('[download], [target="_blank"]')}};function ye(){return ye=Object.assign?Object.assign.bind():function(e){for(var t=1;tString(e).split(".").map(t=>String(parseInt(t||"0",10))).concat(["0","0"]).slice(0,3).join("."),ue=class{constructor(){this.isSwupPlugin=!0,this.swup=void 0,this.version=void 0,this.requires={},this.handlersToUnregister=[]}mount(){}unmount(){this.handlersToUnregister.forEach(t=>t()),this.handlersToUnregister=[]}_beforeMount(){if(!this.name)throw new Error("You must define a name of plugin when creating a class.")}_afterUnmount(){}_checkRequirements(){return typeof this.requires!="object"||Object.entries(this.requires).forEach(([t,n])=>{if(!function(i,r,s){let o=function(a,c){var l;if(a==="swup")return(l=c.version)!=null?l:"";{var u;let h=c.findPlugin(a);return(u=h?.version)!=null?u:""}}(i,s);return!!o&&((a,c)=>c.every(l=>{let[,u,h]=l.match(/^([\D]+)?(.*)$/)||[];var f,y;return((w,b)=>{let g={"":m=>m===0,">":m=>m>0,">=":m=>m>=0,"<":m=>m<0,"<=":m=>m<=0};return(g[b]||g[""])(w)})((y=h,f=Mi(f=a),y=Mi(y),f.localeCompare(y,void 0,{numeric:!0})),u||">=")}))(o,r)}(t,n=Array.isArray(n)?n:[n],this.swup)){let i=`${t} ${n.join(", ")}`;throw new Error(`Plugin version mismatch: ${this.name} requires ${i}`)}}),!0}on(t,n,i={}){var r;n=!(r=n).name.startsWith("bound ")||r.hasOwnProperty("prototype")?n.bind(this):n;let s=this.swup.hooks.on(t,n,i);return this.handlersToUnregister.push(s),s}once(t,n,i={}){return this.on(t,n,ye({},i,{once:!0}))}before(t,n,i={}){return this.on(t,n,ye({},i,{before:!0}))}replace(t,n,i={}){return this.on(t,n,ye({},i,{replace:!0}))}off(t,n){return this.swup.hooks.off(t,n)}};(function(){if(!(typeof window>"u"||typeof document>"u"||typeof HTMLElement>"u")){var e=!1;try{var t=document.createElement("div");t.addEventListener("focus",function(s){s.preventDefault(),s.stopPropagation()},!0),t.focus(Object.defineProperty({},"preventScroll",{get:function(){if(navigator&&typeof navigator.userAgent<"u"&&navigator.userAgent&&navigator.userAgent.match(/Edge\/1[7-8]/))return e=!1;e=!0}}))}catch{}if(HTMLElement.prototype.nativeFocus===void 0&&!e){HTMLElement.prototype.nativeFocus=HTMLElement.prototype.focus;var n=function(s){for(var o=s.parentNode,a=[],c=document.scrollingElement||document.documentElement;o&&o!==c;)(o.offsetHeightn.replace(`{${i}}`,t[i]||""),e||"")}var Gt=class{constructor(){var t;this.id="swup-announcer",this.style="position:absolute;top:0;left:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap;word-wrap:normal;width:1px;height:1px;",this.region=void 0,this.region=(t=this.getRegion())!=null?t:this.createRegion()}getRegion(){return document.getElementById(this.id)}createRegion(){let t=function(n){let i=document.createElement("template");return i.innerHTML=n,i.content.children[0]}(`

`);return document.body.appendChild(t),t}announce(t,n=0){return new Promise(i=>{setTimeout(()=>{this.region.textContent===t&&(t=`${t}.`),this.region.textContent="",this.region.textContent=t,i()},n)})}};function Qi(e){let t;if(t=typeof e=="string"?document.querySelector(e):e,!(t instanceof HTMLElement))return;let n=t.getAttribute("tabindex");t.setAttribute("tabindex","-1"),t.focus({preventScroll:!0}),n!==null&&t.setAttribute("tabindex",n)}var Ge=class extends ue{constructor(t={}){super(),this.name="SwupA11yPlugin",this.requires={swup:">=4"},this.defaults={headingSelector:"h1",respectReducedMotion:!0,autofocus:!1,announcements:{visit:"Navigated to: {title}",url:"New page at {url}"}},this.options=void 0,this.announcer=void 0,this.announcementDelay=100,this.rootSelector="body",this.handleAnchorScroll=(n,{hash:i})=>{let r=this.swup.getAnchorElement(i);r instanceof HTMLElement&&Qi(r)},this.options=Wt({},this.defaults,t),this.announcer=new Gt}mount(){this.swup.hooks.create("content:announce"),this.swup.hooks.create("content:focus"),this.before("visit:start",this.prepareVisit),this.on("visit:start",this.markAsBusy),this.on("visit:end",this.unmarkAsBusy),this.on("visit:end",this.focusContent),this.on("visit:end",this.announceContent),this.on("scroll:anchor",this.handleAnchorScroll),this.before("visit:start",this.disableAnimations),this.before("link:self",this.disableAnimations),this.before("link:anchor",this.disableAnimations),this.swup.announce=this.announce.bind(this)}unmount(){this.swup.announce=void 0}async announce(t){await this.announcer.announce(t)}markAsBusy(){document.documentElement.setAttribute("aria-busy","true")}unmarkAsBusy(){document.documentElement.removeAttribute("aria-busy")}prepareVisit(t){t.a11y={announce:void 0,focus:this.rootSelector}}announceContent(t){this.swup.hooks.callSync("content:announce",t,void 0,n=>{n.a11y.announce===void 0&&(n.a11y.announce=this.getPageAnnouncement()),n.a11y.announce&&this.announcer.announce(n.a11y.announce,this.announcementDelay)})}focusContent(t){this.swup.hooks.callSync("content:focus",t,void 0,n=>{n.a11y.focus&&(this.options.autofocus&&function(){let i=function(){let r=document.querySelector("body [autofocus]");if(r&&!r.closest('[inert], [aria-disabled], [aria-hidden="true"]'))return r}();return!!i&&(i!==document.activeElement&&i.focus(),!0)}()===!0||Qi(n.a11y.focus))})}getPageAnnouncement(){let{headingSelector:t,announcements:n}=this.options;return function({headingSelector:i="h1",announcements:r={}}){var s,o;let a=document.documentElement.lang||"*",{href:c,url:l,pathname:u}=C.fromUrl(window.location.href),h=(s=(o=r[a])!=null?o:r["*"])!=null?s:r;if(typeof h!="object")return;let f=document.querySelector(i);f||console.warn(`SwupA11yPlugin: No main heading (${i}) found on new page`);let y=f?.getAttribute("aria-label")||f?.textContent||document.title||Di(h.url,{href:c,url:l,path:u});return Di(h.visit,{title:y,href:c,url:l,path:u})}({headingSelector:t,announcements:n})}disableAnimations(t){this.options.respectReducedMotion&&window.matchMedia("(prefers-reduced-motion: reduce)").matches&&(t.animation.animate=!1,t.scroll.animate=!1)}};function Kt(){return Kt=Object.assign?Object.assign.bind():function(e){for(var t=1;t{let a=Math.random()*this.trickleValue;this.setValue(this.value+a)},t!==void 0&&(this.className=String(t)),n!==void 0&&(this.styleAttr=String(n)),i!==void 0&&(this.animationDuration=Number(i)),r!==void 0&&(this.minValue=Number(r)),s!==void 0&&(this.initialValue=Number(s)),o!==void 0&&(this.trickleValue=Number(o)),this.styleElement=this.createStyleElement(),this.progressElement=this.createProgressElement()}get defaultStyles(){return` + .${this.className} { + position: fixed; + display: block; + top: 0; + left: 0; + width: 100%; + height: 3px; + background-color: black; + z-index: 9999; + transition: + transform ${this.animationDuration}ms ease-out, + opacity ${this.animationDuration/2}ms ${this.animationDuration/2}ms ease-in; + transform: translate3d(0, 0, 0) scaleX(var(--progress, 0)); + transform-origin: 0; + } + `}show(){this.visible||(this.visible=!0,this.installStyleElement(),this.installProgressElement(),this.startTrickling())}hide(){this.visible&&!this.hiding&&(this.hiding=!0,this.fadeProgressElement(()=>{this.uninstallProgressElement(),this.stopTrickling(),this.visible=!1,this.hiding=!1}))}setValue(t){this.value=Math.min(1,Math.max(this.minValue,t)),this.refresh()}installStyleElement(){document.head.prepend(this.styleElement)}installProgressElement(){this.progressElement.style.setProperty("--progress",String(0)),this.progressElement.style.opacity="1",document.body.prepend(this.progressElement),this.progressElement.scrollTop=0,this.setValue(Math.random()*this.initialValue)}fadeProgressElement(t){this.progressElement.style.opacity="0",setTimeout(t,1.5*this.animationDuration)}uninstallProgressElement(){this.progressElement.remove()}startTrickling(){this.trickleInterval||(this.trickleInterval=window.setInterval(this.trickle,this.animationDuration))}stopTrickling(){window.clearInterval(this.trickleInterval),delete this.trickleInterval}refresh(){requestAnimationFrame(()=>{this.progressElement.style.setProperty("--progress",String(this.value))})}createStyleElement(){let t=document.createElement("style");return this.styleAttr.split(" ").forEach(n=>t.setAttribute(n,"")),t.textContent=this.defaultStyles,t}createProgressElement(){let t=document.createElement("div");return t.className=this.className,t.setAttribute("aria-hidden","true"),t}},Ke=class extends ue{constructor(t={}){super(),this.name="SwupProgressPlugin",this.defaults={className:"swup-progress-bar",delay:300,transition:300,minValue:.1,initialValue:.25,finishAnimation:!0},this.options=void 0,this.progressBar=void 0,this.showProgressBarTimeout=void 0,this.hideProgressBarTimeout=void 0,this.options=Kt({},this.defaults,t);let{className:n,minValue:i,initialValue:r,transition:s}=this.options;this.progressBar=new Yt({className:n,minValue:i,initialValue:r,animationDuration:s})}mount(){this.on("visit:start",this.startShowingProgress),this.on("page:view",this.stopShowingProgress)}startShowingProgress(){this.progressBar.setValue(0),this.showProgressBarAfterDelay()}stopShowingProgress(){this.progressBar.setValue(1),this.options.finishAnimation?this.finishAnimationAndHideProgressBar():this.hideProgressBar()}showProgressBar(){this.cancelHideProgressBarTimeout(),this.progressBar.show()}showProgressBarAfterDelay(){this.cancelShowProgressBarTimeout(),this.cancelHideProgressBarTimeout(),this.showProgressBarTimeout=window.setTimeout(this.showProgressBar.bind(this),this.options.delay)}hideProgressBar(){this.cancelShowProgressBarTimeout(),this.progressBar.hide()}finishAnimationAndHideProgressBar(){this.cancelShowProgressBarTimeout(),this.hideProgressBarTimeout=window.setTimeout(this.hideProgressBar.bind(this),this.options.transition)}cancelShowProgressBarTimeout(){window.clearTimeout(this.showProgressBarTimeout),delete this.showProgressBarTimeout}cancelHideProgressBarTimeout(){window.clearTimeout(this.hideProgressBarTimeout),delete this.hideProgressBarTimeout}};sn(()=>{let e=new URLSearchParams(window.location.search),t=window.self!==window.parent,n=e.has("preview"),i=e.has("hint");Nn(e.get("theme")),Ci(),Ye(),Xe(n),Tt(),Mt(),Dt(),Qt(),n&&t&&Oi(),i&&t?Li():(window.location.protocol!=="file:"&&new We({animationSelector:!1,containers:["#main"],ignoreVisit:r=>{let s=r.split("#")[0];return s===window.location.pathname||s===window.location.pathname+".html"},hooks:{"page:view":()=>{Ye(),Xe(!1),Tt(),Mt(),Dt(),Qt(),yn(),at(),wt(),xt(),Pt()}},linkSelector:'a[href]:not([href^="/"]):not([href^="http"])',plugins:[new Ge,new Ke({delay:500})]}),zn(),ni(),fi(),li(),In(),vn(),Tn(),wt(),xt(),Pt())});})(); +/*! Bundled license information: + +lunr/lunr.js: + (** + * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.3.9 + * Copyright (C) 2020 Oliver Nightingale + * @license MIT + *) + (*! + * lunr.utils + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Set + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.tokenizer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Pipeline + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Vector + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.stemmer + * Copyright (C) 2020 Oliver Nightingale + * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt + *) + (*! + * lunr.stopWordFilter + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.trimmer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.TokenSet + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Index + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Builder + * Copyright (C) 2020 Oliver Nightingale + *) +*/ diff --git a/lib/ex_doc.ex b/lib/ex_doc.ex index c108c3560..992850ecc 100644 --- a/lib/ex_doc.ex +++ b/lib/ex_doc.ex @@ -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 diff --git a/lib/ex_doc/autolink.ex b/lib/ex_doc/autolink.ex index ba2d40d16..fef0fe609 100644 --- a/lib/ex_doc/autolink.ex +++ b/lib/ex_doc/autolink.ex @@ -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 diff --git a/lib/ex_doc/cli.ex b/lib/ex_doc/cli.ex index ed048ed80..5acc46dd1 100644 --- a/lib/ex_doc/cli.ex +++ b/lib/ex_doc/cli.ex @@ -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 @@ -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" diff --git a/lib/ex_doc/doc_ast.ex b/lib/ex_doc/doc_ast.ex index ed84cfd2c..8a6626bc4 100644 --- a/lib/ex_doc/doc_ast.ex +++ b/lib/ex_doc/doc_ast.ex @@ -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)) @@ -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, "") + 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 diff --git a/lib/ex_doc/extra_node.ex b/lib/ex_doc/extra_node.ex new file mode 100644 index 000000000..9f1a3ed21 --- /dev/null +++ b/lib/ex_doc/extra_node.ex @@ -0,0 +1,58 @@ +defmodule ExDoc.ExtraNode do + @moduledoc """ + A structure representing an extra file (guide/documentation) to be included in generated docs. + + This separates the building and processing of extras from the individual formatters, + allowing for better code reuse and cleaner architecture. + """ + + @type t :: %__MODULE__{ + id: String.t(), + title: String.t(), + title_content: String.t(), + source: String.t(), + source_path: String.t(), + source_url: String.t() | nil, + group: String.t() | nil, + content: map() + } + + @enforce_keys [:id, :title, :source, :source_path, :content] + defstruct [ + :id, + :title, + :title_content, + :source, + :source_path, + :source_url, + :group, + :content + ] + + @doc """ + Builds extra nodes from configuration, processing them into a format-agnostic structure. + + The content field contains processed content for different formats: + - `:ast` - The parsed AST from the source + - `:html` - Rendered HTML content + - `:markdown` - Rendered Markdown content + - `:epub` - Rendered EPUB/XHTML content + """ + def build_extras(config) do + ExDoc.Formatter.build_extras_for_extra_node(config) + end + + @doc """ + Gets the rendered content for a specific format. + """ + def content_for_format(%__MODULE__{content: content}, format) do + Map.get(content, format) + end + + @doc """ + Checks if content exists for a specific format. + """ + def has_format?(%__MODULE__{content: content}, format) do + Map.has_key?(content, format) + end +end \ No newline at end of file diff --git a/lib/ex_doc/formatter.ex b/lib/ex_doc/formatter.ex index 25490be56..694a5039f 100644 --- a/lib/ex_doc/formatter.ex +++ b/lib/ex_doc/formatter.ex @@ -48,14 +48,14 @@ defmodule ExDoc.Formatter do specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts)) child_node = %{child_node | specs: specs} - render_doc(child_node, language, autolink_opts, opts) + render_doc(child_node, ext, language, autolink_opts, opts) end - %{render_doc(group, language, autolink_opts, opts) | docs: docs} + %{render_doc(group, ext, language, autolink_opts, opts) | docs: docs} end %{ - render_doc(node, language, [{:id, node.id} | autolink_opts], opts) + render_doc(node, ext, language, [{:id, node.id} | autolink_opts], opts) | docs_groups: docs_groups } end, @@ -64,6 +64,39 @@ defmodule ExDoc.Formatter do |> Enum.map(&elem(&1, 1)) end + defp render_doc(%{doc: nil} = node, _ext, _language, _autolink_opts, _opts), + do: node + + defp render_doc(%{doc: doc} = node, ext, language, autolink_opts, opts) do + rendered = autolink_and_render(doc, ext, language, autolink_opts, opts) + %{node | rendered_doc: rendered} + end + + defp id(%{id: mod_id}, %{id: "c:" <> id}) do + "c:" <> mod_id <> "." <> id + end + + defp id(%{id: mod_id}, %{id: "t:" <> id}) do + "t:" <> mod_id <> "." <> id + end + + defp id(%{id: mod_id}, %{id: id}) do + mod_id <> "." <> id + end + + defp autolink_and_render(doc, ".md", language, autolink_opts, _opts) do + doc + |> language.autolink_doc(autolink_opts) + |> ExDoc.DocAST.to_markdown_string() + end + + defp autolink_and_render(doc, _html_ext, language, autolink_opts, opts) do + doc + |> language.autolink_doc(autolink_opts) + |> ExDoc.DocAST.to_string() + |> ExDoc.DocAST.highlight(language, opts) + end + @doc """ Builds extra nodes by normalizing the config entries. """ @@ -92,7 +125,7 @@ defmodule ExDoc.Formatter do config.extras |> Enum.map(&normalize_extras/1) |> Task.async_stream( - &build_extra(&1, groups, language, autolink_opts, source_url_pattern), + &build_extra(&1, groups, ext, language, autolink_opts, source_url_pattern), timeout: :infinity ) |> Enum.map(&elem(&1, 1)) @@ -107,40 +140,202 @@ defmodule ExDoc.Formatter do |> Enum.sort_by(fn extra -> GroupMatcher.index(groups, extra.group) end) end - def filter_list(:module, nodes) do - Enum.filter(nodes, &(&1.type != :task)) - end + @doc """ + Builds extra nodes in a format-agnostic way, preparing content for all formats. - def filter_list(type, nodes) do - Enum.filter(nodes, &(&1.type == type)) + This function builds ExtraNode structures that contain the processed content + for multiple formats, eliminating the need for each formatter to rebuild the same content. + """ + def build_extras_for_extra_node(config) do + groups = config.groups_for_extras + + language = + case config.proglang do + :erlang -> ExDoc.Language.Erlang + _ -> ExDoc.Language.Elixir + end + + source_url_pattern = config.source_url_pattern + + # Build base autolink options (without ext since we'll render for multiple formats) + base_autolink_opts = [ + apps: config.apps, + deps: config.deps, + extras: extra_paths(config), + language: language, + skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on, + skip_code_autolink_to: config.skip_code_autolink_to + ] + + extras = + config.extras + |> Task.async_stream( + &build_extra_node(&1, groups, language, base_autolink_opts, source_url_pattern), + timeout: :infinity + ) + |> Enum.map(&elem(&1, 1)) + + ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end)) + + extras + |> Enum.map_reduce(1, fn extra, idx -> + if ids_count[extra.id] > 1, do: {disambiguate_extra_node_id(extra, idx), idx + 1}, else: {extra, idx} + end) + |> elem(0) + |> Enum.sort_by(fn extra -> GroupMatcher.index(groups, extra.group) end) end - # Helper functions + defp build_extra_node( + {input, input_options}, + groups, + language, + base_autolink_opts, + source_url_pattern + ) do + input = to_string(input) + id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id() + source_file = input_options[:source] || input + opts = [file: source_file, line: 1] - defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts), - do: node + {source, ast} = + case extension_name(input) do + extension when extension in ["", ".txt"] -> + source = File.read!(input) + ast = [{:pre, [], "\n" <> source, %{}}] + {source, ast} - defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do - doc = autolink_and_highlight(doc, language, autolink_opts, opts) - %{node | doc: doc} + extension when extension in [".md", ".livemd", ".cheatmd"] -> + source = File.read!(input) + + ast = + source + |> Markdown.to_ast(opts) + |> sectionize(extension) + + {source, ast} + + _ -> + raise ArgumentError, + "file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension" + end + + {title_ast, ast} = + case ExDoc.DocAST.extract_title(ast) do + {:ok, title_ast, ast} -> {title_ast, ast} + :error -> {nil, ast} + end + + title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast) + title_html = title_ast && ExDoc.DocAST.to_string(title_ast) + + # Build content for all formats + content = %{ + ast: ast, + html: autolink_and_render(ast, ".html", language, [file: input, ext: ".html"] ++ base_autolink_opts, opts), + epub: autolink_and_render(ast, ".xhtml", language, [file: input, ext: ".xhtml"] ++ base_autolink_opts, opts), + # For markdown, use the original source to preserve markdown syntax without HTML attributes + markdown: source + } + + group = GroupMatcher.match_extra(groups, input) + title = input_options[:title] || title_text || filename_to_title(input) + + source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "") + source_url = Utils.source_url_pattern(source_url_pattern, source_path, 1) + + %ExDoc.ExtraNode{ + id: id, + title: title, + title_content: title_html || title, + source: source, + source_path: source_path, + source_url: source_url, + group: group, + content: content + } end - defp id(%{id: mod_id}, %{id: "c:" <> id}) do - "c:" <> mod_id <> "." <> id + defp build_extra_node(input, groups, language, base_autolink_opts, source_url_pattern) do + build_extra_node({input, []}, groups, language, base_autolink_opts, source_url_pattern) end - defp id(%{id: mod_id}, %{id: "t:" <> id}) do - "t:" <> mod_id <> "." <> id + defp disambiguate_extra_node_id(%ExDoc.ExtraNode{} = extra, idx) do + %{extra | id: "#{extra.id}-#{idx}"} end - defp id(%{id: mod_id}, %{id: id}) do - mod_id <> "." <> id + defp build_extra( + {input, input_options}, + groups, + ext, + language, + autolink_opts, + source_url_pattern + ) do + input = to_string(input) + id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id() + source_file = input_options[:source] || input + opts = [file: source_file, line: 1] + + {source, ast} = + case extension_name(input) do + extension when extension in ["", ".txt"] -> + source = File.read!(input) + ast = [{:pre, [], "\n" <> source, %{}}] + {source, ast} + + extension when extension in [".md", ".livemd", ".cheatmd"] -> + source = File.read!(input) + + ast = + source + |> Markdown.to_ast(opts) + |> sectionize(extension) + + {source, ast} + + _ -> + raise ArgumentError, + "file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension" + end + + {title_ast, ast} = + case ExDoc.DocAST.extract_title(ast) do + {:ok, title_ast, ast} -> {title_ast, ast} + :error -> {nil, ast} + end + + title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast) + title_html = title_ast && ExDoc.DocAST.to_string(title_ast) + content_html = autolink_and_render(ast, ext, language, [file: input] ++ autolink_opts, opts) + + group = GroupMatcher.match_extra(groups, input) + title = input_options[:title] || title_text || filename_to_title(input) + + source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "") + source_url = Utils.source_url_pattern(source_url_pattern, source_path, 1) + + %{ + source: source, + content: content_html, + group: group, + id: id, + source_path: source_path, + source_url: source_url, + title: title, + title_content: title_html || title + } end - defp autolink_and_highlight(doc, language, autolink_opts, opts) do - doc - |> language.autolink_doc(autolink_opts) - |> ExDoc.DocAST.highlight(language, opts) + defp build_extra(input, groups, ext, language, autolink_opts, source_url_pattern) do + build_extra({input, []}, groups, ext, language, autolink_opts, source_url_pattern) + end + + def filter_list(:module, nodes) do + Enum.filter(nodes, &(&1.type != :task)) + end + + def filter_list(type, nodes) do + Enum.filter(nodes, &(&1.type == type)) end defp extra_paths(config) do @@ -267,6 +462,16 @@ defmodule ExDoc.Formatter do input |> Path.basename() |> Path.rootname() end + defp sectionize(ast, ".cheatmd") do + ExDoc.DocAST.sectionize(ast, fn + {:h2, _, _, _} -> true + {:h3, _, _, _} -> true + _ -> false + end) + end + + defp sectionize(ast, _), do: ast + defp extra_type(".cheatmd"), do: :cheatmd defp extra_type(".livemd"), do: :livemd defp extra_type(_), do: :extra @@ -360,4 +565,5 @@ defmodule ExDoc.Formatter do raise ArgumentError, "image format not recognized, allowed formats are: .png, .jpg, .svg" end end +>>>>>>> origin/main end diff --git a/lib/ex_doc/formatter/epub.ex b/lib/ex_doc/formatter/epub.ex index 730efe458..0c0e41ec9 100644 --- a/lib/ex_doc/formatter/epub.ex +++ b/lib/ex_doc/formatter/epub.ex @@ -11,6 +11,50 @@ defmodule ExDoc.Formatter.EPUB do """ @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t() def run(project_nodes, filtered_modules, config) when is_map(config) do + # Legacy implementation - build extras inline + extras = + config + |> Formatter.build_extras(".xhtml") + |> Enum.chunk_by(& &1.group) + |> Enum.map(&{hd(&1).group, &1}) + + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + @doc """ + Generates EPUB documentation using pre-built ExtraNode structures. + + This is the new architecture that accepts pre-processed extras to eliminate + duplicate work when multiple formatters are used. + """ + @spec run_with_extra_nodes([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], [ExDoc.ExtraNode.t()], ExDoc.Config.t()) :: String.t() + def run_with_extra_nodes(project_nodes, filtered_modules, extra_nodes, config) when is_map(config) do + # Convert ExtraNode structures to the format expected by EPUB formatter + extras = extra_nodes_to_epub_extras(extra_nodes) + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + # Convert ExtraNode structures to the format expected by EPUB formatter + defp extra_nodes_to_epub_extras(extra_nodes) do + extra_nodes + |> Enum.map(fn %ExDoc.ExtraNode{} = node -> + %{ + source: node.source, + content: ExDoc.ExtraNode.content_for_format(node, :epub), + group: node.group, + id: node.id, + source_path: node.source_path, + source_url: node.source_url, + title: node.title, + title_content: node.title_content + } + end) + |> Enum.chunk_by(& &1.group) + |> Enum.map(&{hd(&1).group, &1}) + end + + # Common implementation used by both legacy and new architecture + defp run_with_extras(project_nodes, filtered_modules, extras, config) do config = normalize_config(config) File.rm_rf!(config.output) File.mkdir_p!(Path.join(config.output, "OEBPS")) @@ -25,12 +69,6 @@ defmodule ExDoc.Formatter.EPUB do tasks: Formatter.filter_list(:task, project_nodes) } - extras = - config - |> Formatter.build_extras(".xhtml") - |> Enum.chunk_by(& &1.group) - |> Enum.map(&{hd(&1).group, &1}) - config = %{config | extras: extras} static_files = Formatter.generate_assets("OEBPS", default_assets(config), config) diff --git a/lib/ex_doc/formatter/html.ex b/lib/ex_doc/formatter/html.ex index e29171b7a..5961bf1f3 100644 --- a/lib/ex_doc/formatter/html.ex +++ b/lib/ex_doc/formatter/html.ex @@ -12,6 +12,42 @@ defmodule ExDoc.Formatter.HTML do """ @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t() def run(project_nodes, filtered_modules, config) when is_map(config) do + # Legacy implementation - build extras inline + extras = Formatter.build_extras(config, ".html") + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + @doc """ + Generates HTML documentation using pre-built ExtraNode structures. + + This is the new architecture that accepts pre-processed extras to eliminate + duplicate work when multiple formatters are used. + """ + @spec run_with_extra_nodes([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], [ExDoc.ExtraNode.t()], ExDoc.Config.t()) :: String.t() + def run_with_extra_nodes(project_nodes, filtered_modules, extra_nodes, config) when is_map(config) do + # Convert ExtraNode structures to the format expected by HTML formatter + extras = extra_nodes_to_html_extras(extra_nodes) + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + # Convert ExtraNode structures to the format expected by HTML formatter + defp extra_nodes_to_html_extras(extra_nodes) do + Enum.map(extra_nodes, fn %ExDoc.ExtraNode{} = node -> + %{ + source: node.source, + content: ExDoc.ExtraNode.content_for_format(node, :html), + group: node.group, + id: node.id, + source_path: node.source_path, + source_url: node.source_url, + title: node.title, + title_content: node.title_content + } + end) + end + + # Common implementation used by both legacy and new architecture + defp run_with_extras(project_nodes, filtered_modules, extras, config) do config = normalize_config(config) config = %{config | output: Path.expand(config.output)} @@ -19,7 +55,6 @@ defmodule ExDoc.Formatter.HTML do output_setup(build, config) project_nodes = Formatter.render_all(project_nodes, filtered_modules, ".html", config, []) - extras = Formatter.build_extras(config, ".html") static_files = Formatter.generate_assets(".", default_assets(config), config) search_data = generate_search_data(project_nodes, extras, config) @@ -30,6 +65,9 @@ defmodule ExDoc.Formatter.HTML do tasks: Formatter.filter_list(:task, project_nodes) } + # Generate markdown files alongside HTML + markdown_files = generate_markdown_files(project_nodes, filtered_modules, config) + all_files = search_data ++ static_files ++ @@ -42,7 +80,9 @@ defmodule ExDoc.Formatter.HTML do generate_not_found(config) ++ generate_list(nodes_map.modules, config) ++ generate_list(nodes_map.tasks, config) ++ - generate_redirects(config, ".html") + generate_redirects(config, ".html") ++ + generate_llm_index(nodes_map, extras, config) ++ + markdown_files generate_build(all_files, build) config.output |> Path.join("index.html") |> Path.relative_to_cwd() @@ -150,7 +190,7 @@ defmodule ExDoc.Formatter.HTML do defp copy_extras(config, extras) do for %{source_path: source_path, id: id} when source_path != nil <- extras, - ext = extension_name(source_path), + ext = Formatter.extension_name(source_path), ext == ".livemd" do output = "#{config.output}/#{id}#{ext}" @@ -272,4 +312,130 @@ defmodule ExDoc.Formatter.HTML do config end end + + defp generate_llm_index(nodes_map, extras, config) do + content = generate_llm_index_content(nodes_map, extras, config) + File.write!("#{config.output}/llms.txt", content) + ["llms.txt"] + end + + defp generate_llm_index_content(nodes_map, extras, config) do + project_info = """ + # #{config.project} #{config.version} + + #{config.project} documentation index for Large Language Models. + + ## Modules + + """ + + modules_info = + nodes_map.modules + |> Enum.map(fn module_node -> + "- **#{module_node.title}** (#{module_node.id}.html): #{module_node.doc |> extract_summary()}" + end) + |> Enum.join("\n") + + tasks_info = if length(nodes_map.tasks) > 0 do + tasks_list = + nodes_map.tasks + |> Enum.map(fn task_node -> + "- **#{task_node.title}** (#{task_node.id}.html): #{task_node.doc |> extract_summary()}" + end) + |> Enum.join("\n") + + "\n\n## Mix Tasks\n\n" <> tasks_list + else + "" + end + + extras_info = if length(extras) > 0 do + extras_list = + extras + |> Enum.map(fn extra -> + "- **#{extra.title}** (#{extra.id}.html): #{extra.title}" + end) + |> Enum.join("\n") + + "\n\n## Guides\n\n" <> extras_list + else + "" + end + + project_info <> modules_info <> tasks_info <> extras_info + end + + defp extract_summary(nil), do: "No documentation available" + defp extract_summary(""), do: "No documentation available" + defp extract_summary(doc) when is_binary(doc) do + doc + |> String.split("\n") + |> Enum.find("", fn line -> String.trim(line) != "" end) + |> String.trim() + |> case do + "" -> "No documentation available" + summary -> summary |> String.slice(0, 150) |> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end) + end + end + defp extract_summary(doc_ast) when is_list(doc_ast) do + # For DocAST (which is a list), extract the first text node + extract_first_text_from_ast(doc_ast) + end + defp extract_summary(_), do: "No documentation available" + + defp extract_first_text_from_ast([]), do: "No documentation available" + defp extract_first_text_from_ast([{:p, _, content} | _rest]) do + extract_text_from_content(content) |> String.slice(0, 150) |> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end) + end + defp extract_first_text_from_ast([_node | rest]) do + extract_first_text_from_ast(rest) + end + + defp extract_text_from_content([]), do: "" + defp extract_text_from_content([text | _rest]) when is_binary(text), do: text + defp extract_text_from_content([{_tag, _attrs, content} | rest]) do + case extract_text_from_content(content) do + "" -> extract_text_from_content(rest) + text -> text + end + end + defp extract_text_from_content([_ | rest]) do + extract_text_from_content(rest) + end + + # Markdown generation functions + + defp generate_markdown_files(project_nodes, filtered_modules, config) do + # Create markdown subdirectory + markdown_dir = Path.join(config.output, "markdown") + File.mkdir_p!(markdown_dir) + + # Configure for markdown generation + markdown_config = %{config | + output: markdown_dir, + formatter: "markdown" + } + + # Generate markdown docs using the new ExtraNode architecture for consistency + try do + # Use the same architecture path as the main generation + extra_nodes = ExDoc.ExtraNode.build_extras(markdown_config) + ExDoc.Formatter.MARKDOWN.run_with_extra_nodes(project_nodes, filtered_modules, extra_nodes, markdown_config) + + # List all generated markdown files for build tracking + markdown_files = + markdown_dir + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".md")) + + # Prefix all paths with "markdown/" for the build file tracking + Enum.map(markdown_files, &("markdown/" <> &1)) + rescue + e -> + # If markdown generation fails, log and continue without markdown files + require Logger + Logger.warning("Failed to generate markdown files: #{inspect(e)}") + [] + end + end end diff --git a/lib/ex_doc/formatter/html/templates.ex b/lib/ex_doc/formatter/html/templates.ex index 91c4ced07..1cc1e4eb7 100644 --- a/lib/ex_doc/formatter/html/templates.ex +++ b/lib/ex_doc/formatter/html/templates.ex @@ -167,6 +167,35 @@ defmodule ExDoc.Formatter.HTML.Templates do defp sidebar_type(:livemd), do: "extras" defp sidebar_type(:extra), do: "extras" + def asset_rev(output, pattern) do + output = Path.expand(output) + + output + |> Path.join(pattern) + |> Path.wildcard() + |> relative_asset(output, pattern) + end + + defp relative_asset([], output, pattern), + do: raise("could not find matching #{output}/#{pattern}") + + defp relative_asset([h | _], output, _pattern), do: Path.relative_to(h, output) + + defp get_hex_url(config, source_path) do + case config.package do + nil -> + nil + + package -> + base_url = "https://preview.hex.pm/preview/#{package}/#{config.version}" + if source_path, do: "#{base_url}/show/#{source_path}", else: base_url + end + end + + defp get_markdown_path(node) do + if node && node.id, do: URI.encode(node.id), else: "index" + end + @section_header_class_name "section-heading" @doc """ diff --git a/lib/ex_doc/formatter/html/templates/extra_template.eex b/lib/ex_doc/formatter/html/templates/extra_template.eex index 8ea894ca1..9f696573a 100644 --- a/lib/ex_doc/formatter/html/templates/extra_template.eex +++ b/lib/ex_doc/formatter/html/templates/extra_template.eex @@ -16,8 +16,17 @@ View Source <% end %> + + + + View as Markdown + + <%= if node.type == :livemd do %>
diff --git a/lib/ex_doc/formatter/html/templates/footer_template.eex b/lib/ex_doc/formatter/html/templates/footer_template.eex index 8d6d1b4e5..85f0a952d 100644 --- a/lib/ex_doc/formatter/html/templates/footer_template.eex +++ b/lib/ex_doc/formatter/html/templates/footer_template.eex @@ -13,15 +13,37 @@ <% end %> + <% + hex_url = get_hex_url(config, node && (Map.get(node, :source_path) || Map.get(node, :moduledoc_file))) + source_url = node && node.source_url + %> + <%= if hex_url || source_url do %> + View Code: + <%= if source_url do %> + Source Repo + <% end %> + + <%= if hex_url do %> + Hex Preview + <% end %> + <% end %> + + <%= if "markdown" in config.formatters do %> + + View Markdown version + + <% end %> + + <%= if config.package do %> + + Download docs archive + + <% end %> + - <%= if "epub" in config.formatters do %> - - Download ePub version - - <% end %>

diff --git a/lib/ex_doc/formatter/html/templates/module_template.eex b/lib/ex_doc/formatter/html/templates/module_template.eex index fd3f7b4f4..82d3f4e72 100644 --- a/lib/ex_doc/formatter/html/templates/module_template.eex +++ b/lib/ex_doc/formatter/html/templates/module_template.eex @@ -16,8 +16,17 @@ View Source <% end %> + + + + View as Markdown +
+ <%= if deprecated = module.deprecated do %>
This <%= module.type %> is deprecated. <%= h(deprecated) %>. diff --git a/lib/ex_doc/formatter/markdown.ex b/lib/ex_doc/formatter/markdown.ex new file mode 100644 index 000000000..3535f2804 --- /dev/null +++ b/lib/ex_doc/formatter/markdown.ex @@ -0,0 +1,227 @@ +defmodule ExDoc.Formatter.MARKDOWN do + @moduledoc false + + alias __MODULE__.{Templates} + alias ExDoc.Formatter + alias ExDoc.Utils + + @doc """ + Generates Markdown documentation for the given modules. + """ + @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t() + def run(project_nodes, filtered_modules, config) when is_map(config) do + # Legacy implementation - build extras inline + extras = Formatter.build_extras(config, ".md") + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + @doc """ + Generates Markdown documentation using pre-built ExtraNode structures. + + This is the new architecture that accepts pre-processed extras to eliminate + duplicate work when multiple formatters are used. + """ + @spec run_with_extra_nodes([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], [ExDoc.ExtraNode.t()], ExDoc.Config.t()) :: String.t() + def run_with_extra_nodes(project_nodes, filtered_modules, extra_nodes, config) when is_map(config) do + # Convert ExtraNode structures to the format expected by Markdown formatter + extras = extra_nodes_to_markdown_extras(extra_nodes) + run_with_extras(project_nodes, filtered_modules, extras, config) + end + + # Convert ExtraNode structures to the format expected by Markdown formatter + defp extra_nodes_to_markdown_extras(extra_nodes) do + extra_nodes + |> Enum.map(fn %ExDoc.ExtraNode{} = node -> + # Note: Markdown formatter's generate_extras expects 'source' to contain processed markdown content + processed_content = ExDoc.ExtraNode.content_for_format(node, :markdown) + %{ + source: processed_content, # This is what gets written to .md files + content: processed_content, + group: node.group, + id: node.id, + source_path: node.source_path, + source_url: node.source_url, + title: node.title, + title_content: node.title_content + } + end) + |> Enum.chunk_by(& &1.group) + |> Enum.map(&{hd(&1).group, &1}) + end + + # Common implementation used by both legacy and new architecture + defp run_with_extras(project_nodes, filtered_modules, extras, config) do + Utils.unset_warned() + + config = normalize_config(config) + File.rm_rf!(config.output) + File.mkdir_p!(config.output) + + project_nodes = + project_nodes + |> Formatter.render_all(filtered_modules, ".md", config, highlight_tag: "samp") + + nodes_map = %{ + modules: Formatter.filter_list(:module, project_nodes), + tasks: Formatter.filter_list(:task, project_nodes) + } + + config = %{config | extras: extras} + + generate_nav(config, nodes_map) + generate_extras(config) + generate_list(config, nodes_map.modules) + generate_list(config, nodes_map.tasks) + generate_llm_index(config, nodes_map) + + config.output |> Path.join("index.md") |> Path.relative_to_cwd() + end + + defp normalize_config(config) do + output = + config.output + |> Path.expand() + |> Path.join("markdown") + + %{config | output: output} + end + + defp normalize_output(output) do + output + |> String.replace(~r/\r\n|\r|\n/, "\n") + |> String.replace(~r/\n{3,}/, "\n\n") + end + + defp generate_nav(config, nodes) do + nodes = + Map.update!(nodes, :modules, fn modules -> + modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1}) + end) + + content = + Templates.nav_template(config, nodes) + |> normalize_output() + + File.write("#{config.output}/index.md", content) + end + + defp generate_extras(config) do + for {_title, extras} <- config.extras do + Enum.each(extras, fn %{id: id, source: content} -> + output = "#{config.output}/#{id}.md" + + if File.regular?(output) do + Utils.warn("file #{Path.relative_to_cwd(output)} already exists", []) + end + + File.write!(output, normalize_output(content)) + end) + end + end + + defp generate_list(config, nodes) do + nodes + |> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity) + |> Enum.map(&elem(&1, 1)) + end + + ## Helpers + + defp generate_module_page(module_node, config) do + content = + Templates.module_page(config, module_node) + |> normalize_output() + + File.write("#{config.output}/#{module_node.id}.md", content) + end + + defp generate_llm_index(config, nodes_map) do + content = generate_llm_index_content(config, nodes_map) + File.write("#{config.output}/llms.txt", content) + end + + defp generate_llm_index_content(config, nodes_map) do + project_info = """ + # #{config.project} #{config.version} + + #{config.project} documentation index for Large Language Models. + + ## Modules + + """ + + modules_info = + nodes_map.modules + |> Enum.map(fn module_node -> + "- **#{module_node.title}** (#{module_node.id}.md): #{module_node.doc |> extract_summary()}" + end) + |> Enum.join("\n") + + tasks_info = if length(nodes_map.tasks) > 0 do + tasks_list = + nodes_map.tasks + |> Enum.map(fn task_node -> + "- **#{task_node.title}** (#{task_node.id}.md): #{task_node.doc |> extract_summary()}" + end) + |> Enum.join("\n") + + "\n\n## Mix Tasks\n\n" <> tasks_list + else + "" + end + + extras_info = if is_list(config.extras) and length(config.extras) > 0 do + extras_list = + config.extras + |> Enum.flat_map(fn {_group, extras} -> extras end) + |> Enum.map(fn extra -> + "- **#{extra.title}** (#{extra.id}.md): #{extra.title}" + end) + |> Enum.join("\n") + + "\n\n## Guides\n\n" <> extras_list + else + "" + end + + project_info <> modules_info <> tasks_info <> extras_info + end + + defp extract_summary(nil), do: "No documentation available" + defp extract_summary(""), do: "No documentation available" + defp extract_summary(doc) when is_binary(doc) do + doc + |> String.split("\n") + |> Enum.find("", fn line -> String.trim(line) != "" end) + |> String.trim() + |> case do + "" -> "No documentation available" + summary -> summary |> String.slice(0, 150) |> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end) + end + end + defp extract_summary(doc_ast) when is_list(doc_ast) do + # For DocAST (which is a list), extract the first text node + extract_first_text_from_ast(doc_ast) + end + defp extract_summary(_), do: "No documentation available" + + defp extract_first_text_from_ast([]), do: "No documentation available" + defp extract_first_text_from_ast([{:p, _, content} | _rest]) do + extract_text_from_content(content) |> String.slice(0, 150) |> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end) + end + defp extract_first_text_from_ast([_node | rest]) do + extract_first_text_from_ast(rest) + end + + defp extract_text_from_content([]), do: "" + defp extract_text_from_content([text | _rest]) when is_binary(text), do: text + defp extract_text_from_content([{_tag, _attrs, content} | rest]) do + case extract_text_from_content(content) do + "" -> extract_text_from_content(rest) + text -> text + end + end + defp extract_text_from_content([_node | rest]) do + extract_text_from_content(rest) + end +end diff --git a/lib/ex_doc/formatter/markdown/templates.ex b/lib/ex_doc/formatter/markdown/templates.ex new file mode 100644 index 000000000..8b7cd8222 --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates.ex @@ -0,0 +1,155 @@ +defmodule ExDoc.Formatter.MARKDOWN.Templates do + @moduledoc false + + require EEx + + import ExDoc.Utils, + only: [before_closing_body_tag: 2, h: 1, text_to_id: 1] + + alias ExDoc.Formatter.HTML.Templates, as: H + + @doc """ + Generate content from the module template for a given `node` + """ + def module_page(config, module_node) do + summary = H.module_summary(module_node) + module_template(config, module_node, summary) + end + + @doc """ + Returns the formatted title for the module page. + """ + def module_type(%{type: :task}), do: "" + def module_type(%{type: :module}), do: "" + def module_type(%{type: type}), do: "(#{type})" + + @doc """ + Generated ID for static file + """ + def static_file_to_id(static_file) do + static_file |> Path.basename() |> text_to_id() + end + + def node_doc(%{source_doc: %{"en" => source}}) when is_binary(source), do: source + def node_doc(%{rendered_doc: source}) when is_binary(source), do: source + def node_doc(%{source_doc: %{"en" => source}}) when is_list(source) do + # Handle DocAST by converting to markdown + # For Erlang docs, we can extract text content + extract_text_from_doc_ast(source) + end + def node_doc(_), do: nil + + defp extract_text_from_doc_ast(ast) when is_list(ast) do + Enum.map_join(ast, "\n\n", &extract_text_from_doc_ast/1) + end + defp extract_text_from_doc_ast({_tag, _attrs, content}) when is_list(content) do + Enum.map_join(content, "", &extract_text_from_doc_ast/1) + end + defp extract_text_from_doc_ast({_tag, _attrs, content, _meta}) when is_list(content) do + Enum.map_join(content, "", &extract_text_from_doc_ast/1) + end + defp extract_text_from_doc_ast(text) when is_binary(text), do: text + defp extract_text_from_doc_ast(_), do: "" + + @doc """ + Gets the first paragraph of the documentation of a node. It strips + surrounding white-spaces and trailing `:`. + + If `doc` is `nil`, it returns `nil`. + """ + @spec synopsis(String.t()) :: String.t() + @spec synopsis(nil) :: nil + def synopsis(doc) when is_binary(doc) do + case :binary.split(doc, "\n\n") do + [left, _] -> String.trim_trailing(left, ": ") <> "\n\n" + [all] -> all + end + end + + def synopsis(_), do: nil + + @heading_regex ~r/^(\#{1,6})\s+(.*)/m + defp rewrite_headings(content) when is_binary(content) do + @heading_regex + |> Regex.scan(content) + |> Enum.reduce(content, fn [match, level, title], content -> + replacement = rewrite_heading(level, title) + String.replace(content, match, replacement, global: false) + end) + end + + defp rewrite_headings(_), do: nil + + defp rewrite_heading("#", title), do: do_rewrite_heading("#####", title) + defp rewrite_heading(_, title), do: do_rewrite_heading("######", title) + + defp do_rewrite_heading(level, title) do + """ + #{level} #{title} + """ + end + + defp enc(binary), do: URI.encode(binary) |> String.replace("/", "-") + + @doc """ + Creates a chapter which contains all the details about an individual module. + + This chapter can include the following sections: *functions*, *types*, *callbacks*. + """ + EEx.function_from_file( + :def, + :module_template, + Path.expand("templates/module_template.eex", __DIR__), + [:config, :module, :summary], + trim: true + ) + + @doc """ + Creates the table of contents. + + """ + EEx.function_from_file( + :def, + :nav_template, + Path.expand("templates/nav_template.eex", __DIR__), + [:config, :nodes], + trim: true + ) + + EEx.function_from_file( + :defp, + :nav_item_template, + Path.expand("templates/nav_item_template.eex", __DIR__), + [:name, :nodes], + trim: true + ) + + EEx.function_from_file( + :defp, + :nav_grouped_item_template, + Path.expand("templates/nav_grouped_item_template.eex", __DIR__), + [:nodes], + trim: true + ) + + # EEx.function_from_file( + # :defp, + # :toc_item_template, + # Path.expand("templates/toc_item_template.eex", __DIR__), + # [:nodes], + # trim: true + # ) + + # def media_type(_arg), do: nil + + templates = [ + detail_template: [:node, :module], + summary_template: [:name, :nodes] + ] + + Enum.each(templates, fn {name, args} -> + filename = Path.expand("templates/#{name}.eex", __DIR__) + @doc false + EEx.function_from_file(:def, name, filename, args, trim: true) + end) +end diff --git a/lib/ex_doc/formatter/markdown/templates/detail_template.eex b/lib/ex_doc/formatter/markdown/templates/detail_template.eex new file mode 100644 index 000000000..1842c66d0 --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/detail_template.eex @@ -0,0 +1,17 @@ + +#### `<%=h node.signature %>` <%= if node.source_url do %>[🔗](<%= node.source_url %>)<% end %> <%= for annotation <- node.annotations do %>(<%= annotation %>) <% end %> + +<%= if deprecated = node.deprecated do %> +> This <%= node.type %> is deprecated. <%= h(deprecated) %>. +<% end %> + +<%= if node.specs != [] do %> +<%= for spec <- node.specs do %> +```elixir +<%= H.format_spec_attribute(module, node) %> <%= spec %> +``` +<% end %> +<% end %> + +<%= rewrite_headings(node_doc(node)) %> + diff --git a/lib/ex_doc/formatter/markdown/templates/module_template.eex b/lib/ex_doc/formatter/markdown/templates/module_template.eex new file mode 100644 index 000000000..c21285350 --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/module_template.eex @@ -0,0 +1,36 @@ +# <%= module.title %> <%= module_type(module) %> (<%= config.project %> v<%= config.version %>) + +<%= for annotation <- module.annotations do %>*(<%= annotation %>)* <% end %> + +<%= if deprecated = module.deprecated do %> +> This <%= module.type %> is deprecated. <%=h deprecated %>. +<% end %> + +<%= if doc = node_doc(module) do %> +<%= doc %> +<% end %> + +<%= if summary != [] do %> +## Table of Contents +<%= for {name, nodes} <- summary, do: summary_template(name, nodes) %> +<% end %> + +## Contents + +<%= for {name, nodes} <- summary, _key = text_to_id(name) do %> + +### <%=h to_string(name) %> + +<%= for node <- nodes do %> +<%= detail_template(node, module) %> +<% end %> + +<% end %> + +--- + +<%= if module.source_url do %> +[<%= String.capitalize(to_string(module.type)) %> Source Code](<%= module.source_url %>) +<% end %> + +<%= before_closing_body_tag(config, :markdown) %> diff --git a/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex new file mode 100644 index 000000000..874ebdbfd --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex @@ -0,0 +1,8 @@ +<%= for {title, nodes} <- nodes do %> +<%= if title do %> +- <%=h to_string(title) %> +<% end %> +<%= for node <- nodes do %> + - [<%=h node.title %>](<%= URI.encode node.id %>.md) +<% end %> +<% end %> diff --git a/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex new file mode 100644 index 000000000..449c46e22 --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex @@ -0,0 +1,6 @@ +<%= unless Enum.empty?(nodes) do %> +- <%= name %> +<%= for node <- nodes do %> + - [<%=h node.title %>](<%= URI.encode node.id %>.md) +<% end %> +<% end %> diff --git a/lib/ex_doc/formatter/markdown/templates/nav_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_template.eex new file mode 100644 index 000000000..48f11c99a --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/nav_template.eex @@ -0,0 +1,9 @@ +# <%= config.project %> v<%= config.version %> - Documentation - Table of contents + +<%= nav_grouped_item_template config.extras %> +<%= unless Enum.empty?(nodes.modules) do %> +## Modules +<%= nav_grouped_item_template nodes.modules %> +<% end %> +<%= nav_item_template "Mix Tasks", nodes.tasks %> +<%= before_closing_body_tag(config, :markdown) %> diff --git a/lib/ex_doc/formatter/markdown/templates/summary_template.eex b/lib/ex_doc/formatter/markdown/templates/summary_template.eex new file mode 100644 index 000000000..7d8ffcb7b --- /dev/null +++ b/lib/ex_doc/formatter/markdown/templates/summary_template.eex @@ -0,0 +1,15 @@ +### <%= name %> + +<%= for node <- nodes do %> + +#### [`<%=h node.signature %>`](#<%= enc node.id %>) + +<%= if deprecated = node.deprecated do %> +> <%= h(deprecated) %> +<% end %> + +<%= if doc = node_doc(node) do %> +<%= synopsis(doc) %> +<% end %> + +<% end %> diff --git a/lib/ex_doc/language/elixir.ex b/lib/ex_doc/language/elixir.ex index 3fc826389..2a2ee56ce 100644 --- a/lib/ex_doc/language/elixir.ex +++ b/lib/ex_doc/language/elixir.ex @@ -654,6 +654,14 @@ defmodule ExDoc.Language.Elixir do defp typespec_name({:"::", _, [{name, _, _}, _]}), do: Atom.to_string(name) defp typespec_name({:when, _, [left, _]}), do: typespec_name(left) defp typespec_name({name, _, _}) when is_atom(name), do: Atom.to_string(name) + # Handle case where spec is already a string (possibly pre-processed) + defp typespec_name(spec) when is_binary(spec) do + # Extract the function name from the beginning of the spec string + case Regex.run(~r/^([a-zA-Z_][a-zA-Z0-9_]*[?!]?)/, spec) do + [_, name] -> name + _ -> "unknown" + end + end # extract out function name so we don't process it. This is to avoid linking it when there's # a type with the same name @@ -699,7 +707,11 @@ defmodule ExDoc.Language.Elixir do end if url do - ~s[#{ExDoc.Utils.h(call_string)}] + if config.ext == ".md" do + ~s[\[#{ExDoc.Utils.h(call_string)}\](#{url})] + else + ~s[#{ExDoc.Utils.h(call_string)}] + end else call_string end <> do_typespec(rest, config) diff --git a/lib/ex_doc/language/erlang.ex b/lib/ex_doc/language/erlang.ex index 6b6e7edad..46b64eb92 100644 --- a/lib/ex_doc/language/erlang.ex +++ b/lib/ex_doc/language/erlang.ex @@ -224,6 +224,16 @@ defmodule ExDoc.Language.Erlang do def autolink_spec(ast, opts) do config = struct!(Autolink, opts) + # Handle case where spec is already a string (possibly pre-processed) + case ast do + spec when is_binary(spec) -> + spec # Return the spec as-is if it's already processed + _ -> + autolink_spec_ast(ast, config) + end + end + + defp autolink_spec_ast(ast, config) do {name, anno, quoted} = case ast do {:attribute, anno, kind, {mfa, ast}} when kind in [:spec, :callback] -> @@ -695,7 +705,11 @@ defmodule ExDoc.Language.Erlang do end if url do - ~s|#{string}(| + if config.ext == ".md" do + ~s|[#{string}\](#{url})(| + else + ~s|#{string}(| + end else string <> "(" end diff --git a/lib/mix/tasks/docs.ex b/lib/mix/tasks/docs.ex index 35ca940b9..2c8df232d 100644 --- a/lib/mix/tasks/docs.ex +++ b/lib/mix/tasks/docs.ex @@ -12,9 +12,9 @@ defmodule Mix.Tasks.Docs do * `--canonical`, `-n` - Indicate the preferred URL with `rel="canonical"` link element, defaults to no canonical path - * `--formatter`, `-f` - Which formatters to use, `html` or - `epub`. This option can be given more than once. By default, - both `html` and `epub` are generated. + * `--formatter`, `-f` - Which formatters to use, `html`, + `epub`, or `markdown`. This option can be given more than once. By default, + `html`, `epub`, and `markdown` are generated. * `--language` - Specifies the language to annotate the EPUB output in valid [BCP 47](https://tools.ietf.org/html/bcp47) @@ -130,7 +130,7 @@ defmodule Mix.Tasks.Docs do against the complete module name (which includes the "Elixir." prefix for Elixir modules). If a module has `@moduledoc false`, then it is always excluded. - * `:formatters` - Formatter to use; default: ["html", "epub"], options: "html", "epub". + * `:formatters` - Formatter to use; default: ["html", "epub", "markdown"], options: "html", "epub", "markdown". * `:groups_for_extras`, `:groups_for_modules`, `:groups_for_docs`, and `:default_group_for_doc` - See the "Groups" section @@ -622,7 +622,7 @@ defmodule Mix.Tasks.Docs do defp normalize_formatters(options) do formatters = case Keyword.get_values(options, :formatter) do - [] -> options[:formatters] || ["html", "epub"] + [] -> options[:formatters] || ["html", "epub", "markdown"] values -> values end diff --git a/test/ex_doc/cli_test.exs b/test/ex_doc/cli_test.exs index 72dfb28d2..84cabe2e4 100644 --- a/test/ex_doc/cli_test.exs +++ b/test/ex_doc/cli_test.exs @@ -10,13 +10,13 @@ defmodule ExDoc.CLITest do end test "minimum command-line options" do - {[html, epub], _io} = run(["ExDoc", "1.2.3", @ebin]) + {[html, epub, markdown], _io} = run(["ExDoc", "1.2.3", @ebin]) assert html == {"ExDoc", "1.2.3", [ formatter: "html", - formatters: ["html", "epub"], + formatters: ["html", "epub", "markdown"], apps: [:ex_doc], source_beam: [@ebin] ]} @@ -25,7 +25,16 @@ defmodule ExDoc.CLITest do {"ExDoc", "1.2.3", [ formatter: "epub", - formatters: ["html", "epub"], + formatters: ["html", "epub", "markdown"], + apps: [:ex_doc], + source_beam: @ebin + ]} + + assert markdown == + {"ExDoc", "1.2.3", + [ + formatter: "markdown", + formatters: ["html", "epub", "markdown"], apps: [:ex_doc], source_beam: [@ebin] ]} diff --git a/test/ex_doc/formatter/html_test.exs b/test/ex_doc/formatter/html_test.exs index 0f3e7b05c..ab9727f68 100644 --- a/test/ex_doc/formatter/html_test.exs +++ b/test/ex_doc/formatter/html_test.exs @@ -17,12 +17,22 @@ defmodule ExDoc.Formatter.HTMLTest do @before_closing_footer_tag_content_html "UNIQUE:©BEFORE-CLOSING-FOOTER-TAG-EPUB" defp before_closing_head_tag(:html), do: @before_closing_head_tag_content_html + defp before_closing_head_tag(:markdown), do: "" + defp before_closing_body_tag(:html), do: @before_closing_body_tag_content_html + defp before_closing_body_tag(:markdown), do: "" + defp before_closing_footer_tag(:html), do: @before_closing_footer_tag_content_html + defp before_closing_footer_tag(:markdown), do: "" def before_closing_head_tag(:html, name), do: "" + def before_closing_head_tag(:markdown, name), do: "" + def before_closing_body_tag(:html, name), do: "

#{name}

" + def before_closing_body_tag(:markdown, name), do: "" + def before_closing_footer_tag(:html, name), do: "

#{name}

" + def before_closing_footer_tag(:markdown, name), do: "" defp doc_config(%{tmp_dir: tmp_dir} = _context) do [ @@ -1036,4 +1046,16 @@ defmodule ExDoc.Formatter.HTMLTest do after File.rm_rf!("test/tmp/html_assets") end + + test "generates llms.txt index file", %{tmp_dir: tmp_dir} = context do + generate_docs(doc_config(context)) + + assert File.regular?(tmp_dir <> "/html/llms.txt") + content = File.read!(tmp_dir <> "/html/llms.txt") + + assert content =~ "# Elixir 1.0.1" + assert content =~ "documentation index for Large Language Models" + assert content =~ "## Modules" + assert content =~ "**CompiledWithDocs** (CompiledWithDocs.html):" + end end diff --git a/test/ex_doc/formatter/markdown/templates_test.exs b/test/ex_doc/formatter/markdown/templates_test.exs new file mode 100644 index 000000000..0a442ed4e --- /dev/null +++ b/test/ex_doc/formatter/markdown/templates_test.exs @@ -0,0 +1,122 @@ +defmodule ExDoc.Formatter.MARKDOWN.TemplatesTest do + use ExUnit.Case, async: true + + alias ExDoc.Formatter.MARKDOWN.Templates + + defp source_url do + "https://github.com/elixir-lang/elixir" + end + + defp homepage_url do + "https://elixir-lang.org" + end + + defp doc_config(config \\ []) do + default = %ExDoc.Config{ + project: "Elixir", + version: "1.0.1", + source_url_pattern: "#{source_url()}/blob/master/%{path}#L%{line}", + homepage_url: homepage_url(), + source_url: source_url(), + output: "test/tmp/markdown_templates" + } + + struct(default, config) + end + + defp get_module_page(names, config \\ []) do + config = doc_config(config) + {mods, []} = ExDoc.Retriever.docs_from_modules(names, config) + [mod | _] = ExDoc.Formatter.render_all(mods, [], ".md", config, highlight_tag: "samp") + Templates.module_page(config, mod) + end + + setup_all do + # File.mkdir_p!("test/tmp/markdown_templates") + # File.cp_r!("formatters/markdown", "test/tmp/markdown_templates") + :ok + end + + describe "module_page/2" do + test "generates only the module name when there's no more info" do + module_node = %ExDoc.ModuleNode{ + module: XPTOModule, + doc: nil, + id: "XPTOModule", + title: "XPTOModule" + } + + content = Templates.module_page(doc_config(), module_node) + + assert content =~ ~r{#\s*XPTOModule\s*} + end + + test "outputs the functions and docstrings" do + content = get_module_page([CompiledWithDocs]) + + assert content =~ ~r{#\s*CompiledWithDocs\s*} + + assert content =~ ~s{## Table of Contents} + + assert content =~ + ~r{\n## .*Example.*}ms + + assert content =~ + ~r{\n### .*Example H3 heading.*}ms + + assert content =~ + ~r{moduledoc.*Example.*CompiledWithDocs\.example.*}ms + + assert content =~ ~r{Some example}ms + assert content =~ ~r{example_without_docs().*}ms + assert content =~ ~r{example_1().* \(macro\)}ms + + assert content =~ ~s{example(foo, bar \\\\ Baz)} + end + + test "outputs function groups" do + content = + get_module_page([CompiledWithDocs], + groups_for_docs: [ + "Example functions": &(&1[:purpose] == :example), + Legacy: &is_binary(&1[:deprecated]) + ] + ) + + assert content =~ ~r{.*Example functions}ms + assert content =~ ~r{.*Legacy}ms + end + + ## BEHAVIOURS + + test "outputs behavior and callbacks" do + content = get_module_page([CustomBehaviourOne]) + + assert content =~ + ~r{# CustomBehaviourOne \(behaviour\)}m + + assert content =~ ~r{Callbacks} + + content = get_module_page([CustomBehaviourTwo]) + + assert content =~ + ~r{# CustomBehaviourTwo \(behaviour\)}m + + assert content =~ ~r{Callbacks} + end + + ## PROTOCOLS + + test "outputs the protocol type" do + content = get_module_page([CustomProtocol]) + assert content =~ ~r{# CustomProtocol \(protocol\)}m + end + + ## TASKS + + test "outputs the task type" do + content = get_module_page([Mix.Tasks.TaskWithDocs]) + assert content =~ ~r{#\s*mix task_with_docs}m + end + end +end diff --git a/test/ex_doc/formatter/markdown_test.exs b/test/ex_doc/formatter/markdown_test.exs new file mode 100644 index 000000000..69b24b3de --- /dev/null +++ b/test/ex_doc/formatter/markdown_test.exs @@ -0,0 +1,86 @@ +defmodule ExDoc.Formatter.MARKDOWNTest do + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + @before_closing_body_tag_content_md "UNIQUE:©BEFORE-CLOSING-BODY-TAG-MARKDOWN" + + def before_closing_body_tag(:markdown), do: @before_closing_body_tag_content_md + def before_closing_body_tag(:markdown, name), do: "#{name}" + + defp doc_config(%{tmp_dir: tmp_dir} = _context) do + [ + app: :elixir, + project: "Elixir", + version: "1.0.1", + formatter: "markdown", + output: tmp_dir, + source_beam: "test/tmp/beam", + extras: ["test/fixtures/README.md"], + skip_undefined_reference_warnings_on: ["Warnings"] + ] + end + + defp doc_config(context, config) when is_map(context) and is_list(config) do + Keyword.merge(doc_config(context), config) + end + + defp generate_docs(config) do + ExDoc.generate_docs(config[:project], config[:version], config) + end + + defp generate_docs(_context, config) do + generate_docs(config) + end + + test "generates a markdown index file in the default directory", + %{tmp_dir: tmp_dir} = context do + generate_docs(doc_config(context)) + assert File.regular?(tmp_dir <> "/markdown/index.md") + end + + test "generates a markdown file with erlang as proglang", %{tmp_dir: tmp_dir} = context do + config = + context + |> doc_config() + |> Keyword.put(:proglang, :erlang) + |> Keyword.update!(:skip_undefined_reference_warnings_on, &["test/fixtures/README.md" | &1]) + + generate_docs(config) + assert File.regular?(tmp_dir <> "/markdown/index.md") + end + + test "generates a markdown file in specified output directory", %{tmp_dir: tmp_dir} = context do + config = doc_config(context, output: tmp_dir <> "/another_dir", main: "RandomError") + generate_docs(config) + + assert File.regular?(tmp_dir <> "/another_dir/markdown/index.md") + end + + test "generates the readme file", %{tmp_dir: tmp_dir} = context do + config = doc_config(context, main: "README") + generate_docs(context, config) + + content = File.read!(tmp_dir <> "/markdown/readme.md") + assert content =~ ~r{`RandomError`\n} + + assert content =~ + ~r{\n`CustomBehaviourImpl.hello/1`\n} + + assert content =~ + ~r{\n`TypesAndSpecs.Sub`\n} + + content = File.read!(tmp_dir <> "/markdown/index.md") + assert content =~ "Table of contents\n\n - [README](readme.md)" + end + + test "includes before_closing_body_tag content", %{tmp_dir: tmp_dir} = context do + generate_docs(doc_config(context, + before_closing_body_tag: &before_closing_body_tag/1, + extras: ["test/fixtures/README.md"] + )) + + content = File.read!(tmp_dir <> "/markdown/index.md") + assert content =~ @before_closing_body_tag_content_md + end +end diff --git a/test/ex_doc/language/erlang_test.exs b/test/ex_doc/language/erlang_test.exs index b3f95396f..25140f323 100644 --- a/test/ex_doc/language/erlang_test.exs +++ b/test/ex_doc/language/erlang_test.exs @@ -799,8 +799,8 @@ defmodule ExDoc.Language.ErlangTest do end test "function - any", c do - assert autolink_spec(~s"-spec foo() -> fun() | t().", c) == - ~s[foo() -> fun() | t().] + assert autolink_spec(~s"-spec foo() -> fun((...) -> any()) | t().", c) == + ~s[foo() -> fun((...) -> any()) | t().] end test "function - any arity", c do diff --git a/test/ex_doc_test.exs b/test/ex_doc_test.exs index 525b60f70..23571ec35 100644 --- a/test/ex_doc_test.exs +++ b/test/ex_doc_test.exs @@ -15,6 +15,10 @@ defmodule ExDocTest do def run(modules, _filtered, config) do {modules, config} end + + def run_with_extra_nodes(modules, _filtered, _extra_nodes, config) do + {modules, config} + end end test "uses custom markdown processor", %{tmp_dir: tmp_dir} do diff --git a/test/mix/tasks/docs_test.exs b/test/mix/tasks/docs_test.exs index 6019b62a0..cfd57c75e 100644 --- a/test/mix/tasks/docs_test.exs +++ b/test/mix/tasks/docs_test.exs @@ -28,7 +28,7 @@ defmodule Mix.Tasks.DocsTest do {"ex_doc", "0.1.0", [ formatter: "html", - formatters: ["html", "epub"], + formatters: ["html", "epub", "markdown"], deps: _, apps: _, source_beam: _, @@ -37,7 +37,16 @@ defmodule Mix.Tasks.DocsTest do {"ex_doc", "0.1.0", [ formatter: "epub", - formatters: ["html", "epub"], + formatters: ["html", "epub", "markdown"], + deps: _, + apps: _, + source_beam: _, + proglang: :elixir + ]}, + {"ex_doc", "0.1.0", + [ + formatter: "markdown", + formatters: ["html", "epub", "markdown"], deps: _, apps: _, source_beam: _, @@ -116,6 +125,15 @@ defmodule Mix.Tasks.DocsTest do apps: _, source_beam: _, proglang: :elixir + ]}, + {"ExDoc", "0.1.0", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, version: "0.1.0", name: "ExDoc") end @@ -141,6 +159,16 @@ defmodule Mix.Tasks.DocsTest do apps: _, source_beam: _, proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + main: "Sample", + apps: _, + source_beam: _, + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, docs: [main: Sample]) end @@ -166,12 +194,22 @@ defmodule Mix.Tasks.DocsTest do source_beam: _, main: "another", proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + main: "another", + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, docs: [main: "another"]) end test "accepts output in :output", %{tmp_dir: tmp_dir} = context do - [{_, _, html_options}, {_, _, epub_options}] = + [{_, _, html_options}, {_, _, epub_options}, {_, _, markdown_options}] = run_results = run(context, [], app: :ex_doc, docs: [output: tmp_dir <> "/hello"]) assert [ @@ -194,17 +232,28 @@ defmodule Mix.Tasks.DocsTest do source_beam: _, output: _, proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + output: _, + proglang: :elixir ]} ] = run_results assert html_options[:output] == "#{tmp_dir}/hello" assert epub_options[:output] == "#{tmp_dir}/hello" + assert markdown_options[:output] == "#{tmp_dir}/hello" end test "parses output with lower preference than options", %{tmp_dir: tmp_dir} = context do output = tmp_dir <> "/world" - [{_, _, html_options}, {_, _, epub_options}] = + [{_, _, html_options}, {_, _, epub_options}, {_, _, markdown_options}] = run_results = run(context, ["-o", "#{output}"], app: :ex_doc, docs: [output: output]) assert [ @@ -227,11 +276,22 @@ defmodule Mix.Tasks.DocsTest do source_beam: _, output: _, proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + output: _, + proglang: :elixir ]} ] = run_results assert html_options[:output] == "#{tmp_dir}/world" assert epub_options[:output] == "#{tmp_dir}/world" + assert markdown_options[:output] == "#{tmp_dir}/world" end test "includes dependencies", context do @@ -253,6 +313,15 @@ defmodule Mix.Tasks.DocsTest do apps: _, source_beam: _, proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: deps, + apps: _, + source_beam: _, + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, docs: []) @@ -280,6 +349,15 @@ defmodule Mix.Tasks.DocsTest do apps: _, source_beam: _, proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: deps, + apps: _, + source_beam: _, + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, docs: [deps: [earmark_parser: "foo"]]) @@ -308,6 +386,16 @@ defmodule Mix.Tasks.DocsTest do source_beam: _, main: "another", proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + main: "another", + proglang: :elixir ]} ] = run(context, [], app: :ex_doc, docs: fn -> [main: "another"] end) end @@ -336,6 +424,17 @@ defmodule Mix.Tasks.DocsTest do homepage_url: "https://elixir-lang.org", source_url: "https://github.com/elixir-lang/ex_doc", proglang: :elixir + ]}, + {"ExDoc", "1.2.3-dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: _, + source_beam: _, + homepage_url: "https://elixir-lang.org", + source_url: "https://github.com/elixir-lang/ex_doc", + proglang: :elixir ]} ] = run(context, [], @@ -347,7 +446,7 @@ defmodule Mix.Tasks.DocsTest do proglang: :elixir ) - assert [{"ex_doc", "dev", _}, {"ex_doc", "dev", _}] = run(context, [], app: :ex_doc) + assert [{"ex_doc", "dev", _}, {"ex_doc", "dev", _}, {"ex_doc", "dev", _}] = run(context, [], app: :ex_doc) end test "supports umbrella project", context do @@ -370,6 +469,15 @@ defmodule Mix.Tasks.DocsTest do apps: [:bar, :foo], source_beam: _, proglang: :elixir + ]}, + {"umbrella", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: [:bar, :foo], + source_beam: _, + proglang: :elixir ]} ] = run(context, [], app: :umbrella, apps_path: "apps/", docs: []) end) @@ -397,6 +505,16 @@ defmodule Mix.Tasks.DocsTest do source_beam: _, ignore_apps: [:foo], proglang: :elixir + ]}, + {"umbrella", "dev", + [ + formatter: "markdown", + formatters: _, + deps: _, + apps: [:bar], + source_beam: _, + ignore_apps: [:foo], + proglang: :elixir ]} ] = run(context, [], app: :umbrella, apps_path: "apps/", docs: [ignore_apps: [:foo]]) end)