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-28 提交的版本,檢視 最新版本

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