├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── qiita-markdown.rb └── qiita │ ├── markdown.rb │ └── markdown │ ├── base_processor.rb │ ├── embed │ ├── asciinema.rb │ ├── code_pen.rb │ ├── docswell.rb │ ├── figma.rb │ ├── google_slide.rb │ ├── slide_share.rb │ ├── speeker_deck.rb │ ├── tweet.rb │ └── youtube.rb │ ├── filters │ ├── checkbox.rb │ ├── code_block.rb │ ├── custom_block.rb │ ├── emoji.rb │ ├── external_link.rb │ ├── final_sanitizer.rb │ ├── footnote.rb │ ├── group_mention.rb │ ├── heading_anchor.rb │ ├── html_toc.rb │ ├── image_link.rb │ ├── inline_code_color.rb │ ├── mention.rb │ ├── qiita_marker.rb │ ├── simplify.rb │ ├── syntax_highlight.rb │ ├── toc.rb │ ├── truncate.rb │ └── user_input_sanitizer.rb │ ├── processor.rb │ ├── summary_processor.rb │ ├── transformers │ ├── filter_attributes.rb │ ├── filter_iframe.rb │ ├── filter_script.rb │ └── strip_invalid_node.rb │ └── version.rb ├── qiita-markdown.gemspec └── spec ├── qiita └── markdown │ ├── filters │ ├── checkbox_spec.rb │ ├── code_block_spec.rb │ ├── heading_anchor_spec.rb │ ├── html_toc_spec.rb │ ├── inline_code_color_spec.rb │ └── qiita_marker_spec.rb │ ├── processor_spec.rb │ └── summary_processor_spec.rb └── spec_helper.rb /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # package-ecosystem: bundler, directories: / 2 | /Gemfile @increments/qiita-dev-group 3 | 4 | # package-ecosystem: github-actions, directories: / 5 | /.github/workflows @increments/qiita-dev-group 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "bundler" 5 | directories: 6 | - "/" 7 | schedule: 8 | interval: "daily" 9 | time: "14:00" 10 | timezone: "Asia/Tokyo" 11 | open-pull-requests-limit: 5 12 | rebase-strategy: "disabled" 13 | 14 | - package-ecosystem: "github-actions" 15 | directories: 16 | - "/" 17 | schedule: 18 | interval: "daily" 19 | time: "14:00" 20 | timezone: "Asia/Tokyo" 21 | open-pull-requests-limit: 5 22 | rebase-strategy: "disabled" 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | codeclimate: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Get branch names 18 | id: branch-name 19 | uses: tj-actions/branch-names@dde14ac574a8b9b1cedc59a1cf312788af43d8d8 # v8.2.1 20 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 21 | - uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1.244.0 22 | with: 23 | ruby-version: '3.2' 24 | bundler-cache: true 25 | - name: Test & publish code coverage 26 | if: "${{ env.CC_TEST_REPORTER_ID != '' }}" 27 | uses: paambaati/codeclimate-action@7bcf9e73c0ee77d178e72c0ec69f1a99c1afc1f3 # v2.7.5 28 | env: 29 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 30 | GIT_BRANCH: ${{ steps.branch-name.outputs.current_branch }} 31 | GIT_COMMIT_SHA: ${{ github.sha }} 32 | with: 33 | coverageCommand: bundle exec rake 34 | 35 | test: 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | os: ['ubuntu-latest', 'macos-latest'] 40 | ruby: ['3.2', '3.3', '3.4'] 41 | experimental: [false] 42 | include: 43 | - os: 'ubuntu-latest' 44 | ruby: 'head' 45 | experimental: true 46 | runs-on: ${{ matrix.os }} 47 | continue-on-error: ${{ matrix.experimental }} 48 | steps: 49 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 50 | - uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1.244.0 51 | with: 52 | ruby-version: ${{ matrix.ruby }} 53 | bundler-cache: true 54 | - name: Test 55 | run: bundle exec rake 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/examples.txt 9 | /spec/reports/ 10 | /tmp/ 11 | *.bundle 12 | *.so 13 | *.o 14 | *.a 15 | mkmf.log 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | 6 | Layout/LineLength: 7 | Enabled: false 8 | 9 | Metrics/ClassLength: 10 | Enabled: false 11 | 12 | Naming/PredicatePrefix: 13 | ForbiddenPrefixes: 14 | - is_ 15 | 16 | Style/Documentation: 17 | Enabled: false 18 | 19 | Style/EmptyCaseCondition: 20 | Enabled: false 21 | 22 | Style/GuardClause: 23 | Enabled: false 24 | 25 | Style/StringLiterals: 26 | EnforcedStyle: double_quotes 27 | 28 | Style/TrailingCommaInArguments: 29 | EnforcedStyleForMultiline: comma 30 | 31 | Style/TrailingCommaInHashLiteral: 32 | EnforcedStyleForMultiline: comma 33 | 34 | Style/TrailingCommaInArrayLiteral: 35 | EnforcedStyleForMultiline: comma 36 | 37 | Style/RedundantPercentQ: 38 | Enabled: false 39 | 40 | Metrics/BlockLength: 41 | Exclude: 42 | - qiita-markdown.gemspec 43 | - spec/**/* 44 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config --exclude-limit 99999` 3 | # on 2022-11-15 12:42:36 UTC using RuboCop version 1.39.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: Include. 11 | # Include: **/*.gemspec 12 | Gemspec/RequiredRubyVersion: 13 | Exclude: 14 | - 'qiita-markdown.gemspec' 15 | 16 | # Offense count: 8 17 | # Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes. 18 | Metrics/AbcSize: 19 | Max: 26 20 | 21 | # Offense count: 10 22 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods. 23 | Metrics/MethodLength: 24 | Max: 20 25 | 26 | # Offense count: 1 27 | Naming/ConstantName: 28 | Exclude: 29 | - 'lib/qiita/markdown/filters/mention.rb' 30 | 31 | # Offense count: 1 32 | # Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. 33 | # CheckDefinitionPathHierarchyRoots: lib, spec, test, src 34 | # AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS 35 | Naming/FileName: 36 | Exclude: 37 | - 'lib/qiita-markdown.rb' 38 | 39 | # Offense count: 21 40 | # Configuration parameters: ForbiddenDelimiters. 41 | # ForbiddenDelimiters: (?-mix:(^|\s)(EO[A-Z]{1}|END)(\s|$)) 42 | Naming/HeredocDelimiterNaming: 43 | Exclude: 44 | - 'spec/qiita/markdown/summary_processor_spec.rb' 45 | 46 | # Offense count: 1 47 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 48 | # AllowedNames: as, at, by, db, id, if, in, io, ip, of, on, os, pp, to 49 | Naming/MethodParameterName: 50 | Exclude: 51 | - 'lib/qiita/markdown/filters/footnote.rb' 52 | 53 | # Offense count: 37 54 | # This cop supports unsafe autocorrection (--autocorrect-all). 55 | # Configuration parameters: EnforcedStyle. 56 | # SupportedStyles: always, always_true, never 57 | Style/FrozenStringLiteralComment: 58 | Exclude: 59 | - 'Gemfile' 60 | - 'Rakefile' 61 | - 'lib/qiita-markdown.rb' 62 | - 'lib/qiita/markdown.rb' 63 | - 'lib/qiita/markdown/base_processor.rb' 64 | - 'lib/qiita/markdown/embed/asciinema.rb' 65 | - 'lib/qiita/markdown/embed/code_pen.rb' 66 | - 'lib/qiita/markdown/embed/google_slide.rb' 67 | - 'lib/qiita/markdown/embed/slide_share.rb' 68 | - 'lib/qiita/markdown/embed/speeker_deck.rb' 69 | - 'lib/qiita/markdown/embed/tweet.rb' 70 | - 'lib/qiita/markdown/embed/youtube.rb' 71 | - 'lib/qiita/markdown/filters/checkbox.rb' 72 | - 'lib/qiita/markdown/filters/code_block.rb' 73 | - 'lib/qiita/markdown/filters/emoji.rb' 74 | - 'lib/qiita/markdown/filters/external_link.rb' 75 | - 'lib/qiita/markdown/filters/footnote.rb' 76 | - 'lib/qiita/markdown/filters/group_mention.rb' 77 | - 'lib/qiita/markdown/filters/image_link.rb' 78 | - 'lib/qiita/markdown/filters/inline_code_color.rb' 79 | - 'lib/qiita/markdown/filters/mention.rb' 80 | - 'lib/qiita/markdown/filters/simplify.rb' 81 | - 'lib/qiita/markdown/filters/syntax_highlight.rb' 82 | - 'lib/qiita/markdown/filters/toc.rb' 83 | - 'lib/qiita/markdown/filters/truncate.rb' 84 | - 'lib/qiita/markdown/processor.rb' 85 | - 'lib/qiita/markdown/summary_processor.rb' 86 | - 'lib/qiita/markdown/transformers/filter_attributes.rb' 87 | - 'lib/qiita/markdown/transformers/filter_iframe.rb' 88 | - 'lib/qiita/markdown/transformers/filter_script.rb' 89 | - 'lib/qiita/markdown/transformers/strip_invalid_node.rb' 90 | - 'lib/qiita/markdown/version.rb' 91 | - 'qiita-markdown.gemspec' 92 | - 'spec/qiita/markdown/filters/inline_code_color_spec.rb' 93 | - 'spec/qiita/markdown/processor_spec.rb' 94 | - 'spec/qiita/markdown/summary_processor_spec.rb' 95 | - 'spec/spec_helper.rb' 96 | 97 | # Offense count: 17 98 | # This cop supports unsafe autocorrection (--autocorrect-all). 99 | # Configuration parameters: EnforcedStyle. 100 | # SupportedStyles: literals, strict 101 | Style/MutableConstant: 102 | Exclude: 103 | - 'lib/qiita/markdown/embed/code_pen.rb' 104 | - 'lib/qiita/markdown/embed/tweet.rb' 105 | - 'lib/qiita/markdown/filters/checkbox.rb' 106 | - 'lib/qiita/markdown/filters/code_block.rb' 107 | - 'lib/qiita/markdown/filters/group_mention.rb' 108 | - 'lib/qiita/markdown/filters/inline_code_color.rb' 109 | - 'lib/qiita/markdown/filters/mention.rb' 110 | - 'lib/qiita/markdown/filters/simplify.rb' 111 | - 'lib/qiita/markdown/filters/syntax_highlight.rb' 112 | - 'lib/qiita/markdown/version.rb' 113 | 114 | # Offense count: 1 115 | # This cop supports unsafe autocorrection (--autocorrect-all). 116 | # Configuration parameters: Methods. 117 | Style/RedundantArgument: 118 | Exclude: 119 | - 'lib/qiita/markdown/filters/custom_block.rb' 120 | 121 | # Offense count: 2 122 | # This cop supports unsafe autocorrection (--autocorrect-all). 123 | # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. 124 | # AllowedMethods: present?, blank?, presence, try, try! 125 | Style/SafeNavigation: 126 | Exclude: 127 | - 'lib/qiita/markdown/filters/custom_block.rb' 128 | - 'lib/qiita/markdown/filters/toc.rb' 129 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## 1.2.0 4 | 5 | - Accept new codepen script url (public.codepenassets.com) 6 | 7 | ## 1.1.2 8 | 9 | - Add test for code_block filter 10 | - Upgrade github-linguist from 4.x to 7.x 11 | 12 | ## 1.1.1 13 | 14 | - Allow open attribute on details 15 | - Bump tj-actions/branch-names from 4.9 to 7.0.7 16 | 17 | ## 1.1.0 18 | 19 | - Add uri to requiring libraries 20 | - Bump rubocop 1.39.0 to 1.60.2 21 | - Bump rouge 4.1.0 to 4.2.0 22 | 23 | ## 1.0.4 24 | 25 | - Drop Ruby 2.7 support 26 | - Bump qiita_marker 0.23.6 to 0.23.9 27 | - Drop Ubuntu 18.04 support 28 | 29 | ## 1.0.3 30 | 31 | - Bump rake version to work with ruby 3.2 32 | - Bump rouge 3.26.0 to 4.1.0 33 | 34 | ## 1.0.2 35 | 36 | - Avoid sanitizing attributes related to footnotes 37 | 38 | ## 1.0.1 39 | 40 | - Chenge qiita_marker from development dependency to runtime dependency 41 | 42 | ## 1.0.0 43 | 44 | - Drop Ruby 2.6 support 45 | - Change markdown parser from Greenmat to Qiita Marker 46 | - Fix bug on rendering loose tasklist 47 | 48 | ### Braking change on HTML output 49 | 50 | Some notations will be changed between Greenmat and Qiita Marker and rendering results may change. More details, see [#130](https://github.com/increments/qiita-markdown/issues/130). 51 | 52 | ## 0.44.1 53 | 54 | - Rename package name from `Qiita::Markdown` to `Qiita Markdown` in README 55 | - Bump rubocop from 1.27.0 to 1.39.0 and apply rubocop auto correct 56 | 57 | ## 0.44.0 58 | 59 | - Support Figma embedding scripts 60 | - Fix bug of checkbox filter 61 | 62 | ## 0.43.0 63 | 64 | - Fix GitHub Actions can't be executed when public fork 65 | - Support new embed scripts and iframes 66 | - Docswell 67 | 68 | ## 0.42.0 69 | 70 | - Add for Ruby 3.0, 3.1 support 71 | - Bump greenmat from 3.5.1.3 to 3.5.1.4 72 | 73 | ## 0.41.0 74 | 75 | - Bump greenmat from 3.5.1.2 to 3.5.1.3 76 | - Dropping Ruby 2.5 support (#107) 77 | - Bump rubocop from 0.40.0 to 1.7.0 78 | 79 | ## 0.40.1 80 | 81 | - Fix to support file names containing colons. 82 | 83 | ## 0.40.0 84 | - Change ci platform to Github Actions. 85 | - Fix regular expressions to detect group id(url_name) for group mention. 86 | 87 | ## 0.39.0 88 | - Fix an error when custom block type is empty 89 | 90 | ## 0.38.0 91 | - Change default syntax highlighter from pygments to rouge 92 | 93 | ## 0.37.0 94 | - Change keyword of notation 95 | 96 | ## 0.36.0 97 | - Support message notation 98 | 99 | ## 0.35.0 100 | 101 | - Allow Relative URL in iframe src attributes 102 | 103 | ## 0.34.0 104 | 105 | - Delete gist embed rule to avoid XSS 106 | 107 | ## 0.33.0 108 | 109 | - Fix XSS possibility bug 110 | 111 | ## 0.32.0 112 | 113 | - Fix XSS possibility bug 114 | - Fix iframe width to be fixed at 100% 115 | 116 | ## 0.31.0 117 | 118 | - Use greenmat 3.5.1.1 119 | 120 | ## 0.30.0 121 | 122 | - Use greenmat 3.5.1.0 123 | 124 | ## 0.29.0 125 | 126 | - Accept new embeded script and iframes 127 | - Gist 128 | - Youtube 129 | - SlideShare 130 | - SpeekerDeck 131 | - GoogleSlide 132 | 133 | ## 0.28.0 134 | 135 | - Accept new codepen script url (cpwebassets.codepen.io) 136 | 137 | ## 0.27.0 138 | 139 | - Support embed Asciinema 140 | 141 | ## 0.26.0 142 | 143 | - Use greenmat 3.2.2.4 144 | 145 | ## 0.25.0 146 | 147 | - Accept new codepen script url (static.codepen.io) 148 | 149 | ## 0.24.0 150 | 151 | - Fix to strip HTML tags in ToC 152 | - Allow to use data-\* attributes when embedding Tweet and CodePen 153 | 154 | ## 0.23.0 155 | 156 | - Support embed Tweet 157 | 158 | ## 0.22.0 159 | 160 | - Support embed CodePen 161 | 162 | ## 0.21.0 163 | 164 | - Rename `Code` to `CodeBlock` 165 | - Support CSS color in inline code 166 | 167 | ## 0.20.1 168 | 169 | - Fix to sanitize `` which was unexpectedly permitted 170 | 171 | ## 0.20.0 172 | 173 | - Allow `
` 174 | 175 | ## 0.19.1 176 | 177 | - Add missing sanitization for `
` class attribute 178 | 179 | ## 0.19.0 180 | 181 | - Drop 2.0 and 2.1 from support Ruby versions 182 | - Rename `Sanitize` as `FinalSanitizer` 183 | - Add `:strict` context for stricter sanitization 184 | 185 | ## 0.18.0 186 | 187 | - Extract heading decoration logic from Greenmat renderer to `Toc` filter 188 | - Use greenmat 3.2.2.3 189 | 190 | ## 0.17.0 191 | 192 | - Require pygments.rb 1.0 or later 193 | - Remove superfluous leading newline in rendered HTML with pygments.rb 1.0 194 | 195 | ## 0.16.2 196 | 197 | - Add timeout support to `SyntaxHighlightFilter` 198 | - Make `SyntaxHighlightFilter` process code blocks faster when their specified language is unknown to Pygments 199 | 200 | ## 0.16.1 201 | 202 | - Fix a group mention bug that unexpectedly removes preceding space 203 | 204 | ## 0.16.0 205 | 206 | - Add rel=noopener to all external a tags 207 | - Support HTML5 `
` and `` elements 208 | - Enable to change settings for footnotes 209 | 210 | ## 0.15.0 211 | 212 | - Append `rel=nofollow` and `target=_blank` to `a` tags for external link 213 | 214 | ## 0.14.0 215 | 216 | - Add some attributes to mentions for rendering hovercard 217 | 218 | ## 0.13.0 219 | 220 | - Support group mention 221 | 222 | ## 0.12.0 223 | 224 | - Add custom emoji support via `:emoji_names` and `:emoji_url_generator` contexts 225 | 226 | ## 0.11.5 227 | 228 | - Add a leading newline to `
` elements so that leading newlines inputted by user are properly rendered on browsers
229 | 
230 | ## 0.11.4
231 | 
232 | - Avoid stripping leading and trailing newlines in code snippets
233 | 
234 | ## 0.11.3
235 | 
236 | - Ignore menton in blockquote element
237 | 
238 | ## 0.11.2
239 | 
240 | - Support video element on `SCRIPTABLE_RULE`
241 | 
242 | ## 0.11.1
243 | 
244 | - Support email address link
245 | 
246 | ## 0.11.0
247 | 
248 | - Add `autolink` class to autolink element
249 | - Remove activesupport runtime dependency
250 | 
251 | ## 0.10.0
252 | 
253 | - Add ImageLink filter
254 | 
255 | ## 0.9.0
256 | 
257 | - Support html-pipeline v2
258 | 
259 | ## 0.8.1
260 | 
261 | - Fix filters configurations (thx @imishinist)
262 | 
263 | ## 0.8.0
264 | 
265 | - Sanitize data-attributes
266 | 
267 | ## 0.7.1
268 | 
269 | - Support mentions to 2-character usernames
270 | 
271 | ## 0.7.0
272 | 
273 | - Support `@all`
274 | 
275 | ## 0.6.0
276 | 
277 | - Add `:escape_html` extension to Qiita::Markdown::Greenmat::HTMLToCRenderer.
278 | - Fix backward incompatibility of fragment identifier of heading that includes special HTML characters in v0.5.0.
279 | 
280 | ## 0.5.0
281 | 
282 | - Add renderers Qiita::Markdown::Greenmat::HTMLRenderer and Qiita::Markdown::Greenmat::HTMLToCRenderer which can be passed to `Redcarpet::Markdown.new` and generate consistent heading fragment identifiers.
283 | 
284 | ## 0.4.2
285 | 
286 | - Fix bug on SummaryProcessor with mention
287 | 
288 | ## 0.4.1
289 | 
290 | - Ignore mention in filename label
291 | 
292 | ## 0.4.0
293 | 
294 | - Replace the core renderer redcarpet with greenmat, which is a fork of redcarpet.
295 | - Fix a bug where mentions with username including underscores (e.g. `@_username_`) was wrongly emphasized.
296 | 
297 | ## 0.3.0
298 | 
299 | - Introduce another processor Qiita::Markdown::SummaryProcessor, which is for rendering a summary of markdown document.
300 | 
301 | ## 0.2.2
302 | 
303 | - Fix a bug that raised error on rendering `` tag with href for unknown fragment inside of `` tag (e.g. `Link`)
304 | 
305 | ## 0.2.1
306 | 
307 | - Strengthen sanitization (thx xrekkusu)
308 | 
309 | ## 0.2.0
310 | 
311 | - Support text-align style on table syntax (thx @uribou)
312 | 
313 | ## 0.1.9
314 | 
315 | - Fix a bug that raised error while rendering links with absolute URI inside of `` tag (e.g. `[Qiita](http://qiita.com/)`)
316 | 
317 | ## 0.1.8
318 | 
319 | - Add title attribute into footnote link element
320 | 
321 | ## 0.1.7
322 | 
323 | - Enable footnotes markdown syntax
324 | 
325 | ## 0.1.6
326 | 
327 | - Add missing dependency on pygments.rb (thx @kwappa)
328 | 
329 | ## 0.1.5
330 | 
331 | - Memoize Redcarpet::Markdown object
332 | 
333 | ## 0.1.4
334 | 
335 | - Support type attribute of script element
336 | 
337 | ## 0.1.3
338 | 
339 | - Support text-align syntax on table
340 | 
341 | ## 0.1.2
342 | 
343 | - Support rowspan attribute
344 | 
345 | ## 0.1.1
346 | 
347 | - Support empty list
348 | 
349 | ## 0.1.0
350 | 
351 | - Default to add disabled attribute to checkbox
352 | 
353 | ## 0.0.9
354 | 
355 | - Make it Ruby 2.0.0-compatible
356 | 
357 | ## 0.0.8
358 | 
359 | - Support gapped task list
360 | 
361 | ## 0.0.7
362 | 
363 | - Change dependent gem version
364 | 
365 | ## 0.0.6
366 | 
367 | - Remove target="_blank" from a element of mention
368 | 
369 | ## 0.0.5
370 | 
371 | - Allow font element with color attribute
372 | 
373 | ## 0.0.4
374 | 
375 | - Add iframe and data-attributes support
376 | 
377 | ## 0.0.3
378 | 
379 | - Fix bug of code block that has colon-only label
380 | 
381 | ## 0.0.2
382 | 
383 | - Remove version dependency on gemoji
384 | 
385 | ## 0.0.1
386 | 
387 | - 1st Release
388 | 


--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
 1 | source "https://rubygems.org"
 2 | 
 3 | # Specify your gem's dependencies in qiita-markdown.gemspec
 4 | gemspec
 5 | 
 6 | gem "activesupport", "~> 5.2.7"
 7 | gem "bundler"
 8 | gem "codeclimate-test-reporter", "0.4.4"
 9 | gem "pry"
10 | gem "rake"
11 | gem "rspec", "~> 3.1"
12 | gem "rubocop", "~> 1.76.0"
13 | gem "simplecov", "!= 0.18.0", "!= 0.18.1", "!= 0.18.2", "!= 0.18.3", "!= 0.18.4", "!= 0.18.5", "!= 0.19.0", "!= 0.19.1"
14 | 


--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
 1 | Copyright (c) 2014 Ryo Nakamura
 2 | 
 3 | MIT License
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining
 6 | a copy of this software and associated documentation files (the
 7 | "Software"), to deal in the Software without restriction, including
 8 | without limitation the rights to use, copy, modify, merge, publish,
 9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 | 
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 | 
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # Qiita Markdown
  2 | 
  3 | [![Gem Version](https://badge.fury.io/rb/qiita-markdown.svg)](https://badge.fury.io/rb/qiita-markdown)
  4 | [![Build Status](https://travis-ci.org/increments/qiita-markdown.svg)](https://travis-ci.org/increments/qiita-markdown)
  5 | [![Code Climate](https://codeclimate.com/github/increments/qiita-markdown/badges/gpa.svg)](https://codeclimate.com/github/increments/qiita-markdown)
  6 | [![Test Coverage](https://codeclimate.com/github/increments/qiita-markdown/badges/coverage.svg)](https://codeclimate.com/github/increments/qiita-markdown)
  7 | 
  8 | Qiita-specified markdown processor.
  9 | 
 10 | - Markdown conversion
 11 | - Sanitization
 12 | - Code and language detection
 13 | - Task list
 14 | - ToC
 15 | - Emoji
 16 | - Syntax highlighting
 17 | - Mention
 18 | - Footnotes
 19 | - Note notation's custom block
 20 | 
 21 | ## Basic Usage
 22 | 
 23 | Qiita::Markdown::Processor provides markdown rendering logic.
 24 | 
 25 | ```ruby
 26 | processor = Qiita::Markdown::Processor.new(hostname: "example.com")
 27 | processor.call(markdown)
 28 | # => {
 29 | #   codes: [
 30 | #     {
 31 | #       code: "1 + 1\n",
 32 | #       language: "ruby",
 33 | #       filename: "example.rb",
 34 | #     },
 35 | #   ],
 36 | #   mentioned_usernames: [
 37 | #     "alice",
 38 | #     "bob",
 39 | #   ],
 40 | #   output: "

Example

\n...", 41 | # } 42 | ``` 43 | 44 | ### Filters 45 | 46 | Qiita Markdown is built on [jch/html-pipeline](https://github.com/jch/html-pipeline). 47 | Add your favorite html-pipeline-compatible filters. 48 | 49 | ```ruby 50 | processor = Qiita::Markdown::Processor.new(hostname: "example.com") 51 | processor.filters << HTML::Pipeline::ImageMaxWidthFilter 52 | processor.call(text) 53 | ``` 54 | 55 | ### Context 56 | 57 | `.new` and `#call` can take optional context as a Hash with following keys: 58 | 59 | ``` 60 | :allowed_usernames - A list of usernames allowed to be username. (Array) 61 | :asset_path - URL path to link to emoji sprite. (String) 62 | :asset_root - Base URL to link to emoji sprite. (String) 63 | :base_url - Used to construct links to user profile pages for each. (String) 64 | :default_language - Default language used if no language detected from code. (String) 65 | :emoji_names - A list of allowed emoji names. (Array) 66 | :emoji_url_generator - #call'able object that accepts emoji name as argument and returns emoji image URL. (#call) 67 | The original implementation is used when the generator returned a falsey value. 68 | :hostname - FQDN. Used to check whether or not each URL of `href` attributes is external site. (String) 69 | :inline_code_color_class_name - Class name for inline code color. (String) 70 | :language_aliases - Alias table for some language names. (Hash) 71 | :markdown - A hash for enabling / disabling optional Markdown syntax. (Hash) 72 | Currently :footnotes (default: true) and :sourcepos (defalut: false) are supported. 73 | For more information on these options, please see [increments/qiita_marker](https://github.com/increments/qiita_marker). 74 | :rule - Sanitization rule table. (Hash) 75 | :script - A flag to allow to embed script element. (Boolean) 76 | ``` 77 | 78 | ```ruby 79 | processor = Qiita::Markdown::Processor.new(asset_root: "http://example.com/assets", hostname: "example.com") 80 | processor.call(text) 81 | ``` 82 | 83 | ## Rendering Summary 84 | 85 | There's another processor Qiita::Markdown::SummaryProcessor, 86 | which is for rendering a summary of markdown document. 87 | It simplifies a document by removing complex markups 88 | and also truncates it to a specific length without breaking the document structure. 89 | 90 | Note that this processor does not produce the `:codes` output in contrast to the Processor. 91 | 92 | ### Context 93 | 94 | SummaryProcessor accepts the following context in addition to the Processor's context: 95 | 96 | ```ruby 97 | { 98 | truncate: { 99 | length: 100, # Documents will be truncated if it exceeds this character count. (Integer) 100 | omission: '…' # A string added to the end of document when it's truncated. (String, nil) 101 | } 102 | } 103 | ``` 104 | 105 | ```ruby 106 | processor = Qiita::Markdown::SummaryProcessor.new(truncate: { length: 80 }, hostname: "example.com") 107 | processor.call(text) 108 | ``` 109 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "rubocop/rake_task" 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | RuboCop::RakeTask.new(:style) 7 | task default: %i[spec style] 8 | -------------------------------------------------------------------------------- /lib/qiita-markdown.rb: -------------------------------------------------------------------------------- 1 | require "qiita/markdown" 2 | -------------------------------------------------------------------------------- /lib/qiita/markdown.rb: -------------------------------------------------------------------------------- 1 | require "cgi" 2 | require "html/pipeline" 3 | require "linguist" 4 | require "mem" 5 | require "nokogiri" 6 | require "qiita_marker" 7 | require "rouge" 8 | require "sanitize" 9 | require "uri" 10 | 11 | require "qiita/markdown/embed/code_pen" 12 | require "qiita/markdown/embed/tweet" 13 | require "qiita/markdown/embed/asciinema" 14 | require "qiita/markdown/embed/youtube" 15 | require "qiita/markdown/embed/slide_share" 16 | require "qiita/markdown/embed/google_slide" 17 | require "qiita/markdown/embed/speeker_deck" 18 | require "qiita/markdown/embed/docswell" 19 | require "qiita/markdown/embed/figma" 20 | require "qiita/markdown/transformers/filter_attributes" 21 | require "qiita/markdown/transformers/filter_script" 22 | require "qiita/markdown/transformers/filter_iframe" 23 | require "qiita/markdown/transformers/strip_invalid_node" 24 | require "qiita/markdown/filters/checkbox" 25 | require "qiita/markdown/filters/code_block" 26 | require "qiita/markdown/filters/custom_block" 27 | require "qiita/markdown/filters/emoji" 28 | require "qiita/markdown/filters/external_link" 29 | require "qiita/markdown/filters/final_sanitizer" 30 | require "qiita/markdown/filters/footnote" 31 | require "qiita/markdown/filters/group_mention" 32 | require "qiita/markdown/filters/heading_anchor" 33 | require "qiita/markdown/filters/html_toc" 34 | require "qiita/markdown/filters/image_link" 35 | require "qiita/markdown/filters/inline_code_color" 36 | require "qiita/markdown/filters/mention" 37 | require "qiita/markdown/filters/qiita_marker" 38 | require "qiita/markdown/filters/simplify" 39 | require "qiita/markdown/filters/syntax_highlight" 40 | require "qiita/markdown/filters/toc" 41 | require "qiita/markdown/filters/truncate" 42 | require "qiita/markdown/filters/user_input_sanitizer" 43 | require "qiita/markdown/base_processor" 44 | require "qiita/markdown/processor" 45 | require "qiita/markdown/summary_processor" 46 | require "qiita/markdown/version" 47 | -------------------------------------------------------------------------------- /lib/qiita/markdown/base_processor.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | # An abstract base processor for rendering a Markdown document. 4 | class BaseProcessor 5 | # @return [Hash] the default context for HTML::Pipeline 6 | def self.default_context 7 | raise NotImplementedError 8 | end 9 | 10 | # @return [Array] the default HTML::Pipeline filter classes 11 | def self.default_fiters 12 | raise NotImplementedError 13 | end 14 | 15 | # @param [Hash] context Optional context for HTML::Pipeline. 16 | def initialize(context = {}) 17 | @context = self.class.default_context.merge(context) 18 | end 19 | 20 | # Converts Markdown text into HTML string with extracted metadata. 21 | # 22 | # @param [String] input Markdown text. 23 | # @param [Hash] context Optional context merged into default context. 24 | # @return [Hash] Process result. 25 | # @example 26 | # Qiita::Markdown::Processor.new.call(markdown) #=> { 27 | # codes: [...], 28 | # mentioned_usernames: [...], 29 | # output: "...", 30 | # } 31 | def call(input, context = {}) 32 | HTML::Pipeline.new(filters, @context).call(input, context) 33 | end 34 | 35 | # @note Modify filters if you want. 36 | # @return [Array] 37 | def filters 38 | @filters ||= self.class.default_filters 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/asciinema.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module Asciinema 5 | SCRIPT_HOST = "asciinema.org".freeze 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/code_pen.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module CodePen 5 | SCRIPT_URLS = [ 6 | "https://production-assets.codepen.io/assets/embed/ei.js", 7 | "https://static.codepen.io/assets/embed/ei.js", 8 | "https://cpwebassets.codepen.io/assets/embed/ei.js", 9 | "https://public.codepenassets.com/embed/index.js", 10 | ] 11 | CLASS_NAME = %w[codepen] 12 | DATA_ATTRIBUTES = %w[ 13 | data-active-link-color data-active-tab-color data-animations data-border 14 | data-border-color data-class data-custom-css-url data-default-tab 15 | data-embed-version data-height data-link-logo-color data-pen-title 16 | data-preview data-rerun-position data-show-tab-bar data-slug-hash 17 | data-tab-bar-color data-tab-link-color data-theme-id data-user 18 | ] 19 | ATTRIBUTES = %w[class] + DATA_ATTRIBUTES 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/docswell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Embed 6 | module Docswell 7 | SCRIPT_HOSTS = [ 8 | "docswell.com", 9 | "www.docswell.com", 10 | ].freeze 11 | SCRIPT_URLS = [ 12 | "https://www.docswell.com/assets/libs/docswell-embed/docswell-embed.min.js", 13 | "//www.docswell.com/assets/libs/docswell-embed/docswell-embed.min.js", 14 | ].freeze 15 | CLASS_NAME = %w[docswell-embed].freeze 16 | DATA_ATTRIBUTES = %w[ 17 | data-src data-aspect data-height-offset data-width-offset 18 | ].freeze 19 | ATTRIBUTES = %w[class] + DATA_ATTRIBUTES 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/figma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Embed 6 | module Figma 7 | SCRIPT_HOST = "www.figma.com" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/google_slide.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module GoogleSlide 5 | SCRIPT_HOST = "docs.google.com".freeze 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/slide_share.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module SlideShare 5 | SCRIPT_HOST = "www.slideshare.net".freeze 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/speeker_deck.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module SpeekerDeck 5 | SCRIPT_URLS = [ 6 | "//speakerdeck.com/assets/embed.js", 7 | ].freeze 8 | CLASS_NAME = %w[speakerdeck-embed].freeze 9 | DATA_ATTRIBUTES = %w[ 10 | data-id data-ratio 11 | ].freeze 12 | ATTRIBUTES = %w[class] + DATA_ATTRIBUTES 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/tweet.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module Tweet 5 | SCRIPT_URL = "https://platform.twitter.com/widgets.js" 6 | CLASS_NAME = %w[twitter-tweet] 7 | DATA_ATTRIBUTES = %w[ 8 | data-align data-cards data-conversation data-dnt 9 | data-id data-lang data-link-color data-theme data-width 10 | ] 11 | ATTRIBUTES = %w[class] + DATA_ATTRIBUTES 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/qiita/markdown/embed/youtube.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Embed 4 | module Youtube 5 | SCRIPT_HOSTS = [ 6 | "www.youtube-nocookie.com", 7 | "www.youtube.com", 8 | ].freeze 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/checkbox.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | # Converts [ ] and [x] into checkbox elements. 5 | # 6 | # * [x] Foo 7 | # * [ ] Bar 8 | # * [ ] Baz 9 | # 10 | class Checkbox < HTML::Pipeline::Filter 11 | def call 12 | doc.search("li").each do |li| 13 | list = List.new(li) 14 | list.convert if list.has_checkbox? 15 | end 16 | doc 17 | end 18 | 19 | class List 20 | include Mem 21 | 22 | CHECKBOX_CLOSE_MARK = "[x] " 23 | CHECKBOX_OPEN_MARK = "[ ] " 24 | 25 | def initialize(node) 26 | @node = node 27 | end 28 | 29 | def has_checkbox? 30 | has_open_checkbox? || has_close_checkbox? 31 | end 32 | 33 | def convert 34 | first_text_node.content = first_text_node.content.sub(checkbox_mark, "").lstrip 35 | first_text_node.add_previous_sibling(checkbox_node) 36 | @node["class"] = "task-list-item" 37 | end 38 | 39 | private 40 | 41 | def checkbox_mark 42 | case 43 | when has_close_checkbox? 44 | CHECKBOX_CLOSE_MARK 45 | when has_open_checkbox? 46 | CHECKBOX_OPEN_MARK 47 | end 48 | end 49 | 50 | def checkbox_node 51 | node = Nokogiri::HTML.fragment('') 52 | node.children.first["checked"] = true if has_close_checkbox? 53 | node.children.first["disabled"] = true 54 | node 55 | end 56 | 57 | def first_text_node 58 | is_loose_list_node = @node.children.first&.text == "\n" && @node.children[1]&.name == "p" 59 | 60 | if is_loose_list_node 61 | @node.children[1].children.first 62 | elsif @node.children.first && @node.children.first.name == "p" 63 | @node.children.first.children.first 64 | else 65 | @node.children.first 66 | end 67 | end 68 | memoize :first_text_node 69 | 70 | def has_close_checkbox? 71 | !!first_text_node && first_text_node.text? && first_text_node.content.start_with?(CHECKBOX_CLOSE_MARK) 72 | end 73 | memoize :has_close_checkbox? 74 | 75 | def has_open_checkbox? 76 | !!first_text_node && first_text_node.text? && first_text_node.content.start_with?(CHECKBOX_OPEN_MARK) 77 | end 78 | memoize :has_open_checkbox? 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/code_block.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | DEFAULT_LANGUAGE_ALIASES = { 5 | "el" => "common-lisp", 6 | "hack" => "php", 7 | "zsh" => "bash", 8 | } 9 | 10 | # 1. Detects language written in
 element.
 11 |       # 2. Adds lang attribute (but this attribute is consumed by syntax highlighter).
 12 |       # 3. Adds detected code data into `result[:codes]`.
 13 |       #
 14 |       # You can pass language aliases table via context[:language_aliases].
 15 |       class CodeBlock < HTML::Pipeline::Filter
 16 |         def call
 17 |           result[:codes] ||= []
 18 |           doc.search("pre").each do |pre|
 19 |             next unless (code = pre.at("code"))
 20 | 
 21 |             metadata = Metadata.new(code["data-metadata"])
 22 |             filename = metadata.filename
 23 |             language = metadata.language
 24 |             language = language_aliases[language] || language
 25 |             pre["filename"] = filename if filename
 26 |             pre["lang"] = language if language
 27 |             result[:codes] << {
 28 |               code: pre.text,
 29 |               filename: filename,
 30 |               language: language,
 31 |             }
 32 |           end
 33 |           doc
 34 |         end
 35 | 
 36 |         private
 37 | 
 38 |         def language_aliases
 39 |           context[:language_aliases] || DEFAULT_LANGUAGE_ALIASES
 40 |         end
 41 | 
 42 |         # Detects language from code block metadata.
 43 |         class Metadata
 44 |           # @param text [String, nil]
 45 |           def initialize(text)
 46 |             @text = text
 47 |           end
 48 | 
 49 |           # @return [String, nil]
 50 |           def filename
 51 |             case
 52 |             when empty?
 53 |               nil
 54 |             when has_only_filename?
 55 |               sections[0]
 56 |             else
 57 |               sections[1]
 58 |             end
 59 |           end
 60 | 
 61 |           # @example
 62 |           #   Metadata.new(nil).language #=> nil
 63 |           #   Metadata.new("ruby").language #=> "ruby"
 64 |           #   Metadata.new("ruby:foo.rb").language #=> "ruby"
 65 |           #   Metadata.new("foo.rb").language #=> "ruby"
 66 |           # @return [String, nil]
 67 |           def language
 68 |             case
 69 |             when empty?
 70 |               nil
 71 |             when !has_only_filename?
 72 |               sections[0]
 73 |             when linguist_language
 74 |               linguist_language.default_alias_name
 75 |             end
 76 |           end
 77 | 
 78 |           private
 79 | 
 80 |           def empty?
 81 |             @text.nil?
 82 |           end
 83 | 
 84 |           def has_only_filename?
 85 |             sections[1].nil? && sections[0]&.include?(".")
 86 |           end
 87 | 
 88 |           def linguist_language
 89 |             @linguist_language ||= Linguist::Language.find_by_extension(filename).first
 90 |           end
 91 | 
 92 |           def sections
 93 |             splited = (@text || "").split(":")
 94 |             @sections ||= splited.length <= 2 ? splited : @text.split(":", 2)
 95 |           end
 96 |         end
 97 |       end
 98 |     end
 99 |   end
100 | end
101 | 


--------------------------------------------------------------------------------
/lib/qiita/markdown/filters/custom_block.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module Qiita
 4 |   module Markdown
 5 |     module Filters
 6 |       class CustomBlock < HTML::Pipeline::Filter
 7 |         ALLOWED_TYPES = %w[note].freeze
 8 | 
 9 |         def call
10 |           doc.search('div[data-type="customblock"]').each do |div|
11 |             metadata = Metadata.new(div["data-metadata"])
12 |             next unless ALLOWED_TYPES.include?(metadata.type)
13 | 
14 |             klass = Object.const_get("#{self.class}::#{metadata.type.capitalize}")
15 |             klass.new(div, metadata.subtype).convert
16 |           end
17 |           doc
18 |         end
19 | 
20 |         class Metadata
21 |           attr_reader :type, :subtype
22 | 
23 |           # @param text [String, nil]
24 |           # @note Attribute `type` will be nil if `text` is nil
25 |           # @note Attribute `subtype` will be nil if `text` does not include white space.
26 |           def initialize(text)
27 |             # Discared after the second word.
28 |             @type, @subtype = text && text.split(" ")
29 |           end
30 |         end
31 | 
32 |         class Note
33 |           attr_reader :node, :type
34 | 
35 |           ALLOWED_TYPES = %w[info warn alert].freeze
36 |           DEFAULT_TYPE = "info"
37 | 
38 |           # @param node [Nokogiri::XML::Node]
39 |           # @param type [String, nil]
40 |           def initialize(node, type)
41 |             @node = node
42 |             @type = ALLOWED_TYPES.include?(type) ? type : DEFAULT_TYPE
43 |           end
44 | 
45 |           def convert
46 |             children = node.children
47 |             children.each(&:unlink)
48 |             node.add_child("
") 49 | node.children.first.children = children 50 | node["class"] = "note #{type}" 51 | node.children.first.add_previous_sibling(icon) if icon 52 | end 53 | 54 | private 55 | 56 | def icon 57 | { 58 | info: %(), 59 | warn: %(), 60 | alert: %(), 61 | }[type.to_sym] 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/emoji.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class Emoji < HTML::Pipeline::EmojiFilter 5 | # @note Override 6 | def validate 7 | needs :asset_root unless emoji_url_generator 8 | end 9 | 10 | private 11 | 12 | # @note Override 13 | def emoji_url(name) 14 | url = emoji_url_generator.call(name) if emoji_url_generator 15 | url || super 16 | end 17 | 18 | def emoji_url_generator 19 | context[:emoji_url_generator] 20 | end 21 | 22 | # @note Override 23 | def emoji_pattern 24 | @emoji_pattern ||= /:(#{Regexp.union(emoji_names).source}):/ 25 | end 26 | 27 | def emoji_names 28 | context[:emoji_names] || self.class.emoji_names 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/external_link.rb: -------------------------------------------------------------------------------- 1 | require "addressable/uri" 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | class ExternalLink < HTML::Pipeline::Filter 7 | def call 8 | doc.search("a").each do |anchor| 9 | next unless anchor["href"] 10 | 11 | href = anchor["href"].strip 12 | href_host = host_of(href) 13 | next unless href_host 14 | 15 | if href_host != hostname 16 | anchor["rel"] = "nofollow noopener" 17 | anchor["target"] = "_blank" 18 | end 19 | end 20 | 21 | doc 22 | end 23 | 24 | def validate 25 | needs :hostname 26 | end 27 | 28 | private 29 | 30 | def host_of(url) 31 | uri = Addressable::URI.parse(url) 32 | uri.host 33 | rescue Addressable::URI::InvalidURIError 34 | nil 35 | end 36 | 37 | def hostname 38 | context[:hostname] 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/final_sanitizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | # Sanitizes undesirable elements by whitelist-based rule. 7 | # You can pass optional :rule and :script context. 8 | # 9 | # Since this filter is applied at the end of html-pipeline, it's rules 10 | # are intentionally weakened to allow elements and attributes which are 11 | # generated by other filters. 12 | # 13 | # @see Qiita::Markdown::Filters::UserInputSanitizerr 14 | class FinalSanitizer < ::HTML::Pipeline::Filter 15 | RULE = { 16 | attributes: { 17 | "a" => %w[ 18 | data-hovercard-target-name 19 | data-hovercard-target-type 20 | href 21 | rel 22 | ], 23 | "blockquote" => Embed::Tweet::ATTRIBUTES, 24 | "iframe" => %w[ 25 | allowfullscreen 26 | frameborder 27 | height 28 | loading 29 | marginheight 30 | marginwidth 31 | scrolling 32 | src 33 | style 34 | width 35 | ], 36 | "img" => [ 37 | "src", 38 | ], 39 | "input" => %w[ 40 | checked 41 | disabled 42 | type 43 | ], 44 | "div" => %w[ 45 | itemscope 46 | itemtype 47 | ], 48 | "p" => Embed::CodePen::ATTRIBUTES, 49 | "script" => %w[ 50 | async 51 | src 52 | type 53 | ].concat( 54 | Embed::SpeekerDeck::ATTRIBUTES, 55 | Embed::Docswell::ATTRIBUTES, 56 | ), 57 | "span" => [ 58 | "style", 59 | ], 60 | "td" => [ 61 | "style", 62 | ], 63 | "th" => [ 64 | "style", 65 | ], 66 | "details" => [ 67 | "open", 68 | ], 69 | "video" => %w[ 70 | src 71 | autoplay 72 | controls 73 | loop 74 | muted 75 | poster 76 | ], 77 | all: %w[ 78 | abbr 79 | align 80 | alt 81 | border 82 | cellpadding 83 | cellspacing 84 | cite 85 | class 86 | color 87 | cols 88 | colspan 89 | data-lang 90 | data-sourcepos 91 | datetime 92 | height 93 | hreflang 94 | id 95 | itemprop 96 | lang 97 | name 98 | rowspan 99 | tabindex 100 | target 101 | title 102 | width 103 | ], 104 | }, 105 | css: { 106 | properties: %w[ 107 | background-color 108 | border 109 | text-align 110 | ], 111 | }, 112 | elements: %w[ 113 | a 114 | b 115 | blockquote 116 | br 117 | caption 118 | code 119 | dd 120 | del 121 | details 122 | div 123 | dl 124 | dt 125 | em 126 | font 127 | h1 128 | h2 129 | h3 130 | h4 131 | h5 132 | h6 133 | h7 134 | h8 135 | hr 136 | i 137 | img 138 | input 139 | ins 140 | kbd 141 | li 142 | ol 143 | p 144 | pre 145 | q 146 | rp 147 | rt 148 | ruby 149 | s 150 | samp 151 | script 152 | iframe 153 | section 154 | span 155 | strike 156 | strong 157 | sub 158 | summary 159 | sup 160 | table 161 | tbody 162 | td 163 | tfoot 164 | th 165 | thead 166 | tr 167 | tt 168 | ul 169 | var 170 | ], 171 | protocols: { 172 | "a" => { 173 | "href" => [ 174 | :relative, 175 | "http", 176 | "https", 177 | "mailto", 178 | ], 179 | }, 180 | "img" => { 181 | "src" => [ 182 | :relative, 183 | "http", 184 | "https", 185 | ], 186 | }, 187 | "video" => { 188 | "src" => [ 189 | :relative, 190 | "http", 191 | "https", 192 | ], 193 | "poster" => [ 194 | :relative, 195 | "http", 196 | "https", 197 | ], 198 | }, 199 | }, 200 | transformers: [ 201 | Transformers::StripInvalidNode, 202 | Transformers::FilterScript, 203 | Transformers::FilterIframe, 204 | ], 205 | }.freeze 206 | 207 | SCRIPTABLE_RULE = RULE.dup.tap do |rule| 208 | rule[:attributes] = RULE[:attributes].dup 209 | rule[:attributes][:all] = rule[:attributes][:all] + [:data] 210 | rule[:elements] = RULE[:elements] + ["video"] 211 | rule[:transformers] = rule[:transformers] - [Transformers::FilterScript, Transformers::FilterIframe] 212 | end.freeze 213 | 214 | def call 215 | ::Sanitize.clean_node!(doc, rule) 216 | doc 217 | end 218 | 219 | private 220 | 221 | def has_script_context? 222 | context[:script] == true 223 | end 224 | 225 | def rule 226 | case 227 | when context[:rule] 228 | context[:rule] 229 | when has_script_context? 230 | SCRIPTABLE_RULE 231 | else 232 | RULE 233 | end 234 | end 235 | end 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/footnote.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class Footnote < HTML::Pipeline::Filter 5 | def call 6 | doc.search("sup > a").each do |a| 7 | footnote = find_footnote(a) 8 | next unless footnote 9 | 10 | a[:title] = footnote.text.gsub(/\A\n/, "").gsub(/ ↩\n\z/, "") 11 | end 12 | doc 13 | end 14 | 15 | private 16 | 17 | def find_footnote(a) 18 | href = a["href"] 19 | return nil if !href || href.match(/\A#fn\d+\z/).nil? 20 | 21 | doc.search(href).first 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/group_mention.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class GroupMention < HTML::Pipeline::Filter 5 | # @note Override 6 | def call 7 | if context[:group_mention_url_generator] 8 | result[:mentioned_groups] ||= [] 9 | doc.search(".//text()").each do |node| 10 | mentionable_node = MentionableNode.new(node, context[:group_mention_url_generator]) 11 | unless mentionable_node.ignorable? 12 | result[:mentioned_groups] |= mentionable_node.groups 13 | node.replace(mentionable_node.replaced_html) 14 | end 15 | end 16 | end 17 | doc 18 | end 19 | 20 | class MentionableNode 21 | GROUP_IDENTIFIER_PATTERN = %r{ 22 | (?:^|\W) 23 | @((?>[a-z\d][a-z\d-]{2,31})) 24 | / 25 | ([A-Za-z\d][A-Za-z\d-]{0,62}[A-Za-z\d]) 26 | (?!/) 27 | (?= 28 | \.+[ \t\W]| 29 | \.+$| 30 | [^0-9a-zA-Z_.]| 31 | $ 32 | ) 33 | }x 34 | 35 | IGNORED_ANCESTOR_ELEMENT_NAMES = %w[ 36 | a 37 | blockquote 38 | code 39 | pre 40 | style 41 | ].freeze 42 | 43 | # @param node [Nokogiri::XML::Node] 44 | # @param group_mention_url_generator [Proc] 45 | def initialize(node, group_mention_url_generator) 46 | @group_mention_url_generator = group_mention_url_generator 47 | @node = node 48 | end 49 | 50 | # @return [Array] 51 | def groups 52 | @groups ||= [] 53 | end 54 | 55 | # @return [false, true] 56 | def ignorable? 57 | !has_at_mark? || has_any_ignored_ancestor? || !replaced? 58 | end 59 | 60 | # @return [String] 61 | def replaced_html 62 | @replaced_html ||= html.gsub(GROUP_IDENTIFIER_PATTERN) do |string| 63 | team_url_name = ::Regexp.last_match(1) 64 | group_url_name = ::Regexp.last_match(2) 65 | group = { group_url_name: group_url_name, team_url_name: team_url_name } 66 | groups << group 67 | string.sub( 68 | "@#{team_url_name}/#{group_url_name}", 69 | %() + 70 | %(@#{team_url_name}/#{group_url_name}), 71 | ) 72 | end 73 | end 74 | 75 | private 76 | 77 | # @return [false, true] 78 | def has_any_ignored_ancestor? 79 | @node.ancestors.any? do |node| 80 | IGNORED_ANCESTOR_ELEMENT_NAMES.include?(node.name.downcase) 81 | end 82 | end 83 | 84 | # @return [false, true] 85 | def has_at_mark? 86 | html.include?("@") 87 | end 88 | 89 | # @return [String] 90 | def html 91 | @html ||= @node.to_html 92 | end 93 | 94 | # @return [false, true] 95 | def replaced? 96 | html != replaced_html 97 | end 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/heading_anchor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | class HeadingAnchor < ::HTML::Pipeline::Filter 7 | def call 8 | doc.search("h1, h2, h3, h4, h5, h6").each do |heading| 9 | heading["id"] = suffixed_id(heading) 10 | end 11 | 12 | doc 13 | end 14 | 15 | private 16 | 17 | def counter 18 | @counter ||= ::Hash.new(0) 19 | end 20 | 21 | def get_count(id) 22 | counter[id] 23 | end 24 | 25 | def increment_count(id) 26 | counter[id] += 1 27 | end 28 | 29 | def heading_id(node) 30 | node.text.downcase.gsub(/[^\p{Word}\- ]/u, "").tr(" ", "-") 31 | end 32 | 33 | def suffixed_id(node) 34 | id = heading_id(node) 35 | count = get_count(id) 36 | suffix = count.positive? ? "-#{count}" : "" 37 | increment_count(id) 38 | 39 | "#{id}#{suffix}" 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/html_toc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | class HtmlToc < ::HTML::Pipeline::Filter 7 | # @return [Nokogiri::HTML::DocumentFragment] 8 | def call 9 | headings = doc.search("h1, h2, h3, h4, h5, h6") 10 | return "" if headings.empty? 11 | 12 | toc = %W[
    \n] 13 | top_level = nil 14 | last_level = nil 15 | depth = 1 16 | 17 | headings.each do |node| 18 | heading_rank = node.name.match(/h(\d)/)[1].to_i 19 | 20 | # The first heading is displayed as the top level. 21 | # The following headings, of higher rank than the first, are placed as top level. 22 | top_level ||= heading_rank 23 | current_level = [heading_rank, top_level].max 24 | 25 | link = toc_with_link(node.text, node.attributes["id"]&.value) 26 | toc << (nest_string(last_level, current_level) + link) 27 | 28 | depth += current_level - last_level if last_level 29 | 30 | last_level = current_level 31 | end 32 | 33 | toc << ("\n
\n" * depth) 34 | toc.join 35 | end 36 | 37 | private 38 | 39 | # @param text [String] 40 | # @param id [String] 41 | # @return [String] 42 | def toc_with_link(text, id) 43 | %(#{CGI.escapeHTML(text)}\n) 44 | end 45 | 46 | # @param last_level [Integer, nil] 47 | # @param current_level [Integer] 48 | # @return [String] 49 | def nest_string(last_level, current_level) 50 | if last_level.nil? 51 | return "
  • \n" 52 | elsif current_level == last_level 53 | return "
  • \n
  • \n" 54 | elsif current_level > last_level 55 | level_difference = current_level - last_level 56 | return "
      \n
    • \n" * level_difference 57 | elsif current_level < last_level 58 | level_difference = last_level - current_level 59 | return %(#{"
    • \n
    \n" * level_difference}
  • \n
  • \n) 60 | end 61 | 62 | "" 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/image_link.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class ImageLink < HTML::Pipeline::Filter 5 | def call 6 | doc.search("img").each do |img| 7 | next if img.ancestors.any? { |ancestor| ancestor.name == "a" } 8 | 9 | outer = Nokogiri::HTML.fragment(%()) 10 | inner = img.clone 11 | outer.at("a").add_child(inner) 12 | img.replace(outer) 13 | end 14 | doc 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/inline_code_color.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class InlineCodeColor < HTML::Pipeline::Filter 5 | DEFAULT_CLASS_NAME = "inline-code-color".freeze 6 | 7 | REGEXPS = Regexp.union( 8 | /\#(?:\h{3}|\h{6})/, 9 | /rgba?\(\s*(?:\d+(?:,|\s)\s*){2}\d+\s*\)/, 10 | /rgba?\(\s*(?:\d+%(?:,|\s)\s*){2}\d+%\s*\)/, 11 | /rgba?\(\s*(?:\d+,\s*){3}\d*\.?\d+%?\s*\)/, 12 | %r{rgba?\(\s*(?:\d+\s*){2}\d+\s*/\s*\d?\.?\d+%?\s*\)}, 13 | %r{rgba?\(\s*(?:\d+%\s*){2}\d+%\s*/\s*\d?\.?\d+%?\s*\)}, 14 | /hsla?\(\s*\d+(?:deg|rad|grad|turn)?,\s*\d+%,\s*\d+%\s*\)/, 15 | /hsla?\(\s*\d+(?:deg|rad|grad|turn)?\s+\d+%\s+\d+%\s*\)/, 16 | /hsla?\(\s*\d+(?:deg|rad|grad|turn)?,\s*(?:\d+%,\s*){2}\d?\.?\d+%?\s*\)/, 17 | %r{hsla?\(\s*\d+(?:deg|rad|grad|turn)?\s+\d+%\s+\d+%\s*/\s*\d?\.?\d+%?\s*\)}, 18 | ) 19 | 20 | COLOR_CODE_PATTERN = /\A\s*(#{REGEXPS})\s*\z/ 21 | 22 | def call 23 | doc.search(".//code").each do |node| 24 | if (color = node.inner_text) =~ COLOR_CODE_PATTERN 25 | node.add_child(color_element(color.strip)) 26 | end 27 | end 28 | doc 29 | end 30 | 31 | private 32 | 33 | def color_element(color) 34 | %() 35 | end 36 | 37 | def inline_code_color_class_name 38 | context[:inline_code_color_class_name] || DEFAULT_CLASS_NAME 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/mention.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | # 1. Adds :mentioned_usernames into result Hash as Array of String. 5 | # 2. Replaces @mention with link. 6 | # 7 | # You can pass :allowed_usernames context to limit mentioned usernames. 8 | class Mention < HTML::Pipeline::MentionFilter 9 | IGNORE_PARENTS = ::HTML::Pipeline::MentionFilter::IGNORE_PARENTS + Set["blockquote"] 10 | 11 | MentionPattern = %r{ 12 | (?:^|\W) 13 | @((?>\w[\w-]{0,30}\w(?:@github)?)) 14 | (?!/) 15 | (?= 16 | \.+[ \t\W]| 17 | \.+$| 18 | [^0-9a-zA-Z_.]| 19 | $ 20 | ) 21 | }ix 22 | 23 | # @note Override to use another IGNORE_PARENTS 24 | def call 25 | result[:mentioned_usernames] ||= [] 26 | 27 | doc.search(".//text()").each do |node| 28 | content = node.to_html 29 | next unless content.include?("@") 30 | next if has_ancestor?(node, IGNORE_PARENTS) 31 | 32 | html = mention_link_filter(content, base_url, info_url, username_pattern) 33 | next if html == content 34 | 35 | node.replace(html) 36 | end 37 | doc 38 | end 39 | 40 | # @note Override to use customized MentionPattern and allowed_usernames logic. 41 | def mention_link_filter(text, _, _, _) 42 | text.gsub(MentionPattern) do |match| 43 | name = ::Regexp.last_match(1) 44 | case 45 | when allowed_usernames && name == "all" 46 | result[:mentioned_usernames] |= allowed_usernames 47 | match.sub( 48 | "@#{name}", 49 | %(@#{name}), 50 | ) 51 | when (allowed_usernames && !allowed_usernames.include?(name)) || name == "all" 52 | match 53 | else 54 | result[:mentioned_usernames] |= [name] 55 | url = File.join(base_url, name) 56 | match.sub( 57 | "@#{name}", 58 | %(@#{name}), 59 | ) 60 | end 61 | end 62 | end 63 | 64 | private 65 | 66 | def allowed_usernames 67 | context[:allowed_usernames] 68 | end 69 | 70 | def has_ancestor?(node, tags) 71 | super || (node.parent.parent && node.parent.parent["class"] == "code-lang") 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/qiita_marker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | class QiitaMarker < ::HTML::Pipeline::TextFilter 7 | DEFAULT_OPTIONS = { 8 | footnotes: true, 9 | sourcepos: false, 10 | }.freeze 11 | 12 | # @return [Nokogiri::HTML::DocumentFragment] 13 | def call 14 | ::Nokogiri::HTML.fragment(render(@text)) 15 | end 16 | 17 | private 18 | 19 | # @param text [String] 20 | # @return [String] 21 | def render(text) 22 | ::QiitaMarker.render_html(text, qiita_marker_options, qiita_marker_extensions) 23 | end 24 | 25 | def qiita_marker_options 26 | options_to_append = (options[:footnotes] ? [:FOOTNOTES] : []) 27 | .concat(options[:sourcepos] ? [:SOURCEPOS] : []) 28 | @qiita_marker_options ||= %i[ 29 | HARDBREAKS 30 | UNSAFE 31 | LIBERAL_HTML_TAG 32 | STRIKETHROUGH_DOUBLE_TILDE 33 | TABLE_PREFER_STYLE_ATTRIBUTES 34 | CODE_DATA_METADATA 35 | MENTION_NO_EMPHASIS 36 | AUTOLINK_CLASS_NAME 37 | ].concat(options_to_append) 38 | end 39 | 40 | def qiita_marker_extensions 41 | @qiita_marker_extensions ||= %i[ 42 | table 43 | strikethrough 44 | autolink 45 | custom_block 46 | ] 47 | end 48 | 49 | def options 50 | @options ||= DEFAULT_OPTIONS.merge(context[:markdown] || {}) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/simplify.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | # A filter for simplifying document structure by removing complex markups 5 | # (mainly block elements) and complex contents. 6 | # 7 | # The logic of this filter is similar to the `FinalSanitizer` filter, but this 8 | # does not use the `sanitize` gem internally for the following reasons: 9 | # 10 | # * Each filter should do only its own responsibility, and this filter is 11 | # _not_ for sanitization. 12 | # 13 | # * The `sanitize` gem automatically adds extra transformers even if we 14 | # want to clean up only some elements, and they would be run in the 15 | # `FinalSanitizer` filter later. 16 | # https://github.com/rgrove/sanitize/blob/v3.1.2/lib/sanitize.rb#L77-L100 17 | class Simplify < HTML::Pipeline::Filter 18 | SIMPLE_ELEMENTS = %w[a b code em i ins q s samp span strike strong sub sup var] 19 | 20 | COMPLEX_CONTENT_ELEMENTS = %w[table] 21 | 22 | def call 23 | remove_complex_contents 24 | clean_complex_markups 25 | doc 26 | end 27 | 28 | private 29 | 30 | # Remove complex elements along with their contents entirely. 31 | def remove_complex_contents 32 | selector = COMPLEX_CONTENT_ELEMENTS.join(",") 33 | doc.search(selector).each(&:remove) 34 | end 35 | 36 | # Remove complex markups while keeping their contents. 37 | def clean_complex_markups 38 | doc.traverse do |node| 39 | next unless node.element? 40 | next if SIMPLE_ELEMENTS.include?(node.name) 41 | 42 | node.replace(node.children) 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/syntax_highlight.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class SyntaxHighlight < HTML::Pipeline::Filter 5 | DEFAULT_LANGUAGE = "text" 6 | DEFAULT_TIMEOUT = Float::INFINITY 7 | DEFAULT_OPTION = "html_legacy" 8 | 9 | def call 10 | elapsed = 0 11 | timeout_fallback_language = nil 12 | doc.search("pre").each do |node| 13 | elapsed += measure_time do 14 | Highlighter.call( 15 | default_language: default_language, 16 | node: node, 17 | specific_language: timeout_fallback_language, 18 | ) 19 | end 20 | if elapsed >= timeout 21 | timeout_fallback_language = DEFAULT_LANGUAGE 22 | result[:syntax_highlight_timed_out] = true 23 | end 24 | end 25 | doc 26 | end 27 | 28 | private 29 | 30 | def default_language 31 | context[:default_language] || DEFAULT_LANGUAGE 32 | end 33 | 34 | def measure_time 35 | t1 = Time.now 36 | yield 37 | t2 = Time.now 38 | t2 - t1 39 | end 40 | 41 | def timeout 42 | context[:syntax_highlight_timeout] || DEFAULT_TIMEOUT 43 | end 44 | 45 | class Highlighter 46 | def self.call(**args) 47 | new(**args).call 48 | end 49 | 50 | def initialize(default_language: nil, node: nil, specific_language: nil) 51 | @default_language = default_language 52 | @node = node 53 | @specific_language = specific_language 54 | end 55 | 56 | def call 57 | outer = Nokogiri::HTML.fragment(%(
    )) 58 | frame = outer.at("div") 59 | frame.add_child(filename_node) if filename 60 | frame.add_child(highlighted_node) 61 | @node.replace(outer) 62 | end 63 | 64 | private 65 | 66 | def code 67 | @node.inner_text 68 | end 69 | 70 | def filename 71 | @node["filename"] 72 | end 73 | 74 | def filename_node 75 | %(
    #{filename}
    ) 76 | end 77 | 78 | def has_inline_php? 79 | specific_language == "php" && code !~ /^<\?php/ 80 | end 81 | 82 | def highlight(language) 83 | Rouge.highlight(code, language, DEFAULT_OPTION) 84 | end 85 | 86 | def highlighted_node 87 | if specific_language && Rouge::Lexer.find(specific_language) 88 | begin 89 | highlight(specific_language).presence or raise 90 | rescue StandardError 91 | highlight(@default_language) 92 | end 93 | else 94 | highlight(@default_language) 95 | end 96 | end 97 | 98 | def language 99 | specific_language || @default_language 100 | end 101 | 102 | def language_node 103 | Nokogiri::HTML.fragment(%(
    )) 104 | end 105 | 106 | def specific_language 107 | @specific_language || @node["lang"] 108 | end 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/toc.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | class Toc < HTML::Pipeline::Filter 5 | def call 6 | doc.css("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]").each do |node| 7 | Heading.new(node).decorate 8 | end 9 | doc 10 | end 11 | 12 | class Heading 13 | def initialize(node) 14 | @node = node 15 | @id = node.attr("id") 16 | raise unless @id 17 | end 18 | 19 | def decorate 20 | remove_heading_id 21 | first_child.add_previous_sibling(anchor_element) if first_child 22 | end 23 | 24 | def remove_heading_id 25 | @node.remove_attribute("id") 26 | end 27 | 28 | def anchor_element 29 | %() 30 | end 31 | 32 | def first_child 33 | @first_child ||= @node.children.first 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/truncate.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Filters 4 | # A filter for truncating a document without breaking the document 5 | # structure. 6 | # 7 | # You can pass `:length` and `:omission` option to :truncate context. 8 | # 9 | # @example 10 | # Truncate.new(doc, truncate: { length: 50, omission: '... (continued)' }) 11 | class Truncate < HTML::Pipeline::Filter 12 | DEFAULT_OPTIONS = { 13 | length: 100, 14 | omission: "…".freeze, 15 | }.freeze 16 | 17 | def call 18 | @current_length = 0 19 | @previous_char_was_blank = false 20 | 21 | traverse(doc) do |node| 22 | if exceeded? 23 | node.remove 24 | elsif node.text? 25 | process_text_node(node) 26 | end 27 | end 28 | 29 | doc 30 | end 31 | 32 | private 33 | 34 | # Traverse the given node recursively in the depth-first order. 35 | # Note that we cannot use Nokogiri::XML::Node#traverse 36 | # since it traverses the node's descendants _before_ the node itself. 37 | # https://github.com/sparklemotion/nokogiri/blob/v1.6.6.2/lib/nokogiri/xml/node.rb#L571-L574 38 | def traverse(node, &block) 39 | yield(node) 40 | 41 | node.children.each do |child_node| 42 | traverse(child_node, &block) 43 | end 44 | end 45 | 46 | def exceeded? 47 | @current_length > max_length 48 | end 49 | 50 | def process_text_node(node) 51 | node.content.each_char.with_index do |char, index| 52 | current_char_is_blank = char.strip.empty? 53 | 54 | @current_length += 1 if !@previous_char_was_blank || !current_char_is_blank 55 | 56 | @previous_char_was_blank = current_char_is_blank 57 | 58 | if exceeded? 59 | node.content = node.content.slice(0...(index - omission.size)) + omission 60 | break 61 | end 62 | end 63 | end 64 | 65 | def max_length 66 | options[:length] 67 | end 68 | 69 | def omission 70 | options[:omission] || "".freeze 71 | end 72 | 73 | def options 74 | @options ||= DEFAULT_OPTIONS.merge(context[:truncate] || {}) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/qiita/markdown/filters/user_input_sanitizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Filters 6 | # Sanitizes user input if :strict context is given. 7 | class UserInputSanitizer < ::HTML::Pipeline::Filter 8 | RULE = { 9 | elements: %w[ 10 | a b blockquote br caption code dd del details div dl dt em font h1 h2 h3 h4 h5 h6 11 | hr i img ins kbd li ol p pre q rp rt ruby s samp script iframe section strike strong sub 12 | summary sup table tbody td tfoot th thead tr ul var 13 | ], 14 | attributes: { 15 | "a" => %w[class href rel title id], 16 | "blockquote" => %w[cite] + Embed::Tweet::ATTRIBUTES, 17 | "code" => %w[data-metadata], 18 | "div" => %w[class data-type data-metadata], 19 | "details" => %w[open], 20 | "font" => %w[color], 21 | "h1" => %w[id], 22 | "h2" => %w[id], 23 | "h3" => %w[id], 24 | "h4" => %w[id], 25 | "h5" => %w[id], 26 | "h6" => %w[id], 27 | "img" => %w[alt height src title width], 28 | "ins" => %w[cite datetime], 29 | "li" => %w[id], 30 | "p" => Embed::CodePen::ATTRIBUTES, 31 | "q" => %w[cite], 32 | "section" => %w[class], 33 | "script" => %w[async src id].concat( 34 | Embed::SpeekerDeck::ATTRIBUTES, 35 | Embed::Docswell::ATTRIBUTES, 36 | ), 37 | "iframe" => %w[ 38 | allowfullscreen 39 | frameborder 40 | height 41 | loading 42 | marginheight 43 | marginwidth 44 | scrolling 45 | src 46 | style 47 | width 48 | ], 49 | "sup" => %w[id], 50 | "td" => %w[colspan rowspan style], 51 | "th" => %w[colspan rowspan style], 52 | all: %w[data-sourcepos], 53 | }, 54 | protocols: { 55 | "a" => { "href" => ["http", "https", "mailto", :relative] }, 56 | "blockquote" => { "cite" => ["http", "https", :relative] }, 57 | "q" => { "cite" => ["http", "https", :relative] }, 58 | }, 59 | css: { 60 | properties: %w[text-align border], 61 | }, 62 | transformers: [ 63 | Transformers::FilterAttributes, 64 | Transformers::FilterScript, 65 | Transformers::FilterIframe, 66 | ], 67 | }.freeze 68 | 69 | def call 70 | ::Sanitize.clean_node!(doc, RULE) if context[:strict] 71 | doc 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/qiita/markdown/processor.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | class Processor < BaseProcessor 4 | def self.default_context 5 | { 6 | asset_root: "/images", 7 | } 8 | end 9 | 10 | def self.default_filters 11 | [ 12 | Filters::QiitaMarker, 13 | Filters::HeadingAnchor, 14 | Filters::UserInputSanitizer, 15 | Filters::ImageLink, 16 | Filters::Footnote, 17 | Filters::CodeBlock, 18 | Filters::CustomBlock, 19 | Filters::Checkbox, 20 | Filters::Toc, 21 | Filters::Emoji, 22 | Filters::SyntaxHighlight, 23 | Filters::Mention, 24 | Filters::GroupMention, 25 | Filters::ExternalLink, 26 | Filters::InlineCodeColor, 27 | Filters::FinalSanitizer, 28 | ] 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/qiita/markdown/summary_processor.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | # A processor for rendering a summary of markdown document. This simplifies 4 | # a document by removing complex markups and also truncates it to a 5 | # specific length without breaking the document structure. 6 | class SummaryProcessor < BaseProcessor 7 | def self.default_context 8 | { 9 | asset_root: "/images", 10 | markdown: { 11 | footnotes: false, 12 | }, 13 | } 14 | end 15 | 16 | def self.default_filters 17 | [ 18 | Filters::QiitaMarker, 19 | Filters::UserInputSanitizer, 20 | Filters::Simplify, 21 | Filters::Emoji, 22 | Filters::Mention, 23 | Filters::ExternalLink, 24 | Filters::FinalSanitizer, 25 | Filters::Truncate, 26 | ] 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/qiita/markdown/transformers/filter_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Qiita 4 | module Markdown 5 | module Transformers 6 | class FilterAttributes 7 | FILTERS = { 8 | "a" => { 9 | "class" => %w[autolink], 10 | "rel" => %w[footnote url], 11 | "rev" => %w[footnote], 12 | "id" => /\Afnref-.+\z/, 13 | }, 14 | "blockquote" => { 15 | "class" => Embed::Tweet::CLASS_NAME, 16 | }, 17 | "div" => { 18 | "class" => %w[footnotes], 19 | }, 20 | "p" => { 21 | "class" => Embed::CodePen::CLASS_NAME, 22 | }, 23 | "section" => { 24 | "class" => %w[footnotes], 25 | }, 26 | "sup" => { 27 | "id" => /\Afnref\d+\z/, 28 | }, 29 | "li" => { 30 | "id" => /\Afn.+\z/, 31 | }, 32 | }.freeze 33 | 34 | DELIMITER = " " 35 | 36 | def self.call(**args) 37 | new(**args).transform 38 | end 39 | 40 | def initialize(env) 41 | @env = env 42 | end 43 | 44 | def transform 45 | return unless FILTERS.key?(name) 46 | 47 | FILTERS[name].each_pair do |attr, pattern| 48 | filter_attribute(attr, pattern) if node.attributes.key?(attr) 49 | end 50 | end 51 | 52 | private 53 | 54 | def filter_attribute(attr, pattern) 55 | node[attr] = node[attr].split(DELIMITER).select do |value| 56 | pattern.is_a?(Array) ? pattern.include?(value) : (pattern =~ value) 57 | end.join(DELIMITER) 58 | end 59 | 60 | def name 61 | @env[:node_name] 62 | end 63 | 64 | def node 65 | @env[:node] 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/qiita/markdown/transformers/filter_iframe.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Transformers 4 | class FilterIframe 5 | URL_WHITE_LIST = [].flatten.freeze 6 | 7 | HOST_WHITE_LIST = [ 8 | Embed::Youtube::SCRIPT_HOSTS, 9 | Embed::SlideShare::SCRIPT_HOST, 10 | Embed::GoogleSlide::SCRIPT_HOST, 11 | Embed::Docswell::SCRIPT_HOSTS, 12 | Embed::Figma::SCRIPT_HOST, 13 | ].flatten.freeze 14 | 15 | def self.call(**args) 16 | new(**args).transform 17 | end 18 | 19 | def initialize(env) 20 | @env = env 21 | end 22 | 23 | def transform 24 | if name == "iframe" 25 | if URL_WHITE_LIST.include?(node["src"]) || HOST_WHITE_LIST.include?(host_of(node["src"])) 26 | node["width"] = "100%" 27 | node.children.unlink 28 | else 29 | node.unlink 30 | end 31 | end 32 | end 33 | 34 | private 35 | 36 | def name 37 | @env[:node_name] 38 | end 39 | 40 | def node 41 | @env[:node] 42 | end 43 | 44 | def host_of(url) 45 | if url 46 | scheme = URI.parse(url).scheme 47 | Addressable::URI.parse(url).host if ["http", "https", nil].include? scheme 48 | end 49 | rescue Addressable::URI::InvalidURIError, URI::InvalidURIError 50 | nil 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/qiita/markdown/transformers/filter_script.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Transformers 4 | class FilterScript 5 | URL_WHITE_LIST = [ 6 | Embed::CodePen::SCRIPT_URLS, 7 | Embed::Tweet::SCRIPT_URL, 8 | Embed::SpeekerDeck::SCRIPT_URLS, 9 | Embed::Docswell::SCRIPT_URLS, 10 | ].flatten.freeze 11 | 12 | HOST_WHITE_LIST = [ 13 | Embed::Asciinema::SCRIPT_HOST, 14 | ].flatten.freeze 15 | 16 | def self.call(**args) 17 | new(**args).transform 18 | end 19 | 20 | def initialize(env) 21 | @env = env 22 | end 23 | 24 | def transform 25 | if name == "script" 26 | if URL_WHITE_LIST.include?(node["src"]) || HOST_WHITE_LIST.include?(host_of(node["src"])) 27 | node["async"] = "async" unless node.attributes.key?("async") 28 | node.children.unlink 29 | else 30 | node.unlink 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | def name 38 | @env[:node_name] 39 | end 40 | 41 | def node 42 | @env[:node] 43 | end 44 | 45 | def host_of(url) 46 | if url 47 | scheme = URI.parse(url).scheme 48 | Addressable::URI.parse(url).host if %w[http https].include? scheme 49 | end 50 | rescue Addressable::URI::InvalidURIError, URI::InvalidURIError 51 | nil 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/qiita/markdown/transformers/strip_invalid_node.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | module Transformers 4 | # Wraps a node env to transform invalid node. 5 | class StripInvalidNode 6 | def self.call(**args) 7 | new(**args).transform 8 | end 9 | 10 | def initialize(env) 11 | @env = env 12 | end 13 | 14 | def transform 15 | node.replace(node.children) if has_invalid_list_node? || has_invalid_table_node? 16 | end 17 | 18 | private 19 | 20 | def has_invalid_list_node? 21 | name == "li" && node.ancestors.none? do |ancestor| 22 | %w[ol ul].include?(ancestor.name) 23 | end 24 | end 25 | 26 | def has_invalid_table_node? 27 | %w[thead tbody tfoot tr td th].include?(name) && node.ancestors.none? do |ancestor| 28 | ancestor.name == "table" 29 | end 30 | end 31 | 32 | def name 33 | @env[:node_name] 34 | end 35 | 36 | def node 37 | @env[:node] 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/qiita/markdown/version.rb: -------------------------------------------------------------------------------- 1 | module Qiita 2 | module Markdown 3 | VERSION = "1.2.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /qiita-markdown.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "qiita/markdown/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "qiita-markdown" 7 | spec.version = Qiita::Markdown::VERSION 8 | spec.authors = ["Ryo Nakamura"] 9 | spec.email = ["r7kamura@gmail.com"] 10 | spec.summary = "Qiita-specified markdown processor." 11 | spec.homepage = "https://github.com/increments/qiita-markdown" 12 | spec.license = "MIT" 13 | 14 | spec.files = `git ls-files -z`.split("\x0") 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.require_paths = ["lib"] 17 | 18 | spec.required_ruby_version = ">= 3.0.0" 19 | 20 | spec.add_dependency "addressable" 21 | spec.add_dependency "gemoji" 22 | spec.add_dependency "github-linguist", "~> 7.0" 23 | spec.add_dependency "html-pipeline", "~> 2.0" 24 | spec.add_dependency "mem" 25 | spec.add_dependency "qiita_marker", "~> 0.23.9" 26 | spec.add_dependency "rouge", "~> 4.2" 27 | spec.add_dependency "sanitize" 28 | spec.metadata["rubygems_mfa_required"] = "true" 29 | end 30 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/checkbox_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Qiita::Markdown::Filters::Checkbox do 4 | subject(:filter) do 5 | described_class.new(input_html) 6 | end 7 | 8 | context "with checkbox" do 9 | let(:input_html) do 10 | <<~HTML 11 |
  • [ ] a
  • 12 |
  • [x] a
  • 13 | HTML 14 | end 15 | 16 | let(:output_html) do 17 | <<~HTML 18 |
  • 19 | a
  • 20 |
  • 21 | a
  • 22 | HTML 23 | end 24 | 25 | it "replaces checkboxes" do 26 | expect(filter.call.to_s).to eq(output_html) 27 | end 28 | 29 | context "when list is loose" do 30 | let(:input_html) do 31 | <<~HTML 32 |
  • 33 |

    [ ] a

    34 |
  • 35 |
  • 36 |

    [x] b

    37 |
  • 38 | HTML 39 | end 40 | 41 | let(:output_html) do 42 | <<~HTML 43 |
  • 44 |

    a

    45 |
  • 46 |
  • 47 |

    b

    48 |
  • 49 | HTML 50 | end 51 | 52 | it "replaces checkboxes" do 53 | expect(filter.call.to_s).to eq(output_html) 54 | end 55 | end 56 | 57 | context "when input html has many spaces after checkbox mark" do 58 | let(:input_html) do 59 | <<~HTML 60 |
  • [ ] a
  • 61 |
  • [x] a
  • 62 | HTML 63 | end 64 | 65 | it "replaces checkboxes and remove spaces" do 66 | expect(filter.call.to_s).to eq(output_html) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/code_block_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Qiita::Markdown::Filters::CodeBlock do 4 | subject(:filter) { described_class.new(input_html) } 5 | 6 | let(:context) { nil } 7 | 8 | context "without code" do 9 | let(:input_html) do 10 | <<~HTML 11 |
     12 |         
    13 | HTML 14 | end 15 | 16 | it "does not change" do 17 | expect(filter.call.to_s).to eq(input_html) 18 | end 19 | end 20 | 21 | context "with code" do 22 | let(:input_html) do 23 | <<~HTML 24 |
    
     25 |         
    26 | HTML 27 | end 28 | 29 | it "does not change" do 30 | expect(filter.call.to_s).to eq(input_html) 31 | end 32 | 33 | context "with data-metadata" do 34 | let(:input_html) do 35 | <<~HTML 36 |
    
     37 |           
    38 | HTML 39 | end 40 | 41 | it "does not change" do 42 | expect(filter.call.to_s).to eq(input_html) 43 | end 44 | 45 | context "with data-metadata value" do 46 | let(:input_html) do 47 | <<~HTML 48 |
    
     49 |             
    50 | HTML 51 | end 52 | 53 | let(:output_html) do 54 | <<~HTML 55 |
    
     56 |             
    57 | HTML 58 | end 59 | 60 | it "adds lang on pre" do 61 | expect(filter.call.to_s).to eq(output_html) 62 | end 63 | 64 | context "with value include filename" do 65 | let(:input_html) do 66 | <<~HTML 67 |
    
     68 |               
    69 | HTML 70 | end 71 | 72 | let(:output_html) do 73 | <<~HTML 74 |
    
     75 |               
    76 | HTML 77 | end 78 | 79 | it "adds lang and filename on pre" do 80 | expect(filter.call.to_s).to eq(output_html) 81 | end 82 | end 83 | end 84 | 85 | context "with data-metadata value like filename" do 86 | let(:input_html) do 87 | <<~HTML 88 |
    
     89 |             
    90 | HTML 91 | end 92 | 93 | let(:output_html) do 94 | <<~HTML 95 |
    
     96 |             
    97 | HTML 98 | end 99 | 100 | it "adds lang and filename on pre" do 101 | expect(filter.call.to_s).to eq(output_html) 102 | end 103 | end 104 | 105 | context "with data-metadata value like filename without extension" do 106 | let(:input_html) do 107 | <<~HTML 108 |
    
    109 |             
    110 | HTML 111 | end 112 | 113 | let(:output_html) do 114 | <<~HTML 115 |
    
    116 |             
    117 | HTML 118 | end 119 | 120 | it "adds lang and filename on pre" do 121 | expect(filter.call.to_s).to eq(output_html) 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/heading_anchor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Qiita::Markdown::Filters::HeadingAnchor do 4 | subject(:filter) { described_class.new(html) } 5 | 6 | let(:html) do 7 | <<~HTML 8 |

    foo

    9 |

    bar

    10 |

    fizz

    11 |

    paragraph

    12 |

    buzz

    13 |
    hoge
    14 |
    fuga
    15 | code 16 | HTML 17 | end 18 | 19 | it "renders ids" do 20 | expect(filter.call.to_s).to eq(<<~HTML) 21 |

    foo

    22 |

    bar

    23 |

    fizz

    24 |

    paragraph

    25 |

    buzz

    26 |
    hoge
    27 |
    fuga
    28 | code 29 | HTML 30 | end 31 | 32 | context "with headings text is same" do 33 | let(:html) do 34 | <<~HTML 35 |

    foo

    36 |

    foo

    37 |

    foo

    38 |

    paragraph

    39 |

    foo

    40 |
    foo
    41 |
    foo
    42 | code 43 | HTML 44 | end 45 | 46 | it "renders suffixed ids" do 47 | expect(filter.call.to_s).to eq(<<~HTML) 48 |

    foo

    49 |

    foo

    50 |

    foo

    51 |

    paragraph

    52 |

    foo

    53 |
    foo
    54 |
    foo
    55 | code 56 | HTML 57 | end 58 | end 59 | 60 | context "with characters that cannot included" do 61 | let(:html) do 62 | <<~HTML 63 |

    test [foo-bar]

    64 | HTML 65 | end 66 | 67 | it "renders id with omitted characters" do 68 | expect(filter.call.to_s).to eq(<<~HTML) 69 |

    test [foo-bar]

    70 | HTML 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/html_toc_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Qiita::Markdown::Filters::HtmlToc do 4 | subject(:filter) { described_class.new(html) } 5 | 6 | context "with headings h1, h2, h3, h4, h5, h6" do 7 | let(:html) do 8 | <<~HTML 9 |

    foo

    10 |

    bar

    11 |

    fizz

    12 |

    paragraph

    13 |

    buzz

    14 |
    hoge
    15 |
    fuga
    16 | code 17 | HTML 18 | end 19 | 20 | let(:result) do 21 | <<~HTML 22 |
      23 |
    • 24 | foo 25 |
        26 |
      • 27 | bar 28 |
          29 |
        • 30 | fizz 31 |
            32 |
          • 33 | buzz 34 |
              35 |
            • 36 | hoge 37 |
                38 |
              • 39 | fuga 40 |
              • 41 |
              42 |
            • 43 |
            44 |
          • 45 |
          46 |
        • 47 |
        48 |
      • 49 |
      50 |
    • 51 |
    52 | HTML 53 | end 54 | 55 | it "renders nested toc" do 56 | expect(filter.call).to eq(result) 57 | end 58 | end 59 | 60 | context "headings are same rank" do 61 | let(:html) do 62 | <<~HTML 63 |

    foo

    64 |

    bar

    65 |

    fizz

    66 | HTML 67 | end 68 | 69 | let(:result) do 70 | <<~HTML 71 |
      72 |
    • 73 | foo 74 |
    • 75 |
    • 76 | bar 77 |
    • 78 |
    • 79 | fizz 80 |
    • 81 |
    82 | HTML 83 | end 84 | 85 | it "renders toc of same level" do 86 | expect(filter.call).to eq(result) 87 | end 88 | end 89 | 90 | context "with heading rank going up" do 91 | let(:html) do 92 | <<~HTML 93 |

    foo

    94 |

    bar

    95 |

    bazz

    96 | HTML 97 | end 98 | 99 | let(:result) do 100 | <<~HTML 101 |
      102 |
    • 103 | foo 104 |
        105 |
      • 106 |
          107 |
        • 108 | bar 109 |
        • 110 |
        111 |
      • 112 |
      113 |
    • 114 |
    • 115 | bazz 116 |
    • 117 |
    118 | HTML 119 | end 120 | 121 | it "renders toc that the depth goes up" do 122 | expect(filter.call).to eq(result) 123 | end 124 | end 125 | 126 | context "with starting from h2" do 127 | let(:html) do 128 | <<~HTML 129 |

    bar

    130 |

    fizz

    131 | HTML 132 | end 133 | 134 | let(:result) do 135 | <<~HTML 136 |
      137 |
    • 138 | bar 139 |
        140 |
      • 141 | fizz 142 |
      • 143 |
      144 |
    • 145 |
    146 | HTML 147 | end 148 | 149 | it "renders h2 as top level" do 150 | expect(filter.call).to eq(result) 151 | end 152 | end 153 | 154 | context "with some heading rank is higher than first heading" do 155 | let(:html) do 156 | <<~HTML 157 |

    foo

    158 |

    bar

    159 |

    fizz

    160 |

    bazz

    161 | HTML 162 | end 163 | 164 | let(:result) do 165 | <<~HTML 166 |
      167 |
    • 168 | foo 169 |
        170 |
      • 171 | bar 172 |
      • 173 |
      174 |
    • 175 |
    • 176 | fizz 177 |
    • 178 |
    • 179 | bazz 180 |
    • 181 |
    182 | HTML 183 | end 184 | 185 | it "renders higher rank headings at the same level as the first heading" do 186 | expect(filter.call).to eq(result) 187 | end 188 | end 189 | 190 | context "with include html tag" do 191 | let(:html) do 192 | <<~HTML 193 |

    foo

    194 | HTML 195 | end 196 | 197 | let(:result) do 198 | <<~HTML 199 |
      200 |
    • 201 | foo 202 |
    • 203 |
    204 | HTML 205 | end 206 | 207 | it "anchor text does not include html tag" do 208 | expect(filter.call).to eq(result) 209 | end 210 | end 211 | 212 | context "without headings" do 213 | let(:html) do 214 | <<~HTML 215 |

    paragraph

    216 | HTML 217 | end 218 | 219 | it "renders empty string" do 220 | expect(filter.call.to_s).to eq("") 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/inline_code_color_spec.rb: -------------------------------------------------------------------------------- 1 | describe Qiita::Markdown::Filters::InlineCodeColor do 2 | subject(:filter) do 3 | described_class.new(html) 4 | end 5 | 6 | let(:html) do 7 | "

    #{color}

    " 8 | end 9 | 10 | shared_examples "adds span element for its color" do |color| 11 | let(:color) { color } 12 | 13 | it "adds span element for its color" do 14 | expect(filter.call.to_s).to eq(%(

    #{color}

    )) 15 | end 16 | end 17 | 18 | shared_examples "does not add span element" do |color| 19 | let(:color) { color } 20 | 21 | it "does not add span element" do 22 | expect(filter.call.to_s).to eq(%(

    #{color}

    )) 23 | end 24 | end 25 | 26 | context "when contents of code is hexadecimal color" do 27 | %w[ 28 | #000 29 | #f03 30 | #F03 31 | #fff 32 | #FFF 33 | #000000 34 | #ff0033 35 | #FF0033 36 | #ffffff 37 | #FFFFFF 38 | ].each do |color| 39 | context "when contents of code is #{color}" do 40 | include_examples "adds span element for its color", color 41 | end 42 | end 43 | end 44 | 45 | context "when contents of code is not hexadecimal color" do 46 | %w[ 47 | #-1-1-1 48 | #ggg 49 | #GGG 50 | #gggggg 51 | #gggGGG 52 | #GGGGGG 53 | ].each do |color| 54 | context "when contents of code is #{color}" do 55 | include_examples "does not add span element", color 56 | end 57 | end 58 | end 59 | 60 | context "when contents of code is rgb color" do 61 | [ 62 | "rgb(255,0,51)", 63 | "rgb(255, 0, 51)", 64 | "rgb(100%,0%,20%)", 65 | "rgb(100%, 0%, 20%)", 66 | "rgb(255,0,0,0.4)", 67 | "rgb(255, 0, 0, 0.4)", 68 | "rgb(255,0,0,.4)", 69 | "rgb(255, 0, 0, .4)", 70 | "rgb(255,0,0,40%)", 71 | "rgb(255, 0, 0, 40%)", 72 | "rgb(255 0 0/0.4)", 73 | "rgb(255 0 0 / 0.4)", 74 | "rgb(255 0 0/.4)", 75 | "rgb(255 0 0 / .4)", 76 | "rgb(255 0 0/40%)", 77 | "rgb(255 0 0 / 40%)", 78 | ].each do |color| 79 | context "when contents of code is #{color}" do 80 | include_examples "adds span element for its color", color 81 | end 82 | end 83 | end 84 | 85 | context "when contents of code is not rgb color" do 86 | [ 87 | "rgb(0)", 88 | "rgb(0, 0)", 89 | "rgb(0, 0, 0%)", 90 | "rgb(0, 0, 0, 0, 0)", 91 | ].each do |color| 92 | context "when contents of code is #{color}" do 93 | include_examples "does not add span element", color 94 | end 95 | end 96 | end 97 | 98 | context "when contents of code is rgba color" do 99 | [ 100 | "rgba(255,0,0,0)", 101 | "rgba(255, 0, 0, 0)", 102 | "rgba(255,0,0,0.1)", 103 | "rgba(255, 0, 0, 0.1)", 104 | "rgba(255,0,0,.4)", 105 | "rgba(255, 0, 0, .4)", 106 | "rgba(255,0,0,1)", 107 | "rgba(255, 0, 0, 1)", 108 | "rgba(255 0 0/0.4)", 109 | "rgba(255 0 0 / 0.4)", 110 | "rgba(255 0 0/.4)", 111 | "rgba(255 0 0 / .4)", 112 | "rgba(255 0 0/40%)", 113 | "rgba(255 0 0 / 40%)", 114 | ].each do |color| 115 | context "when contents of code is #{color}" do 116 | include_examples "adds span element for its color", color 117 | end 118 | end 119 | end 120 | 121 | context "when contents of code is not rgba color" do 122 | [ 123 | "rgba(0)", 124 | "rgba(0, 0)", 125 | "rgba(0, 0, 0%)", 126 | "rgba(0, 0, 0, 0, 0)", 127 | ].each do |color| 128 | context "when contents of code is #{color}" do 129 | include_examples "does not add span element", color 130 | end 131 | end 132 | end 133 | 134 | context "when contents of code is hsl color" do 135 | [ 136 | "hsl(0,100%,50%)", 137 | "hsl(0, 100%, 50%)", 138 | "hsl(360,100%,50%)", 139 | "hsl(360, 100%, 50%)", 140 | "hsl(120 60% 70%)", 141 | "hsl(120deg 60% 70%)", 142 | "hsl(240,100%,50%,0.05)", 143 | "hsl(240, 100%, 50%, 0.05)", 144 | "hsl(240,100%,50%,.05)", 145 | "hsl(240, 100%, 50%, .05)", 146 | "hsl(240,100%,50%,5%)", 147 | "hsl(240, 100%, 50%, 5%)", 148 | "hsl(240 100% 50%/0.05)", 149 | "hsl(240 100% 50% / 0.05)", 150 | "hsl(240 100% 50%/.05)", 151 | "hsl(240 100% 50% / .05)", 152 | "hsl(240 100% 50%/5%)", 153 | "hsl(240 100% 50% / 5%)", 154 | ].each do |color| 155 | context "when contents of code is #{color}" do 156 | include_examples "adds span element for its color", color 157 | end 158 | end 159 | end 160 | 161 | context "when contents of code is not hsl color" do 162 | [ 163 | "hsl(0)", 164 | "hsl(0, 0)", 165 | "hsl(0, 0, 0)", 166 | "hsl(0, 0, 0, 0)", 167 | ].each do |color| 168 | context "when contents of code is #{color}" do 169 | include_examples "does not add span element", color 170 | end 171 | end 172 | end 173 | 174 | context "when contents of code is hsla color" do 175 | [ 176 | "hsla(240,100%,50%,0.05)", 177 | "hsla(240, 100%, 50%, 0.05)", 178 | "hsla(240,100%,50%,.05)", 179 | "hsla(240, 100%, 50%, .05)", 180 | "hsla(240 100% 50%/0.05)", 181 | "hsla(240 100% 50% / 0.05)", 182 | "hsla(240 100% 50%/5%)", 183 | "hsla(240 100% 50% / 5%)", 184 | "hsla(240deg 100% 50% / 5%)", 185 | "hsla(240deg,100%,50%, 0.4)", 186 | ].each do |color| 187 | context "when contents of code is #{color}" do 188 | include_examples "adds span element for its color", color 189 | end 190 | end 191 | 192 | context "when contents of code is not hsla color" do 193 | [ 194 | "hsla(0)", 195 | "hsla(0, 0)", 196 | "hsla(0, 0, 0)", 197 | "hsla(0, 0, 0, 0)", 198 | ].each do |color| 199 | context "when contents of code is #{color}" do 200 | include_examples "does not add span element", color 201 | end 202 | end 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /spec/qiita/markdown/filters/qiita_marker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Qiita::Markdown::Filters::QiitaMarker do 4 | subject(:filter) { described_class.new(markdown, context) } 5 | 6 | let(:context) { nil } 7 | 8 | context "with footnotes" do 9 | let(:markdown) do 10 | <<~MD 11 | foo [^1] 12 | [^1]: bar 13 | MD 14 | end 15 | 16 | it "renders footnotes" do 17 | expect(filter.call.to_s).to include('class="footnotes"') 18 | end 19 | 20 | context "and disable footnotes option" do 21 | let(:context) do 22 | { 23 | markdown: { 24 | footnotes: false, 25 | }, 26 | } 27 | end 28 | 29 | it "does not render footnotes" do 30 | expect(filter.call.to_s).not_to include('class="footnotes"') 31 | end 32 | end 33 | end 34 | 35 | context "with sourcepos" do 36 | let(:markdown) do 37 | <<~MD 38 | foo bar 39 | MD 40 | end 41 | 42 | it "does not render HTML containing data-sourcepos" do 43 | expect(filter.call.to_s).not_to include("data-sourcepos") 44 | end 45 | 46 | context "and enable sourcepos option" do 47 | let(:context) do 48 | { 49 | markdown: { 50 | sourcepos: true, 51 | }, 52 | } 53 | end 54 | 55 | it "renders HTML containing data-sourcepos" do 56 | expect(filter.call.to_s).to include("data-sourcepos") 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/qiita/markdown/processor_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/strip" 2 | 3 | describe Qiita::Markdown::Processor do 4 | describe "#call" do 5 | subject do 6 | result[:output].to_s 7 | end 8 | 9 | let(:context) do 10 | { hostname: "example.com" } 11 | end 12 | 13 | let(:markdown) do 14 | raise NotImplementedError 15 | end 16 | 17 | let(:result) do 18 | described_class.new(context).call(markdown) 19 | end 20 | 21 | shared_examples_for "basic markdown syntax" do 22 | context "with valid condition" do 23 | let(:markdown) do 24 | <<-MARKDOWN.strip_heredoc 25 | example 26 | MARKDOWN 27 | end 28 | 29 | it "returns a Hash with HTML output and other metadata" do 30 | expect(result[:codes]).to be_an Array 31 | expect(result[:mentioned_usernames]).to be_an Array 32 | expect(result[:output]).to be_a Nokogiri::HTML::DocumentFragment 33 | end 34 | end 35 | 36 | context "with HTML-characters" do 37 | let(:markdown) do 38 | "<>&" 39 | end 40 | 41 | it "sanitizes them" do 42 | should eq <<-HTML.strip_heredoc 43 |

    <>&

    44 | HTML 45 | end 46 | end 47 | 48 | context "with email address" do 49 | let(:markdown) do 50 | "test@example.com" 51 | end 52 | 53 | it "replaces with mailto link" do 54 | should eq <<-HTML.strip_heredoc 55 |

    test@example.com

    56 | HTML 57 | end 58 | end 59 | 60 | context "with headings" do 61 | let(:markdown) do 62 | <<-MARKDOWN.strip_heredoc 63 | # a 64 | ## a 65 | ### a 66 | ### a 67 | MARKDOWN 68 | end 69 | 70 | it "adds ID for ToC" do 71 | should eq <<-HTML.strip_heredoc 72 |

    73 | a

    74 |

    75 | a

    76 |

    77 | a

    78 |

    79 | a

    80 | HTML 81 | end 82 | end 83 | 84 | context "with heading whose title includes special HTML characters" do 85 | let(:markdown) do 86 | <<-MARKDOWN.strip_heredoc 87 | # R&B 88 | MARKDOWN 89 | end 90 | 91 | it "generates fragment identifier by sanitizing the special characters in the title" do 92 | should eq <<-HTML.strip_heredoc 93 |

    94 | R&B 95 |

    96 | HTML 97 | end 98 | end 99 | 100 | context "with manually inputted heading HTML tags without id attribute" do 101 | let(:markdown) do 102 | <<-MARKDOWN.strip_heredoc 103 |

    foo

    104 | MARKDOWN 105 | end 106 | 107 | it "adds ID for ToC" do 108 | should eq <<-HTML.strip_heredoc 109 |

    110 | foo

    111 | HTML 112 | end 113 | end 114 | 115 | context "with code" do 116 | let(:markdown) do 117 | <<-MARKDOWN.strip_heredoc 118 | ```foo.rb 119 | puts 'hello world' 120 | ``` 121 | MARKDOWN 122 | end 123 | 124 | it "returns detected codes" do 125 | expect(result[:codes]).to eq [ 126 | { 127 | code: "puts 'hello world'\n", 128 | filename: "foo.rb", 129 | language: "ruby", 130 | }, 131 | ] 132 | end 133 | end 134 | 135 | context "with code & filename" do 136 | let(:markdown) do 137 | <<-MARKDOWN.strip_heredoc 138 | ```example.rb 139 | 1 140 | ``` 141 | MARKDOWN 142 | end 143 | 144 | it "returns code-frame, code-lang, and highlighted pre element" do 145 | should eq <<-HTML.strip_heredoc 146 |
    147 |
    example.rb
    148 |
    1
     149 |             
    150 |
    151 | HTML 152 | end 153 | end 154 | 155 | context "with code & filename with `:`" do 156 | let(:markdown) do 157 | <<-MARKDOWN.strip_heredoc 158 | ```ruby:test:example.rb 159 | 1 160 | ``` 161 | MARKDOWN 162 | end 163 | 164 | it "returns code-frame, code-lang, and highlighted pre element" do 165 | should eq <<-HTML.strip_heredoc 166 |
    167 |
    test:example.rb
    168 |
    1
     169 |             
    170 |
    171 | HTML 172 | end 173 | end 174 | 175 | context "with code & filename with .php" do 176 | let(:markdown) do 177 | <<-MARKDOWN.strip_heredoc 178 | ```example.php 179 | 1 180 | ``` 181 | MARKDOWN 182 | end 183 | 184 | it "returns PHP code-frame" do 185 | should eq <<-HTML.strip_heredoc 186 |
    187 |
    example.php
    188 |
    1
     189 |             
    190 |
    191 | HTML 192 | end 193 | end 194 | 195 | context "with code & no filename" do 196 | let(:markdown) do 197 | <<-MARKDOWN.strip_heredoc 198 | ```ruby 199 | 1 200 | ``` 201 | MARKDOWN 202 | end 203 | 204 | it "returns code-frame and highlighted pre element" do 205 | should eq <<-HTML.strip_heredoc 206 |
    1
     207 |             
    208 | HTML 209 | end 210 | end 211 | 212 | context "with undefined but aliased language" do 213 | let(:markdown) do 214 | <<-MARKDOWN.strip_heredoc 215 | ```zsh 216 | true 217 | ``` 218 | MARKDOWN 219 | end 220 | 221 | it "returns aliased language name" do 222 | expect(result[:codes]).to eq [ 223 | { 224 | code: "true\n", 225 | filename: nil, 226 | language: "bash", 227 | }, 228 | ] 229 | end 230 | end 231 | 232 | context "with code with leading and trailing newlines" do 233 | let(:markdown) do 234 | <<-MARKDOWN.strip_heredoc 235 | ``` 236 | 237 | foo 238 | 239 | ``` 240 | MARKDOWN 241 | end 242 | 243 | it "does not strip the newlines" do 244 | should eq <<-HTML.strip_heredoc 245 |
    
     246 |             foo
     247 | 
     248 |             
    249 | HTML 250 | end 251 | end 252 | 253 | context "with mention" do 254 | let(:markdown) do 255 | "@alice" 256 | end 257 | 258 | it "replaces mention with link" do 259 | should include(<<-HTML.strip_heredoc.rstrip) 260 | @alice 261 | HTML 262 | end 263 | end 264 | 265 | context "with mention to short name user" do 266 | let(:markdown) do 267 | "@al" 268 | end 269 | 270 | it "replaces mention with link" do 271 | should include(<<-HTML.strip_heredoc.rstrip) 272 | @al 273 | HTML 274 | end 275 | end 276 | 277 | context "with mentions in complex patterns" do 278 | let(:markdown) do 279 | <<-MARKDOWN.strip_heredoc 280 | @alice 281 | 282 | ``` 283 | @bob 284 | ``` 285 | 286 | @charlie/@dave 287 | @ell_en 288 | @fran-k 289 | @Isaac 290 | @justin 291 | @justin 292 | @mallory@github 293 | @#{'o' * 33} 294 | @o 295 | @o- 296 | @-o 297 | @o_ 298 | @_o 299 | MARKDOWN 300 | end 301 | 302 | it "extracts mentions correctly" do 303 | expect(result[:mentioned_usernames]).to eq %w[ 304 | alice 305 | dave 306 | ell_en 307 | fran-k 308 | Isaac 309 | justin 310 | mallory@github 311 | o_ 312 | _o 313 | ] 314 | end 315 | end 316 | 317 | context "with mention-like filename on code block" do 318 | let(:markdown) do 319 | <<-MARKDOWN.strip_heredoc 320 | ```ruby:@alice 321 | 1 322 | ``` 323 | MARKDOWN 324 | end 325 | 326 | it "does not treat it as mention" do 327 | should include(<<-HTML.strip_heredoc.rstrip) 328 |
    329 |
    @alice
    330 |
    1
     331 |             
    332 |
    333 | HTML 334 | end 335 | end 336 | 337 | context "with mention in blockquote" do 338 | let(:markdown) do 339 | "> @alice" 340 | end 341 | 342 | it "does not replace mention with link" do 343 | should include(<<-HTML.strip_heredoc.rstrip) 344 |
    345 |

    @alice

    346 |
    347 | HTML 348 | end 349 | end 350 | 351 | context "with mention to user whose name starts and ends with underscore" do 352 | let(:markdown) do 353 | "@_alice_" 354 | end 355 | 356 | it "does not emphasize the name" do 357 | should include(<<-HTML.strip_heredoc.rstrip) 358 | @_alice_ 359 | HTML 360 | end 361 | end 362 | 363 | context "with allowed_usernames context" do 364 | before do 365 | context[:allowed_usernames] = ["alice"] 366 | end 367 | 368 | let(:markdown) do 369 | <<-MARKDOWN.strip_heredoc 370 | @alice 371 | @bob 372 | MARKDOWN 373 | end 374 | 375 | it "limits mentions to allowed usernames" do 376 | expect(result[:mentioned_usernames]).to eq ["alice"] 377 | end 378 | end 379 | 380 | context "with @all and allowed_usernames context" do 381 | before do 382 | context[:allowed_usernames] = %w[alice bob] 383 | end 384 | 385 | let(:markdown) do 386 | "@all" 387 | end 388 | 389 | it "links it and reports all allowed users as mentioned user names" do 390 | should include(<<-HTML.strip_heredoc.rstrip) 391 | @all 392 | HTML 393 | expect(result[:mentioned_usernames]).to eq context[:allowed_usernames] 394 | end 395 | end 396 | 397 | context "with @all and @alice" do 398 | before do 399 | context[:allowed_usernames] = %w[alice bob] 400 | end 401 | 402 | let(:markdown) do 403 | "@all @alice" 404 | end 405 | 406 | it "does not duplicate mentioned user names" do 407 | expect(result[:mentioned_usernames]).to eq context[:allowed_usernames] 408 | end 409 | end 410 | 411 | context "with @all and no allowed_usernames context" do 412 | let(:markdown) do 413 | "@all" 414 | end 415 | 416 | it "does not repond to @all" do 417 | should eq "

    @all

    \n" 418 | expect(result[:mentioned_usernames]).to eq [] 419 | end 420 | end 421 | 422 | context "with group mention without group_memberion_url_generator" do 423 | let(:markdown) do 424 | "@alice/bob" 425 | end 426 | 427 | it "does not replace it" do 428 | is_expected.to eq <<-HTML.strip_heredoc 429 |

    @alice/bob

    430 | HTML 431 | end 432 | end 433 | 434 | context "with group mention" do 435 | let(:context) do 436 | super().merge(group_mention_url_generator: lambda do |group| 437 | "https://#{group[:team_url_name]}.example.com/groups/#{group[:group_url_name]}" 438 | end) 439 | end 440 | 441 | let(:markdown) do 442 | "@alice/bob" 443 | end 444 | 445 | it "replaces it with preferred link and updates :mentioned_groups" do 446 | is_expected.to eq <<-HTML.strip_heredoc 447 |

    @alice/bob

    448 | HTML 449 | expect(result[:mentioned_groups]).to eq [{ 450 | group_url_name: "bob", 451 | team_url_name: "alice", 452 | }] 453 | end 454 | end 455 | 456 | context "with group mention following another text" do 457 | let(:context) do 458 | super().merge(group_mention_url_generator: lambda do |group| 459 | "https://#{group[:team_url_name]}.example.com/groups/#{group[:group_url_name]}" 460 | end) 461 | end 462 | 463 | let(:markdown) do 464 | "FYI @alice/bob" 465 | end 466 | 467 | it "preserves space after preceding text" do 468 | is_expected.to start_with("

    FYI

    480 | HTML 481 | end 482 | end 483 | 484 | context "with anchor link" do 485 | let(:markdown) do 486 | "[](#example)" 487 | end 488 | 489 | it "creates link for that" do 490 | should eq <<-HTML.strip_heredoc 491 |

    492 | HTML 493 | end 494 | end 495 | 496 | context "with link with title" do 497 | let(:markdown) do 498 | '[](/example "Title")' 499 | end 500 | 501 | it "creates link for that with the title" do 502 | should eq <<-HTML.strip_heredoc 503 |

    504 | HTML 505 | end 506 | end 507 | 508 | context "with raw URL" do 509 | let(:markdown) do 510 | "http://example.com/search?q=日本語" 511 | end 512 | 513 | it "creates link for that with .autolink class" do 514 | should eq( 515 | '

    ' \ 516 | "http://example.com/search?q=日本語

    \n", 517 | ) 518 | end 519 | end 520 | 521 | context "with javascript: link" do 522 | let(:markdown) do 523 | "[](javascript:alert(1))" 524 | end 525 | 526 | it "removes that link by creating empty a element" do 527 | should eq <<-HTML.strip_heredoc 528 |

    529 | HTML 530 | end 531 | end 532 | 533 | context "with emoji" do 534 | let(:markdown) do 535 | ":+1:" 536 | end 537 | 538 | it "replaces it with img element" do 539 | should include("img") 540 | end 541 | end 542 | 543 | context "with emoji in pre or code element" do 544 | let(:markdown) do 545 | <<-MARKDOWN.strip_heredoc 546 | ``` 547 | :+1: 548 | ``` 549 | MARKDOWN 550 | end 551 | 552 | it "does not replace it" do 553 | should_not include("img") 554 | end 555 | end 556 | 557 | context "with image notation" do 558 | let(:markdown) do 559 | "![a](http://example.com/b.png)" 560 | end 561 | 562 | it "wraps it in a element" do 563 | should eq %(

    ) + 564 | %(a

    \n) 565 | end 566 | end 567 | 568 | context "with image notation with title" do 569 | let(:markdown) do 570 | '![a](http://example.com/b.png "Title")' 571 | end 572 | 573 | it "generates tag with the title" do 574 | should eq %(

    ) + 575 | %(a

    \n) 576 | end 577 | end 578 | 579 | context "with tag with width and height attribute (for Retina image)" do 580 | let(:markdown) do 581 | 'a' 582 | end 583 | 584 | it "wraps it in a element while keeping the width attribute" do 585 | should eq %() + 586 | %(a\n) 587 | end 588 | end 589 | 590 | context "with colon-only label" do 591 | let(:markdown) do 592 | <<-MARKDOWN.strip_heredoc 593 | ```: 594 | 1 595 | ``` 596 | MARKDOWN 597 | end 598 | 599 | it "does not replace it" do 600 | expect(result[:codes]).to eq [ 601 | { 602 | code: "1\n", 603 | filename: nil, 604 | language: nil, 605 | }, 606 | ] 607 | end 608 | end 609 | 610 | context "with font element with color attribute" do 611 | let(:markdown) do 612 | %(test) 613 | end 614 | 615 | it "allows font element with color attribute" do 616 | should eq <<-HTML.strip_heredoc 617 |

    #{markdown}

    618 | HTML 619 | end 620 | end 621 | 622 | context "with task list" do 623 | let(:markdown) do 624 | <<-MARKDOWN.strip_heredoc 625 | - [ ] a 626 | - [x] b 627 | MARKDOWN 628 | end 629 | 630 | it "inserts checkbox" do 631 | should eq <<-HTML.strip_heredoc 632 |
      633 |
    • 634 | a
    • 635 |
    • 636 | b
    • 637 |
    638 | HTML 639 | end 640 | end 641 | 642 | context "with nested task list" do 643 | let(:markdown) do 644 | <<-MARKDOWN.strip_heredoc 645 | - [ ] a 646 | - [ ] b 647 | MARKDOWN 648 | end 649 | 650 | it "inserts checkbox" do 651 | should eq <<-HTML.strip_heredoc 652 |
      653 |
    • 654 | a 655 |
        656 |
      • 657 | b
      • 658 |
      659 |
    • 660 |
    661 | HTML 662 | end 663 | end 664 | 665 | context "with task list in code block" do 666 | let(:markdown) do 667 | <<-MARKDOWN.strip_heredoc 668 | ``` 669 | - [ ] a 670 | - [x] b 671 | ``` 672 | MARKDOWN 673 | end 674 | 675 | it "does not replace checkbox" do 676 | should eq <<-HTML.strip_heredoc 677 |
    - [ ] a
     678 |             - [x] b
     679 |             
    680 | HTML 681 | end 682 | end 683 | 684 | context "with empty line between task list" do 685 | let(:markdown) do 686 | <<-MARKDOWN.strip_heredoc 687 | - [ ] a 688 | 689 | - [x] b 690 | MARKDOWN 691 | end 692 | 693 | it "inserts checkbox" do 694 | should eq <<-HTML.strip_heredoc 695 |
      696 |
    • 697 |

      a

      698 |
    • 699 |
    • 700 |

      b

      701 |
    • 702 |
    703 | HTML 704 | end 705 | end 706 | 707 | context "with empty list" do 708 | let(:markdown) do 709 | "- \n" 710 | end 711 | 712 | it "inserts checkbox" do 713 | should eq <<-HTML.strip_heredoc 714 |
      715 |
    • 716 |
    717 | HTML 718 | end 719 | end 720 | 721 | context "with text-aligned table" do 722 | let(:markdown) do 723 | <<-MARKDOWN.strip_heredoc 724 | | a | b | c | 725 | |:---|---:|:---:| 726 | | a | b | c | 727 | MARKDOWN 728 | end 729 | 730 | it "creates table element with text-align style" do 731 | should eq <<-HTML.strip_heredoc 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 |
    abc
    abc
    748 | HTML 749 | end 750 | end 751 | 752 | context "with footenotes syntax" do 753 | let(:markdown) do 754 | <<-MARKDOWN.strip_heredoc 755 | [^1] 756 | [^1]: test 757 | MARKDOWN 758 | end 759 | 760 | it "generates footnotes elements" do 761 | should include('class="footnotes"') 762 | end 763 | end 764 | 765 | context "with footenotes syntax with code block" do 766 | let(:markdown) do 767 | <<-MARKDOWN.strip_heredoc 768 | ``` 769 | [^1] 770 | [^1]: test 771 | ``` 772 | MARKDOWN 773 | end 774 | 775 | it "generates only code blocks without footnotes" do 776 | should eq <<-HTML.strip_heredoc 777 |
    [^1]
     778 |             [^1]: test
     779 |             
    780 | HTML 781 | end 782 | end 783 | 784 | context "with manually written link inside of tag" do 785 | let(:markdown) do 786 | <<-MARKDOWN.strip_heredoc 787 | [Example](http://example.com/) 788 | MARKDOWN 789 | end 790 | 791 | it "does not confuse the structure with automatically generated footnote reference" do 792 | should eq <<-HTML.strip_heredoc 793 |

    Example

    794 | HTML 795 | end 796 | end 797 | 798 | context "with manually written tag with strange href inside of tag" do 799 | let(:markdown) do 800 | <<-MARKDOWN.strip_heredoc 801 | Link
    802 | MARKDOWN 803 | end 804 | 805 | it "does not confuse the structure with automatically generated footnote reference" do 806 | should eq <<-HTML.strip_heredoc 807 |

    Link

    808 | HTML 809 | end 810 | end 811 | 812 | context "with markdown: { footnotes: false } context" do 813 | before do 814 | context[:markdown] = { footnotes: false } 815 | end 816 | 817 | let(:markdown) do 818 | <<-MARKDOWN.strip_heredoc 819 | [^1] 820 | [^1]: test 821 | MARKDOWN 822 | end 823 | 824 | it "does not generate footnote elements" do 825 | should eq <<-HTML.strip_heredoc 826 |

    [^1]
    827 | [^1]: test

    828 | HTML 829 | end 830 | end 831 | 832 | context "with emoji_names and emoji_url_generator context" do 833 | before do 834 | context[:emoji_names] = %w[foo o] 835 | 836 | context[:emoji_url_generator] = proc do |emoji_name| 837 | "https://example.com/foo.png" if emoji_name == "foo" 838 | end 839 | end 840 | 841 | let(:markdown) do 842 | <<-MARKDOWN.strip_heredoc 843 | :foo: :o: :x: 844 | MARKDOWN 845 | end 846 | 847 | it "replaces only the specified emoji names with img elements with custom URL" do 848 | should include( 849 | ':foo:' \ 871 | "http://qiita.com/?a=b

    \n", 872 | ) 873 | end 874 | end 875 | 876 | context "with external URL" do 877 | let(:markdown) do 878 | "http://external.com/?a=b" 879 | end 880 | 881 | let(:context) do 882 | { hostname: "qiita.com" } 883 | end 884 | 885 | it "creates link which has rel='nofollow noopener' and target='_blank'" do 886 | should eq( 887 | '

    ' \ 888 | "http://external.com/?a=b

    \n", 889 | ) 890 | end 891 | end 892 | 893 | context "with internal anchor tag" do 894 | let(:markdown) do 895 | 'foobar' 896 | end 897 | 898 | let(:context) do 899 | { hostname: "qiita.com" } 900 | end 901 | 902 | it "creates link which does not have rel='nofollow noopener' and target='_blank'" do 903 | should eq( 904 | "

    foobar

    \n", 905 | ) 906 | end 907 | end 908 | 909 | context "with external anchor tag" do 910 | let(:markdown) do 911 | 'foobar' 912 | end 913 | 914 | let(:context) do 915 | { hostname: "qiita.com" } 916 | end 917 | 918 | it "creates link which has rel='nofollow noopener' and target='_blank'" do 919 | should eq( 920 | "

    foobar

    \n", 921 | ) 922 | end 923 | end 924 | 925 | context "with external URL which ends with the hostname parameter" do 926 | let(:markdown) do 927 | "http://qqqqqqiita.com/?a=b" 928 | end 929 | 930 | let(:context) do 931 | { hostname: "qiita.com" } 932 | end 933 | 934 | it "creates link which has rel='nofollow noopener' and target='_blank'" do 935 | should eq( 936 | '

    ' \ 937 | "http://qqqqqqiita.com/?a=b

    \n", 938 | ) 939 | end 940 | end 941 | 942 | context "with external anchor tag which ends with the hostname parameter" do 943 | let(:markdown) do 944 | 'foobar' 945 | end 946 | 947 | let(:context) do 948 | { hostname: "qiita.com" } 949 | end 950 | 951 | it "creates link which has rel='nofollow noopener' and target='_blank'" do 952 | should eq( 953 | "

    foobar

    \n", 954 | ) 955 | end 956 | end 957 | 958 | context "with sub-domain URL of hostname parameter" do 959 | let(:markdown) do 960 | "http://sub.qiita.com/?a=b" 961 | end 962 | 963 | let(:context) do 964 | { hostname: "qiita.com" } 965 | end 966 | 967 | it "creates link which has rel='nofollow noopener' and target='_blank'" do 968 | should eq( 969 | '

    ' \ 970 | "http://sub.qiita.com/?a=b

    \n", 971 | ) 972 | end 973 | end 974 | 975 | context "with external anchor tag which has rel attribute" do 976 | let(:markdown) do 977 | 'foobar' 978 | end 979 | 980 | let(:context) do 981 | { hostname: "qiita.com" } 982 | end 983 | 984 | it "creates link which has rel='nofollow noopener' and target='_blank', and rel value is overwritten" do 985 | should eq( 986 | "

    foobar

    \n", 987 | ) 988 | end 989 | end 990 | 991 | context "with blockquote syntax" do 992 | let(:markdown) do 993 | "> foo" 994 | end 995 | 996 | it "does not confuse it with HTML tag angle brackets" do 997 | should eq "
    \n

    foo

    \n
    \n" 998 | end 999 | end 1000 | 1001 | context "with inline code containing hexadecimal color only" do 1002 | let(:markdown) do 1003 | "`#FF0000`" 1004 | end 1005 | 1006 | it "returns code element with its color" do 1007 | should eq "

    #FF0000

    \n" 1008 | end 1009 | 1010 | context "with class name of inline code color parameter" do 1011 | let(:context) do 1012 | super().merge(inline_code_color_class_name: "qiita-inline-code-color") 1013 | end 1014 | 1015 | it "returns returns code element with its color whose class name is parameter value" do 1016 | should eq "

    #FF0000

    \n" 1017 | end 1018 | end 1019 | end 1020 | 1021 | context "with inline code containing rgb color only" do 1022 | let(:markdown) do 1023 | "`rgb(255, 0, 0)`" 1024 | end 1025 | 1026 | it "returns code element with its color" do 1027 | should eq "

    rgb(255, 0, 0)

    \n" 1028 | end 1029 | 1030 | context "with class name of inline code color parameter" do 1031 | let(:context) do 1032 | super().merge(inline_code_color_class_name: "qiita-inline-code-color") 1033 | end 1034 | 1035 | it "returns returns code element with its color whose class name is parameter value" do 1036 | should eq "

    rgb(255, 0, 0)

    \n" 1037 | end 1038 | end 1039 | end 1040 | 1041 | context "with inline code containing hsl color only" do 1042 | let(:markdown) do 1043 | "`hsl(0, 100%, 50%)`" 1044 | end 1045 | 1046 | it "returns code element with its color" do 1047 | should eq "

    hsl(0, 100%, 50%)

    \n" 1048 | end 1049 | 1050 | context "with class name of inline code color parameter" do 1051 | let(:context) do 1052 | super().merge(inline_code_color_class_name: "qiita-inline-code-color") 1053 | end 1054 | 1055 | it "returns returns code element with its color whose class name is parameter value" do 1056 | should eq "

    hsl(0, 100%, 50%)

    \n" 1057 | end 1058 | end 1059 | end 1060 | 1061 | context "with details tag" do 1062 | let(:markdown) do 1063 | <<-MARKDOWN.strip_heredoc 1064 |
    Folding sample
    1065 | 1066 | ```rb 1067 | puts "Hello, World" 1068 | ``` 1069 |
    1070 | 1071 |
    Folding sample2 1072 | 1073 | it allows open attributes 1074 |
    1075 | MARKDOWN 1076 | end 1077 | 1078 | it "returns HTML output parsed as markdown" do 1079 | expect(subject).to eq <<-HTML.strip_heredoc 1080 |
    Folding sample
    1081 |
    puts "Hello, World"
    1082 |             
    1083 |
    1084 |
    Folding sample2 1085 |

    it allows open attributes

    1086 |
    1087 | HTML 1088 | end 1089 | end 1090 | end 1091 | 1092 | shared_examples_for "script element" do |allowed:| 1093 | context "with script element" do 1094 | let(:markdown) do 1095 | <<-MARKDOWN.strip_heredoc 1096 | 1097 | MARKDOWN 1098 | end 1099 | 1100 | if allowed 1101 | it "allows script element" do 1102 | should eq markdown 1103 | end 1104 | 1105 | context "and allowed attributes" do 1106 | let(:markdown) do 1107 | <<-MARKDOWN.strip_heredoc 1108 |

    1109 | MARKDOWN 1110 | end 1111 | 1112 | it "allows data-attributes" do 1113 | should eq markdown 1114 | end 1115 | end 1116 | else 1117 | it "removes script element" do 1118 | should eq "\n" 1119 | end 1120 | end 1121 | end 1122 | end 1123 | 1124 | shared_examples_for "malicious script in filename" do |allowed:| 1125 | context "with malicious script in filename" do 1126 | let(:markdown) do 1127 | <<-MARKDOWN.strip_heredoc 1128 | ```js:test 1129 | 1 1130 | ``` 1131 | MARKDOWN 1132 | end 1133 | 1134 | if allowed 1135 | it "does not sanitize script element" do 1136 | should eq <<-HTML.strip_heredoc 1137 |
    1138 |
    test
    1139 |
    1
    1140 |               
    1141 |
    1142 | HTML 1143 | end 1144 | else 1145 | it "sanitizes script element" do 1146 | should eq <<-HTML.strip_heredoc 1147 |
    1148 |
    test
    1149 |
    1
    1150 |               
    1151 |
    1152 | HTML 1153 | end 1154 | end 1155 | end 1156 | end 1157 | 1158 | shared_examples_for "iframe element" do |allowed:| 1159 | shared_examples "iframe element example" do 1160 | let(:markdown) do 1161 | <<-MARKDOWN.strip_heredoc 1162 | 1163 | MARKDOWN 1164 | end 1165 | let(:url) { "#{scheme}//example.com" } 1166 | 1167 | if allowed 1168 | it "allows iframe with some attributes" do 1169 | should eq markdown 1170 | end 1171 | else 1172 | it "sanitizes iframe element" do 1173 | should eq "\n" 1174 | end 1175 | end 1176 | end 1177 | 1178 | context "with iframe" do 1179 | context "with scheme" do 1180 | let(:scheme) { "https:" } 1181 | 1182 | include_examples "iframe element example" 1183 | end 1184 | 1185 | context "without scheme" do 1186 | let(:scheme) { "" } 1187 | 1188 | include_examples "iframe element example" 1189 | end 1190 | end 1191 | end 1192 | 1193 | shared_examples_for "input element" do |allowed:| 1194 | context "with input" do 1195 | let(:markdown) do 1196 | <<-MARKDOWN.strip_heredoc 1197 | foo 1198 | MARKDOWN 1199 | end 1200 | 1201 | if allowed 1202 | it "allows input with some attributes" do 1203 | should eq "

    foo

    \n" 1204 | end 1205 | else 1206 | it "sanitizes input element" do 1207 | should eq "

    foo

    \n" 1208 | end 1209 | end 1210 | end 1211 | end 1212 | 1213 | shared_examples_for "data-attributes" do |allowed:| 1214 | context "with data-attributes for general tags" do 1215 | let(:markdown) do 1216 | <<-MARKDOWN.strip_heredoc 1217 |
    1218 | MARKDOWN 1219 | end 1220 | 1221 | if allowed 1222 | it "does not sanitize data-attributes" do 1223 | should eq <<-HTML.strip_heredoc 1224 |
    1225 | HTML 1226 | end 1227 | else 1228 | it "sanitizes data-attributes" do 1229 | should eq <<-HTML.strip_heredoc 1230 |
    1231 | HTML 1232 | end 1233 | end 1234 | end 1235 | 1236 | context "with data-attributes for
    tag" do 1237 | let(:markdown) do 1238 | <<-MARKDOWN.strip_heredoc 1239 |
    1240 | MARKDOWN 1241 | end 1242 | 1243 | if allowed 1244 | it "does not sanitize data-attributes" do 1245 | should eq <<-HTML.strip_heredoc 1246 |
    1247 | HTML 1248 | end 1249 | else 1250 | it "sanitizes data-attributes except the attributes used by tweet" do 1251 | should eq <<-HTML.strip_heredoc 1252 |
    1253 | HTML 1254 | end 1255 | end 1256 | end 1257 | 1258 | context "with data-attributes for

    tag" do 1259 | let(:markdown) do 1260 | <<-MARKDOWN.strip_heredoc 1261 |

    1262 | MARKDOWN 1263 | end 1264 | 1265 | if allowed 1266 | it "does not sanitize data-attributes" do 1267 | should eq <<-HTML.strip_heredoc 1268 |

    1269 | HTML 1270 | end 1271 | else 1272 | it "sanitizes data-attributes except the attributes used by codepen" do 1273 | should eq <<-HTML.strip_heredoc 1274 |

    1275 | HTML 1276 | end 1277 | end 1278 | end 1279 | end 1280 | 1281 | shared_examples_for "class attribute" do |allowed:| 1282 | context "with class attribute for general tags" do 1283 | let(:markdown) do 1284 | 'user' 1285 | end 1286 | 1287 | if allowed 1288 | it "does not sanitize the attribute" do 1289 | should eq "

    user

    \n" 1290 | end 1291 | else 1292 | it "sanitizes the attribute" do 1293 | should eq "

    user

    \n" 1294 | end 1295 | end 1296 | end 1297 | 1298 | context "with class attribute for tag" do 1299 | let(:markdown) do 1300 | <<-MARKDOWN.strip_heredoc 1301 | foo 1302 | http://qiita.com/ 1303 | MARKDOWN 1304 | end 1305 | 1306 | if allowed 1307 | it "does not sanitize the classes" do 1308 | should eq <<-HTML.strip_heredoc 1309 |

    foo
    1310 | http://qiita.com/

    1311 | HTML 1312 | end 1313 | else 1314 | it "sanitizes classes except `autolink`" do 1315 | should eq <<-HTML.strip_heredoc 1316 |

    foo
    1317 | http://qiita.com/

    1318 | HTML 1319 | end 1320 | end 1321 | end 1322 | 1323 | context "with class attribute for
    tag" do 1324 | let(:markdown) do 1325 | <<-MARKDOWN.strip_heredoc 1326 | 1327 | MARKDOWN 1328 | end 1329 | 1330 | if allowed 1331 | it "does not sanitize the classes" do 1332 | should eq <<-HTML.strip_heredoc 1333 | 1334 | HTML 1335 | end 1336 | else 1337 | it "sanitizes classes except `twitter-tweet`" do 1338 | should eq <<-HTML.strip_heredoc 1339 | 1340 | HTML 1341 | end 1342 | end 1343 | end 1344 | 1345 | context "with class attribute for
    tag" do 1346 | let(:markdown) do 1347 | <<-MARKDOWN.strip_heredoc 1348 |
    foo
    1349 | MARKDOWN 1350 | end 1351 | 1352 | if allowed 1353 | it "does not sanitize the classes" do 1354 | should eq <<-HTML.strip_heredoc 1355 |
    foo
    1356 | HTML 1357 | end 1358 | else 1359 | it "sanitizes classes except `footnotes`" do 1360 | should eq <<-HTML.strip_heredoc 1361 |
    foo
    1362 | HTML 1363 | end 1364 | end 1365 | end 1366 | 1367 | context "with class attribute for

    tag" do 1368 | let(:markdown) do 1369 | <<-MARKDOWN.strip_heredoc 1370 |

    foo

    1371 | MARKDOWN 1372 | end 1373 | 1374 | if allowed 1375 | it "does not sanitize the classes" do 1376 | should eq <<-HTML.strip_heredoc 1377 |

    foo

    1378 | HTML 1379 | end 1380 | else 1381 | it "sanitizes classes except `codepen`" do 1382 | should eq <<-HTML.strip_heredoc 1383 |

    foo

    1384 | HTML 1385 | end 1386 | end 1387 | end 1388 | end 1389 | 1390 | shared_examples_for "background-color" do |allowed:| 1391 | context "with style attribute" do 1392 | let(:markdown) do 1393 | "" 1394 | end 1395 | 1396 | if allowed 1397 | it "does not sanitize span element" do 1398 | should eq "

    \n" 1399 | end 1400 | else 1401 | it "sanitizes span element" do 1402 | should eq "

    \n" 1403 | end 1404 | end 1405 | end 1406 | end 1407 | 1408 | shared_examples_for "override embed code attributes" do |allowed:| 1409 | [ 1410 | "https://production-assets.codepen.io/assets/embed/ei.js", 1411 | "https://static.codepen.io/assets/embed/ei.js", 1412 | "https://cpwebassets.codepen.io/assets/embed/ei.js", 1413 | "https://public.codepenassets.com/embed/index.js", 1414 | ].each do |script_url| 1415 | context "with HTML embed code for CodePen using script url `#{script_url}`" do 1416 | let(:markdown) do 1417 | <<-MARKDOWN.strip_heredoc 1418 |

    1419 | 1420 | MARKDOWN 1421 | end 1422 | 1423 | if allowed 1424 | it "does not sanitize embed code" do 1425 | should eq <<-HTML.strip_heredoc 1426 |

    1427 | 1428 | HTML 1429 | end 1430 | else 1431 | it "forces async attribute on script" do 1432 | should eq <<-HTML.strip_heredoc 1433 |

    1434 | 1435 | HTML 1436 | end 1437 | end 1438 | end 1439 | end 1440 | 1441 | context "with HTML embed code for Asciinema" do 1442 | let(:markdown) do 1443 | <<-MARKDOWN.strip_heredoc 1444 | 1445 | MARKDOWN 1446 | end 1447 | 1448 | if allowed 1449 | it "does not sanitize embed code" do 1450 | should eq <<-HTML.strip_heredoc 1451 | 1452 | HTML 1453 | end 1454 | else 1455 | it "forces async attribute on script" do 1456 | should eq <<-HTML.strip_heredoc 1457 | 1458 | HTML 1459 | end 1460 | end 1461 | end 1462 | 1463 | context "with HTML embed code for Youtube" do 1464 | shared_examples "embed code youtube example" do 1465 | let(:markdown) do 1466 | <<-MARKDOWN.strip_heredoc 1467 | 1468 | MARKDOWN 1469 | end 1470 | let(:url) { "#{scheme}//www.youtube.com/embed/example" } 1471 | 1472 | if allowed 1473 | it "does not sanitize embed code" do 1474 | should eq <<-HTML.strip_heredoc 1475 | 1476 | HTML 1477 | end 1478 | else 1479 | it "forces width attribute on iframe" do 1480 | should eq <<-HTML.strip_heredoc 1481 | 1482 | HTML 1483 | end 1484 | end 1485 | 1486 | context "when url is privacy enhanced mode" do 1487 | let(:markdown) do 1488 | <<-MARKDOWN.strip_heredoc 1489 | 1490 | MARKDOWN 1491 | end 1492 | let(:url) { "#{scheme}//www.youtube-nocookie.com/embed/example" } 1493 | 1494 | if allowed 1495 | it "does not sanitize embed code" do 1496 | should eq <<-HTML.strip_heredoc 1497 | 1498 | HTML 1499 | end 1500 | else 1501 | it "forces width attribute on iframe" do 1502 | should eq <<-HTML.strip_heredoc 1503 | 1504 | HTML 1505 | end 1506 | end 1507 | end 1508 | end 1509 | 1510 | context "with scheme" do 1511 | let(:scheme) { "https:" } 1512 | 1513 | include_examples "embed code youtube example" 1514 | end 1515 | 1516 | context "without scheme" do 1517 | let(:scheme) { "" } 1518 | 1519 | include_examples "embed code youtube example" 1520 | end 1521 | end 1522 | 1523 | context "with HTML embed code for SlideShare" do 1524 | shared_examples "embed code slideshare example" do 1525 | let(:markdown) do 1526 | <<-MARKDOWN.strip_heredoc 1527 | 1528 | MARKDOWN 1529 | end 1530 | let(:url) { "#{scheme}//www.slideshare.net/embed/example" } 1531 | 1532 | if allowed 1533 | it "does not sanitize embed code" do 1534 | should eq <<-HTML.strip_heredoc 1535 | 1536 | HTML 1537 | end 1538 | else 1539 | it "forces width attribute on iframe" do 1540 | should eq <<-HTML.strip_heredoc 1541 | 1542 | HTML 1543 | end 1544 | end 1545 | end 1546 | 1547 | context "with scheme" do 1548 | let(:scheme) { "https:" } 1549 | 1550 | include_examples "embed code slideshare example" 1551 | end 1552 | 1553 | context "without scheme" do 1554 | let(:scheme) { "" } 1555 | 1556 | include_examples "embed code slideshare example" 1557 | end 1558 | end 1559 | 1560 | context "with HTML embed code for GoogleSlide" do 1561 | shared_examples "embed code googleslide example" do 1562 | let(:markdown) do 1563 | <<-MARKDOWN.strip_heredoc 1564 | 1565 | MARKDOWN 1566 | end 1567 | let(:url) { "#{scheme}//docs.google.com/presentation/d/example/embed" } 1568 | 1569 | if allowed 1570 | it "does not sanitize embed code" do 1571 | should eq <<-HTML.strip_heredoc 1572 | 1573 | HTML 1574 | end 1575 | else 1576 | it "forces width attribute on iframe" do 1577 | should eq <<-HTML.strip_heredoc 1578 | 1579 | HTML 1580 | end 1581 | end 1582 | end 1583 | 1584 | context "with scheme" do 1585 | let(:scheme) { "https:" } 1586 | 1587 | include_examples "embed code googleslide example" 1588 | end 1589 | 1590 | context "without scheme" do 1591 | let(:scheme) { "" } 1592 | 1593 | include_examples "embed code googleslide example" 1594 | end 1595 | end 1596 | 1597 | context "with HTML embed code for Figma" do 1598 | shared_examples "embed code figma example" do 1599 | let(:markdown) do 1600 | <<-MARKDOWN.strip_heredoc 1601 | 1602 | MARKDOWN 1603 | end 1604 | let(:url) { "#{scheme}//www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com" } 1605 | let(:encoded_url) { CGI.escapeHTML(url) } 1606 | 1607 | if allowed 1608 | it "does not sanitize embed code" do 1609 | should eq <<-HTML.strip_heredoc 1610 | 1611 | HTML 1612 | end 1613 | else 1614 | it "forces width attribute on iframe" do 1615 | should eq <<-HTML.strip_heredoc 1616 | 1617 | HTML 1618 | end 1619 | end 1620 | end 1621 | 1622 | context "with scheme" do 1623 | let(:scheme) { "https:" } 1624 | 1625 | include_examples "embed code figma example" 1626 | end 1627 | 1628 | context "without scheme" do 1629 | let(:scheme) { "" } 1630 | 1631 | include_examples "embed code figma example" 1632 | end 1633 | end 1634 | 1635 | context "with HTML embed code for SpeekerDeck" do 1636 | let(:markdown) do 1637 | <<-MARKDOWN.strip_heredoc 1638 | 1639 | MARKDOWN 1640 | end 1641 | 1642 | if allowed 1643 | it "does not sanitize embed code" do 1644 | should eq <<-HTML.strip_heredoc 1645 | 1646 | HTML 1647 | end 1648 | else 1649 | it "forces async attribute on script" do 1650 | should eq <<-HTML.strip_heredoc 1651 | 1652 | HTML 1653 | end 1654 | end 1655 | end 1656 | 1657 | context "with HTML embed code for Docswell" do 1658 | let(:markdown) do 1659 | <<-MARKDOWN.strip_heredoc 1660 | 1661 | MARKDOWN 1662 | end 1663 | 1664 | if allowed 1665 | it "does not sanitize embed code" do 1666 | should eq <<-HTML.strip_heredoc 1667 | 1668 | HTML 1669 | end 1670 | else 1671 | it "forces async attribute on script" do 1672 | should eq <<-HTML.strip_heredoc 1673 | 1674 | HTML 1675 | end 1676 | end 1677 | 1678 | shared_examples "iframe code docswell example" do 1679 | let(:markdown) do 1680 | <<-MARKDOWN.strip_heredoc 1681 | 1682 | MARKDOWN 1683 | end 1684 | let(:url) { "#{scheme}//www.docswell.com/slide/example/embed" } 1685 | 1686 | if allowed 1687 | it "does not sanitize embed code" do 1688 | should eq <<-HTML.strip_heredoc 1689 | 1690 | HTML 1691 | end 1692 | else 1693 | it "forces width attribute on iframe" do 1694 | should eq <<-HTML.strip_heredoc 1695 | 1696 | HTML 1697 | end 1698 | end 1699 | end 1700 | 1701 | context "with scheme" do 1702 | let(:scheme) { "https:" } 1703 | 1704 | include_examples "iframe code docswell example" 1705 | end 1706 | 1707 | context "without scheme" do 1708 | let(:scheme) { "" } 1709 | 1710 | include_examples "iframe code docswell example" 1711 | end 1712 | end 1713 | 1714 | context "with embed code for Tweet" do 1715 | let(:markdown) do 1716 | <<-MARKDOWN.strip_heredoc 1717 | 1718 | 1719 | MARKDOWN 1720 | end 1721 | 1722 | it "does not sanitize embed code" do 1723 | should eq <<-HTML.strip_heredoc 1724 | 1725 | 1726 | HTML 1727 | end 1728 | end 1729 | 1730 | context "with embed script code with xss" do 1731 | let(:markdown) do 1732 | <<-MARKDOWN.strip_heredoc 1733 | 1734 | MARKDOWN 1735 | end 1736 | 1737 | if allowed 1738 | it "does not sanitize embed code" do 1739 | should eq markdown 1740 | end 1741 | else 1742 | it "forces width attribute on iframe" do 1743 | should eq "\n" 1744 | end 1745 | end 1746 | end 1747 | 1748 | context "with embed iframe code with xss" do 1749 | let(:markdown) do 1750 | <<-MARKDOWN.strip_heredoc 1751 | 1752 | MARKDOWN 1753 | end 1754 | 1755 | if allowed 1756 | it "does not sanitize embed code" do 1757 | should eq <<-HTML.strip_heredoc 1758 | 1759 | HTML 1760 | end 1761 | else 1762 | it "forces width attribute on iframe" do 1763 | should eq "\n" 1764 | end 1765 | end 1766 | end 1767 | end 1768 | 1769 | shared_examples_for "custom block" do |allowed:| 1770 | context "with custom block" do 1771 | let(:type) { "" } 1772 | let(:subtype) { "" } 1773 | 1774 | let(:markdown) do 1775 | <<-MARKDOWN.strip_heredoc 1776 | :::#{[type, subtype].join(' ').rstrip} 1777 | Some kind of text is here. 1778 | ::: 1779 | MARKDOWN 1780 | end 1781 | 1782 | context "when type is empty" do 1783 | if allowed 1784 | it "returns simple div element" do 1785 | should eq <<-HTML.strip_heredoc 1786 |
    1787 |

    Some kind of text is here.

    1788 |
    1789 | HTML 1790 | end 1791 | else 1792 | it "returns simple div element" do 1793 | should eq <<-HTML.strip_heredoc 1794 |
    1795 |

    Some kind of text is here.

    1796 |
    1797 | HTML 1798 | end 1799 | end 1800 | end 1801 | 1802 | context "when type is not allowed" do 1803 | let(:type) { "anytype" } 1804 | 1805 | if allowed 1806 | it "returns simple div element" do 1807 | should eq <<-HTML.strip_heredoc 1808 |
    1809 |

    Some kind of text is here.

    1810 |
    1811 | HTML 1812 | end 1813 | else 1814 | it "returns simple div element" do 1815 | should eq <<-HTML.strip_heredoc 1816 |
    1817 |

    Some kind of text is here.

    1818 |
    1819 | HTML 1820 | end 1821 | end 1822 | end 1823 | 1824 | context "when type is note" do 1825 | let(:type) { "note" } 1826 | 1827 | context "when subtype is empty" do 1828 | if allowed 1829 | it "returns info note block with class including icon as default type" do 1830 | should eq <<-HTML.strip_heredoc 1831 |
    1832 |
    1833 |

    Some kind of text is here.

    1834 |
    1835 |
    1836 | HTML 1837 | end 1838 | else 1839 | it "returns note block with class including icon" do 1840 | should eq <<-HTML.strip_heredoc 1841 |
    1842 |
    1843 |

    Some kind of text is here.

    1844 |
    1845 |
    1846 | HTML 1847 | end 1848 | end 1849 | end 1850 | 1851 | context "when subtype is warn" do 1852 | let(:subtype) { "warn" } 1853 | 1854 | if allowed 1855 | it "returns warning note block with class including icon" do 1856 | should eq <<-HTML.strip_heredoc 1857 |
    1858 |
    1859 |

    Some kind of text is here.

    1860 |
    1861 |
    1862 | HTML 1863 | end 1864 | else 1865 | it "returns note block with class including icon" do 1866 | should eq <<-HTML.strip_heredoc 1867 |
    1868 |
    1869 |

    Some kind of text is here.

    1870 |
    1871 |
    1872 | HTML 1873 | end 1874 | end 1875 | end 1876 | 1877 | context "when subtype is alert" do 1878 | let(:subtype) { "alert" } 1879 | 1880 | if allowed 1881 | it "returns alerting note block with class including icon" do 1882 | should eq <<-HTML.strip_heredoc 1883 |
    1884 |
    1885 |

    Some kind of text is here.

    1886 |
    1887 |
    1888 | HTML 1889 | end 1890 | else 1891 | it "returns note block with class including icon" do 1892 | should eq <<-HTML.strip_heredoc 1893 |
    1894 |
    1895 |

    Some kind of text is here.

    1896 |
    1897 |
    1898 | HTML 1899 | end 1900 | end 1901 | end 1902 | end 1903 | end 1904 | end 1905 | 1906 | context "without script and strict context" do 1907 | let(:context) do 1908 | super().merge(script: false, strict: false) 1909 | end 1910 | 1911 | include_examples "basic markdown syntax" 1912 | include_examples "script element", allowed: false 1913 | include_examples "malicious script in filename", allowed: false 1914 | include_examples "iframe element", allowed: false 1915 | include_examples "input element", allowed: true 1916 | include_examples "data-attributes", allowed: false 1917 | include_examples "class attribute", allowed: true 1918 | include_examples "background-color", allowed: true 1919 | include_examples "override embed code attributes", allowed: false 1920 | include_examples "custom block", allowed: false 1921 | end 1922 | 1923 | context "with script context" do 1924 | let(:context) do 1925 | super().merge(script: true, strict: false) 1926 | end 1927 | 1928 | include_examples "basic markdown syntax" 1929 | include_examples "script element", allowed: true 1930 | include_examples "malicious script in filename", allowed: true 1931 | include_examples "iframe element", allowed: true 1932 | include_examples "input element", allowed: true 1933 | include_examples "data-attributes", allowed: true 1934 | include_examples "class attribute", allowed: true 1935 | include_examples "background-color", allowed: true 1936 | include_examples "override embed code attributes", allowed: true 1937 | include_examples "custom block", allowed: true 1938 | end 1939 | 1940 | context "with strict context" do 1941 | let(:context) do 1942 | super().merge(script: false, strict: true) 1943 | end 1944 | 1945 | include_examples "basic markdown syntax" 1946 | include_examples "script element", allowed: false 1947 | include_examples "malicious script in filename", allowed: false 1948 | include_examples "iframe element", allowed: false 1949 | include_examples "input element", allowed: false 1950 | include_examples "data-attributes", allowed: false 1951 | include_examples "class attribute", allowed: false 1952 | include_examples "background-color", allowed: false 1953 | include_examples "override embed code attributes", allowed: false 1954 | include_examples "custom block", allowed: false 1955 | end 1956 | 1957 | context "with script and strict context" do 1958 | let(:context) do 1959 | super().merge(script: true, strict: true) 1960 | end 1961 | 1962 | include_examples "basic markdown syntax" 1963 | include_examples "script element", allowed: false 1964 | include_examples "malicious script in filename", allowed: true 1965 | include_examples "iframe element", allowed: false 1966 | include_examples "input element", allowed: false 1967 | include_examples "data-attributes", allowed: false 1968 | include_examples "class attribute", allowed: false 1969 | include_examples "background-color", allowed: false 1970 | include_examples "override embed code attributes", allowed: false 1971 | include_examples "custom block", allowed: true 1972 | end 1973 | end 1974 | end 1975 | -------------------------------------------------------------------------------- /spec/qiita/markdown/summary_processor_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/strip" 2 | 3 | describe Qiita::Markdown::SummaryProcessor do 4 | describe "#call" do 5 | subject(:html) do 6 | result[:output].to_s 7 | end 8 | 9 | let(:context) do 10 | { hostname: "example.com" } 11 | end 12 | 13 | let(:markdown) do 14 | raise NotImplementedError 15 | end 16 | 17 | let(:result) do 18 | described_class.new(context).call(markdown) 19 | end 20 | 21 | context "with valid condition" do 22 | let(:markdown) do 23 | <<-EOS.strip_heredoc 24 | example 25 | EOS 26 | end 27 | 28 | it "returns a Hash with HTML output and other metadata but no codes" do 29 | expect(result[:mentioned_usernames]).to be_an Array 30 | expect(result[:output]).to be_a Nokogiri::HTML::DocumentFragment 31 | expect(result).not_to have_key(:codes) 32 | end 33 | end 34 | 35 | context "with HTML-characters" do 36 | let(:markdown) do 37 | "<>&" 38 | end 39 | 40 | it "sanitizes them" do 41 | should eq <<-EOS.strip_heredoc 42 | <>& 43 | EOS 44 | end 45 | end 46 | 47 | context "with code" do 48 | let(:markdown) do 49 | <<-EOS.strip_heredoc 50 | ```ruby 51 | puts 'hello world' 52 | ``` 53 | EOS 54 | end 55 | 56 | it "returns simple code element" do 57 | should eq <<-EOS.strip_heredoc 58 | puts 'hello world' 59 | 60 | EOS 61 | end 62 | end 63 | 64 | context "with emoji" do 65 | let(:markdown) do 66 | ":+1:" 67 | end 68 | 69 | it "replaces it with img element" do 70 | should include("img") 71 | end 72 | end 73 | 74 | context "with image" do 75 | let(:markdown) do 76 | <<-EOS.strip_heredoc 77 | ![Qiita](http://qiita.com/icons/favicons/public/apple-touch-icon.png) 78 | EOS 79 | end 80 | 81 | it "removes it" do 82 | expect(html.strip).to be_empty 83 | end 84 | end 85 | 86 | context "with line breaks" do 87 | let(:markdown) do 88 | <<-EOS.strip_heredoc 89 | foo 90 | bar 91 | EOS 92 | end 93 | 94 | it "removes them" do 95 | should eq <<-EOS.strip_heredoc 96 | foo 97 | bar 98 | EOS 99 | end 100 | end 101 | 102 | context "with paragraphs" do 103 | let(:markdown) do 104 | <<-EOS.strip_heredoc 105 | Lorem ipsum dolor sit amet. 106 | 107 | Consectetur adipisicing elit. 108 | EOS 109 | end 110 | 111 | it "flattens them" do 112 | should eq <<-EOS.strip_heredoc 113 | Lorem ipsum dolor sit amet. 114 | Consectetur adipisicing elit. 115 | EOS 116 | end 117 | end 118 | 119 | context "with normal list items" do 120 | let(:markdown) do 121 | <<-EOS.strip_heredoc 122 | - foo 123 | - bar 124 | EOS 125 | end 126 | 127 | it "flattens them" do 128 | should eq <<-EOS.strip_heredoc 129 | 130 | foo 131 | bar 132 | 133 | EOS 134 | end 135 | end 136 | 137 | context "with task list items" do 138 | let(:markdown) do 139 | <<-EOS.strip_heredoc 140 | - [ ] foo 141 | - [x] bar 142 | EOS 143 | end 144 | 145 | it "flattens them without converting to checkboxes" do 146 | should eq <<-EOS.strip_heredoc 147 | 148 | [ ] foo 149 | [x] bar 150 | 151 | EOS 152 | end 153 | end 154 | 155 | context "with table" do 156 | let(:markdown) do 157 | <<-EOS.strip_heredoc 158 | | a | b | c | 159 | |---|---|---| 160 | | a | b | c | 161 | EOS 162 | end 163 | 164 | it "removes it entirely" do 165 | expect(html.strip).to be_empty 166 | end 167 | end 168 | 169 | context "with a simple long document" do 170 | before do 171 | context[:truncate] = { length: 10 } 172 | end 173 | 174 | let(:markdown) do 175 | <<-EOS.strip_heredoc 176 | Lorem ipsum dolor sit amet. 177 | EOS 178 | end 179 | 180 | it "truncates it to the specified length" do 181 | should eq "Lorem ips…" 182 | end 183 | end 184 | 185 | context "with a long document consisting of nested elements" do 186 | before do 187 | context[:truncate] = { length: 12 } 188 | end 189 | 190 | let(:markdown) do 191 | <<-EOS.strip_heredoc 192 | _[Example](http://example.com/) is **a technical knowledge sharing and collaboration platform for programmers**._ 193 | EOS 194 | end 195 | 196 | it "truncates it while honoring the document structure" do 197 | should eq 'Example is ' 198 | end 199 | end 200 | 201 | context "with a long document including consecutive whitespaces" do 202 | before do 203 | context[:truncate] = { length: 10 } 204 | end 205 | 206 | let(:markdown) do 207 | <<-EOS.strip_heredoc 208 | **12** 4 [ 6](http://example.com/)_7 209 | 9_ 123 210 | EOS 211 | end 212 | 213 | it "truncates it while counting the consecutive whilespaces as one" do 214 | should eq "12 4 67\n9…" 215 | end 216 | end 217 | 218 | context "with truncate: { omission: nil } context" do 219 | before do 220 | context[:truncate] = { length: 10, omission: nil } 221 | end 222 | 223 | let(:markdown) do 224 | <<-EOS.strip_heredoc 225 | Lorem ipsum dolor sit amet. 226 | EOS 227 | end 228 | 229 | it "does not add extra omission text" do 230 | should eq "Lorem ipsu" 231 | end 232 | end 233 | 234 | context "with mention" do 235 | let(:markdown) do 236 | <<-EOS.strip_heredoc 237 | @alice 238 | EOS 239 | end 240 | 241 | it "replaces mention with link" do 242 | should eq %(@alice\n) 243 | end 244 | end 245 | 246 | context "with footenote syntax" do 247 | let(:markdown) do 248 | <<-EOS.strip_heredoc 249 | [^1] 250 | [^1]: test 251 | EOS 252 | end 253 | 254 | it "does not generate footnote elements by default" do 255 | should eq <<-EOS.strip_heredoc 256 | [^1] 257 | [^1]: test 258 | EOS 259 | end 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV["CI"] 2 | if ENV["GITHUB_ACTIONS"] 3 | require "simplecov" 4 | SimpleCov.start 5 | else 6 | require "codeclimate-test-reporter" 7 | CodeClimate::TestReporter.start 8 | end 9 | end 10 | 11 | require "qiita-markdown" 12 | 13 | RSpec.configure do |config| 14 | config.expect_with :rspec do |expectations| 15 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 16 | end 17 | 18 | config.mock_with :rspec do |mocks| 19 | mocks.verify_partial_doubles = true 20 | end 21 | 22 | config.default_formatter = "doc" 23 | config.filter_run :focus 24 | config.run_all_when_everything_filtered = true 25 | 26 | config.example_status_persistence_file_path = "spec/examples.txt" 27 | end 28 | --------------------------------------------------------------------------------