diff --git a/_data/meta.yml b/_data/meta.yml index 61d7160ee..9957474b9 100644 --- a/_data/meta.yml +++ b/_data/meta.yml @@ -54,3 +54,7 @@ - name: redirect_from description: Relative path(s) to redirect to the license from, to prevent breaking old URLs required: false + +- name: annotations + description: Map of rule tags to excerpt strings that appear in the license body (enables in-text rule highlighting) + required: false diff --git a/_includes/footer.html b/_includes/footer.html index a4a549107..b22668ef3 100644 --- a/_includes/footer.html +++ b/_includes/footer.html @@ -27,6 +27,9 @@ }{% unless forloop.last %},{% endunless %} {% endfor %} ]; + {% if page.annotations %} + window.ruleAnnotations = {{ page.annotations | jsonify }}; + {% endif %} {% endif %} diff --git a/_layouts/license.html b/_layouts/license.html index 602089dd3..69f3cf8f1 100644 --- a/_layouts/license.html +++ b/_layouts/license.html @@ -45,6 +45,14 @@ + {% if page.annotations %} +

+ +

+ {% endif %} +
{{ content | replace:"<", "<" | replace:">", ">" }}
diff --git a/_licenses/mit.txt b/_licenses/mit.txt index 1ad0488f9..5eb19bd98 100644 --- a/_licenses/mit.txt +++ b/_licenses/mit.txt @@ -26,6 +26,14 @@ limitations: - liability - warranty +annotations: + include-copyright: + - "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software." + liability: + - "IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE" + warranty: + - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND" + --- MIT License diff --git a/assets/css/application.scss b/assets/css/application.scss index 11df9809e..5b48d2c22 100644 --- a/assets/css/application.scss +++ b/assets/css/application.scss @@ -568,6 +568,21 @@ strong { .with-love { float: right; clear: right; } +.rule-annotation { + background: rgba(20, 154, 212, 0.2); + border-radius: 2px; +} + +.rule-annotation.is-focused { + background: rgba(61, 198, 55, 0.35); + outline: 1px solid #3dc637; +} + +.license-annotations-toggle .button { + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + @media only screen and (-webkit-min-device-pixel-ratio: 2.0), only screen and (min--moz-device-pixel-ratio: 2.0), only screen and (-o-min-device-pixel-ratio: 200/100), diff --git a/assets/js/app.js b/assets/js/app.js index e8706dde1..36a587528 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -19,6 +19,7 @@ class Choosealicense { this.initTooltips(); this.initClipboard(); this.initLicenseSuggestion(); + this.initRuleAnnotations(); } // Selects the content of a given element @@ -138,6 +139,50 @@ class Choosealicense { const licenseId = inputEl.getAttribute('data-license-id'); new LicenseSuggestion(inputEl, licenseId, statusIndicator); } + + initRuleAnnotations() { + const mapping = window.ruleAnnotations; + const pre = document.querySelector('#license-text'); + const toggle = document.querySelector('#toggle-rule-annotations'); + if (!mapping || !pre || !toggle) return; + + const original = pre.textContent; + let active = false; + + const applyHighlights = () => { + let html = original.replace(/&/g, '&').replace(//g, '>'); + Object.entries(mapping).forEach(([tag, excerpts]) => { + excerpts.forEach((excerpt) => { + const escaped = excerpt.replace(/&/g, '&').replace(//g, '>'); + const mark = `${escaped}`; + html = html.replace(escaped, mark); + }); + }); + pre.innerHTML = html; + }; + + const clearHighlights = () => { + pre.textContent = original; + }; + + toggle.addEventListener('click', () => { + active = !active; + toggle.setAttribute('aria-pressed', String(active)); + toggle.textContent = active ? 'Hide rule highlights' : 'Highlight license rules in text'; + if (active) applyHighlights(); + else clearHighlights(); + }); + + document.querySelectorAll('.license-rules li').forEach((item) => { + item.addEventListener('mouseenter', () => { + if (!active) return; + const tag = Array.from(item.classList).find((className) => mapping[className]); + if (!tag) return; + pre.querySelectorAll('.rule-annotation').forEach((el) => el.classList.remove('is-focused')); + pre.querySelectorAll(`.rule-annotation-${tag}`).forEach((el) => el.classList.add('is-focused')); + }); + }); + } } class LicenseSuggestion { diff --git a/spec/license_annotations_spec.rb b/spec/license_annotations_spec.rb new file mode 100644 index 000000000..417267054 --- /dev/null +++ b/spec/license_annotations_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'license rule annotations' do + licenses.each do |license| + next unless license['annotations'] + + context license['spdx-id'] do + it 'maps annotation keys to tags declared on the license' do + declared = (license['permissions'] + license['conditions'] + license['limitations']).uniq + extra = license['annotations'].keys - declared + expect(extra).to be_empty, "unknown annotation tags: #{extra.join(', ')}" + end + + it 'references text that appears in the license body' do + body = license['content'] + license['annotations'].each_value do |excerpts| + excerpts.each do |excerpt| + expect(body).to include(excerpt), "excerpt not found: #{excerpt[0, 60]}..." + end + end + end + end + end +end