Cursor Rule Markdown Renderer for GitHub

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

当前为 2025-05-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Cursor Rule Markdown Renderer for GitHub
  3. // @namespace https://github.com/texarkanine
  4. // @version 1.3.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://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/marked.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. const DEBUG = true;
  21.  
  22. const MDC_FILE_REGEX = /^https:\/\/github\.com\/.*\.mdc$/;
  23. const YAML_FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
  24.  
  25. const RENDERED_LABEL = 'M⇩';
  26. const SOURCE_LABEL = '.mdc';
  27.  
  28. const MAX_RENDER_ATTEMPTS = 50;
  29. const RENDER_RETRY_INTERVAL = 100;
  30.  
  31. const RENDERED_ID = 'client-side-mdc-markdown';
  32.  
  33. let currentUrl = location.href;
  34. let isActive = false;
  35. let textareaObserver = null;
  36.  
  37. GM_addStyle(`
  38. #client-side-mdc-markdown {
  39. box-sizing: border-box;
  40. min-width: 200px;
  41. max-width: 980px;
  42. margin: 24px auto 0;
  43. padding: 45px;
  44. word-wrap: break-word;
  45. }
  46. `);
  47.  
  48. /**
  49. * Processes MDC content by extracting YAML frontmatter and converting it to a code block
  50. * @param {string} content - Raw MDC file content
  51. * @returns {string} Processed content with YAML frontmatter as code block
  52. */
  53. function processContent(content) {
  54. const match = content.match(YAML_FRONTMATTER_REGEX);
  55. if (match) {
  56. const [, yamlContent, markdownContent] = match;
  57. return `\`\`\`yaml\n${yamlContent}\n\`\`\`\n\n${markdownContent}`;
  58. }
  59. return content;
  60. }
  61.  
  62. /**
  63. * Creates a button element with GitHub's styling
  64. * @param {string} label - Button text content
  65. * @param {string} mode - View mode ('rendered' or 'source')
  66. * @param {boolean} isSelected - Whether button should be in selected state
  67. * @returns {HTMLLIElement} Complete button list item element
  68. */
  69. function createButton(label, mode, isSelected = false) {
  70. const li = document.createElement('li');
  71. li.className = `SegmentedControl-item${isSelected ? ' SegmentedControl-item--selected' : ''}`;
  72. li.setAttribute('role', 'listitem');
  73.  
  74. const button = document.createElement('button');
  75. button.setAttribute('aria-current', isSelected.toString());
  76. button.setAttribute('type', 'button');
  77. button.setAttribute('data-view-component', 'true');
  78. button.className = 'Button--invisible Button--small Button Button--invisible-noVisuals';
  79. button.onclick = () => setViewMode(mode);
  80.  
  81. const content = document.createElement('span');
  82. content.className = 'Button-content';
  83. const labelSpan = document.createElement('span');
  84. labelSpan.className = 'Button-label';
  85. labelSpan.setAttribute('data-content', label);
  86. labelSpan.textContent = label;
  87.  
  88. content.appendChild(labelSpan);
  89. button.appendChild(content);
  90. li.appendChild(button);
  91.  
  92. return li;
  93. }
  94.  
  95. /**
  96. * Creates a GitHub-styled segmented control for toggling between rendered and source views
  97. * @returns {HTMLDivElement} Complete toggle button control
  98. */
  99. function createToggleButton() {
  100. const container = document.createElement('div');
  101. container.className = 'mdc-segmented-control';
  102.  
  103. const segmentedControl = document.createElement('segmented-control');
  104. segmentedControl.setAttribute('data-catalyst', '');
  105.  
  106. const ul = document.createElement('ul');
  107. ul.setAttribute('aria-label', 'MDC view');
  108. ul.setAttribute('role', 'list');
  109. ul.setAttribute('data-view-component', 'true');
  110. ul.className = 'SegmentedControl--small SegmentedControl';
  111.  
  112. ul.appendChild(createButton(RENDERED_LABEL, 'rendered', true));
  113. ul.appendChild(createButton(SOURCE_LABEL, 'source', false));
  114.  
  115. segmentedControl.appendChild(ul);
  116. container.appendChild(segmentedControl);
  117.  
  118. return container;
  119. }
  120.  
  121. /**
  122. * Switches between rendered markdown and source code views
  123. * @param {'rendered'|'source'} mode - View mode to activate
  124. */
  125. function setViewMode(mode) {
  126. const rendered = document.getElementById(RENDERED_ID);
  127. const original = document.querySelector('#read-only-cursor-text-area')?.closest('section');
  128. const buttons = document.querySelectorAll('.mdc-segmented-control .SegmentedControl-item');
  129.  
  130. if (!rendered || !original || buttons.length !== 2) return;
  131.  
  132. const [renderedItem, sourceItem] = buttons;
  133. const renderedButton = renderedItem.querySelector('button');
  134. const sourceButton = sourceItem.querySelector('button');
  135.  
  136. const isRenderedMode = mode === 'rendered';
  137.  
  138. rendered.style.display = isRenderedMode ? 'block' : 'none';
  139. original.style.display = isRenderedMode ? 'none' : 'block';
  140.  
  141. renderedItem.classList.toggle('SegmentedControl-item--selected', isRenderedMode);
  142. sourceItem.classList.toggle('SegmentedControl-item--selected', !isRenderedMode);
  143.  
  144. if (renderedButton) renderedButton.setAttribute('aria-current', isRenderedMode.toString());
  145. if (sourceButton) sourceButton.setAttribute('aria-current', (!isRenderedMode).toString());
  146. }
  147.  
  148. /**
  149. * Renders MDC content as HTML and inserts it into the page
  150. * @returns {boolean} True if rendering was successful, false otherwise
  151. */
  152. function renderMDC() {
  153. const textarea = document.querySelector('#read-only-cursor-text-area');
  154. if (!textarea) {
  155. DEBUG && console.log('[mdc-lite] No textarea found');
  156. return false;
  157. }
  158.  
  159. const content = textarea.textContent?.trim();
  160. if (!content) {
  161. DEBUG && console.log('[mdc-lite] No content in textarea');
  162. return false;
  163. }
  164.  
  165. const existing = document.getElementById(RENDERED_ID);
  166. existing?.remove();
  167.  
  168. const processedContent = processContent(content);
  169. const rendered = document.createElement('div');
  170. rendered.id = RENDERED_ID;
  171. rendered.className = 'markdown-body';
  172. rendered.innerHTML = marked.parse(processedContent);
  173.  
  174. rendered.querySelectorAll('pre code').forEach(block => {
  175. hljs.highlightElement(block);
  176. });
  177.  
  178. const section = textarea.closest('section');
  179. if (!section?.parentElement) {
  180. DEBUG && console.log('[mdc-lite] Could not find section to insert rendered content');
  181. return false;
  182. }
  183.  
  184. section.parentElement.insertBefore(rendered, section);
  185. section.style.display = 'none';
  186.  
  187. const toolbar = document.querySelector('.react-blob-header-edit-and-raw-actions');
  188. if (toolbar && !toolbar.querySelector('.mdc-segmented-control')) {
  189. toolbar.insertBefore(createToggleButton(), toolbar.firstChild);
  190. }
  191.  
  192. // Ensure toggle reflects actual display state (always rendered initially)
  193. setViewMode('rendered');
  194.  
  195. DEBUG && console.log('[mdc-lite] Successfully rendered MDC');
  196. return true;
  197. }
  198.  
  199. /**
  200. * Removes all MDC-related elements and restores original state
  201. */
  202. function cleanup() {
  203. document.getElementById(RENDERED_ID)?.remove();
  204. document.querySelector('.mdc-segmented-control')?.remove();
  205.  
  206. const original = document.querySelector('#read-only-cursor-text-area')?.closest('section');
  207. if (original) original.style.display = 'block';
  208.  
  209. if (textareaObserver) {
  210. textareaObserver.disconnect();
  211. textareaObserver = null;
  212. }
  213.  
  214. isActive = false;
  215. DEBUG && console.log('[mdc-lite] Cleaned up');
  216. }
  217.  
  218. /**
  219. * Sets up a MutationObserver to watch for textarea content changes and re-render accordingly
  220. * @returns {boolean} True if observer was successfully set up, false otherwise
  221. */
  222. function setupTextareaObserver() {
  223. const textarea = document.querySelector('#read-only-cursor-text-area');
  224. if (!textarea) return false;
  225.  
  226. textareaObserver?.disconnect();
  227.  
  228. textareaObserver = new MutationObserver(() => {
  229. DEBUG && console.log('[mdc-lite] Textarea content changed, re-rendering');
  230. renderMDC();
  231. });
  232.  
  233. textareaObserver.observe(textarea, {
  234. childList: true,
  235. subtree: true,
  236. characterData: true
  237. });
  238.  
  239. DEBUG && console.log('[mdc-lite] Textarea observer set up');
  240. return true;
  241. }
  242.  
  243. /**
  244. * Handles page navigation changes, activating or deactivating MDC rendering based on URL
  245. */
  246. function handlePageChange() {
  247. if (MDC_FILE_REGEX.test(location.href)) {
  248. if (!isActive) {
  249. DEBUG && console.log('[mdc-lite] MDC file detected:', location.href);
  250. isActive = true;
  251.  
  252. if (renderMDC()) {
  253. setupTextareaObserver();
  254. } else {
  255. // Content not ready yet - retry with exponential backoff would be better, but keeping simple
  256. let attempts = 0;
  257.  
  258. const interval = setInterval(() => {
  259. attempts++;
  260. if (renderMDC()) {
  261. clearInterval(interval);
  262. setupTextareaObserver();
  263. } else if (attempts >= MAX_RENDER_ATTEMPTS) {
  264. clearInterval(interval);
  265. DEBUG && console.log('[mdc-lite] Timeout waiting for content');
  266. }
  267. }, RENDER_RETRY_INTERVAL);
  268. }
  269. } else {
  270. // SPA navigation to another MDC file - re-render to sync toggle state
  271. if (renderMDC()) {
  272. setupTextareaObserver();
  273. }
  274. }
  275. } else if (isActive) {
  276. cleanup();
  277. }
  278. }
  279.  
  280. /**
  281. * Initializes the userscript by setting up page change detection and handling the current page
  282. */
  283. function init() {
  284. handlePageChange();
  285.  
  286. // Monitor for SPA navigation changes
  287. new MutationObserver(() => {
  288. if (location.href !== currentUrl) {
  289. currentUrl = location.href;
  290. DEBUG && console.log('[mdc-lite] Navigation detected:', currentUrl);
  291. handlePageChange();
  292. }
  293. }).observe(document, { subtree: true, childList: true });
  294.  
  295. DEBUG && console.log('[mdc-lite] Initialized');
  296. }
  297.  
  298. if (document.readyState === 'loading') {
  299. document.addEventListener('DOMContentLoaded', init);
  300. } else {
  301. init();
  302. }
  303.  
  304. })();