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-29 提交的版本,查看 最新版本

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