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