astro.config.mjs (7925B)
1 // @ts-check 2 import { defineConfig } from 'astro/config'; 3 import mdx from '@astrojs/mdx'; 4 import sitemap from '@astrojs/sitemap'; 5 import Slugger from 'github-slugger'; 6 7 /** 8 * MkDocs anchor aliases for the InternalAllTheThings mirror. 9 * 10 * Upstream is a MkDocs site, and python-markdown's slugifier collapses every 11 * run of `[-\s]` to one dash where github-slugger (Astro's) keeps them: the 12 * heading "Meterpreter - Basic" is `#meterpreter-basic` upstream and 13 * `#meterpreter---basic` here. Every deep link ever written against the 14 * upstream page carries the MkDocs form — outside bookmarks and search hits, 15 * and the IATT links this site rewrites out of the PayloadsAllTheThings stubs 16 * — so without an alias they all land at the top of the page. 17 * 18 * The github-slugger id stays primary, because IATT's own in-page tables of 19 * contents are written GitHub-style and resolve against it (they are in fact 20 * broken on the live upstream site). The MkDocs form is emitted as an empty 21 * span *inside* the heading: a sibling before it would break 22 * `.prose > h1:first-child`, which hides the duplicated page title. 23 * 24 * Ids are assigned here rather than left to Astro because user rehype plugins 25 * run before its heading-id pass, so the id has to exist to be compared 26 * against. Astro keeps an id that is already a string, so this is the same 27 * value it would have written. 28 */ 29 const RAW_NODE_TYPES = new Set(['text', 'raw', 'mdxTextExpression']); 30 const CODE_TAG_NAMES = new Set(['code', 'pre']); 31 32 /** python-markdown's `toc.slugify`, separator "-". */ 33 function mkdocsSlug(text) { 34 return text 35 .normalize('NFKD') 36 .replace(/[^\x00-\x7f]/g, '') 37 .replace(/[^\w\s-]/g, '') 38 .trim() 39 .toLowerCase() 40 .replace(/[-\s]+/g, '-'); 41 } 42 43 /** python-markdown disambiguates a repeat with `_1`, `_2`; github-slugger `-1`. */ 44 function uniqueMkdocsSlug(slug, used) { 45 let id = slug; 46 while (!id || used.has(id)) { 47 const m = /^(.*)_(\d+)$/.exec(id); 48 id = m ? `${m[1]}_${Number(m[2]) + 1}` : `${id}_1`; 49 } 50 used.add(id); 51 return id; 52 } 53 54 // Mirrors @astrojs/markdown-remark's own heading-text collection, so the two 55 // slugifiers are fed byte-identical input. 56 function headingText(heading) { 57 let text = ''; 58 const walk = (node, parent) => { 59 if (node.type !== 'element' && parent !== null && RAW_NODE_TYPES.has(node.type)) { 60 if (!(node.type === 'raw' && /^\n?<.*>\n?$/.test(node.value))) { 61 text += CODE_TAG_NAMES.has(parent.tagName) 62 ? node.value 63 : node.value.replace(/\{/g, '${'); 64 } 65 } 66 if (node.children) for (const child of node.children) walk(child, node); 67 }; 68 walk(heading, null); 69 return text; 70 } 71 72 function rehypeMkdocsAnchors() { 73 return (tree, file) => { 74 const source = (file?.history?.[0] ?? '').replace(/\\/g, '/'); 75 if (!source.includes('/src/content/internal/')) return; 76 77 const slugger = new Slugger(); 78 const mkdocsUsed = new Set(); 79 const walk = (node) => { 80 if (node.type === 'element' && /^h[1-6]$/.test(node.tagName)) { 81 const text = headingText(node); 82 node.properties = node.properties || {}; 83 if (typeof node.properties.id !== 'string') { 84 const slug = slugger.slug(text); 85 node.properties.id = slug.endsWith('-') ? slug.slice(0, -1) : slug; 86 } 87 const alias = uniqueMkdocsSlug(mkdocsSlug(text), mkdocsUsed); 88 if (alias !== node.properties.id) { 89 node.children.unshift({ 90 type: 'element', 91 tagName: 'span', 92 properties: { id: alias, className: ['anchor-alias'] }, 93 children: [], 94 }); 95 } 96 return; // headings do not nest 97 } 98 if (node.children) for (const child of node.children) walk(child); 99 }; 100 walk(tree); 101 }; 102 } 103 104 /** 105 * The drop cap, as the main site sets it: the opening letter of a sheet 106 * floated three lines deep in EB Garamond. 107 * 108 * The letter is split out of the paragraph's first text node and wrapped 109 * in a `<span class="cap">` rather than left to `::first-letter`. That 110 * keeps it real text — selectable, searchable, and read aloud as part of 111 * the word it opens — and it is the only way to give it a different family 112 * and weight that renders the same across engines. 113 * 114 * Only the first top-level paragraph of the document takes one, and only 115 * if it is long enough to have three lines for the letter to sit in. 116 * Short openers — an image caption, a one-line note, a lede that is really 117 * a subtitle — are left alone, because a cap floated into a two-line 118 * paragraph pushes the text into a column beside it. 119 */ 120 const CAP_MIN_CHARS = 80; 121 122 function rehypeDropCap() { 123 return (tree) => { 124 const paragraph = tree.children.find( 125 (n) => n.type === 'element' && n.tagName === 'p' && textLength(n) >= CAP_MIN_CHARS, 126 ); 127 if (!paragraph) return; 128 129 // The first non-empty text node, wherever it sits — the paragraph may 130 // legitimately open with a <strong> or an <a>, and the cap belongs to 131 // the first *letter* rather than to the first direct child. 132 const lead = firstText(paragraph); 133 if (!lead) return; 134 const trimmed = lead.value.trimStart(); 135 const letter = trimmed[0]; 136 // Punctuation and quotes make poor caps: floated at 72px an opening 137 // quote reads as a stray mark rather than as a letter. 138 if (!letter || !/[A-Za-z0-9]/.test(letter)) return; 139 140 lead.value = trimmed.slice(1); 141 paragraph.children.unshift({ 142 type: 'element', 143 tagName: 'span', 144 properties: { className: ['cap'] }, 145 children: [{ type: 'text', value: letter }], 146 }); 147 }; 148 149 function textLength(node) { 150 if (node.type === 'text') return node.value.trim().length; 151 if (!node.children) return 0; 152 return node.children.reduce((n, c) => n + textLength(c), 0); 153 } 154 155 function firstText(node) { 156 if (!node.children) return null; 157 for (const child of node.children) { 158 if (child.type === 'text' && child.value.trim()) return child; 159 if (child.type === 'element') { 160 const found = firstText(child); 161 if (found) return found; 162 } 163 } 164 return null; 165 } 166 } 167 168 // Deployed to Cloudflare Workers at the domain root — no base path. 169 // `site` feeds the sitemap and canonical URLs, so it must be the live custom 170 // domain from wrangler.jsonc, not a platform-generated hostname. 171 export default defineConfig({ 172 site: 'https://cheatsheet.daemon-sec.xyz', 173 trailingSlash: 'ignore', 174 integrations: [mdx(), sitemap()], 175 markdown: { 176 rehypePlugins: [rehypeMkdocsAnchors, rehypeDropCap], 177 shikiConfig: { 178 // One theme, not two. Code blocks are dark plates in both modes (see 179 // prose.css) — a listing that turns cream in light mode stops reading 180 // as terminal output — so there is no second palette to flip to. 181 theme: 'rose-pine-moon', 182 wrap: false, 183 // Fence languages the sheets actually use, mapped onto grammars Shiki 184 // ships. Without these the block silently falls back to plaintext, so a 185 // listing that should be highlighted reads as flat text. 186 // 187 // The capitalised three come from the generated mirrors 188 // (payloads/, internal/) where upstream wrote ```SQL and Shiki's lookup 189 // is case-sensitive. Those files are rewritten by the sync scripts, so 190 // the alias is the only place the fix can live. 191 // 192 // The rest have no grammar of their own; each is mapped to the closest 193 // one that reads correctly rather than left to plaintext. `smali` is 194 // deliberately absent — nothing Shiki ships resembles it, so plaintext 195 // is the honest result. 196 langAlias: { 197 SQL: 'sql', 198 Javascript: 'javascript', 199 C: 'c', 200 conf: 'ini', 201 svg: 'xml', 202 yara: 'c', 203 zeek: 'c', 204 ldif: 'yaml', 205 django: 'html', 206 dataviewjs: 'javascript', 207 }, 208 }, 209 }, 210 });