Cursor Rule Markdown Renderer for GitHub

Renders Cursor Rules (*.mdc) markdown on GitHub into actual Markdown locally, using the marked library + highlight.js.

  1. // ==UserScript==
  2. // @name Cursor Rule Markdown Renderer for GitHub
  3. // @namespace https://github.com/texarkanine
  4. // @version 1.5.0
  5. // @description Renders Cursor Rules (*.mdc) markdown on GitHub into actual Markdown locally, using the marked library + highlight.js.
  6. // @author Texarkanine
  7. // @licence GPLv3
  8. // @homepageURL https://github.com/texarkanine/client-side-mdc-render
  9. // @supportURL https://github.com/texarkanine/client-side-mdc-render/issues
  10. // @match https://github.com/*
  11. // @icon data:image/png;base64,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
  12. // @grant GM_addStyle
  13. // @require https://cdn.jsdelivr.net/npm/marked@15/lib/marked.umd.min.js
  14. // @require https://cdn.jsdelivr.net/npm/marked-footnote@1/dist/index.umd.min.js
  15. // @require https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. 'use strict';
  20.  
  21. const DEBUG = true;
  22.  
  23. // Updated regex to match .mdc files
  24. const MDC_FILE_REGEX = /^https:\/\/github\.com\/.*\.mdc(#.*)?$/;
  25. const YAML_FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
  26. // Regex to detect specific anchor types
  27. const LINE_NUMBER_ANCHOR_REGEX = /^#L\d+$/;
  28. const FOOTNOTE_ANCHOR_REGEX = /^#footnote-/;
  29.  
  30. const RENDERED_LABEL = 'M⇩';
  31. const SOURCE_LABEL = '.mdc';
  32.  
  33. const MAX_RENDER_ATTEMPTS = 50;
  34. const RENDER_RETRY_INTERVAL = 100;
  35.  
  36. const RENDERED_ID = 'client-side-mdc-markdown';
  37.  
  38. let currentUrl = location.href;
  39. let isActive = false;
  40. let textareaObserver = null;
  41.  
  42. GM_addStyle(`
  43. #client-side-mdc-markdown {
  44. box-sizing: border-box;
  45. min-width: 200px;
  46. max-width: 980px;
  47. margin: 24px auto 0;
  48. padding: 45px;
  49. word-wrap: break-word;
  50. }
  51. `);
  52.  
  53. /**
  54. * Gets the current anchor from the URL if present
  55. * @returns {string|null} The anchor part of the URL, or null if no anchor
  56. */
  57. function getCurrentAnchor() {
  58. const hashIndex = location.href.indexOf('#');
  59. return hashIndex !== -1 ? location.href.substring(hashIndex) : null;
  60. }
  61.  
  62. /**
  63. * Determines the default view mode based on the current anchor
  64. * @returns {'rendered'|'source'} The view mode to use
  65. */
  66. function getDefaultViewMode() {
  67. const anchor = getCurrentAnchor();
  68. if (!anchor) {
  69. return 'rendered';
  70. }
  71. // If it's a line number anchor, default to source mode
  72. if (LINE_NUMBER_ANCHOR_REGEX.test(anchor)) {
  73. return 'source';
  74. }
  75. // If it's a footnote anchor, default to rendered mode
  76. if (FOOTNOTE_ANCHOR_REGEX.test(anchor)) {
  77. return 'rendered';
  78. }
  79. // Default to rendered for all other anchors
  80. return 'rendered';
  81. }
  82.  
  83. /**
  84. * Scrolls to the current anchor if it exists
  85. * @param {string} mode - Current view mode ('rendered' or 'source')
  86. */
  87. function scrollToAnchor(mode) {
  88. const anchor = getCurrentAnchor();
  89. if (!anchor) return;
  90. // For line number anchors in source mode, GitHub's native handling works
  91. if (mode === 'source' && LINE_NUMBER_ANCHOR_REGEX.test(anchor)) {
  92. return;
  93. }
  94. // For footnote anchors in rendered mode, we need to handle scrolling
  95. if (mode === 'rendered' && FOOTNOTE_ANCHOR_REGEX.test(anchor)) {
  96. // Use setTimeout to ensure the DOM has updated
  97. setTimeout(() => {
  98. const targetElement = document.querySelector(anchor);
  99. if (targetElement) {
  100. targetElement.scrollIntoView();
  101. }
  102. }, 100);
  103. }
  104. }
  105.  
  106. /**
  107. * Processes MDC content by extracting YAML frontmatter and converting it to a code block
  108. * @param {string} content - Raw MDC file content
  109. * @returns {string} Processed content with YAML frontmatter as code block
  110. */
  111. function processContent(content) {
  112. const match = content.match(YAML_FRONTMATTER_REGEX);
  113. if (match) {
  114. const [, yamlContent, markdownContent] = match;
  115. return `\`\`\`yaml\n${yamlContent}\n\`\`\`\n\n${markdownContent}`;
  116. }
  117. return content;
  118. }
  119.  
  120. /**
  121. * Creates a button element with GitHub's styling
  122. * @param {string} label - Button text content
  123. * @param {string} mode - View mode ('rendered' or 'source')
  124. * @param {boolean} isSelected - Whether button should be in selected state
  125. * @returns {HTMLLIElement} Complete button list item element
  126. */
  127. function createButton(label, mode, isSelected = false) {
  128. const li = document.createElement('li');
  129. li.className = `SegmentedControl-item${isSelected ? ' SegmentedControl-item--selected' : ''}`;
  130. li.setAttribute('role', 'listitem');
  131.  
  132. const button = document.createElement('button');
  133. button.setAttribute('aria-current', isSelected.toString());
  134. button.setAttribute('type', 'button');
  135. button.setAttribute('data-view-component', 'true');
  136. button.className = 'Button--invisible Button--small Button Button--invisible-noVisuals';
  137. button.onclick = () => setViewMode(mode);
  138.  
  139. const content = document.createElement('span');
  140. content.className = 'Button-content';
  141. const labelSpan = document.createElement('span');
  142. labelSpan.className = 'Button-label';
  143. labelSpan.setAttribute('data-content', label);
  144. labelSpan.textContent = label;
  145.  
  146. content.appendChild(labelSpan);
  147. button.appendChild(content);
  148. li.appendChild(button);
  149.  
  150. return li;
  151. }
  152.  
  153. /**
  154. * Creates a GitHub-styled segmented control for toggling between rendered and source views
  155. * @param {string} defaultMode - The default view mode to select
  156. * @returns {HTMLDivElement} Complete toggle button control
  157. */
  158. function createToggleButton(defaultMode = 'rendered') {
  159. const container = document.createElement('div');
  160. container.className = 'mdc-segmented-control';
  161.  
  162. const segmentedControl = document.createElement('segmented-control');
  163. segmentedControl.setAttribute('data-catalyst', '');
  164.  
  165. const ul = document.createElement('ul');
  166. ul.setAttribute('aria-label', 'MDC view');
  167. ul.setAttribute('role', 'list');
  168. ul.setAttribute('data-view-component', 'true');
  169. ul.className = 'SegmentedControl--small SegmentedControl';
  170.  
  171. const isRenderedSelected = defaultMode === 'rendered';
  172.  
  173. ul.appendChild(createButton(RENDERED_LABEL, 'rendered', isRenderedSelected));
  174. ul.appendChild(createButton(SOURCE_LABEL, 'source', !isRenderedSelected));
  175.  
  176. segmentedControl.appendChild(ul);
  177. container.appendChild(segmentedControl);
  178.  
  179. return container;
  180. }
  181.  
  182. /**
  183. * Switches between rendered markdown and source code views
  184. * @param {'rendered'|'source'} mode - View mode to activate
  185. */
  186. function setViewMode(mode) {
  187. const rendered = document.getElementById(RENDERED_ID);
  188. const original = document.querySelector('#read-only-cursor-text-area')?.closest('section');
  189. const buttons = document.querySelectorAll('.mdc-segmented-control .SegmentedControl-item');
  190.  
  191. if (!rendered || !original || buttons.length !== 2) return;
  192.  
  193. const [renderedItem, sourceItem] = buttons;
  194. const renderedButton = renderedItem.querySelector('button');
  195. const sourceButton = sourceItem.querySelector('button');
  196.  
  197. const isRenderedMode = mode === 'rendered';
  198.  
  199. rendered.style.display = isRenderedMode ? 'block' : 'none';
  200. original.style.display = isRenderedMode ? 'none' : 'block';
  201.  
  202. renderedItem.classList.toggle('SegmentedControl-item--selected', isRenderedMode);
  203. sourceItem.classList.toggle('SegmentedControl-item--selected', !isRenderedMode);
  204.  
  205. if (renderedButton) renderedButton.setAttribute('aria-current', isRenderedMode.toString());
  206. if (sourceButton) sourceButton.setAttribute('aria-current', (!isRenderedMode).toString());
  207. // Scroll to anchor if needed
  208. scrollToAnchor(mode);
  209. }
  210.  
  211. /**
  212. * Renders MDC content as HTML and inserts it into the page
  213. * @param {string} defaultMode - The default view mode to select ('rendered' or 'source')
  214. * @returns {boolean} True if rendering was successful, false otherwise
  215. */
  216. function renderMDC(defaultMode = 'rendered') {
  217. const textarea = document.querySelector('#read-only-cursor-text-area');
  218. if (!textarea) {
  219. DEBUG && console.log('[mdc-lite] No textarea found');
  220. return false;
  221. }
  222.  
  223. const content = textarea.textContent?.trim();
  224. if (!content) {
  225. DEBUG && console.log('[mdc-lite] No content in textarea');
  226. return false;
  227. }
  228.  
  229. const existing = document.getElementById(RENDERED_ID);
  230. existing?.remove();
  231.  
  232. const processedContent = processContent(content);
  233. const rendered = document.createElement('div');
  234. rendered.id = RENDERED_ID;
  235. rendered.className = 'markdown-body';
  236. rendered.innerHTML = marked.use(markedFootnote()).parse(processedContent);
  237.  
  238. rendered.querySelectorAll('pre code').forEach(block => {
  239. hljs.highlightElement(block);
  240. });
  241.  
  242. const section = textarea.closest('section');
  243. if (!section?.parentElement) {
  244. DEBUG && console.log('[mdc-lite] Could not find section to insert rendered content');
  245. return false;
  246. }
  247.  
  248. section.parentElement.insertBefore(rendered, section);
  249. // Always show toggle button, but set initial state based on defaultMode
  250. const toolbar = document.querySelector('.react-blob-header-edit-and-raw-actions');
  251. if (toolbar && !toolbar.querySelector('.mdc-segmented-control')) {
  252. toolbar.insertBefore(createToggleButton(defaultMode), toolbar.firstChild);
  253. }
  254.  
  255. // Apply the default view mode
  256. setViewMode(defaultMode);
  257.  
  258. DEBUG && console.log('[mdc-lite] Successfully rendered MDC with mode:', defaultMode);
  259. return true;
  260. }
  261.  
  262. /**
  263. * Removes all MDC-related elements and restores original state
  264. */
  265. function cleanup() {
  266. document.getElementById(RENDERED_ID)?.remove();
  267. document.querySelector('.mdc-segmented-control')?.remove();
  268.  
  269. const original = document.querySelector('#read-only-cursor-text-area')?.closest('section');
  270. if (original) original.style.display = 'block';
  271.  
  272. if (textareaObserver) {
  273. textareaObserver.disconnect();
  274. textareaObserver = null;
  275. }
  276.  
  277. isActive = false;
  278. DEBUG && console.log('[mdc-lite] Cleaned up');
  279. }
  280.  
  281. /**
  282. * Sets up a MutationObserver to watch for textarea content changes and re-render accordingly
  283. * @returns {boolean} True if observer was successfully set up, false otherwise
  284. */
  285. function setupTextareaObserver() {
  286. const textarea = document.querySelector('#read-only-cursor-text-area');
  287. if (!textarea) return false;
  288.  
  289. textareaObserver?.disconnect();
  290.  
  291. textareaObserver = new MutationObserver(() => {
  292. DEBUG && console.log('[mdc-lite] Textarea content changed, re-rendering');
  293. renderMDC(getDefaultViewMode());
  294. });
  295.  
  296. textareaObserver.observe(textarea, {
  297. childList: true,
  298. subtree: true,
  299. characterData: true
  300. });
  301.  
  302. DEBUG && console.log('[mdc-lite] Textarea observer set up');
  303. return true;
  304. }
  305.  
  306. /**
  307. * Handles page navigation changes, activating or deactivating MDC rendering based on URL
  308. */
  309. function handlePageChange() {
  310. if (MDC_FILE_REGEX.test(location.href)) {
  311. if (!isActive) {
  312. DEBUG && console.log('[mdc-lite] MDC file detected:', location.href);
  313. isActive = true;
  314. // Determine the default view mode based on anchor
  315. const defaultMode = getDefaultViewMode();
  316. DEBUG && console.log('[mdc-lite] Default mode:', defaultMode);
  317.  
  318. if (renderMDC(defaultMode)) {
  319. setupTextareaObserver();
  320. } else {
  321. // Content not ready yet - retry with exponential backoff would be better, but keeping simple
  322. let attempts = 0;
  323.  
  324. const interval = setInterval(() => {
  325. attempts++;
  326. if (renderMDC(defaultMode)) {
  327. clearInterval(interval);
  328. setupTextareaObserver();
  329. } else if (attempts >= MAX_RENDER_ATTEMPTS) {
  330. clearInterval(interval);
  331. DEBUG && console.log('[mdc-lite] Timeout waiting for content');
  332. }
  333. }, RENDER_RETRY_INTERVAL);
  334. }
  335. } else {
  336. // SPA navigation to another MDC file - re-render to sync toggle state
  337. const defaultMode = getDefaultViewMode();
  338. if (renderMDC(defaultMode)) {
  339. setupTextareaObserver();
  340. }
  341. }
  342. } else if (isActive) {
  343. cleanup();
  344. }
  345. }
  346.  
  347. /**
  348. * Initializes the userscript by setting up page change detection and handling the current page
  349. */
  350. function init() {
  351. handlePageChange();
  352.  
  353. // Monitor for SPA navigation changes
  354. new MutationObserver(() => {
  355. if (location.href !== currentUrl) {
  356. currentUrl = location.href;
  357. DEBUG && console.log('[mdc-lite] Navigation detected:', currentUrl);
  358. handlePageChange();
  359. }
  360. }).observe(document, { subtree: true, childList: true });
  361.  
  362. DEBUG && console.log('[mdc-lite] Initialized');
  363. }
  364.  
  365. if (document.readyState === 'loading') {
  366. document.addEventListener('DOMContentLoaded', init);
  367. } else {
  368. init();
  369. }
  370.  
  371. })();