diff --git a/.github/workflows/dependabot-sync-saas-lockfile.yml b/.github/workflows/dependabot-sync-saas-lockfile.yml new file mode 100644 index 000000000..5afe97244 --- /dev/null +++ b/.github/workflows/dependabot-sync-saas-lockfile.yml @@ -0,0 +1,35 @@ +name: Sync Gemfile.saas.lock + +on: + push: + branches: + - "dependabot/bundler/**" + paths: + - Gemfile.lock + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + + - name: Forward Gemfile.lock changes to Gemfile.saas.lock + run: bin/bundle-drift forward + + - name: Commit updated lockfile + run: | + git add Gemfile.saas.lock + if ! git diff --cached --quiet; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Sync Gemfile.saas.lock" + git push + fi diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index d89381607..676e6b7a3 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -48,7 +48,7 @@ jobs: uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -101,7 +101,7 @@ jobs: - name: Attest image provenance (per-arch) if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v3.1.0 + uses: actions/attest-build-provenance@v3.2.0 with: subject-name: ${{ steps.vars.outputs.canonical }} subject-digest: ${{ steps.build.outputs.digest }} @@ -121,7 +121,7 @@ jobs: uses: docker/setup-buildx-action@v3.12.0 - name: Log in to GHCR - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/Gemfile b/Gemfile index 23b1223ce..c524ee3c1 100644 --- a/Gemfile +++ b/Gemfile @@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9" gem "bcrypt", "~> 3.1.7" gem "geared_pagination", "~> 1.2" gem "rqrcode" -gem "redcarpet" gem "rouge" gem "jbuilder" gem "lexxy", bc: "lexxy" @@ -41,6 +40,7 @@ gem "useragent", bc: "useragent" # Operations gem "autotuner" gem "mission_control-jobs" +gem "stackprof" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index 86a856e37..559b2990b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 + revision: 4bcf8d0213971560e90734493f8bd835addc3642 specs: - lexxy (0.7.0.beta) + lexxy (0.7.4.beta) rails (>= 8.0.2) GIT @@ -147,9 +147,9 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.21.1) + bootsnap (1.22.0) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.1) racc builder (3.3.0) bundler-audit (0.9.3) @@ -184,7 +184,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.5.3) + faker (3.6.0) i18n (>= 1.8.11, < 2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -355,7 +355,6 @@ GEM erb psych (>= 4.0.0) tsort - redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -400,7 +399,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.39.0) + selenium-webdriver (4.40.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -437,6 +436,7 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -520,7 +520,6 @@ DEPENDENCIES puma (>= 5.0) rack-mini-profiler rails! - redcarpet rouge rqrcode rubocop-rails-omakase @@ -529,6 +528,7 @@ DEPENDENCIES solid_cache (~> 1.0) solid_queue (~> 1.2) sqlite3 (>= 2.0) + stackprof stimulus-rails thruster trilogy (~> 2.9) diff --git a/Gemfile.saas b/Gemfile.saas index e022ab6b2..b93efb1ee 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -24,3 +24,4 @@ gem "yabeda-puma-plugin" gem "yabeda-rails" gem "webrick" # required for yabeda-prometheus metrics server gem "prometheus-client-mmap", "~> 1.3" +gem "gvltools" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 8de89e1fb..b09872738 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/basecamp/console1984 - revision: 02b1b9ee7fd7050174b6a98c2b43057553621dc4 + revision: 23419ec8861e73f12ced75a3fc559d03f7be5854 specs: console1984 (0.2.2) irb (~> 1.13) @@ -23,9 +23,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 + revision: 4bcf8d0213971560e90734493f8bd835addc3642 specs: - lexxy (0.7.0.beta) + lexxy (0.7.4.beta) rails (>= 8.0.2) GIT @@ -227,9 +227,9 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.21.1) + bootsnap (1.22.0) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.1) racc builder (3.3.0) bundler-audit (0.9.3) @@ -265,7 +265,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.5.3) + faker (3.6.0) i18n (>= 1.8.11, < 2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -282,6 +282,7 @@ GEM addressable (>= 2.5.0) globalid (1.3.0) activesupport (>= 6.1) + gvltools (0.4.0) hashdiff (1.2.1) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -466,7 +467,6 @@ GEM erb psych (>= 4.0.0) tsort - redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -513,7 +513,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.39.0) + selenium-webdriver (4.40.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -558,6 +558,7 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -657,6 +658,7 @@ DEPENDENCIES faker fizzy-saas! geared_pagination (~> 1.2) + gvltools image_processing (~> 1.14) importmap-rails jbuilder @@ -675,7 +677,6 @@ DEPENDENCIES rack-mini-profiler rails! rails_structured_logging! - redcarpet rouge rqrcode rubocop-rails-omakase @@ -686,6 +687,7 @@ DEPENDENCIES solid_cache (~> 1.0) solid_queue (~> 1.2) sqlite3 (>= 2.0) + stackprof stimulus-rails stripe (~> 18.0) thruster diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css new file mode 100644 index 000000000..85dc7652d --- /dev/null +++ b/app/assets/stylesheets/attachments.css @@ -0,0 +1,207 @@ +@layer components { + .attachment { + block-size: auto; + display: block; + inline-size: fit-content; + position: relative; + max-inline-size: 100%; + + progress { + inline-size: 100%; + margin: auto; + } + } + + .attachment__caption { + color: color-mix(in oklch, var(--color-ink) 66%, transparent); + font-size: var(--text-small); + + textarea { + --input-border-radius: 0.3em; + --input-border-size: 0; + --input-padding: 0; + + background-color: var(--input-background, transparent); + border: none; + color: inherit; + inline-size: 100%; + max-inline-size: 100%; + resize: none; + text-align: center; + + &:focus { + --focus-ring-size: 0; + } + + @supports (field-sizing: content) { + field-sizing: content; + inline-size: auto; + min-inline-size: 20ch; + } + } + } + + .attachment__icon { + aspect-ratio: 4/5; + background-color: color-mix(var(--attachment-icon-color), transparent 90%); + block-size: 2.5lh; + border: 2px solid var(--attachment-icon-color); + border-block-start-width: 1ch; + border-radius: 0.5ch; + box-sizing: border-box; + color: var(--attachment-icon-color); + display: grid; + font-size: var(--text-small); + font-weight: bold; + inline-size: auto; + padding-inline: 0.5ch; + place-content: center; + text-transform: uppercase; + white-space: nowrap; + } + + .attachment--preview { + margin-inline: auto; + text-align: center; + + img, video { + block-size: auto; + display: block; + margin-inline: auto; + max-inline-size: 100%; + user-select: none; + } + + > a { + display: block; + } + + .attachment__caption { + column-gap: 0.5ch; + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-block-start: 0.5ch; + } + } + + .attachment--file { + --attachment-icon-color: var(--color-ink-medium); + + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 1ch; + inline-size: 100%; + margin-inline: 0; + + .attachment__caption { + display: grid; + flex: 1; + text-align: start; + } + + .attachment__name { + color: var(--color-ink); + font-weight: bold; + } + + /* Video attachments don't have an identifiable class, but we need to + * make sure the caption is always below the video */ + &:has(video) { + .attachment__caption { + flex: none; + inline-size: 100%; + } + } + } + + .attachment--psd, + .attachment--key, + .attachment--sketch, + .attachment--ai, + .attachment--eps, + .attachment--indd, + .attachment--svg, + .attachment--ppt, + .attachment--pptx { + --attachment-icon-color: oklch(var(--lch-red-medium)); + } + + .attachment--css, + .attachment--crash, + .attachment--php, + .attachment--json, + .attachment--htm, + .attachment--html, + .attachment--rb, + .attachment--erb, + .attachment--ts, + .attachment--js { + --attachment-icon-color: oklch(var(--lch-purple-medium)); + } + + .attachment--txt, + .attachment--pages, + .attachment--rtf, + .attachment--md, + .attachment--doc, + .attachment--docx { + --attachment-icon-color: oklch(var(--lch-blue-medium)); + } + + .attachment--csv &, + .attachment--numbers &, + .attachment--xls &, + .attachment--xlsx & { + --attachment-icon-color: oklch(var(--lch-green-medium)); + } + + .attachment__link { + color: var(--color-link); + text-decoration: underline; + } + + /* Custom attachments such as mentions, etc. */ + action-text-attachment[content-type^='application/vnd.actiontext'] { + --attachment-bg-color: transparent; + --attachment-image-size: 1em; + --attachment-text-color: currentColor; + + align-items: center; + background: var(--attachment-bg-color); + border-radius: 99rem; + box-shadow: + -0.25ch 0 0 var(--attachment-bg-color), + 0.5ch 0 0 var(--attachment-bg-color); + color: var(--attachment-text-color); + display: inline-flex; + gap: 0.25ch; + margin: 0; + padding: 0; + position: relative; + vertical-align: bottom; + white-space: normal; + + lexxy-editor & { + cursor: pointer; + } + + img { + block-size: var(--attachment-image-size); + border-radius: 50%; + inline-size: var(--attachment-image-size); + } + + &.node--selected { + --attachment-bg-color: oklch(var(--lch-blue-dark)); + --attachment-text-color: var(--color-ink-inverted); + } + } + + action-text-attachment[content-type^='application/vnd.actiontext.mention'] { + img { + object-fit: cover; + } + } +} diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 689b80215..0c76ee544 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -34,7 +34,7 @@ :is(a, button, input, textarea, .switch, .btn) { transition: 100ms ease-out; - transition-property: background-color, border-color, box-shadow, filter, outline; + transition-property: background-color, border-color, box-shadow, outline; touch-action: manipulation; /* Keyboard navigation */ diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 89638fc12..5f15dc278 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -19,7 +19,7 @@ pointer-events: auto; position: relative; transition: 100ms ease-out; - transition-property: background-color, border, box-shadow, color, filter, opacity, scale; + transition-property: background-color, border, box-shadow, color, opacity, scale; @media (any-hover: hover) { &:hover { diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 7cb14e15b..e2b576058 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -185,15 +185,16 @@ .cards__transition-container { block-size: 100%; border-radius: calc(var(--column-width-collapsed) / 2); - translate: 0 0.5ch; /* Allow a little room for the mini bubble */ + margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */ transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle); @media (min-width: 640px) { .is-expanded & { - translate: 0 0.5ch; /* Allow a little room for the mini bubble */ + translate: 0; /* Animate back from collapsed state */ } .is-collapsed & { + margin-block-start: 0; translate: 0 var(--column-width-collapsed); } } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68a..68d0cb2db 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,6 +34,26 @@ } } + @media (max-width: 639px) { + &:has(.card__closed) { + padding-block-start: 2rem; + } + + &:has(.reactions .reaction) { + .card__meta-avatars--assignees { + margin-inline-end: unset; + } + } + + &:not(:has(.reactions .reaction)) { + &:has(.card__closed) { + .card__meta-text--assignees { + margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block)); + } + } + } + } + @media (max-width: 799px) { --half-btn-height: 1.25rem; --padding-inline: 1.5ch; @@ -63,12 +83,33 @@ opacity: 0.2; } + .card__header { + @media (max-width: 639px) { + flex-wrap: wrap; + gap: var(--card-header-space) unset; + } + } + + .card__tags { + @media (max-width: 639px) { + padding: 0.25lh; + } + } + + .card__tags-list { + @media (min-width: 640px) { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + .card__body { position: relative; @media (max-width: 639px) { flex-direction: column; - padding-block-end: calc(var(--card-padding-block) * 1.5); + padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5); } } @@ -90,12 +131,24 @@ } &:has(textarea) { - margin-block-end: 0; + @media (min-width: 640px) { + margin-block-end: 0; + } @supports not (field-sizing: content) { text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */ } } + + @media (max-width: 639px) { + margin-block-end: 0.75ch; + } + } + + .card__description { + @media (max-width: 639px) { + margin-block-end: 1ch; + } } .card__meta, @@ -108,6 +161,43 @@ .card__meta { grid-area: meta; margin-inline-end: auto; + + @media (max-width: 639px) { + --meta-spacer-block: 0.75ch; + + inline-size: 100%; + grid-template-areas: + "avatars-author text-added" + "avatars-author text-author" + "text-updated text-updated" + "text-assignees text-assignees" + "avatars-assignees avatars-assignees"; + grid-template-columns: auto 1fr; + + .card__meta-text { + border: 0; + padding: 0; + } + + .card__meta-text + .card__meta-text { + border-block-start: var(--card-border); + margin-block-start: var(--meta-spacer-block); + padding-block-start: var(--meta-spacer-block); + } + + .card__meta-avatars--assignees { + margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block)); + margin-block-start: var(--meta-spacer-block); + } + + .card__meta-avatars--author { + display: initial; + } + + .card__meta-avatars--assignees .avatar { + display: grid; + } + } } .card__stages { @@ -115,12 +205,12 @@ @media (max-width: 639px) { border: 1px solid var(--card-color); - border-radius: 0.25em; + border-radius: calc(0.2em + 3px); flex-direction: row; gap: 0; overflow: auto; max-inline-size: 100%; - padding: 0; + padding: 3px; position: relative; white-space: nowrap; @@ -130,7 +220,7 @@ min-block-size: 2.5em; } - & > form + form:not(:has(.card__column-name--current)) { + & > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) { box-shadow: -1px 0 0 0 var(--color-container); } } @@ -138,7 +228,6 @@ .card__column-name { @media (max-width: 639px) { - --btn-border-radius: 0; justify-content: center; } } @@ -163,6 +252,7 @@ @media (max-width: 639px) { font-size: var(--text-x-small); + position: relative; } } @@ -189,14 +279,24 @@ inset: 0 calc(-1 * var(--card-padding-inline)) auto; position: absolute; } + + @media (any-hover: none) { + --reaction-size: 2.25rem; + } } &:not(:has(.reaction)) { - position: static; - .reactions__trigger { --btn-border-color: var(--color-ink-light); } + + @media (max-width: 639px) { + inset-inline-end: 0; + } + + @media (min-width: 640px) { + position: static; + } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index cbaa66c91..27dbb2c46 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -220,6 +220,15 @@ .card__title-link { color: inherit; } + + code { + background-color: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + border-radius: 0.25ch; + font-family: var(--font-mono); + font-size: smaller; + padding: 0.1ch 0.25ch; + } } .card__description { @@ -229,7 +238,7 @@ } lexxy-toolbar { - border-block-start: 1px solid var(--color-ink-light); + border-block-start: 1px solid var(--lexxy-border-color); } & ~ .btn.btn--reversed { @@ -372,6 +381,7 @@ --stamp-color: oklch(var(--lch-green-medium) / 0.65); align-items: center; + backdrop-filter: blur(2px); background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent); border-radius: 0.2em; border: 0.5ch solid var(--stamp-color); @@ -399,6 +409,12 @@ } } } + + @media (max-width: 639px) { + inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto; + translate: 0 40%; + scale: 75%; + } } .card:has(.card__closed), diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index a8605503b..01a9fbe91 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -91,6 +91,17 @@ .btn { font-weight: inherit; } + + @media (max-width: 639px) { + margin-block-end: calc(var(--block-space-half) / 2); + + h3 { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.4em; + } + } } .comment__avatar { @@ -155,7 +166,7 @@ text-transform: capitalize; @media (max-width: 639px) { - line-height: 1.5lh; + font-size: var(--text-small); } } diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 1336cad9b..22574979d 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -7,32 +7,37 @@ :is(.dialog) { border: 0; opacity: 0; - transform: scale(0.2); - transform-origin: top center; - transition: var(--dialog-duration) allow-discrete; + transform: scale(0.85); + transform-origin: center; + transition-behavior: allow-discrete; + transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */ transition-property: display, opacity, overlay, transform; + transition-timing-function: ease-out; &::backdrop { background-color: var(--color-black); opacity: 0; - transform: scale(1); - transition: var(--dialog-duration) allow-discrete; + transition-behavior: allow-discrete; + transition-duration: calc(var(--dialog-duration) / 2); transition-property: display, opacity, overlay; + transition-timing-function: ease-out; } &[open] { opacity: 1; transform: scale(1); + transition-duration: var(--dialog-duration); /* Normal opening speed */ &::backdrop { opacity: 0.5; + transition-duration: var(--dialog-duration); } } @starting-style { &[open] { opacity: 0; - transform: scale(0.2); + transform: scale(0.85); } &[open]::backdrop { diff --git a/app/assets/stylesheets/import.css b/app/assets/stylesheets/import.css new file mode 100644 index 000000000..5ab6b2cc5 --- /dev/null +++ b/app/assets/stylesheets/import.css @@ -0,0 +1,33 @@ +@layer components { + .import-status { + --import-status-border-color: var(--color-ink-light); + --import-status-color: var(--color-ink); + + border: 1px dashed var(--import-status-border-color); + border-radius: 1ch; + color: var(--import-status-color); + font-size: var(--text-medium); + padding: 1.5ch; + + .btn { + font-size: var(--text-small); + margin-block-start: 1.5ch; + } + } + + .import-status--success { + --import-status-border-color: var(--color-positive); + --import-status-color: var(--color-positive); + } + + .import-status--error { + --import-status-border-color: var(--color-negative); + --import-status-color: var(--color-negative); + } + + @keyframes dash { + to { + background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%; + } + } +} diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index c35f14fee..0b33162fb 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -1,515 +1,187 @@ +@import url("lexxy-variables.css") layer(base); +@import url("lexxy-content.css") layer(base); +@import url("lexxy-editor.css") layer(base); + +:root { + --lexxy-color-ink: var(--color-ink); + --lexxy-color-ink-medium: var(--color-ink-dark); + --lexxy-color-ink-light: var(--color-ink-medium); + --lexxy-color-ink-lighter: var(--color-ink-light); + --lexxy-color-ink-lightest: var(--color-ink-lighter); + --lexxy-color-ink-inverted: var(--color-ink-inverted); + + --lexxy-color-canvas: var(--color-canvas); + + --lexxy-color-accent-dark: var(--color-accent-dark); + --lexxy-color-accent-medium: var(--color-accent-medium); + --lexxy-color-accent-light: var(--color-accent-light); + --lexxy-color-accent-lightest: var(--color-accent-lightest); + + --lexxy-color-red: oklch(var(--lch-red-medium)); + --lexxy-color-green: oklch(var(--lch-green-medium)); + --lexxy-color-blue: oklch(var(--lch-blue-medium)); + --lexxy-color-purple: oklch(var(--lch-purple-medium)); + + --lexxy-color-code-token-att: var(--color-code-token__att); + --lexxy-color-code-token-comment: var(--color-code-token__comment); + --lexxy-color-code-token-function: var(--color-code-token__function); + --lexxy-color-code-token-operator: var(--color-code-token__operator); + --lexxy-color-code-token-property: var(--color-code-token__property); + --lexxy-color-code-token-punctuation: var(--color-code-token__punctuation); + --lexxy-color-code-token-selector: var(--color-code-token__selector); + --lexxy-color-code-token-variable: var(--color-code-token__variable); + + --lexxy-color-selected: oklch(var(--lch-blue-light)); + --lexxy-color-selected-dark: oklch(var(--lch-blue-medium)); + + --lexxy-color-table-cell-border: var(--color-ink-ligher); + --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected); + --lexxy-color-table-cell-toggle: var(--lexxy-color-selected); + --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%); +} + @layer components { /* Editor /* ------------------------------------------------------------------------ */ lexxy-editor { - display: block; - position: relative; - overflow: visible; + --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%); + --lexxy-editor-padding: 0; + --lexxy-toolbar-button-size: 2rem; - figure.node--selected, - div.node--selected { - &:has(img) { - img { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - } - - &:not(:has(img)) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - } - } - - .lexxy-content__table-wrapper { - margin: 0; - margin-block: 1ch; - overflow-x: auto; - } - - table { - th, td { - &.table-cell--selected { - background-color: var(--color-selected-light); - } - - &.lexxy-content__table-cell--selected { - background-color: var(--color-selected); - border-color: var(--color-selected-dark); - } - } - - &.lexxy-content__table--selection { - ::selection { - background: transparent; - } - } - } - - /* Lexical uses the `lexxy-editor--empty` class even if you have a list - * started but haven't added other characters. Here, we hide the placeholder - * when you click the List button in the toolbar. */ - .lexxy-editor--empty { - .lexxy-editor__content:not(:has(ul, ol))::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; - } - } - - .lexxy-editor__content { - margin-block-start: var(--block-space-half); - min-block-size: calc(7lh + var(--block-space)); - outline: 0; - - /* Allow color highlights to show through a bit */ - ::selection { - background: oklch(var(--lch-blue-light) / 0.5); - } - - > :last-child { - margin-block-end: 0; - } - } - - .lexxy-editor--drag-over { - background-color: var(--color-selected); - border-radius: 4px; - outline: 2px dashed var(--color-selected-dark); - } - - .lexxy-code-language-picker { - --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); - --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - - -webkit-appearance: none; - appearance: none; - background-color: var(--color-canvas); - background-image: var(--caret-icon); - background-position: center right 0.9em; - background-repeat: no-repeat; - background-size: 0.5em; - border: 1px solid var(--color-ink-light); - border-radius: 99rem; - color: var(--color-ink); - cursor: pointer; - font-family: var(--font-base); - font-size: var(--text-x-small); - font-weight: 500; - inset-inline-end: 0; - line-height: 1.15lh; - margin: 0.75ch 0.75ch 0 0; - padding-inline: 1.5ch 1.8em; - text-align: start; - - html[data-theme="dark"] & { - --caret-icon: var(--caret-icon-dark); - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - --caret-icon: var(--caret-icon-dark); - } - } - - option { - background-color: var(--color-canvas); - color: var(--color-ink); - } - } - - /* Toolbar - /* ------------------------------------------------------------------------ */ - - /* TODO: Temporary - hide table button while we work on the styles */ - button[name="table"] { - display: none; + background-color: transparent; + border: none; + border-radius: 0; } lexxy-toolbar { - --lexxy-toolbar-icon-size: 1em; - - background-color: var(--lexxy-bg-color, var(--color-canvas)); - border-block-end: 1px solid var(--color-ink-light); - color: currentColor; - display: flex; - font-size: inherit; - margin: 0; - max-inline-size: 100%; - padding: 0.2em 0; - position: relative; - position: sticky; - inset-block-start: 0; - z-index: 2; + border-color: var(--lexxy-border-color); + gap: 0; } .lexxy-editor__toolbar-button { - --toolbar-button-size: 44px; + background: transparent; - appearance: none; - aspect-ratio: 1; - background-color: transparent; - block-size: var(--toolbar-button-size); - border: none; - border-radius: 0.2em; - color: currentColor; - cursor: pointer; - display: grid; - font-size: inherit; - place-items: center; - - &:is(:focus, :hover) { - background-color: var(--color-ink-lighter); - box-shadow: none; + &[aria-pressed="true"], + [open] > & { + background-color: oklch(var(--lch-blue-medium) / 20%); } - &:is(:active), - &[aria-pressed="true"] { - background-color: var(--color-selected); - } - - svg { - -webkit-touch-callout: none; - block-size: var(--lexxy-toolbar-icon-size); - fill: currentColor; - grid-area: 1/1; - inline-size: var(--lexxy-toolbar-icon-size); - user-select: none; - } - - @media (min-width: 640px) { - --toolbar-button-size: 2em; - } - } - - .lexxy-editor__toolbar-overflow-menu { - background-color: var(--color-canvas); - border-radius: 0.5ch; - box-shadow: var(--shadow); - display: flex; - flex-wrap: wrap; - inset-inline-end: 0; - padding: 4px; - position: absolute; - z-index: 2; - } - - .lexxy-editor__toolbar-spacer { - flex: 1; - } - - /* Dropdowns - /* ------------------------------------------------------------------------ */ - - .lexxy-editor__toolbar-dropdown-content { - --lexxy-dropdown-padding: 0.75rem; - --lexxy-dropdown-btn-size: 2rem; - - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - font-size: var(--text-small); - padding: var(--lexxy-dropdown-padding); - position: absolute; - z-index: 2; - - button { - block-size: var(--lexxy-dropdown-btn-size); - } - } - - .lexxy-editor__toolbar-dropdown-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--lexxy-dropdown-padding); - margin-block-start: var(--lexxy-dropdown-padding); - - .btn { - --radius: 99rem; - - inline-size: 100%; - } - - .btn[type="submit"] { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --focus-ring-color: var(--card-color, var(--color-link)); - } - - span { - inline-size: 100%; + @media(any-hover: hover) { + &:hover:not([aria-pressed="true"]) { + background-color: oklch(var(--lch-ink-dark) / 20%); + } } } lexxy-link-dropdown { - .input { - min-inline-size: 30ch; - } - } + --lexxy-toolbar-spacing: 1.5ch; - lexxy-highlight-dropdown { - --gap: 0.5ch; + .lexxy-editor__toolbar-button { + border-radius: 99rem; - [data-button-group] { - display: flex; - flex-direction: row; - gap: var(--gap); - - + & { - margin-block-start: var(--gap); - } - - @media (max-width: 639px) { - flex-wrap: wrap; - } - } - } - - .lexxy-highlight-button { - --outline-color: oklch(var(--lch-ink-darkest) / 0.2); - - appearance: none; - background: var(--color-canvas); - border: none; - border-radius: 0.5ch; - color: inherit; - display: grid; - font-weight: 500; - inline-size: var(--lexxy-dropdown-btn-size); - place-content: center; - position: relative; - outline: none; - - &:after { - content: "Aa"; - } - - &:hover, - &[aria-pressed="true"] { - box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color); - } - - &[aria-pressed="true"] { - --outline-color: var(--color-link); - - &:after { - content: "✓"; - } - } - } - - .lexxy-editor__toolbar-dropdown-reset { - background: var(--color-canvas); - border: 1px solid var(--color-ink-light); - border-radius: 99rem; - inline-size: 100%; - margin-block-start: var(--lexxy-dropdown-padding); - - &[disabled] { - display: none; - } - } - - .lexxy-table-handle-buttons { - --button-size: 2.5em; - - color: var(--color-ink-inverted); - display: none; - flex-direction: row; - font-size: var(--text-x-small); - gap: 0.25ch; - line-height: 1; - position: absolute; - transform: translate(-50%, -120%); - z-index: 1; - - &:has([open]) { - z-index: 4; - } - - .lexxy-table-control { - align-items: center; - background-color: var(--color-ink); - border-radius: 0.75ch; - display: flex; - flex-direction: row; - gap: 1ch; - padding: 2px; - white-space: nowrap; - - button, - summary { - aspect-ratio: 1 / 1; - align-items: center; - background-color: transparent; - border-radius: 0.5ch; - border: 0; - color: var(--color-ink-inverted); - cursor: pointer; - display: flex; - font-size: inherit; - font-weight: 700; - justify-content: center; - line-height: 1; - list-style: none; - min-block-size: var(--button-size); - min-inline-size: var(--button-size); - padding: 0; - user-select: none; - -webkit-user-select: none; - + @media(any-hover: hover) { &:hover { - background-color: var(--color-ink-darker); + filter: brightness(0.9); + opacity: 1; } + } - &:focus, - &:focus-visible { - background-color: var(--color-ink-darker); - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - - svg { - block-size: 1em; - inline-size: 1em; - fill: currentColor; - } - - span { - display: none; - } + &[type="submit"], + &[type="submit"]:hover { + background-color: var(--color-link); } - } - - .lexxy-table-control__more-menu { - gap: 0; - padding: 2px; - position: relative; - - summary { - &::-webkit-details-marker { - display: none; - } - } - - .lexxy-table-control__more-menu-details { - display: flex; - flex-direction: column; - gap: 0.25ch; - inset-block-start: 105%; - inset-inline-end: 0; - padding: 0; - position: absolute; - - .lexxy-table-control__more-menu-section { - background: var(--color-ink); - border-radius: 0.75ch; - display: flex; - flex-direction: column; - padding: 2px; - } - - button { - aspect-ratio: unset; - align-items: center; - flex-direction: row; - font-weight: normal; - gap: 1ch; - justify-content: flex-start; - padding: 0.5ch 2ch; - padding-inline-start: 1ch; - white-space: nowrap; - - svg { - block-size: 1.3em; - inline-size: 1.3em; - } - - span { - display: inline-block; - } - } + + &[type="button"] { + border: 1px solid var(--color-ink-light); } } } - /* Prompt menu (@mentions, etc.) + .lexxy-editor__content { + margin-block-start: 0.5lh; + } + + .lexxy-code-language-picker { + border-radius: 99rem; + } + + lexxy-table-tools { + font-size: var(--text-x-small); + } + + [data-lexical-cursor] { + animation: blink 1s step-end infinite; + block-size: 1lh; + border-inline-start: 1px solid currentColor; + line-height: inherit; + margin-block: 1em; + } + + /* Content /* ------------------------------------------------------------------------ */ - .lexxy-prompt-menu { - --lexxy-prompt-avatar-size: 24px; - --lexxy-prompt-min-width: 20ch; - --lexxy-prompt-padding: 0.5ch; + .lexxy-content { + --lexxy-content-margin: 0.5lh; - background-color: var(--color-canvas); - border-radius: calc(var(--lexxy-prompt-padding) * 2); - box-shadow: var(--shadow); - color: var(--color-ink); - font-family: var(--font-sans); - font-size: var(--text-small); - list-style: none; - margin: 0; - max-height: 200px; - min-inline-size: var(--lexxy-prompt-min-width); - overflow: auto; - padding: var(--lexxy-prompt-padding); - visibility: hidden; - z-index: var(--z-popup); - } + color: currentColor; - .lexxy-prompt-menu--visible { - visibility: initial; - } - - .lexxy-prompt-menu__item { - align-items: center; - border-radius: 0.5ch; - cursor: pointer; - display: flex; - gap: var(--lexxy-prompt-padding); - padding: var(--lexxy-prompt-padding); - white-space: nowrap; - - &:hover { - background-color: var(--color-ink-lightest); + h1, h2, h3, h4, h5, h6 { + font-weight: 800; + letter-spacing: -0.02ch; + line-height: 1.1; + overflow-wrap: break-word; + text-wrap: balance; } - &[aria-selected] { - background-color: var(--color-selected); - } - - img { - block-size: var(--lexxy-prompt-avatar-size); - border-radius: 50%; - flex-shrink: 0; - inline-size: var(--lexxy-prompt-avatar-size); + p:has(+ p) { margin: 0; } - + & { - margin-top: 2px; + ol, ul { + padding-inline-start: 1ch; + } + + blockquote { + border-inline-start: 0.25em solid var(--color-ink-lighter); + padding-block: 0; } code { - background-color: var(--color-terminal-text-light); - border-radius: 0.25ch; - font-size: 0.95em; - padding-inline: 0.5ch; + background: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + } + + .horizontal-divider { + padding-block: var(--lexxy-content-margin); + hr { margin: 0; } + } + + hr { + border: 0; + border-block-end: 2px solid currentColor; + color: currentColor; + inline-size: 20%; + margin: calc(var(--lexxy-content-margin) * 2) 0; + } + + table { + th, td { + font-size: var(--text-small); + padding-block: 0.75ch; + } + tr:not([data-action="delete"]) { + th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } + td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } + } + } + + .attachment { + margin-inline: auto; } } - /* Empty state */ - .lexxy-prompt-menu__item--empty { - color: var(--color-ink-medium); - padding: var(--lexxy-prompt-padding); + .attachment { + margin-inline: 0; } } diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index c67d05d14..34259e9c6 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -66,6 +66,12 @@ translate: unset; } + .card-perma--draft { + .card-perma__bg { + box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container); + } + } + /* Search /* ------------------------------------------------------------------------ */ diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index fabea58a8..ca7a058f4 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -67,7 +67,7 @@ /* Help /* ------------------------------------------------------------------------ */ .notifications-help { - h2 { + h3 { font-size: var(--text-medium); margin: 0; } @@ -95,12 +95,12 @@ display: none; .notifications--on & { - display: inline; + display: revert; } } .notifications__off-message { - display: inline; + display: revert; .notifications--on & { display: none; diff --git a/app/assets/stylesheets/performance-notice.css b/app/assets/stylesheets/performance-notice.css new file mode 100644 index 000000000..a39bde2cd --- /dev/null +++ b/app/assets/stylesheets/performance-notice.css @@ -0,0 +1,11 @@ +.performance-notice { + background: oklch(var(--lch-yellow-lightest)); + border-radius: 1ch; + border: 1px solid oklch(var(--lch-yellow-light)); + font-size: var(--text-small); + margin-block-end: 2ch; + margin-inline: auto; + max-inline-size: 60ch; + padding-inline: 2ch; + padding: 1ch; +} diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/qr-codes.css index 5019bf828..91aebcd64 100644 --- a/app/assets/stylesheets/qr-codes.css +++ b/app/assets/stylesheets/qr-codes.css @@ -1,6 +1,8 @@ @layer components { .qr-code { aspect-ratio: 1; - block-size: 50dvh; + border-radius: 1ch; + inline-size: clamp(20ch, 50dvh, 70ch); + margin-block: var(--block-space); } } diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 64f2a5164..efd487f64 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -86,6 +86,10 @@ font-size: 0.6em; inline-size: auto; } + + @media (any-hover: none) { + padding-inline-end: 0.12em; + } } .reaction--deleteable { @@ -166,6 +170,11 @@ .reaction__cancel-btn { --btn-size: 1.25rem; --icon-size: var(--btn-size); + + @media (any-hover: none) { + --btn-size: 2rem; + --icon-size: 90%; + } } .reaction__submit-btn { @@ -219,6 +228,12 @@ &:hover { scale: 1.3; } + + @media (any-hover: none) { + --btn-size: calc(1.6rem * 1.3); + + font-size: 1.6rem; + } } } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css deleted file mode 100644 index bf5328478..000000000 --- a/app/assets/stylesheets/rich-text-content.css +++ /dev/null @@ -1,469 +0,0 @@ -@layer components { - :where(.rich-text-content) { - --block-margin: 0.5lh; - - h1, h2, h3, h4, h5, h6 { - display: block; - font-weight: 800; - letter-spacing: -0.02ch; - line-height: 1.1; - margin-block: 0 var(--block-margin); - overflow-wrap: break-word; - text-wrap: balance; - } - - h1 { font-size: 2em; } - h2 { font-size: 1.5em; } - h3 { font-size: 1.17em; } - h4 { font-size: 1em; } - h5 { font-size: 0.83em; } - h6 { font-size: 0.67em; } - - p, ul, ol, dl, blockquote, figure, .attachment { - margin-block: 0 var(--block-margin); - - &:not(lexxy-editor &) { - overflow-wrap: break-word; - text-wrap: pretty; - } - } - - p:has(+ p) { - margin: 0; - } - - ol, ul { - padding-inline-start: 3ch; - } - - ol { - list-style: decimal; - } - - ul { - list-style: disc; - } - - li:has(li) { - list-style: none; - - ol, ul { - margin: 0; - padding-inline-start: 2ch; - } - } - - b, strong, .lexxy-content__bold { - font-weight: 700; - } - - i, em, .lexxy-content__italic { - font-style: italic; - } - - s, .lexxy-content__strikethrough { - text-decoration: line-through; - } - - mark, .lexxy-content__highlight { - background-color: transparent; - color: inherit; - } - - p, blockquote { - letter-spacing: -0.005ch; - } - - /* Avoid extra space due to empty paragraphs */ - p:empty { - display: none; - } - - blockquote { - border-inline-start: 0.25em solid var(--color-ink-lighter); - font-style: italic; - margin: var(--block-margin) 0; - padding-inline-start: 2ch; - } - - img, video, embed, object { - inline-size: auto; - margin-inline: auto; - max-block-size: 32rem; - object-fit: contain; - } - - /* Links should hug media contained within */ - a:has(img), - a:has(video) { - inline-size: fit-content; - margin-inline: auto; - } - - hr { - color: currentColor; - block-size: 0; - border: none; - border-block-end: 2px solid currentColor; - inline-size: 20%; - margin: calc(var(--block-margin) * 2) 0; - } - - .horizontal-divider { - margin: var(--block-margin) 0; - padding-block: var(--block-margin); - } - - /* Code */ - code, pre { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.3em; - color: var(--color-ink); - font-family: var(--font-mono); - font-size: 0.85em; - font-weight: 500; - margin-block: var(--block-margin); - padding: 0.1em 0.3em; - - &:is(pre), - &[data-language] { - border-radius: 0.5em; - display: block; - overflow-x: auto; - padding: 0.5lh 2ch; - tab-size: 2; - text-wrap: nowrap; - white-space: pre; - word-break: break-word; - - /* Keywords and attributes */ - .code-token__attr, - .token.atrule, - .token.attr, - .token.keyword { - color: var(--color-code-token__att); - } - - /* Constants, booleans, numbers, properties, tags */ - .code-token__property, - .token.boolean, - .token.constant, - .token.deleted, - .token.number, - .token.property, - .token.symbol, - .token.tag { - color: var(--color-code-token__property); - } - - /* Strings, selectors, and built-in constructs */ - .code-token__selector, - .token.builtin, - .token.char, - .token.inserted, - .token.selector, - .token.string { - color: var(--color-code-token__selector); - } - - /* Comments and meta information */ - .code-token__comment, - .token.cdata, - .token.comment, - .token.doctype, - .token.prolog { - color: var(--color-code-token__comment); - font-style: italic; - } - - /* Operators and symbolic entities */ - .code-token__operator, - .token.entity, - .token.operator, - .token.url { - color: var(--color-code-token__operator); - } - - /* Functions and class names */ - .code-token__function, - .token.class, - .token.class-name, - .token.function { - color: var(--color-code-token__function); - } - - /* Variables, regex, namespaces, important */ - .code-token__variable, - .token.important, - .token.namespace, - .token.regex, - .token.variable { - color: var(--color-code-token__variable); - } - - /* Punctuation */ - .code-token__punctuation, - .token.punctuation { - color: var(--color-code-token__punctuation); - } - } - } - - /* Tables */ - .lexxy-content__table-wrapper { - margin: 0; - margin-block: 1ch; - overflow-x: auto; - } - - table { - border-collapse: collapse; - border-spacing: 0; - inline-size: calc(100% - 0.5ch); - margin: 0.25ch; - - th, - td { - border: 1px solid var(--color-ink-light); - padding: 0.5ch 1ch; - text-align: start; - word-break: normal; - - *:last-child { - margin-block-end: 0; - } - - *:not(pre, code) { - word-break: normal; - } - } - - th, - .lexxy-content__table-cell--header { - background: var(--color-ink-lightest); - } - - td { - background: var(--color-canvas); - } - } - } - - /* Attachments - /* ------------------------------------------------------------------------ */ - - .attachment { - block-size: auto; - display: block; - inline-size: fit-content; - position: relative; - max-inline-size: 100%; - - progress { - inline-size: 100%; - margin: auto; - } - } - - .attachment__caption { - color: color-mix(in oklch, var(--color-ink) 66%, transparent); - font-size: var(--text-small); - - textarea { - --input-border-radius: 0.3em; - --input-border-size: 0; - --input-padding: 0; - - background-color: var(--input-background, transparent); - border: none; - color: inherit; - inline-size: 100%; - max-inline-size: 100%; - resize: none; - text-align: center; - - &:focus { - --focus-ring-size: 0; - } - - @supports (field-sizing: content) { - field-sizing: content; - inline-size: auto; - min-inline-size: 20ch; - } - } - } - - .attachment__icon { - aspect-ratio: 4/5; - background-color: color-mix(var(--attachment-icon-color), transparent 90%); - block-size: 2.5lh; - border: 2px solid var(--attachment-icon-color); - border-block-start-width: 1ch; - border-radius: 0.5ch; - box-sizing: border-box; - color: var(--attachment-icon-color); - display: grid; - font-size: var(--text-small); - font-weight: bold; - inline-size: auto; - padding-inline: 0.5ch; - place-content: center; - text-transform: uppercase; - white-space: nowrap; - } - - .attachment--preview { - margin-inline: auto; - text-align: center; - - img, video { - block-size: auto; - display: block; - margin-inline: auto; - max-inline-size: 100%; - user-select: none; - } - - > a { - display: block; - } - - .attachment__caption { - column-gap: 0.5ch; - display: flex; - flex-wrap: wrap; - justify-content: center; - margin-block-start: 0.5ch; - } - } - - .attachment--file { - --attachment-icon-color: var(--color-ink-medium); - - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 1ch; - inline-size: 100%; - margin-inline: 0; - - .attachment__caption { - display: grid; - flex: 1; - text-align: start; - } - - .attachment__name { - color: var(--color-ink); - font-weight: bold; - } - - /* Video attachments don't have an identifiable class, but we need to - * make sure the caption is always below the video */ - &:has(video) { - .attachment__caption { - flex: none; - inline-size: 100%; - } - } - } - - .attachment--psd, - .attachment--key, - .attachment--sketch, - .attachment--ai, - .attachment--eps, - .attachment--indd, - .attachment--svg, - .attachment--ppt, - .attachment--pptx { - --attachment-icon-color: oklch(var(--lch-red-medium)); - } - - .attachment--css, - .attachment--crash, - .attachment--php, - .attachment--json, - .attachment--htm, - .attachment--html, - .attachment--rb, - .attachment--erb, - .attachment--ts, - .attachment--js { - --attachment-icon-color: oklch(var(--lch-purple-medium)); - } - - .attachment--txt, - .attachment--pages, - .attachment--rtf, - .attachment--md, - .attachment--doc, - .attachment--docx { - --attachment-icon-color: oklch(var(--lch-blue-medium)); - } - - .attachment--csv &, - .attachment--numbers &, - .attachment--xls &, - .attachment--xlsx & { - --attachment-icon-color: oklch(var(--lch-green-medium)); - } - - .attachment__link { - color: var(--color-link); - text-decoration: underline; - } - - /* Custom attachments such as mentions, etc. */ - action-text-attachment[content-type^='application/vnd.actiontext'] { - --attachment-bg-color: transparent; - --attachment-image-size: 1em; - --attachment-text-color: currentColor; - - align-items: center; - background: var(--attachment-bg-color); - border-radius: 99rem; - box-shadow: - -0.25ch 0 0 var(--attachment-bg-color), - 0.5ch 0 0 var(--attachment-bg-color); - color: var(--attachment-text-color); - display: inline-flex; - gap: 0.25ch; - margin: 0; - padding: 0; - position: relative; - vertical-align: bottom; - white-space: normal; - - lexxy-editor & { - cursor: pointer; - } - - img { - block-size: var(--attachment-image-size); - border-radius: 50%; - inline-size: var(--attachment-image-size); - } - - &.node--selected { - --attachment-bg-color: oklch(var(--lch-blue-dark)); - --attachment-text-color: var(--color-ink-inverted); - } - } - - action-text-attachment[content-type^='application/vnd.actiontext.mention'] { - img { - object-fit: cover; - } - } - - [data-lexical-cursor] { - animation: blink 1s step-end infinite; - block-size: 1lh; - border-inline-start: 1px solid currentColor; - line-height: inherit; - margin-block: 1em; - } -} diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index 0e39c33ee..2a2d0c9cd 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -58,6 +58,14 @@ /* Users /* ------------------------------------------------------------------------ */ + .settings__section--users { + @media (min-width: 640px) { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + } .settings__user-filter { --btn-size: 3.5ch; --avatar-size: var(--btn-size); diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 76dedfe12..53837034c 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -32,6 +32,7 @@ .txt-link { color: var(--color-link); text-decoration: underline; } .font-weight-normal { font-weight: normal; } + .font-weight-semibold { font-weight: 600; } .font-weight-bold { font-weight: bold; } .font-weight-black { font-weight: 900; } diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb index 95acc7511..75fdbac19 100644 --- a/app/controllers/cards/readings_controller.rb +++ b/app/controllers/cards/readings_controller.rb @@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController include CardScoped def create - @notifications = @card.read_by(Current.user) + @notification = @card.read_by(Current.user) record_board_access end def destroy - @notifications = @card.unread_by(Current.user) + @notification = @card.unread_by(Current.user) record_board_access end diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb index c2bac662a..0adc0e17b 100644 --- a/app/controllers/notifications/trays_controller.rb +++ b/app/controllers/notifications/trays_controller.rb @@ -1,6 +1,6 @@ class Notifications::TraysController < ApplicationController def show - @notifications = Current.user.notifications.preloaded.unread.ordered.limit(100) + @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100) # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. diff --git a/app/controllers/qr_codes_controller.rb b/app/controllers/qr_codes_controller.rb index aa8ca8334..a49809876 100644 --- a/app/controllers/qr_codes_controller.rb +++ b/app/controllers/qr_codes_controller.rb @@ -6,7 +6,7 @@ class QrCodesController < ApplicationController qr_code_svg = RQRCode::QRCode .new(QrCodeLink.from_signed(params[:id]).url) - .as_svg(viewbox: true, fill: :white, color: :black) + .as_svg(viewbox: true, fill: :white, color: :black, offset: 16) render svg: qr_code_svg end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 5f12b9259..eae2f3665 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,4 +9,12 @@ module BoardsHelper icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end + + def bridged_button_to_board(board) + link_to "Go to #{board.name}", board, hidden: true, data: { + bridge__buttons_target: "button", + bridge_icon_url: bridge_icon("board"), + bridge_title: "Go to #{board.name}" + } + end end diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index b3e98b30e..9938907e1 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -5,7 +5,7 @@ module ColumnsHelper card_triage_path(card, column_id: column), method: :post, class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ], - disabled: column == card.column, + disabled: column == card.column && card.open?, style: "--column-color: #{column.color}", form_class: "flex gap-half", data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil } diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index a3d0bbab3..7b65d34cf 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -2,4 +2,10 @@ module HtmlHelper def format_html(html) Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe end + + def card_html_title(card) + return card.title if card.title.blank? + + ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '\1').html_safe + end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 355f60550..f376d15c1 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -29,9 +29,7 @@ module NotificationsHelper def notification_tag(notification, &) tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: { navigable_list_target: "item", - notifications_tray_target: "notification", - card_id: notification.card.id, - timestamp: notification.created_at.to_i + card_id: notification.card.id } do link_to(notification, class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ], @@ -58,7 +56,7 @@ module NotificationsHelper data: { action: "form#submit:stop badge#update:stop", form_target: "submit" }, form: { data: { controller: "form" } } do concat(icon_tag("remove")) - concat(tag.span("1", class: "badge-count", data: { group_count: "" })) + concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1 end end end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index c1bc2232a..c580b4507 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -15,11 +15,7 @@ module RichTextHelper content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true end - def code_language_picker - content_tag "lexxy-code-language-picker" - end - def general_prompts(board) - safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ]) + safe_join([ mentions_prompt(board), cards_prompt ]) end end diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 939fe8441..35c5efedf 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { nextFrame, debounce } from "helpers/timing_helpers"; +import { isNative } from "helpers/platform_helpers"; export default class extends Controller { static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ] @@ -49,7 +50,7 @@ export default class extends Controller { focusOnColumn({ target }) { if (this.#isDesktop && this.#isCollapsed(target)) { this.#collapseAllExcept(target) - this.#expand(target) + this.#expand({ column: target }) } } @@ -74,7 +75,7 @@ export default class extends Controller { this.#collapseAllExcept(column) if (this.#isCollapsed(column)) { - this.#expand(column) + this.#expand({ column }) } else { this.#collapse(column) } @@ -103,7 +104,7 @@ export default class extends Controller { localStorage.removeItem(key) } - #expand(column, saveState = true) { + #expand({ column, saveState = true, scrollBehavior = "smooth" }) { this.#buttonFor(column)?.setAttribute("aria-expanded", "true") column.classList.remove(this.collapsedClass) column.classList.add(this.expandedClass) @@ -114,7 +115,7 @@ export default class extends Controller { } if (window.matchMedia('(max-width: 639px)').matches) { - column.scrollIntoView({ behavior: "smooth", inline: "center" }) + column.scrollIntoView({ behavior: scrollBehavior, inline: "center" }) } } @@ -132,7 +133,7 @@ export default class extends Controller { const key = this.#localStorageKeyFor(column) if (localStorage.getItem(key)) { this.#collapseAllExcept(column) - this.#expand(column) + this.#expand({ column, scrollBehavior: isNative() ? "instant" : "smooth" }) } } @@ -168,7 +169,7 @@ export default class extends Controller { } async #handleDesktopMode() { - this.#expand(this.maybeColumnTarget, false) + this.#expand({ column: this.maybeColumnTarget, saveState: false }) this.#maybeButton.setAttribute("disabled", true) } @@ -181,7 +182,7 @@ export default class extends Controller { this.#collapseAllExcept(expandedColumn) } else { this.#collapseAllExcept(this.maybeColumnTarget) - this.#expand(this.maybeColumnTarget, false) + this.#expand({ column: this.maybeColumnTarget, saveState: false }) } } diff --git a/app/javascript/controllers/expandable_on_native_controller.js b/app/javascript/controllers/expandable_on_native_controller.js index a58ddfa9a..913437809 100644 --- a/app/javascript/controllers/expandable_on_native_controller.js +++ b/app/javascript/controllers/expandable_on_native_controller.js @@ -9,8 +9,6 @@ export default class extends Controller { static values = { autoExpandSelector: String } connect() { - if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) { - this.element.open = true - } + this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue) } } diff --git a/app/javascript/controllers/notifications_tray_controller.js b/app/javascript/controllers/notifications_tray_controller.js deleted file mode 100644 index 12077425b..000000000 --- a/app/javascript/controllers/notifications_tray_controller.js +++ /dev/null @@ -1,89 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ "notification", "hiddenNotifications" ] - static classes = [ "grouped" ] - - connect() { - this.group() - } - - group() { - const notificationsByCardId = this.#groupNotificationsByCardId() - - for (const cardId in notificationsByCardId) { - const notifications = notificationsByCardId[cardId] - if (notifications.length > 1) { - this.#renderGroup(notifications) - } - } - - this.grouped = true - } - - notificationTargetConnected(notification) { - if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) { - this.#groupNotification(notification) - } - } - - #groupNotificationsByCardId() { - const notificationsByCardId = {} - - this.notificationTargets.forEach(notification => { - const cardId = notification.dataset.cardId - notificationsByCardId[cardId] ||= [] - notificationsByCardId[cardId].push(notification) - }) - - return notificationsByCardId - } - - #groupNotification(notification) { - const groupedNotifications = this.#groupedNotificationsFor(notification) - - if (groupedNotifications.length > 1) { - this.#renderGroup(groupedNotifications) - } - } - - #groupedNotificationsFor(notification) { - const cardId = notification.dataset.cardId - return this.notificationTargets - .filter(notification => notification.dataset.cardId === cardId) - } - - #renderGroup(groupedNotifications) { - groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp)) - - this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length) - groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification)) - } - - #showAsGrouped(notification, size) { - notification.classList.add(this.groupedClass) - this.#showGroupedNotification(notification) - this.#setGroupCount(notification, size) - } - - #hideInGroup(notification) { - this.#hideGroupedNotification(notification) - this.#setGroupCount(notification, "") - } - - // We use a hidden container instead of hiding the notifications directly so that the CSS that sort the - // tray element indexes doesn't get messed up with the child positions changing. - #showGroupedNotification(notification) { - if (notification.parentElement === this.hiddenNotificationsTarget) { - this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget) - } - } - - #hideGroupedNotification(notification) { - this.hiddenNotificationsTarget.appendChild(notification) - } - - #setGroupCount(notification, count) { - notification.querySelector("[data-group-count]").textContent = count - } -} diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb index c626ddb2e..3ab25e481 100644 --- a/app/mailers/import_mailer.rb +++ b/app/mailers/import_mailer.rb @@ -4,7 +4,8 @@ class ImportMailer < ApplicationMailer mail to: identity.email_address, subject: "Your Fizzy account import is done" end - def failed(identity) - mail to: identity.email_address, subject: "Your Fizzy account import failed" + def failed(import) + @import = import + mail to: import.identity.email_address, subject: "Your Fizzy account import failed" end end diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb index 89fea208a..d0998fa23 100644 --- a/app/mailers/notification/bundle_mailer.rb +++ b/app/mailers/notification/bundle_mailer.rb @@ -7,10 +7,14 @@ class Notification::BundleMailer < ApplicationMailer @user = bundle.user @bundle = bundle @notifications = bundle.notifications + .preload(:card, :creator, source: [ :board, :creator ]) + .reject { |n| n.source.nil? || n.card.nil? } @unsubscribe_token = @user.generate_token_for(:unsubscribe) - mail \ - to: bundle.user.identity.email_address, - subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications" + if @notifications.any? + mail \ + to: bundle.user.identity.email_address, + subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications" + end end end diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb index ec2f4e17f..fa1a23cb9 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -84,9 +84,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer gid = GlobalID.parse(node["gid"]) if gid - record = gid.find - node["sgid"] = record.attachable_sgid - node.remove_attribute("gid") + record = begin + gid.find + rescue ActiveRecord::RecordNotFound + nil + end + + if record&.account_id == account.id + node["sgid"] = record.attachable_sgid + node.remove_attribute("gid") + end end end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index b7955932a..8eeafbe10 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -1,5 +1,6 @@ class Account::DataTransfer::RecordSet class IntegrityError < StandardError; end + class ConflictError < IntegrityError; end IMPORT_BATCH_SIZE = 100 @@ -93,7 +94,7 @@ class Account::DataTransfer::RecordSet end if model.exists?(id: data["id"]) - raise IntegrityError, "#{model} record with ID #{data['id']} already exists" + raise ConflictError, "#{model} record with ID #{data['id']} already exists" end check_associations_dont_exist(data) @@ -118,7 +119,7 @@ class Account::DataTransfer::RecordSet end if associated_class.exists?(id: associated_id) - raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" + raise ConflictError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" end end diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 814e6b00c..14ee376ea 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -7,6 +7,7 @@ class Account::Import < ApplicationRecord has_one_attached :file enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) } @@ -26,6 +27,12 @@ class Account::Import < ApplicationRecord record_set.check(from: zip, start: last_id, callback: callback) end end + rescue Account::DataTransfer::RecordSet::ConflictError => e + mark_as_failed(:conflict) + raise e + rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e + mark_as_failed(:invalid_export) + raise e rescue => e mark_as_failed raise e @@ -44,6 +51,12 @@ class Account::Import < ApplicationRecord reconcile_account_storage mark_completed + rescue Account::DataTransfer::RecordSet::ConflictError => e + mark_as_failed(:conflict) + raise e + rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e + mark_as_failed(:invalid_export) + raise e rescue => e mark_as_failed raise e @@ -60,9 +73,9 @@ class Account::Import < ApplicationRecord ImportMailer.completed(identity, account).deliver_later end - def mark_as_failed - failed! - ImportMailer.failed(identity).deliver_later + def mark_as_failed(failure_reason = nil) + update!(status: :failed, failure_reason: failure_reason) + ImportMailer.failed(self).deliver_later end def add_importer_to_all_access_boards diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb index 9c272dd75..aec8c18dd 100644 --- a/app/models/card/readable.rb +++ b/app/models/card/readable.rb @@ -2,15 +2,11 @@ module Card::Readable extend ActiveSupport::Concern def read_by(user) - notifications_for(user).tap do |notifications| - notifications.each(&:read) - end + user.notifications.find_by(card: self)&.tap(&:read) end def unread_by(user) - all_notifications_for(user).tap do |notifications| - notifications.each(&:unread) - end + user.notifications.find_by(card: self)&.tap(&:unread) end def remove_inaccessible_notifications @@ -25,18 +21,6 @@ module Card::Readable Card::RemoveInaccessibleNotificationsJob.perform_later(self) end - def notifications_for(user) - scope = user.notifications.unread - scope.where(source: event_notification_sources) - .or(scope.where(source: mention_notification_sources)) - end - - def all_notifications_for(user) - scope = user.notifications - scope.where(source: event_notification_sources) - .or(scope.where(source: mention_notification_sources)) - end - def event_notification_sources events.or(comment_creation_events) end diff --git a/app/models/concerns/push_notifiable.rb b/app/models/concerns/push_notifiable.rb index e497bc3fe..e7bfdc56a 100644 --- a/app/models/concerns/push_notifiable.rb +++ b/app/models/concerns/push_notifiable.rb @@ -3,6 +3,7 @@ module PushNotifiable included do after_create_commit :push_notification_later + after_update_commit :push_notification_later, if: :source_id_previously_changed? end private diff --git a/app/models/event/description.rb b/app/models/event/description.rb index fd7a66ae1..454e636bb 100644 --- a/app/models/event/description.rb +++ b/app/models/event/description.rb @@ -14,7 +14,7 @@ class Event::Description end def to_plain_text - to_sentence(creator_name, quoted(card.title)) + to_sentence(creator_name, quoted(card.title)).html_safe end private @@ -38,11 +38,11 @@ class Event::Description end def creator_name - h(event.creator.name) + h event.creator.name end def quoted(text) - %("#{h text}") + h %("#{text}") end def card @@ -86,27 +86,43 @@ class Event::Description if event.assignees.include?(user) "#{creator} will handle #{card_title}" else - "#{creator} assigned #{h event.assignees.pluck(:name).to_sentence} to #{card_title}" + "#{creator} assigned #{assignee_names} to #{card_title}" end end def unassigned_sentence(creator, card_title) - assignees_text = event.assignees.include?(user) ? "yourself" : event.assignees.pluck(:name).to_sentence - "#{creator} unassigned #{h(assignees_text)} from #{card_title}" + "#{creator} unassigned #{unassigned_names} from #{card_title}" end def renamed_sentence(creator, card_title) - old_title = event.particulars.dig("particulars", "old_title") - %(#{creator} renamed #{card_title} (was: "#{h old_title}")) + %(#{creator} renamed #{card_title} (was: "#{old_title}")) end def moved_sentence(creator, card_title) - new_location = event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection") - %(#{creator} moved #{card_title} to "#{h new_location}") + %(#{creator} moved #{card_title} to "#{new_location}") end def triaged_sentence(creator, card_title) - column = event.particulars.dig("particulars", "column") - %(#{creator} moved #{card_title} to "#{h column}") + %(#{creator} moved #{card_title} to "#{column}") + end + + def assignee_names + h event.assignees.pluck(:name).to_sentence + end + + def unassigned_names + h(event.assignees.include?(user) ? "yourself" : assignee_names) + end + + def old_title + h event.particulars.dig("particulars", "old_title") + end + + def new_location + h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")) + end + + def column + h event.particulars.dig("particulars", "column") end end diff --git a/app/models/notification.rb b/app/models/notification.rb index 545f6ab75..3d7df5885 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -5,32 +5,39 @@ class Notification < ApplicationRecord belongs_to :user belongs_to :creator, class_name: "User" belongs_to :source, polymorphic: true + belongs_to :card scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } - scope :ordered, -> { order(read_at: :desc, created_at: :desc) } + scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } + scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } - after_create_commit :broadcast_unread - after_destroy_commit :broadcast_read + before_validation :set_card after_create :bundle + after_update :bundle, if: :source_id_previously_changed? - scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } + after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" } + after_update_commit -> { broadcast_update } + after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source - delegate :card, to: :source - def self.read_all - all.each { |notification| notification.read } + class << self + def read_all + all.each(&:read) + end + + def unread_all + all.each(&:unread) + end end def read - update!(read_at: Time.current) - broadcast_read + update!(read_at: Time.current, unread_count: 0) end def unread - update!(read_at: nil) - broadcast_unread + update!(read_at: nil, unread_count: 1) end def read? @@ -38,15 +45,19 @@ class Notification < ApplicationRecord end private - def broadcast_unread - broadcast_prepend_later_to user, :notifications, target: "notifications" - end - - def broadcast_read - broadcast_remove_to user, :notifications + def set_card + self.card = source.card end def bundle user.bundle(self) if user.settings.bundling_emails? end + + def broadcast_update + if read? + broadcast_remove_to(user, :notifications) + else + broadcast_prepend_later_to(user, :notifications, target: "notifications") + end + end end diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index fb57a4fd0..1eadb3c9e 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord enum :status, %i[ pending processing delivered ] scope :due, -> { pending.where("ends_at <= ?", Time.current) } - scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) } + scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) } scope :overlapping_with, ->(other_bundle) do where( "(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)", @@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord end def notifications - user.notifications.where(created_at: window).unread + user.notifications.where(updated_at: window).unread end def deliver diff --git a/app/models/notifier.rb b/app/models/notifier.rb index 24c1b3b4e..f6f356fa4 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -16,7 +16,23 @@ class Notifier if should_notify? # Processing recipients in order avoids deadlocks if notifications overlap. recipients.sort_by(&:id).map do |recipient| - Notification.create! user: recipient, source: source, creator: creator + notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n| + n.source = source + n.creator = creator + n.unread_count = 1 + end + + unless notification.previously_new_record? + # Always include source_type in the update to prevent a race condition between + # concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking + # may skip source_type when it hasn't changed from the stale in-memory value, + # even though another job has since modified it in the database, leaving + # source_type and source_id mismatched. + notification.source_type_will_change! + notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1) + end + + notification end end end diff --git a/app/models/user/notifiable.rb b/app/models/user/notifiable.rb index f6788aaac..7598f0e0f 100644 --- a/app/models/user/notifiable.rb +++ b/app/models/user/notifiable.rb @@ -25,12 +25,12 @@ module User::Notifiable def expand_pending_bundle_for(notification) pending = notification_bundles.pending.last - if pending.present? && notification.created_at < pending.starts_at - pending.update!(starts_at: notification.created_at) # expand the window to include this notification + if pending.present? && notification.updated_at < pending.starts_at + pending.update!(starts_at: notification.updated_at) # expand the window to include this notification end end def create_bundle_for(notification) - notification_bundles.create!(starts_at: notification.created_at) + notification_bundles.create!(starts_at: notification.updated_at) end end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 50a0cd560..40dab42f0 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -22,8 +22,8 @@ class Webhook::Delivery < ApplicationRecord after_create_commit :deliver_later - def self.cleanup - stale.delete_all + def self.cleanup(batch_size: 500, pause: 0.1) + sleep pause until stale.limit(batch_size).delete_all.zero? end def deliver_later diff --git a/app/views/account/imports/new.html.erb b/app/views/account/imports/new.html.erb index fd837a7b6..b4e12e94b 100644 --- a/app/views/account/imports/new.html.erb +++ b/app/views/account/imports/new.html.erb @@ -1,9 +1,15 @@ <% @page_title = "Import an account" %> - -
+
-

Import a Fizzy account

-
Create an account using data from a Fizzy export. Upload the exported .zip file below.
+

Import a Fizzy account

+ + <% if Fizzy.saas? %> +
Running Fizzy on your own server and want us to host on fizzy.do instead?
+
Export your self-hosted account, then upload the .zip file below.
+ <% else %> +
Ready to host Fizzy on your own server?
+
Export your fizzy.do account, then upload the .zip file below.
+ <% end %>
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> @@ -17,6 +23,8 @@ Start Import → <% end %> + +

If you run into issues or would like assistance, just send us an email.

<% content_for :footer do %> diff --git a/app/views/account/imports/show.html.erb b/app/views/account/imports/show.html.erb index 26dbf88d5..745eb7bae 100644 --- a/app/views/account/imports/show.html.erb +++ b/app/views/account/imports/show.html.erb @@ -2,19 +2,31 @@ <%= turbo_stream_from @import %> -
+

Import status

<% case @import.status %> <% when "pending", "processing" %> -

Your import is in progress. This may take a while for large accounts.

+
+ Your import is in progress. This may take a while for large accounts. +
<% when "completed" %> -

Your import has completed successfully!

- <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> +
+
Your import was successful!
+ <%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %> +
<% when "failed" %> -

Your import failed.

-

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.

- <%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %> +
+
Import failed
+ <% if @import.failed_due_to_conflict? %> +
The account you’re trying to import already exists. Make sure you’re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.
+ <% elsif @import.failed_due_to_invalid_export? %> +
The .zip file you uploaded doesn’t look like a Fizzy account export.
+ <% else %> +
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
+ <% end %> + <%= link_to "Try again", new_account_import_path, class: "btn" %> +
<% end %>
diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index f3cfb1e37..aa7ed7d87 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -39,8 +39,10 @@ Get QR code <% end %> - -

Scan this code with the camera on your mobile device

+ +

+ Scan this code to join <%= Current.account.name %>: +

<%= qr_code_image(url) %> diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb index ad2d8392a..3608e764f 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -10,7 +10,7 @@

Export all account data

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

-

We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.

+

We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.

<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index aeee05638..ceb17ff23 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,9 +1,9 @@ -
+

People on this account

- <%= tag.div class: "flex flex-column gap settings__user-filter", data: { + <%= tag.div class: "settings__user-filter flex flex-column gap", data: { controller: "filter navigable-list", action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", navigable_list_focus_on_selection_value: true, diff --git a/app/views/boards/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb index c0f60cd77..b37f0c1ac 100644 --- a/app/views/boards/edit/_publication.html.erb +++ b/app/views/boards/edit/_publication.html.erb @@ -30,7 +30,7 @@ Add an optional description to the public page
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %> - <%= form.rich_textarea :public_description, class: "rich-text-content txt-small", + <%= form.rich_textarea :public_description, class: "lexxy-content txt-small", placeholder: "Add a public note about this board…", data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" }, readonly: !Current.user.can_administer_board?(@board) %> diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 5058d6455..c9518ba78 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -4,7 +4,7 @@
<%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

<%= @page_title %>

- <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %> + <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can’t be blank" } %>
-
+
<%= comment.body %>
diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index 115f30ebe..d2de939a5 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -4,7 +4,7 @@
-
+
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form local-save", local_save_key_value: "comment-#{card.id}", diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index ca23b331f..15e3cbd4a 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -5,7 +5,7 @@
-
+
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 05e292315..990a17982 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,6 +1,6 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %> + data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e diff --git a/app/views/cards/container/_content.html.erb b/app/views/cards/container/_content.html.erb index ba94041c1..5da73f266 100644 --- a/app/views/cards/container/_content.html.erb +++ b/app/views/cards/container/_content.html.erb @@ -20,7 +20,7 @@ <% end %> - <%= form.rich_textarea :description, class: "card__description rich-text-content", + <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %> <%= general_prompts(card.board) %> diff --git a/app/views/cards/container/_content_display.html.erb b/app/views/cards/container/_content_display.html.erb index 3395c40bf..e99b5784f 100644 --- a/app/views/cards/container/_content_display.html.erb +++ b/app/views/cards/container/_content_display.html.erb @@ -1,9 +1,9 @@

- <%= link_to card.title, edit_card_path(card), class: "card__title-link" %> + <%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>

<% unless card.description.blank? %> -
+
<%= card.description %>
<% end %> diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index fe8b7c613..f70aa6ade 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -36,7 +36,7 @@

- <%= card.title %> + <%= card_html_title(card) %>

diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index 7aa53fed7..0c297df15 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -17,7 +17,7 @@

- <%= card.title %> + <%= card_html_title(card) %>

diff --git a/app/views/cards/display/perma/_tags.html.erb b/app/views/cards/display/perma/_tags.html.erb index ea4d1f76b..cdfede474 100644 --- a/app/views/cards/display/perma/_tags.html.erb +++ b/app/views/cards/display/perma/_tags.html.erb @@ -15,7 +15,7 @@
<% if card.tags.any? %> -
+
<% card.tags.each_with_index do |tag, index| %> <%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]), class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %> diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb index 409621b21..fa7134b55 100644 --- a/app/views/cards/drafts/_container.html.erb +++ b/app/views/cards/drafts/_container.html.erb @@ -1,4 +1,4 @@ -
+
<% cache card do %>
<%= render "cards/container/image", card: card %> diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb index a7efd21fe..1e81896a1 100644 --- a/app/views/cards/edit.html.erb +++ b/app/views/cards/edit.html.erb @@ -16,7 +16,7 @@ <% end %> - <%= form.rich_textarea :description, class: "card__description rich-text-content", + <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %> <%= general_prompts(@card.board) %> diff --git a/app/views/cards/readings/create.turbo_stream.erb b/app/views/cards/readings/create.turbo_stream.erb index 5d7fe7801..ebfcee900 100644 --- a/app/views/cards/readings/create.turbo_stream.erb +++ b/app/views/cards/readings/create.turbo_stream.erb @@ -1,3 +1 @@ -<% @notifications.each do |notification| %> - <%= turbo_stream.remove notification %> -<% end %> +<%= turbo_stream.remove @notification if @notification %> diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index bfaa9c676..cf6d239db 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -9,6 +9,7 @@
<%= link_back_to_board(@card.board) %>
+ <%= bridged_button_to_board(@card.board) %> <% end %> <%= turbo_stream_from @card %> diff --git a/app/views/filters/_settings.html.erb b/app/views/filters/_settings.html.erb index 66bbe3d54..d0a7d3d3e 100644 --- a/app/views/filters/_settings.html.erb +++ b/app/views/filters/_settings.html.erb @@ -1,4 +1,5 @@ <%= tag.details class: "expandable-on-native", + open: true, data: { controller: "expandable-on-native", expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 51acf3ea0..40cf6191d 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -9,11 +9,12 @@ } body { + color: #17233C; font-family: system-ui, sans-serif; font-size: 16px; line-height: 1.3; margin: 0; - padding: 1rem; + padding: 16px; } img { @@ -72,13 +73,13 @@ .footer { border-top: 1px solid #ccc; - margin-top: 3em; + margin-top: 32px; padding-top: 1em; } .notification { - margin-top: 1rem; - margin-bottom: 1rem; + margin-top: 16px; + margin-bottom: 16px; } .notification__author { @@ -88,12 +89,25 @@ } .notification__board { - font-size: 0.8em; - font-weight: normal; - margin-bottom: 0; + font-size: 1.4em; + font-weight: 900; + margin-bottom: 16px; margin-top: 0; - opacity: 0.66; - text-transform: uppercase; + } + + .notification__board a { + color: #17233C; + text-decoration: none; + } + + .notification__board a:hover { + text-decoration: underline; + } + + .notification__board-separator { + border: none; + border-top: 1px solid #ddd; + margin: 2em 0 1em; } .notification__details { diff --git a/app/views/mailers/import_mailer/failed.html.erb b/app/views/mailers/import_mailer/failed.html.erb index c7b6d9b5c..6aa9fab0a 100644 --- a/app/views/mailers/import_mailer/failed.html.erb +++ b/app/views/mailers/import_mailer/failed.html.erb @@ -1,8 +1,11 @@

Unfortunately, we couldn't import your Fizzy account.

-

-This may be due to corrupted export data or a conflict with existing data. -Please try again with a fresh export, or reach out for help if the problem persists. -

+<% if @import.failed_due_to_conflict? %> +

It looks like the account you are trying to import already exists.

+<% elsif @import.failed_due_to_invalid_export? %> +

The ZIP file isn't a Fizzy account export.

+<% else %> +

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.

+<% end %> diff --git a/app/views/mailers/import_mailer/failed.text.erb b/app/views/mailers/import_mailer/failed.text.erb index 82e3b772a..1895f0d5d 100644 --- a/app/views/mailers/import_mailer/failed.text.erb +++ b/app/views/mailers/import_mailer/failed.text.erb @@ -1,6 +1,11 @@ Unfortunately, we couldn't import your Fizzy account. -This may be due to corrupted export data or a conflict with existing data. -Please try again with a fresh export, or reach out for help if the problem persists. +<% if @import.failed_due_to_conflict? -%> +It looks like the account you are trying to import already exists. +<% elsif @import.failed_due_to_invalid_export? -%> +The ZIP file isn't a Fizzy account export. +<% else -%> +This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists. +<% end -%> Need help? Send us an email at support@fizzy.do diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index e1fec4895..1545f8598 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -5,10 +5,13 @@ You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.

- <% @notifications.group_by(&:card).each do |card, notifications| %> -

<%= card.board.name %>

- <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> - <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> + <% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %> +
+

<%= link_to board.name, board %>

+ <% board_notifications.group_by(&:card).each do |card, notifications| %> + <%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %> + <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> + <% end %> <% end %>
diff --git a/app/views/notifications/index.json.jbuilder b/app/views/notifications/index.json.jbuilder index 660bbb673..e8d9a93ed 100644 --- a/app/views/notifications/index.json.jbuilder +++ b/app/views/notifications/index.json.jbuilder @@ -1 +1 @@ -json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification +json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true diff --git a/app/views/notifications/index/_notification.html.erb b/app/views/notifications/index/_notification.html.erb deleted file mode 100644 index 90f3d45eb..000000000 --- a/app/views/notifications/index/_notification.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%= notification_tag notification do %> - <%= render "notifications/notification/header", notification: notification do %> - <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %> - <% end %> - <%= render "notifications/notification/body", notification: notification %> -<% end %> diff --git a/app/views/notifications/index/_read_notifications.html.erb b/app/views/notifications/index/_read_notifications.html.erb index 04c2e3c48..c73d1d3c2 100644 --- a/app/views/notifications/index/_read_notifications.html.erb +++ b/app/views/notifications/index/_read_notifications.html.erb @@ -3,7 +3,7 @@

Previously seen

- <%= render partial: "notifications/index/notification", collection: page.records, cached: true %> + <%= render partial: "notifications/notification", collection: page.records, cached: true %>
<% end %> diff --git a/app/views/notifications/index/_unread_notifications.html.erb b/app/views/notifications/index/_unread_notifications.html.erb index 4890a4822..fed57dbb9 100644 --- a/app/views/notifications/index/_unread_notifications.html.erb +++ b/app/views/notifications/index/_unread_notifications.html.erb @@ -11,12 +11,12 @@ <% end %>
- <%= render partial: "notifications/index/notification", collection: unread, cached: true %> + <%= render partial: "notifications/notification", collection: unread, cached: true %>
<% if unread.any? %> <% total_unread_count = Current.user.notifications.unread.count %> - <% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %> + <% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
diff --git a/app/views/notifications/settings/_browser.html.erb b/app/views/notifications/settings/_browser.html.erb index 67d86acfd..bc196d535 100644 --- a/app/views/notifications/settings/_browser.html.erb +++ b/app/views/notifications/settings/_browser.html.erb @@ -2,18 +2,18 @@
<% case when platform.firefox? && platform.android? %> -

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Tap <%= icon_tag "lock", alt: "the View site information button" %> in the address bar.
  2. Tap Notification to change to Allowed.
<% when platform.edge? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click <%= icon_tag "lock", alt: "the View site information button" %> left of the address bar.
  2. Under Permissions for this site > Notifications, choose Allow.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -21,24 +21,24 @@
  3. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
  4. <% else %>
  5. Click in the top left.
  6. -
  7. Click System Settings….
  8. +
  9. Click System Settings…
  10. Click Notifications.
  11. Click <%= platform.browser.capitalize %>.
  12. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  13. <% end %>
<% when platform.firefox? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click <%= platform.browser.capitalize %> in the top left.
  2. -
  3. Click Settings….
  4. +
  5. Click Settings…
  6. Click Privacy & Security in the sidebar.
  7. Scroll down to Permissions.
  8. Click Settings next to Notifications.
  9. Select Allow next to <%= root_url %>.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -46,21 +46,21 @@
  3. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for <%= platform.browser.capitalize %>.
  4. <% else %>
  5. Click in the top left.
  6. -
  7. Click System Settings….
  8. +
  9. Click System Settings…
  10. Click Notifications.
  11. Click <%= platform.browser.capitalize %>.
  12. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  13. <% end %>
<% when platform.chrome? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click the <%= icon_tag "sliders", alt: "View site information" %> icon in the address bar.
  2. Click Site Settings.
  3. Ensure notifications are Allowed.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

    <% if platform.windows? %>
  1. Click Start, then Settings.
  2. @@ -68,14 +68,14 @@
  3. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
  4. <% else %>
  5. Click in the top left.
  6. -
  7. Click System Settings….
  8. +
  9. Click System Settings…
  10. Click Notifications.
  11. Click <%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %>.
  12. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
  13. <% end %>
<% when platform.chrome? && platform.android? %> -

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
  2. Tap Settings.
  3. @@ -85,18 +85,18 @@
  4. Tap <%= icon_tag "bell-alert", alt: "the notification bell" %> and select Allow.
<% when platform.safari? && platform.desktop? %> -

Turn on notifications for this website.

+

Turn on notifications for this website.

  1. Click in the top left.
  2. -
  3. Click System Settings….
  4. +
  5. Click System Settings…
  6. Click Notifications.
  7. Click <%= request.base_url %> in the list.
  8. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
-

Turn on notifications for <%= platform.browser.capitalize %>.

+

Turn on notifications for <%= platform.browser.capitalize %>.

  1. Click <%= platform.browser.capitalize %> in the top left.
  2. -
  3. Click Settings….
  4. +
  5. Click Settings…
  6. Click the Websites tab.
  7. Click Notifications in the sidebar.
  8. Click <%= request.base_url %> in the list.
  9. diff --git a/app/views/notifications/settings/_install.html.erb b/app/views/notifications/settings/_install.html.erb index f8d664043..4c2f0d1ca 100644 --- a/app/views/notifications/settings/_install.html.erb +++ b/app/views/notifications/settings/_install.html.erb @@ -1,6 +1,6 @@ <% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
    -

    <%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

    +

    <%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

    <% case when platform.edge? %>
      @@ -20,7 +20,7 @@ <% when platform.safari? && platform.desktop? %>
      1. Click File in the top left.
      2. -
      3. Click Add to Dock….
      4. +
      5. Click Add to Dock…
      <% when (platform.safari? || platform.chrome?) && platform.ios? %>

      To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.

      diff --git a/app/views/notifications/settings/_system.html.erb b/app/views/notifications/settings/_system.html.erb index afe0efd45..7630680d7 100644 --- a/app/views/notifications/settings/_system.html.erb +++ b/app/views/notifications/settings/_system.html.erb @@ -1,5 +1,5 @@
      -

      Check your <%= platform.operating_system %> settings

      +

      Check your <%= platform.operating_system %> settings

      <% case when platform.firefox? && platform.android? %>
        @@ -22,7 +22,7 @@
      1. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
      2. <% else %>
      3. Click in the top left.
      4. -
      5. Click System Settings….
      6. +
      7. Click System Settings…
      8. Click Notifications.
      9. Click Fizzy.
      10. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
      11. @@ -31,7 +31,7 @@ <% when platform.safari? && platform.desktop? %>
        1. Click in the top left.
        2. -
        3. Click System Settings….
        4. +
        5. Click System Settings…
        6. Click Notifications.
        7. Click Fizzy.
        8. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
        9. diff --git a/app/views/notifications/trays/show.html.erb b/app/views/notifications/trays/show.html.erb index 278d97a4d..f61c52d51 100644 --- a/app/views/notifications/trays/show.html.erb +++ b/app/views/notifications/trays/show.html.erb @@ -1,4 +1,3 @@ <%= turbo_frame_tag "notifications" do %> <%= render partial: "notifications/notification", collection: @notifications, cached: true %> - <% end %> diff --git a/app/views/public/boards/show.html.erb b/app/views/public/boards/show.html.erb index b73a17050..c4bd75861 100644 --- a/app/views/public/boards/show.html.erb +++ b/app/views/public/boards/show.html.erb @@ -20,7 +20,7 @@ <% end %> <% if @board.public_description.present? %> -
          +
          <%= @board.public_description %>
          <% end %> diff --git a/app/views/public/cards/show/_content.html.erb b/app/views/public/cards/show/_content.html.erb index cbdf7603f..eb3ccd044 100644 --- a/app/views/public/cards/show/_content.html.erb +++ b/app/views/public/cards/show/_content.html.erb @@ -1,8 +1,8 @@

          - <%= tag.span card.title, class: "card__title-link" %> + <%= tag.span card_html_title(card), class: "card__title-link" %>

          -
          +
          <%= card.description %>
          diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 914f35f32..fa84a579d 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -14,7 +14,7 @@ <% end %> diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb index 566d41f0b..46e964483 100644 --- a/app/views/users/_data_export.html.erb +++ b/app/views/users/_data_export.html.erb @@ -10,7 +10,7 @@

          Export your data

          This will generate a ZIP archive of all cards you have access to.

          -

          We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.

          +

          We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.

          <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 0744e862c..98ad27f4e 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -15,13 +15,16 @@ Display auto-login QR code <% end %> - + +

          + Scan this code to instantly log in on another device: +

          + <%= qr_code_image(url) %>
          -
          diff --git a/bin/bundle-drift b/bin/bundle-drift index 64fd23292..b208d0c9c 100755 --- a/bin/bundle-drift +++ b/bin/bundle-drift @@ -5,6 +5,7 @@ # Usage: # bin/bundle-drift [check] # check for drift (default subcommand) # bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative) +# bin/bundle-drift forward # push Gemfile.lock changes into Gemfile.saas.lock require "bundler" require "fileutils" @@ -118,11 +119,63 @@ class GemfileDriftCorrector end end +class GemfileDriftForwarder + def forward + drift = GemfileDriftChecker.new.check + return puts "\nNothing to forward." if drift.empty? + + puts "\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\n\n" + + original_content = File.read(GEMFILE_SAAS_LOCK) + patched_content = original_content.dup + + drift.each do |d| + old_entry = "#{d[:name]} (#{d[:saas]})" + new_entry = "#{d[:name]} (#{d[:oss]})" + puts " #{old_entry} → #{new_entry}" + patched_content.gsub!(old_entry, new_entry) + end + + File.write(GEMFILE_SAAS_LOCK, patched_content) + + puts "\n▸ Verifying alignment" + new_drift = GemfileDriftChecker.new.check + + if new_drift.empty? + puts "\n✓ Lock files are now in sync" + show_diff(original_content, patched_content) + else + puts "\n✗ Lock files still have drift after forwarding." + puts " Restoring original Gemfile.saas.lock." + File.write(GEMFILE_SAAS_LOCK, original_content) + exit 1 + end + end + + private + def show_diff(original, patched) + require "tempfile" + + Tempfile.create("gemfile-saas-lock-original") do |f| + f.write(original) + f.flush + + diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null` + unless diff.empty? + puts "\nChanges made to Gemfile.saas.lock:" + puts diff + end + end + end +end + case command = ARGV[0] || "check" when "check" exit 1 unless GemfileDriftChecker.new.check.empty? when "correct" GemfileDriftCorrector.new.correct +when "forward" + GemfileDriftForwarder.new.forward else - abort "Usage: bin/bundle-drift [check|correct]" + abort "Usage: bin/bundle-drift [check|correct|forward]" end diff --git a/bin/setup b/bin/setup index 306282cb5..e095a41e5 100755 --- a/bin/setup +++ b/bin/setup @@ -115,7 +115,7 @@ step "Installing Ruby" mise install --yes eval "$(mise hook-env -s bash)" if which pacman >/dev/null 2>&1; then - packages=(imagemagick mariadb-libs openslide libvips gitleaks) + packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks) if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}" fi diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 90f9d53c6..25441a680 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -95,7 +95,7 @@ { "warning_type": "Remote Code Execution", "warning_code": 24, - "fingerprint": "b00571393e95a8c7d77b1ed963cfc5cc89333bc82f447cf8783549d817990305", + "fingerprint": "f13f9f972c3f026ab4509a66ac284b8b7c1ba6191a3c4c89d2e9fb4584478f6d", "check_name": "UnsafeReflection", "message": "Unsafe reflection method `safe_constantize` called on model attribute", "file": "app/models/notifier.rb", @@ -139,5 +139,5 @@ "note": "No user input allowed" } ], - "brakeman_version": "7.1.2" + "brakeman_version": "8.0.1" } diff --git a/config/importmap.rb b/config/importmap.rb index 27dbd7015..5da84683c 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers" pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge" -pin "marked" # @15.0.11 pin "lexxy" pin "@rails/activestorage", to: "activestorage.esm.js" pin "@rails/actiontext", to: "actiontext.esm.js" diff --git a/config/recurring.yml b/config/recurring.yml index 209d8fed4..894c57b95 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -20,7 +20,7 @@ production: &production schedule: every hour at minute 12 cleanup_webhook_deliveries: command: "Webhook::Delivery.cleanup" - schedule: every 4 hours at minute 51 + schedule: every 15 minutes cleanup_magic_links: command: "MagicLink.cleanup" schedule: every 4 hours @@ -41,11 +41,11 @@ production: &production schedule: every 60 seconds <% end %> -beta: +beta: &beta # Only Solid Queue maintenance clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 -staging: *production +staging: *beta development: *production diff --git a/db/migrate/20260206104338_add_card_id_to_notifications.rb b/db/migrate/20260206104338_add_card_id_to_notifications.rb new file mode 100644 index 000000000..8d89f61a3 --- /dev/null +++ b/db/migrate/20260206104338_add_card_id_to_notifications.rb @@ -0,0 +1,6 @@ +class AddCardIdToNotifications < ActiveRecord::Migration[8.2] + def change + add_column :notifications, :card_id, :uuid + add_column :notifications, :unread_count, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20260209165805_notifications_data_migration.rb b/db/migrate/20260209165805_notifications_data_migration.rb new file mode 100644 index 000000000..d1522f780 --- /dev/null +++ b/db/migrate/20260209165805_notifications_data_migration.rb @@ -0,0 +1,84 @@ +class NotificationsDataMigration < ActiveRecord::Migration[8.2] + BATCH_SIZE = 10_000 + + class Notification < ActiveRecord::Base + self.table_name = "notifications" + end + + def change + reversible do |dir| + dir.up do + populate_card_id + collapse_duplicates + end + end + + change_column_null :notifications, :card_id, false + add_index :notifications, [ :user_id, :card_id ], unique: true + end + + private + def populate_card_id + execute(<<~SQL) + UPDATE notifications + SET card_id = ( + SELECT CASE events.eventable_type + WHEN 'Card' THEN events.eventable_id + WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id) + END + FROM events + WHERE events.id = notifications.source_id + ) + WHERE notifications.card_id IS NULL + AND notifications.source_type = 'Event' + SQL + + execute(<<~SQL) + UPDATE notifications + SET card_id = ( + SELECT CASE mentions.source_type + WHEN 'Card' THEN mentions.source_id + WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id) + END + FROM mentions + WHERE mentions.id = notifications.source_id + ) + WHERE notifications.card_id IS NULL + AND notifications.source_type = 'Mention' + SQL + end + + def collapse_duplicates + loop do + duplicates = Notification.find_by_sql(<<~SQL) + SELECT user_id, card_id, + MAX(id) AS keep_id, + COUNT(*) AS total, + SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total + FROM notifications + WHERE card_id IS NOT NULL + GROUP BY user_id, card_id + HAVING COUNT(*) > 1 + LIMIT #{BATCH_SIZE} + SQL + + break if duplicates.empty? + + duplicates.each do |row| + Notification.where(user_id: row.user_id, card_id: row.card_id) + .where.not(id: row.keep_id) + .delete_all + + Notification.where(id: row.keep_id) + .update_all(unread_count: row.unread_total.to_i) + end + end + + # Set unread_count for remaining non-collapsed notifications + execute(<<~SQL) + UPDATE notifications + SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END + WHERE unread_count = 0 AND card_id IS NOT NULL + SQL + end +end diff --git a/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb b/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb new file mode 100644 index 000000000..d7e7d6ceb --- /dev/null +++ b/db/migrate/20260211122517_add_failure_reason_to_account_imports.rb @@ -0,0 +1,5 @@ +class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2] + def change + add_column :account_imports, :failure_reason, :string + end +end diff --git a/db/migrate/20260212102026_fix_notifications_ordered_index.rb b/db/migrate/20260212102026_fix_notifications_ordered_index.rb new file mode 100644 index 000000000..f7e3cfcac --- /dev/null +++ b/db/migrate/20260212102026_fix_notifications_ordered_index.rb @@ -0,0 +1,8 @@ +class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2] + def change + add_index :notifications, [ :user_id, :read_at, :updated_at ], + order: { read_at: :desc, updated_at: :desc }, + name: "index_notifications_on_user_id_and_read_at_and_updated_at" + remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at" + end +end diff --git a/db/migrate/20260213170100_add_created_at_index_to_webhook_deliveries.rb b/db/migrate/20260213170100_add_created_at_index_to_webhook_deliveries.rb new file mode 100644 index 000000000..1ae01a00e --- /dev/null +++ b/db/migrate/20260213170100_add_created_at_index_to_webhook_deliveries.rb @@ -0,0 +1,5 @@ +class AddCreatedAtIndexToWebhookDeliveries < ActiveRecord::Migration[8.2] + def change + add_index :webhook_deliveries, :created_at + end +end diff --git a/db/schema.rb b/db/schema.rb index a1f2a33bc..e74a97212 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do +ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -42,6 +42,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false + t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", default: "pending", null: false t.datetime "updated_at", null: false @@ -384,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false + t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", null: false + t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" - t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true + t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end @@ -817,6 +821,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" + t.index ["created_at"], name: "index_webhook_deliveries_on_created_at" t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index dad03edde..6ea0a137d 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do +ActiveRecord::Schema[8.2].define(version: 2026_02_13_170100) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -42,6 +42,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false + t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", limit: 255, default: "pending", null: false t.datetime "updated_at", null: false @@ -384,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do create_table "notifications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false + t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false + t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" - t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true + t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end @@ -590,6 +594,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" + t.index ["created_at"], name: "index_webhook_deliveries_on_created_at" t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 47b0480e1..3d20f9df8 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,6 +1,7 @@ require_relative "transaction_pinning" require_relative "signup" require_relative "authorization" +require_relative "gvl_instrumentation" require_relative "../../rails_ext/active_record_tasks_database_tasks.rb" module Fizzy @@ -51,6 +52,10 @@ module Fizzy app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end + initializer "fizzy_saas.gvl_instrumentation" do |app| + app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation) + end + initializer "fizzy_saas.solid_queue" do SolidQueue.on_start do Process.warmup @@ -117,6 +122,9 @@ module Fizzy config.channel_class_name = "ActionCable::Channel::Base" end + require "yabeda/gvl" + Yabeda::GVL.install! + require_relative "metrics" end diff --git a/saas/lib/fizzy/saas/gvl_instrumentation.rb b/saas/lib/fizzy/saas/gvl_instrumentation.rb new file mode 100644 index 000000000..2b628d4b9 --- /dev/null +++ b/saas/lib/fizzy/saas/gvl_instrumentation.rb @@ -0,0 +1,20 @@ +module Fizzy + module Saas + class GvlInstrumentation + def initialize(app) + @app = app + end + + def call(env) + GVLTools::LocalTimer.enable + before = GVLTools::LocalTimer.monotonic_time + result = @app.call(env) + gvl_wait_ns = GVLTools::LocalTimer.monotonic_time - before + Yabeda.gvl.request_wait.measure({}, gvl_wait_ns / 1_000_000_000.0) + result + ensure + GVLTools::LocalTimer.disable + end + end + end +end diff --git a/saas/lib/yabeda/gvl.rb b/saas/lib/yabeda/gvl.rb new file mode 100644 index 000000000..2c77d098b --- /dev/null +++ b/saas/lib/yabeda/gvl.rb @@ -0,0 +1,30 @@ +module Yabeda + module GVL + WAIT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 ] + + def self.install! + GVLTools::GlobalTimer.enable + GVLTools::WaitingThreads.enable + + Yabeda.configure do + group :gvl do + gauge :waiting_threads, + comment: "Number of threads currently waiting to acquire the GVL" + + gauge :global_timer_total_seconds, + comment: "Total time all threads spent waiting on the GVL (seconds)" + + histogram :request_wait, + unit: :seconds, + comment: "GVL wait time experienced during a single request (seconds)", + buckets: WAIT_HISTOGRAM_BUCKETS + end + + collect do + gvl.waiting_threads.set({}, GVLTools::WaitingThreads.count) + gvl.global_timer_total_seconds.set({}, GVLTools::GlobalTimer.monotonic_time / 1_000_000_000.0) + end + end + end + end +end diff --git a/test/controllers/cards/readings_controller_test.rb b/test/controllers/cards/readings_controller_test.rb index b7f7474c7..0c9c9aeed 100644 --- a/test/controllers/cards/readings_controller_test.rb +++ b/test/controllers/cards/readings_controller_test.rb @@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest test "create" do freeze_time - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do post card_reading_url(cards(:logo)), as: :turbo_stream end @@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "read one notification on card visit" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + test "read notification on card visit" do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do post card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end - test "read multiple notifications on card visit" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do - post card_reading_path(cards(:logo)), as: :turbo_stream - end - end - - assert_response :success - end - test "destroy" do freeze_time - notifications(:logo_published_kevin).read notifications(:logo_assignment_kevin).read - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do delete card_reading_url(cards(:logo)), as: :turbo_stream end @@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "unread one notification on destroy" do - notifications(:logo_published_kevin).read + test "unread notification on destroy" do + notifications(:logo_assignment_kevin).read - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do delete card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end - - test "unread multiple notifications on destroy" do - notifications(:logo_published_kevin).read - notifications(:logo_assignment_kevin).read - - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do - assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do - delete card_reading_path(cards(:logo)), as: :turbo_stream - end - end - - assert_response :success - end end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 8007657d4..3a97f3166 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -42,9 +42,14 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card_draft_path(card) end - test "show" do - get card_path(cards(:logo)) - assert_response :success + test "show renders inline code in title" do + card = cards(:logo) + card.update_column :title, "Fix the `bug` in production" + + get card_path(card) + assert_select ".card__title-link" do |element| + assert_equal "Fix the bug in production", element.inner_html + end end test "edit" do diff --git a/test/controllers/notifications/bulk_readings_controller_test.rb b/test/controllers/notifications/bulk_readings_controller_test.rb index 3512b7b43..f889dfaac 100644 --- a/test/controllers/notifications/bulk_readings_controller_test.rb +++ b/test/controllers/notifications/bulk_readings_controller_test.rb @@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes end test "create marks all notifications as read" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path end @@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes end test "create as JSON" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path, as: :json end diff --git a/test/controllers/notifications/readings_controller_test.rb b/test/controllers/notifications/readings_controller_test.rb index 3f2a690a9..fca29a35b 100644 --- a/test/controllers/notifications/readings_controller_test.rb +++ b/test/controllers/notifications/readings_controller_test.rb @@ -3,38 +3,37 @@ require "test_helper" class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin + @notification = notifications(:logo_assignment_kevin) end test "create" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream) + assert_changes -> { @notification.reload.read? }, from: false, to: true do + post notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "destroy" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first + @notification.read - assert_changes -> { notification.reload.read? }, from: true, to: false do - delete notification_reading_path(notification, format: :turbo_stream) + assert_changes -> { @notification.reload.read? }, from: true, to: false do + delete notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "create as JSON" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - post notification_reading_path(notifications(:logo_published_kevin)), as: :json + assert_changes -> { @notification.reload.read? }, from: false, to: true do + post notification_reading_path(@notification), as: :json assert_response :no_content end end test "destroy as JSON" do - notification = notifications(:logo_published_kevin) - notification.read + @notification.read - assert_changes -> { notification.reload.read? }, from: true, to: false do - delete notification_reading_path(notification), as: :json + assert_changes -> { @notification.reload.read? }, from: true, to: false do + delete notification_reading_path(@notification), as: :json assert_response :no_content end end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index e4e7bec38..0d0565f24 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_kind_of Array, @response.parsed_body - assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id } + assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id } end test "index as JSON includes notification attributes" do get notifications_path, as: :json - notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id } + notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id } - assert_not_nil notification["title"] - assert_not_nil notification["body"] assert_not_nil notification["created_at"] assert_not_nil notification["card"] assert_not_nil notification["creator"] + assert_not_nil notification["unread_count"] end end diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index 9fe7da317..b7ae167cd 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -1,15 +1,9 @@ -logo_published_kevin: - id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %> - user: kevin_uuid - source: logo_published_uuid (Event) - created_at: <%= 1.week.ago %> - creator: david_uuid - account: 37s_uuid - logo_assignment_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %> user: kevin_uuid source: logo_assignment_km_uuid (Event) + card: logo_uuid + unread_count: 2 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid @@ -18,22 +12,18 @@ layout_commented_kevin: id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %> user: kevin_uuid source: layout_commented_uuid (Event) + card: layout_uuid + unread_count: 1 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid -logo_card_david_mention_by_jz: - id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %> - user: david_uuid - source: logo_card_david_mention_by_jz_uuid (Mention) - created_at: <%= 1.week.ago %> - creator: david_uuid - account: 37s_uuid - -logo_comment_david_mention_by_jz: - id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %> +logo_mentioned_david: + id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %> user: david_uuid source: logo_comment_david_mention_by_jz_uuid (Mention) + card: logo_uuid + unread_count: 2 created_at: <%= 1.week.ago %> creator: david_uuid account: 37s_uuid diff --git a/test/helpers/html_helper_test.rb b/test/helpers/html_helper_test.rb index 1af507ddf..0fd6c268a 100644 --- a/test/helpers/html_helper_test.rb +++ b/test/helpers/html_helper_test.rb @@ -119,4 +119,29 @@ class HtmlHelperTest < ActionView::TestCase assert_no_match(/bug in production", card_html_title(cards(:logo).tap { _1.title = "Fix the `bug` in production" }) + end + + test "card_html_title renders multiple code spans" do + assert_equal "foo and bar", card_html_title(cards(:logo).tap { _1.title = "`foo` and `bar`" }) + end + + test "card_html_title renders code spans without surrounding spaces" do + assert_equal "whataboutthis", card_html_title(cards(:logo).tap { _1.title = "what`about`this" }) + end + + test "card_html_title escapes HTML tags" do + assert_equal "<script>alert(1)</script>", card_html_title(cards(:logo).tap { _1.title = "" }) + end + + test "card_html_title escapes HTML inside backticks" do + assert_equal "<script>", card_html_title(cards(:logo).tap { _1.title = "`") diff --git a/test/models/notification/bundle_test.rb b/test/models/notification/bundle_test.rb index 2a8b242af..66dd8dd77 100644 --- a/test/models/notification/bundle_test.rb +++ b/test/models/notification/bundle_test.rb @@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase setup do @user = users(:david) + @user.notifications.destroy_all @user.settings.bundle_email_every_few_hours! end @@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase end end - test "notifications are bundled withing the aggregation period" do + test "notifications are bundled within the aggregation period" do @user.notification_bundles.destroy_all notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do @@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase travel_to 3.hours.from_now notification_2 = assert_no_difference -> { @user.notification_bundles.count } do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:layout_published), creator: @user) end travel_to 3.days.from_now notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:text_published), creator: @user) end assert_equal 2, @user.notification_bundles.count @@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase test "out-of-order notification bundling should still work" do first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) - second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) + second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user) @user.notification_bundles.destroy_all - assert first_notification.created_at < second_notification.created_at + assert first_notification.updated_at <= second_notification.updated_at @user.bundle(second_notification) @user.bundle(first_notification) diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb index 21b9e202b..53380effe 100644 --- a/test/models/notification_pusher_test.rb +++ b/test/models/notification_pusher_test.rb @@ -3,10 +3,7 @@ require "test_helper" class NotificationPusherTest < ActiveSupport::TestCase setup do @user = users(:david) - @notification = @user.notifications.create!( - source: events(:logo_published), - creator: users(:jason) - ) + @notification = notifications(:logo_mentioned_david) @pusher = NotificationPusher.new(@notification) @user.push_subscriptions.create!( diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 9c8ebbe12..8d9809ba1 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -1,49 +1,67 @@ require "test_helper" class NotificationTest < ActiveSupport::TestCase - test "unread marks notification as unread" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first - - assert_changes -> { notification.reload.read? }, from: true, to: false do - notification.unread - end - end - - test "unread broadcasts to notifications" do - notification = notifications(:logo_published_kevin) - notification.read # Mark as read first - - assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do - perform_enqueued_jobs do - notification.unread - end - end - end - test "read marks notification as read" do - notification = notifications(:logo_published_kevin) - # Ensure it starts as unread - notification.update!(read_at: nil) + notification = notifications(:logo_assignment_kevin) + notification.update!(read_at: nil, unread_count: 2) assert_changes -> { notification.reload.read? }, from: false, to: true do notification.read end + + assert_equal 0, notification.unread_count end - test "read broadcasts to notifications" do - notification = notifications(:logo_published_kevin) - # Ensure it starts as unread - notification.update!(read_at: nil) + test "unread marks notification as unread" do + notification = notifications(:logo_assignment_kevin) + notification.read + + assert_changes -> { notification.reload.read? }, from: true, to: false do + notification.unread + end + + assert_equal 1, notification.unread_count + end + + test "read_all marks all notifications and resets unread counts" do + kevin = users(:kevin) + + kevin.notifications.unread.read_all + + assert kevin.notifications.reload.all?(&:read?) + assert kevin.notifications.reload.all? { |n| n.unread_count == 0 } + end + + test "unread_count tracks notification count per card" do + notification = notifications(:logo_assignment_kevin) + assert_equal 2, notification.unread_count + end + + test "broadcasting on create prepends" do + kevin = users(:kevin) + layout = cards(:layout) + + notifications(:layout_commented_kevin).destroy + + perform_enqueued_jobs do + Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david)) + end + + assert_turbo_stream_broadcasts [ kevin, :notifications ] + end + + test "broadcasting on update when read removes" do + notification = notifications(:layout_commented_kevin) assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do - notification.read + perform_enqueued_jobs do + notification.read + end end end - test "deleting notification broadcasts its removal" do - notification = notifications(:logo_published_kevin) - notification.update!(read_at: nil) + test "broadcasting on destroy removes" do + notification = notifications(:logo_assignment_kevin) assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do notification.destroy diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb index 82942b96c..6393ddb99 100644 --- a/test/models/notifier/event_notifier_test.rb +++ b/test/models/notifier/event_notifier_test.rb @@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase test "create notifications on publish for mentionees" do users(:kevin).mentioned_by(users(:david), at: cards(:logo)) - assert_difference -> { users(:kevin).notifications.count }, +1 do - Notifier.for(events(:logo_published)).notify - end + notifications = Notifier.for(events(:logo_published)).notify + + assert_includes notifications.map(&:user), users(:kevin) end - test "don'create notifications on publish for mentionees that are not watching" do + test "create notifications on publish for mentionees that are not watching" do users(:kevin).mentioned_by(users(:david), at: cards(:logo)) cards(:logo).unwatch_by(users(:kevin)) - assert_difference -> { users(:kevin).notifications.count }, +1 do - Notifier.for(events(:logo_published)).notify - end + notifications = Notifier.for(events(:logo_published)).notify + + assert_includes notifications.map(&:user), users(:kevin) end test "don't create notifications on comment for mentionees" do diff --git a/test/models/notifier/mention_notifier_test.rb b/test/models/notifier/mention_notifier_test.rb index 833f55ec8..2d0b274c0 100644 --- a/test/models/notifier/mention_notifier_test.rb +++ b/test/models/notifier/mention_notifier_test.rb @@ -24,4 +24,47 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase Notifier.for(events(:layout_commented)).notify end end + + test "updates source_type correctly even when a concurrent job modifies it between load and save" do + # Start with a notification sourced from a Mention for kevin on the layout card + notifications(:layout_commented_kevin).destroy + mention = users(:kevin).mentioned_by(users(:david), at: comments(:layout_overflowing_david)) + notification = Notification.create!( + user: users(:kevin), card: cards(:layout), + source: mention, creator: users(:david), unread_count: 1 + ) + + # Override create_or_find_by to simulate a concurrent EventNotifier updating + # source_type in the database after the notification is loaded but before + # the MentionNotifier's update! runs — reproducing the race condition where + # Rails' dirty tracking skips source_type because it hasn't changed from + # the stale in-memory value. + Notification.class_eval do + class << self + alias_method :original_create_or_find_by, :create_or_find_by + + def create_or_find_by(...) + original_create_or_find_by(...).tap do |record| + unless record.previously_new_record? + where(id: record.id).update_all(source_type: "Event") + end + end + end + end + end + + new_mention = users(:kevin).mentioned_by(users(:jz), at: comments(:layout_overflowing_david)) + Notifier.for(new_mention).notify + + notification.reload + assert_equal "Mention", notification.source_type + assert_equal new_mention, notification.source + ensure + Notification.class_eval do + class << self + alias_method :create_or_find_by, :original_create_or_find_by + remove_method :original_create_or_find_by + end + end + end end diff --git a/test/models/user/notifiable_test.rb b/test/models/user/notifiable_test.rb index c7bc4fabc..aabfdda2d 100644 --- a/test/models/user/notifiable_test.rb +++ b/test/models/user/notifiable_test.rb @@ -3,6 +3,7 @@ require "test_helper" class User::NotifiableTest < ActiveSupport::TestCase setup do @user = users(:david) + @user.notifications.destroy_all @user.settings.bundle_email_every_few_hours! end @@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase end bundle = @user.notification_bundles.last - assert_equal notification.created_at, bundle.starts_at + assert_equal notification.updated_at, bundle.starts_at assert bundle.pending? end @@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase @user.notifications.create!(source: events(:logo_published), creator: @user) assert_no_difference -> { @user.notification_bundles.count } do - @user.notifications.create!(source: events(:logo_published), creator: @user) + @user.notifications.create!(source: events(:layout_published), creator: @user) end end end diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index d4d95528c..8d40c4417 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -245,6 +245,102 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert_requested request_stub end + test "basecamp webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Basecamp", + url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + content = CGI.parse(captured_body)["content"].first + + expected = <<~HTML.strip + David added "Tom & Jerry's <Great> "Adventure"" + ↗︎ + HTML + assert_equal expected, content + end + + test "slack webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Slack", + url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + text = JSON.parse(captured_body)["text"] + + expected = <<~TEXT.strip + David added "Tom & Jerry's <Great> "Adventure"" + TEXT + assert_equal expected, text + end + + test "campfire webhook payload html-escapes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Campfire", + url: "https://example.com/rooms/123/456-room-name/messages" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + expected = <<~HTML.strip + David added "Tom & Jerry's <Great> "Adventure"" + ↗︎ + HTML + assert_equal expected, captured_body + end + + test "generic webhook payload json-encodes special characters" do + cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) + + webhook = Webhook.create!( + board: boards(:writebook), + name: "Generic", + url: "https://example.com/webhook" + ) + delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) + + captured_body = nil + stub_request(:post, webhook.url) + .with { |request| captured_body = request.body; true } + .to_return(status: 200) + + delivery.deliver + + json = JSON.parse(captured_body) + assert_equal %(Tom & Jerry's "Adventure"), json["eventable"]["title"] + end + test "renders creator name when event creator is not current user" do webhook = Webhook.create!( board: boards(:writebook), diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index c307c50d2..31e8f723f 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase test "dismissing notifications" do sign_in_as(users(:david)) - notif = notifications(:logo_card_david_mention_by_jz) + notification = notifications(:logo_mentioned_david) - assert_selector "div##{dom_id(notif)}" + assert_selector "div##{dom_id(notification)}" - within_window(open_new_window) { visit card_url(notif.card) } + within_window(open_new_window) { visit card_url(notification.card) } - assert_no_selector "div##{dom_id(notif)}" + assert_no_selector "div##{dom_id(notification)}" end test "dragging card to a new column" do diff --git a/vendor/javascript/marked.js b/vendor/javascript/marked.js deleted file mode 100644 index c881ef5ff..000000000 --- a/vendor/javascript/marked.js +++ /dev/null @@ -1,2583 +0,0 @@ -// marked@15.0.11 downloaded from https://cdn.jsdelivr.net/npm/marked@15.0.11/lib/marked.esm.js - -/** - * marked v15.0.11 - a markdown parser - * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ - -/** - * DO NOT EDIT THIS FILE - * The code in this file is generated from files in ./src/ - */ - -/** - * Gets the original marked default options. - */ -function _getDefaults() { - return { - async: false, - breaks: false, - extensions: null, - gfm: true, - hooks: null, - pedantic: false, - renderer: null, - silent: false, - tokenizer: null, - walkTokens: null, - }; -} -let _defaults = _getDefaults(); -function changeDefaults(newDefaults) { - _defaults = newDefaults; -} - -const noopTest = { exec: () => null }; -function edit(regex, opt = '') { - let source = typeof regex === 'string' ? regex : regex.source; - const obj = { - replace: (name, val) => { - let valSource = typeof val === 'string' ? val : val.source; - valSource = valSource.replace(other.caret, '$1'); - source = source.replace(name, valSource); - return obj; - }, - getRegex: () => { - return new RegExp(source, opt); - }, - }; - return obj; -} -const other = { - codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, - outputLinkReplace: /\\([\[\]])/g, - indentCodeCompensation: /^(\s+)(?:```)/, - beginningSpace: /^\s+/, - endingHash: /#$/, - startingSpaceChar: /^ /, - endingSpaceChar: / $/, - nonSpaceChar: /[^ ]/, - newLineCharGlobal: /\n/g, - tabCharGlobal: /\t/g, - multipleSpaceGlobal: /\s+/g, - blankLine: /^[ \t]*$/, - doubleBlankLine: /\n[ \t]*\n[ \t]*$/, - blockquoteStart: /^ {0,3}>/, - blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, - blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, - listReplaceTabs: /^\t+/, - listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, - listIsTask: /^\[[ xX]\] /, - listReplaceTask: /^\[[ xX]\] +/, - anyLine: /\n.*\n/, - hrefBrackets: /^<(.*)>$/, - tableDelimiter: /[:|]/, - tableAlignChars: /^\||\| *$/g, - tableRowBlankLine: /\n[ \t]*$/, - tableAlignRight: /^ *-+: *$/, - tableAlignCenter: /^ *:-+: *$/, - tableAlignLeft: /^ *:-+ *$/, - startATag: /^/i, - startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, - endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, - startAngleBracket: /^$/, - pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, - unicodeAlphaNumeric: /[\p{L}\p{N}]/u, - escapeTest: /[&<>"']/, - escapeReplace: /[&<>"']/g, - escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, - escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, - unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, - caret: /(^|[^\[])\^/g, - percentDecode: /%25/g, - findPipe: /\|/g, - splitPipe: / \|/, - slashPipe: /\\\|/g, - carriageReturn: /\r\n|\r/g, - spaceLine: /^ +$/gm, - notSpaceStart: /^\S*/, - endingNewline: /\n$/, - listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), - nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), - hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), - fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), - headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), - htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), -}; -/** - * Block-Level Grammar - */ -const newline = /^(?:[ \t]*(?:\n|$))+/; -const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; -const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; -const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; -const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; -const bullet = /(?:[*+-]|\d{1,9}[.)])/; -const lheadingCore = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; -const lheading = edit(lheadingCore) - .replace(/bull/g, bullet) // lists can interrupt - .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt - .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt - .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt - .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt - .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt - .replace(/\|table/g, '') // table not in commonmark - .getRegex(); -const lheadingGfm = edit(lheadingCore) - .replace(/bull/g, bullet) // lists can interrupt - .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt - .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt - .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt - .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt - .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt - .replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt - .getRegex(); -const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; -const blockText = /^[^\n]+/; -const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; -const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) - .replace('label', _blockLabel) - .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) - .getRegex(); -const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) - .replace(/bull/g, bullet) - .getRegex(); -const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' - + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' - + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' - + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' - + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' - + '|tr|track|ul'; -const _comment = /|$))/; -const html = edit('^ {0,3}(?:' // optional indentation - + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) - + '|comment[^\\n]*(\\n+|$)' // (2) - + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) - + '|\\n*|$)' // (4) - + '|\\n*|$)' // (5) - + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) - + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag - + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag - + ')', 'i') - .replace('comment', _comment) - .replace('tag', _tag) - .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) - .getRegex(); -const paragraph = edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs - .replace('|table', '') - .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // pars can be interrupted by type (6) html blocks - .getRegex(); -const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) - .replace('paragraph', paragraph) - .getRegex(); -/** - * Normal Block Grammar - */ -const blockNormal = { - blockquote, - code: blockCode, - def, - fences, - heading, - hr, - html, - lheading, - list, - newline, - paragraph, - table: noopTest, - text: blockText, -}; -/** - * GFM Block Grammar - */ -const gfmTable = edit('^ *([^\\n ].*)\\n' // Header - + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align - + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('blockquote', ' {0,3}>') - .replace('code', '(?: {4}| {0,3}\t)[^\\n]') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // tables can be interrupted by type (6) html blocks - .getRegex(); -const blockGfm = { - ...blockNormal, - lheading: lheadingGfm, - table: gfmTable, - paragraph: edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs - .replace('table', gfmTable) // interrupt paragraphs with table - .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // pars can be interrupted by type (6) html blocks - .getRegex(), -}; -/** - * Pedantic grammar (original John Gruber's loose markdown specification) - */ -const blockPedantic = { - ...blockNormal, - html: edit('^ *(?:comment *(?:\\n|\\s*$)' - + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag - + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') - .replace('comment', _comment) - .replace(/tag/g, '(?!(?:' - + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' - + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' - + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') - .getRegex(), - def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, - heading: /^(#{1,6})(.*)(?:\n+|$)/, - fences: noopTest, // fences not supported - lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, - paragraph: edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' *#{1,6} *[^\n]') - .replace('lheading', lheading) - .replace('|table', '') - .replace('blockquote', ' {0,3}>') - .replace('|fences', '') - .replace('|list', '') - .replace('|html', '') - .replace('|tag', '') - .getRegex(), -}; -/** - * Inline-Level Grammar - */ -const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; -const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; -const br = /^( {2,}|\\)\n(?!\s*$)/; -const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ -const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; -const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; -const emStrongLDelim = edit(emStrongLDelimCore, 'u') - .replace(/punct/g, _punctuation) - .getRegex(); -const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') - .replace(/punct/g, _punctuationGfmStrongEm) - .getRegex(); -const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong - + '|[^*]+(?=[^*])' // Consume to delim - + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter - + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter - + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter - + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter - + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter - + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter -const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') - .replace(/notPunctSpace/g, _notPunctuationOrSpace) - .replace(/punctSpace/g, _punctuationOrSpace) - .replace(/punct/g, _punctuation) - .getRegex(); -const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') - .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) - .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) - .replace(/punct/g, _punctuationGfmStrongEm) - .getRegex(); -// (6) Not allowed for _ -const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong - + '|[^_]+(?=[^_])' // Consume to delim - + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter - + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter - + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter - + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter - + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter - .replace(/notPunctSpace/g, _notPunctuationOrSpace) - .replace(/punctSpace/g, _punctuationOrSpace) - .replace(/punct/g, _punctuation) - .getRegex(); -const anyPunctuation = edit(/\\(punct)/, 'gu') - .replace(/punct/g, _punctuation) - .getRegex(); -const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) - .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) - .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) - .getRegex(); -const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); -const tag = edit('^comment' - + '|^' // self-closing tag - + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag - + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. - + '|^' // declaration, e.g. - + '|^') // CDATA section - .replace('comment', _inlineComment) - .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) - .getRegex(); -const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; -const link = edit(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/) - .replace('label', _inlineLabel) - .replace('href', /<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/) - .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) - .getRegex(); -const reflink = edit(/^!?\[(label)\]\[(ref)\]/) - .replace('label', _inlineLabel) - .replace('ref', _blockLabel) - .getRegex(); -const nolink = edit(/^!?\[(ref)\](?:\[\])?/) - .replace('ref', _blockLabel) - .getRegex(); -const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') - .replace('reflink', reflink) - .replace('nolink', nolink) - .getRegex(); -/** - * Normal Inline Grammar - */ -const inlineNormal = { - _backpedal: noopTest, // only used for GFM url - anyPunctuation, - autolink, - blockSkip, - br, - code: inlineCode, - del: noopTest, - emStrongLDelim, - emStrongRDelimAst, - emStrongRDelimUnd, - escape: escape$1, - link, - nolink, - punctuation, - reflink, - reflinkSearch, - tag, - text: inlineText, - url: noopTest, -}; -/** - * Pedantic Inline Grammar - */ -const inlinePedantic = { - ...inlineNormal, - link: edit(/^!?\[(label)\]\((.*?)\)/) - .replace('label', _inlineLabel) - .getRegex(), - reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) - .replace('label', _inlineLabel) - .getRegex(), -}; -/** - * GFM Inline Grammar - */ -const inlineGfm = { - ...inlineNormal, - emStrongRDelimAst: emStrongRDelimAstGfm, - emStrongLDelim: emStrongLDelimGfm, - url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') - .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) - .getRegex(), - _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, - del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, - text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', - '"': '"', - "'": ''', -}; -const getEscapeReplacement = (ch) => escapeReplacements[ch]; -function escape(html, encode) { - if (encode) { - if (other.escapeTest.test(html)) { - return html.replace(other.escapeReplace, getEscapeReplacement); - } - } - else { - if (other.escapeTestNoEncode.test(html)) { - return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); - } - } - return html; -} -function cleanUrl(href) { - try { - href = encodeURI(href).replace(other.percentDecode, '%'); - } - catch { - return null; - } - return href; -} -function splitCells(tableRow, count) { - // ensure that every cell-delimiting pipe has a space - // before it to distinguish it from an escaped pipe - const row = tableRow.replace(other.findPipe, (match, offset, str) => { - let escaped = false; - let curr = offset; - while (--curr >= 0 && str[curr] === '\\') - escaped = !escaped; - if (escaped) { - // odd number of slashes means | is escaped - // so we leave it alone - return '|'; - } - else { - // add space before unescaped | - return ' |'; - } - }), cells = row.split(other.splitPipe); - let i = 0; - // First/last cell in a row cannot be empty if it has no leading/trailing pipe - if (!cells[0].trim()) { - cells.shift(); - } - if (cells.length > 0 && !cells.at(-1)?.trim()) { - cells.pop(); - } - if (count) { - if (cells.length > count) { - cells.splice(count); - } - else { - while (cells.length < count) - cells.push(''); - } - } - for (; i < cells.length; i++) { - // leading or trailing whitespace is ignored per the gfm spec - cells[i] = cells[i].trim().replace(other.slashPipe, '|'); - } - return cells; -} -/** - * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). - * /c*$/ is vulnerable to REDOS. - * - * @param str - * @param c - * @param invert Remove suffix of non-c chars instead. Default falsey. - */ -function rtrim(str, c, invert) { - const l = str.length; - if (l === 0) { - return ''; - } - // Length of suffix matching the invert condition. - let suffLen = 0; - // Step left until we fail to match the invert condition. - while (suffLen < l) { - const currChar = str.charAt(l - suffLen - 1); - if (currChar === c && true) { - suffLen++; - } - else { - break; - } - } - return str.slice(0, l - suffLen); -} -function findClosingBracket(str, b) { - if (str.indexOf(b[1]) === -1) { - return -1; - } - let level = 0; - for (let i = 0; i < str.length; i++) { - if (str[i] === '\\') { - i++; - } - else if (str[i] === b[0]) { - level++; - } - else if (str[i] === b[1]) { - level--; - if (level < 0) { - return i; - } - } - } - if (level > 0) { - return -2; - } - return -1; -} - -function outputLink(cap, link, raw, lexer, rules) { - const href = link.href; - const title = link.title || null; - const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); - lexer.state.inLink = true; - const token = { - type: cap[0].charAt(0) === '!' ? 'image' : 'link', - raw, - href, - title, - text, - tokens: lexer.inlineTokens(text), - }; - lexer.state.inLink = false; - return token; -} -function indentCodeCompensation(raw, text, rules) { - const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); - if (matchIndentToCode === null) { - return text; - } - const indentToCode = matchIndentToCode[1]; - return text - .split('\n') - .map(node => { - const matchIndentInNode = node.match(rules.other.beginningSpace); - if (matchIndentInNode === null) { - return node; - } - const [indentInNode] = matchIndentInNode; - if (indentInNode.length >= indentToCode.length) { - return node.slice(indentToCode.length); - } - return node; - }) - .join('\n'); -} -/** - * Tokenizer - */ -class _Tokenizer { - options; - rules; // set by the lexer - lexer; // set by the lexer - constructor(options) { - this.options = options || _defaults; - } - space(src) { - const cap = this.rules.block.newline.exec(src); - if (cap && cap[0].length > 0) { - return { - type: 'space', - raw: cap[0], - }; - } - } - code(src) { - const cap = this.rules.block.code.exec(src); - if (cap) { - const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); - return { - type: 'code', - raw: cap[0], - codeBlockStyle: 'indented', - text: !this.options.pedantic - ? rtrim(text, '\n') - : text, - }; - } - } - fences(src) { - const cap = this.rules.block.fences.exec(src); - if (cap) { - const raw = cap[0]; - const text = indentCodeCompensation(raw, cap[3] || '', this.rules); - return { - type: 'code', - raw, - lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], - text, - }; - } - } - heading(src) { - const cap = this.rules.block.heading.exec(src); - if (cap) { - let text = cap[2].trim(); - // remove trailing #s - if (this.rules.other.endingHash.test(text)) { - const trimmed = rtrim(text, '#'); - if (this.options.pedantic) { - text = trimmed.trim(); - } - else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { - // CommonMark requires space before trailing #s - text = trimmed.trim(); - } - } - return { - type: 'heading', - raw: cap[0], - depth: cap[1].length, - text, - tokens: this.lexer.inline(text), - }; - } - } - hr(src) { - const cap = this.rules.block.hr.exec(src); - if (cap) { - return { - type: 'hr', - raw: rtrim(cap[0], '\n'), - }; - } - } - blockquote(src) { - const cap = this.rules.block.blockquote.exec(src); - if (cap) { - let lines = rtrim(cap[0], '\n').split('\n'); - let raw = ''; - let text = ''; - const tokens = []; - while (lines.length > 0) { - let inBlockquote = false; - const currentLines = []; - let i; - for (i = 0; i < lines.length; i++) { - // get lines up to a continuation - if (this.rules.other.blockquoteStart.test(lines[i])) { - currentLines.push(lines[i]); - inBlockquote = true; - } - else if (!inBlockquote) { - currentLines.push(lines[i]); - } - else { - break; - } - } - lines = lines.slice(i); - const currentRaw = currentLines.join('\n'); - const currentText = currentRaw - // precede setext continuation with 4 spaces so it isn't a setext - .replace(this.rules.other.blockquoteSetextReplace, '\n $1') - .replace(this.rules.other.blockquoteSetextReplace2, ''); - raw = raw ? `${raw}\n${currentRaw}` : currentRaw; - text = text ? `${text}\n${currentText}` : currentText; - // parse blockquote lines as top level tokens - // merge paragraphs if this is a continuation - const top = this.lexer.state.top; - this.lexer.state.top = true; - this.lexer.blockTokens(currentText, tokens, true); - this.lexer.state.top = top; - // if there is no continuation then we are done - if (lines.length === 0) { - break; - } - const lastToken = tokens.at(-1); - if (lastToken?.type === 'code') { - // blockquote continuation cannot be preceded by a code block - break; - } - else if (lastToken?.type === 'blockquote') { - // include continuation in nested blockquote - const oldToken = lastToken; - const newText = oldToken.raw + '\n' + lines.join('\n'); - const newToken = this.blockquote(newText); - tokens[tokens.length - 1] = newToken; - raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; - text = text.substring(0, text.length - oldToken.text.length) + newToken.text; - break; - } - else if (lastToken?.type === 'list') { - // include continuation in nested list - const oldToken = lastToken; - const newText = oldToken.raw + '\n' + lines.join('\n'); - const newToken = this.list(newText); - tokens[tokens.length - 1] = newToken; - raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; - text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; - lines = newText.substring(tokens.at(-1).raw.length).split('\n'); - continue; - } - } - return { - type: 'blockquote', - raw, - tokens, - text, - }; - } - } - list(src) { - let cap = this.rules.block.list.exec(src); - if (cap) { - let bull = cap[1].trim(); - const isordered = bull.length > 1; - const list = { - type: 'list', - raw: '', - ordered: isordered, - start: isordered ? +bull.slice(0, -1) : '', - loose: false, - items: [], - }; - bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; - if (this.options.pedantic) { - bull = isordered ? bull : '[*+-]'; - } - // Get next list item - const itemRegex = this.rules.other.listItemRegex(bull); - let endsWithBlankLine = false; - // Check if current bullet point can start a new List Item - while (src) { - let endEarly = false; - let raw = ''; - let itemContents = ''; - if (!(cap = itemRegex.exec(src))) { - break; - } - if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) - break; - } - raw = cap[0]; - src = src.substring(raw.length); - let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); - let nextLine = src.split('\n', 1)[0]; - let blankLine = !line.trim(); - let indent = 0; - if (this.options.pedantic) { - indent = 2; - itemContents = line.trimStart(); - } - else if (blankLine) { - indent = cap[1].length + 1; - } - else { - indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char - indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent - itemContents = line.slice(indent); - indent += cap[1].length; - } - if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line - raw += nextLine + '\n'; - src = src.substring(nextLine.length + 1); - endEarly = true; - } - if (!endEarly) { - const nextBulletRegex = this.rules.other.nextBulletRegex(indent); - const hrRegex = this.rules.other.hrRegex(indent); - const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); - const headingBeginRegex = this.rules.other.headingBeginRegex(indent); - const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); - // Check if following lines should be included in List Item - while (src) { - const rawLine = src.split('\n', 1)[0]; - let nextLineWithoutTabs; - nextLine = rawLine; - // Re-align to follow commonmark nesting rules - if (this.options.pedantic) { - nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); - nextLineWithoutTabs = nextLine; - } - else { - nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); - } - // End list item if found code fences - if (fencesBeginRegex.test(nextLine)) { - break; - } - // End list item if found start of new heading - if (headingBeginRegex.test(nextLine)) { - break; - } - // End list item if found start of html block - if (htmlBeginRegex.test(nextLine)) { - break; - } - // End list item if found start of new bullet - if (nextBulletRegex.test(nextLine)) { - break; - } - // Horizontal rule found - if (hrRegex.test(nextLine)) { - break; - } - if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible - itemContents += '\n' + nextLineWithoutTabs.slice(indent); - } - else { - // not enough indentation - if (blankLine) { - break; - } - // paragraph continuation unless last line was a different block level element - if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block - break; - } - if (fencesBeginRegex.test(line)) { - break; - } - if (headingBeginRegex.test(line)) { - break; - } - if (hrRegex.test(line)) { - break; - } - itemContents += '\n' + nextLine; - } - if (!blankLine && !nextLine.trim()) { // Check if current line is blank - blankLine = true; - } - raw += rawLine + '\n'; - src = src.substring(rawLine.length + 1); - line = nextLineWithoutTabs.slice(indent); - } - } - if (!list.loose) { - // If the previous item ended with a blank line, the list is loose - if (endsWithBlankLine) { - list.loose = true; - } - else if (this.rules.other.doubleBlankLine.test(raw)) { - endsWithBlankLine = true; - } - } - let istask = null; - let ischecked; - // Check for task list items - if (this.options.gfm) { - istask = this.rules.other.listIsTask.exec(itemContents); - if (istask) { - ischecked = istask[0] !== '[ ] '; - itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); - } - } - list.items.push({ - type: 'list_item', - raw, - task: !!istask, - checked: ischecked, - loose: false, - text: itemContents, - tokens: [], - }); - list.raw += raw; - } - // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic - const lastItem = list.items.at(-1); - if (lastItem) { - lastItem.raw = lastItem.raw.trimEnd(); - lastItem.text = lastItem.text.trimEnd(); - } - else { - // not a list since there were no items - return; - } - list.raw = list.raw.trimEnd(); - // Item child tokens handled here at end because we needed to have the final item to trim it first - for (let i = 0; i < list.items.length; i++) { - this.lexer.state.top = false; - list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); - if (!list.loose) { - // Check if list should be loose - const spacers = list.items[i].tokens.filter(t => t.type === 'space'); - const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); - list.loose = hasMultipleLineBreaks; - } - } - // Set all items to loose if list is loose - if (list.loose) { - for (let i = 0; i < list.items.length; i++) { - list.items[i].loose = true; - } - } - return list; - } - } - html(src) { - const cap = this.rules.block.html.exec(src); - if (cap) { - const token = { - type: 'html', - block: true, - raw: cap[0], - pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', - text: cap[0], - }; - return token; - } - } - def(src) { - const cap = this.rules.block.def.exec(src); - if (cap) { - const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); - const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; - const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; - return { - type: 'def', - tag, - raw: cap[0], - href, - title, - }; - } - } - table(src) { - const cap = this.rules.block.table.exec(src); - if (!cap) { - return; - } - if (!this.rules.other.tableDelimiter.test(cap[2])) { - // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading - return; - } - const headers = splitCells(cap[1]); - const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); - const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; - const item = { - type: 'table', - raw: cap[0], - header: [], - align: [], - rows: [], - }; - if (headers.length !== aligns.length) { - // header and align columns must be equal, rows can be different. - return; - } - for (const align of aligns) { - if (this.rules.other.tableAlignRight.test(align)) { - item.align.push('right'); - } - else if (this.rules.other.tableAlignCenter.test(align)) { - item.align.push('center'); - } - else if (this.rules.other.tableAlignLeft.test(align)) { - item.align.push('left'); - } - else { - item.align.push(null); - } - } - for (let i = 0; i < headers.length; i++) { - item.header.push({ - text: headers[i], - tokens: this.lexer.inline(headers[i]), - header: true, - align: item.align[i], - }); - } - for (const row of rows) { - item.rows.push(splitCells(row, item.header.length).map((cell, i) => { - return { - text: cell, - tokens: this.lexer.inline(cell), - header: false, - align: item.align[i], - }; - })); - } - return item; - } - lheading(src) { - const cap = this.rules.block.lheading.exec(src); - if (cap) { - return { - type: 'heading', - raw: cap[0], - depth: cap[2].charAt(0) === '=' ? 1 : 2, - text: cap[1], - tokens: this.lexer.inline(cap[1]), - }; - } - } - paragraph(src) { - const cap = this.rules.block.paragraph.exec(src); - if (cap) { - const text = cap[1].charAt(cap[1].length - 1) === '\n' - ? cap[1].slice(0, -1) - : cap[1]; - return { - type: 'paragraph', - raw: cap[0], - text, - tokens: this.lexer.inline(text), - }; - } - } - text(src) { - const cap = this.rules.block.text.exec(src); - if (cap) { - return { - type: 'text', - raw: cap[0], - text: cap[0], - tokens: this.lexer.inline(cap[0]), - }; - } - } - escape(src) { - const cap = this.rules.inline.escape.exec(src); - if (cap) { - return { - type: 'escape', - raw: cap[0], - text: cap[1], - }; - } - } - tag(src) { - const cap = this.rules.inline.tag.exec(src); - if (cap) { - if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { - this.lexer.state.inLink = true; - } - else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { - this.lexer.state.inLink = false; - } - if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { - this.lexer.state.inRawBlock = true; - } - else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { - this.lexer.state.inRawBlock = false; - } - return { - type: 'html', - raw: cap[0], - inLink: this.lexer.state.inLink, - inRawBlock: this.lexer.state.inRawBlock, - block: false, - text: cap[0], - }; - } - } - link(src) { - const cap = this.rules.inline.link.exec(src); - if (cap) { - const trimmedUrl = cap[2].trim(); - if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { - // commonmark requires matching angle brackets - if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { - return; - } - // ending angle bracket cannot be escaped - const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); - if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { - return; - } - } - else { - // find closing parenthesis - const lastParenIndex = findClosingBracket(cap[2], '()'); - if (lastParenIndex === -2) { - // more open parens than closed - return; - } - if (lastParenIndex > -1) { - const start = cap[0].indexOf('!') === 0 ? 5 : 4; - const linkLen = start + cap[1].length + lastParenIndex; - cap[2] = cap[2].substring(0, lastParenIndex); - cap[0] = cap[0].substring(0, linkLen).trim(); - cap[3] = ''; - } - } - let href = cap[2]; - let title = ''; - if (this.options.pedantic) { - // split pedantic href and title - const link = this.rules.other.pedanticHrefTitle.exec(href); - if (link) { - href = link[1]; - title = link[3]; - } - } - else { - title = cap[3] ? cap[3].slice(1, -1) : ''; - } - href = href.trim(); - if (this.rules.other.startAngleBracket.test(href)) { - if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { - // pedantic allows starting angle bracket without ending angle bracket - href = href.slice(1); - } - else { - href = href.slice(1, -1); - } - } - return outputLink(cap, { - href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, - title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, - }, cap[0], this.lexer, this.rules); - } - } - reflink(src, links) { - let cap; - if ((cap = this.rules.inline.reflink.exec(src)) - || (cap = this.rules.inline.nolink.exec(src))) { - const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); - const link = links[linkString.toLowerCase()]; - if (!link) { - const text = cap[0].charAt(0); - return { - type: 'text', - raw: text, - text, - }; - } - return outputLink(cap, link, cap[0], this.lexer, this.rules); - } - } - emStrong(src, maskedSrc, prevChar = '') { - let match = this.rules.inline.emStrongLDelim.exec(src); - if (!match) - return; - // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well - if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) - return; - const nextChar = match[1] || match[2] || ''; - if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { - // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) - const lLength = [...match[0]].length - 1; - let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; - const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; - endReg.lastIndex = 0; - // Clip maskedSrc to same section of string as src (move to lexer?) - maskedSrc = maskedSrc.slice(-1 * src.length + lLength); - while ((match = endReg.exec(maskedSrc)) != null) { - rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; - if (!rDelim) - continue; // skip single * in __abc*abc__ - rLength = [...rDelim].length; - if (match[3] || match[4]) { // found another Left Delim - delimTotal += rLength; - continue; - } - else if (match[5] || match[6]) { // either Left or Right Delim - if (lLength % 3 && !((lLength + rLength) % 3)) { - midDelimTotal += rLength; - continue; // CommonMark Emphasis Rules 9-10 - } - } - delimTotal -= rLength; - if (delimTotal > 0) - continue; // Haven't found enough closing delimiters - // Remove extra characters. *a*** -> *a* - rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); - // char length can be >1 for unicode characters; - const lastCharLength = [...match[0]][0].length; - const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); - // Create `em` if smallest delimiter has odd char count. *a*** - if (Math.min(lLength, rLength) % 2) { - const text = raw.slice(1, -1); - return { - type: 'em', - raw, - text, - tokens: this.lexer.inlineTokens(text), - }; - } - // Create 'strong' if smallest delimiter has even char count. **a*** - const text = raw.slice(2, -2); - return { - type: 'strong', - raw, - text, - tokens: this.lexer.inlineTokens(text), - }; - } - } - } - codespan(src) { - const cap = this.rules.inline.code.exec(src); - if (cap) { - let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); - const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); - const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); - if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { - text = text.substring(1, text.length - 1); - } - return { - type: 'codespan', - raw: cap[0], - text, - }; - } - } - br(src) { - const cap = this.rules.inline.br.exec(src); - if (cap) { - return { - type: 'br', - raw: cap[0], - }; - } - } - del(src) { - const cap = this.rules.inline.del.exec(src); - if (cap) { - return { - type: 'del', - raw: cap[0], - text: cap[2], - tokens: this.lexer.inlineTokens(cap[2]), - }; - } - } - autolink(src) { - const cap = this.rules.inline.autolink.exec(src); - if (cap) { - let text, href; - if (cap[2] === '@') { - text = cap[1]; - href = 'mailto:' + text; - } - else { - text = cap[1]; - href = text; - } - return { - type: 'link', - raw: cap[0], - text, - href, - tokens: [ - { - type: 'text', - raw: text, - text, - }, - ], - }; - } - } - url(src) { - let cap; - if (cap = this.rules.inline.url.exec(src)) { - let text, href; - if (cap[2] === '@') { - text = cap[0]; - href = 'mailto:' + text; - } - else { - // do extended autolink path validation - let prevCapZero; - do { - prevCapZero = cap[0]; - cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; - } while (prevCapZero !== cap[0]); - text = cap[0]; - if (cap[1] === 'www.') { - href = 'http://' + cap[0]; - } - else { - href = cap[0]; - } - } - return { - type: 'link', - raw: cap[0], - text, - href, - tokens: [ - { - type: 'text', - raw: text, - text, - }, - ], - }; - } - } - inlineText(src) { - const cap = this.rules.inline.text.exec(src); - if (cap) { - const escaped = this.lexer.state.inRawBlock; - return { - type: 'text', - raw: cap[0], - text: cap[0], - escaped, - }; - } - } -} - -/** - * Block Lexer - */ -class _Lexer { - tokens; - options; - state; - tokenizer; - inlineQueue; - constructor(options) { - // TokenList cannot be created in one go - this.tokens = []; - this.tokens.links = Object.create(null); - this.options = options || _defaults; - this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); - this.tokenizer = this.options.tokenizer; - this.tokenizer.options = this.options; - this.tokenizer.lexer = this; - this.inlineQueue = []; - this.state = { - inLink: false, - inRawBlock: false, - top: true, - }; - const rules = { - other, - block: block.normal, - inline: inline.normal, - }; - if (this.options.pedantic) { - rules.block = block.pedantic; - rules.inline = inline.pedantic; - } - else if (this.options.gfm) { - rules.block = block.gfm; - if (this.options.breaks) { - rules.inline = inline.breaks; - } - else { - rules.inline = inline.gfm; - } - } - this.tokenizer.rules = rules; - } - /** - * Expose Rules - */ - static get rules() { - return { - block, - inline, - }; - } - /** - * Static Lex Method - */ - static lex(src, options) { - const lexer = new _Lexer(options); - return lexer.lex(src); - } - /** - * Static Lex Inline Method - */ - static lexInline(src, options) { - const lexer = new _Lexer(options); - return lexer.inlineTokens(src); - } - /** - * Preprocessing - */ - lex(src) { - src = src.replace(other.carriageReturn, '\n'); - this.blockTokens(src, this.tokens); - for (let i = 0; i < this.inlineQueue.length; i++) { - const next = this.inlineQueue[i]; - this.inlineTokens(next.src, next.tokens); - } - this.inlineQueue = []; - return this.tokens; - } - blockTokens(src, tokens = [], lastParagraphClipped = false) { - if (this.options.pedantic) { - src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); - } - while (src) { - let token; - if (this.options.extensions?.block?.some((extTokenizer) => { - if (token = extTokenizer.call({ lexer: this }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - continue; - } - // newline - if (token = this.tokenizer.space(src)) { - src = src.substring(token.raw.length); - const lastToken = tokens.at(-1); - if (token.raw.length === 1 && lastToken !== undefined) { - // if there's a single \n as a spacer, it's terminating the last line, - // so move it there so that we don't get unnecessary paragraph tags - lastToken.raw += '\n'; - } - else { - tokens.push(token); - } - continue; - } - // code - if (token = this.tokenizer.code(src)) { - src = src.substring(token.raw.length); - const lastToken = tokens.at(-1); - // An indented code block cannot interrupt a paragraph. - if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.at(-1).src = lastToken.text; - } - else { - tokens.push(token); - } - continue; - } - // fences - if (token = this.tokenizer.fences(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // heading - if (token = this.tokenizer.heading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // hr - if (token = this.tokenizer.hr(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // blockquote - if (token = this.tokenizer.blockquote(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // list - if (token = this.tokenizer.list(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // html - if (token = this.tokenizer.html(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // def - if (token = this.tokenizer.def(src)) { - src = src.substring(token.raw.length); - const lastToken = tokens.at(-1); - if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.raw; - this.inlineQueue.at(-1).src = lastToken.text; - } - else if (!this.tokens.links[token.tag]) { - this.tokens.links[token.tag] = { - href: token.href, - title: token.title, - }; - } - continue; - } - // table (gfm) - if (token = this.tokenizer.table(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // lheading - if (token = this.tokenizer.lheading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // top-level paragraph - // prevent paragraph consuming extensions by clipping 'src' to extension start - let cutSrc = src; - if (this.options.extensions?.startBlock) { - let startIndex = Infinity; - const tempSrc = src.slice(1); - let tempStart; - this.options.extensions.startBlock.forEach((getStartIndex) => { - tempStart = getStartIndex.call({ lexer: this }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { - const lastToken = tokens.at(-1); - if (lastParagraphClipped && lastToken?.type === 'paragraph') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue.at(-1).src = lastToken.text; - } - else { - tokens.push(token); - } - lastParagraphClipped = cutSrc.length !== src.length; - src = src.substring(token.raw.length); - continue; - } - // text - if (token = this.tokenizer.text(src)) { - src = src.substring(token.raw.length); - const lastToken = tokens.at(-1); - if (lastToken?.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue.at(-1).src = lastToken.text; - } - else { - tokens.push(token); - } - continue; - } - if (src) { - const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (this.options.silent) { - console.error(errMsg); - break; - } - else { - throw new Error(errMsg); - } - } - } - this.state.top = true; - return tokens; - } - inline(src, tokens = []) { - this.inlineQueue.push({ src, tokens }); - return tokens; - } - /** - * Lexing/Compiling - */ - inlineTokens(src, tokens = []) { - // String with links masked to avoid interference with em and strong - let maskedSrc = src; - let match = null; - // Mask out reflinks - if (this.tokens.links) { - const links = Object.keys(this.tokens.links); - if (links.length > 0) { - while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { - if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { - maskedSrc = maskedSrc.slice(0, match.index) - + '[' + 'a'.repeat(match[0].length - 2) + ']' - + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); - } - } - } - } - // Mask out escaped characters - while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); - } - // Mask out other blocks - while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); - } - let keepPrevChar = false; - let prevChar = ''; - while (src) { - if (!keepPrevChar) { - prevChar = ''; - } - keepPrevChar = false; - let token; - // extensions - if (this.options.extensions?.inline?.some((extTokenizer) => { - if (token = extTokenizer.call({ lexer: this }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - continue; - } - // escape - if (token = this.tokenizer.escape(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // tag - if (token = this.tokenizer.tag(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // link - if (token = this.tokenizer.link(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // reflink, nolink - if (token = this.tokenizer.reflink(src, this.tokens.links)) { - src = src.substring(token.raw.length); - const lastToken = tokens.at(-1); - if (token.type === 'text' && lastToken?.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } - else { - tokens.push(token); - } - continue; - } - // em & strong - if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // code - if (token = this.tokenizer.codespan(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // br - if (token = this.tokenizer.br(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // del (gfm) - if (token = this.tokenizer.del(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // autolink - if (token = this.tokenizer.autolink(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // url (gfm) - if (!this.state.inLink && (token = this.tokenizer.url(src))) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // text - // prevent inlineText consuming extensions by clipping 'src' to extension start - let cutSrc = src; - if (this.options.extensions?.startInline) { - let startIndex = Infinity; - const tempSrc = src.slice(1); - let tempStart; - this.options.extensions.startInline.forEach((getStartIndex) => { - tempStart = getStartIndex.call({ lexer: this }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (token = this.tokenizer.inlineText(cutSrc)) { - src = src.substring(token.raw.length); - if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started - prevChar = token.raw.slice(-1); - } - keepPrevChar = true; - const lastToken = tokens.at(-1); - if (lastToken?.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } - else { - tokens.push(token); - } - continue; - } - if (src) { - const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (this.options.silent) { - console.error(errMsg); - break; - } - else { - throw new Error(errMsg); - } - } - } - return tokens; - } -} - -/** - * Renderer - */ -class _Renderer { - options; - parser; // set by the parser - constructor(options) { - this.options = options || _defaults; - } - space(token) { - return ''; - } - code({ text, lang, escaped }) { - const langString = (lang || '').match(other.notSpaceStart)?.[0]; - const code = text.replace(other.endingNewline, '') + '\n'; - if (!langString) { - return '
          '
          -                + (escaped ? code : escape(code, true))
          -                + '
          \n'; - } - return '
          '
          -            + (escaped ? code : escape(code, true))
          -            + '
          \n'; - } - blockquote({ tokens }) { - const body = this.parser.parse(tokens); - return `
          \n${body}
          \n`; - } - html({ text }) { - return text; - } - heading({ tokens, depth }) { - return `${this.parser.parseInline(tokens)}\n`; - } - hr(token) { - return '
          \n'; - } - list(token) { - const ordered = token.ordered; - const start = token.start; - let body = ''; - for (let j = 0; j < token.items.length; j++) { - const item = token.items[j]; - body += this.listitem(item); - } - const type = ordered ? 'ol' : 'ul'; - const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; - return '<' + type + startAttr + '>\n' + body + '\n'; - } - listitem(item) { - let itemBody = ''; - if (item.task) { - const checkbox = this.checkbox({ checked: !!item.checked }); - if (item.loose) { - if (item.tokens[0]?.type === 'paragraph') { - item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; - if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { - item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); - item.tokens[0].tokens[0].escaped = true; - } - } - else { - item.tokens.unshift({ - type: 'text', - raw: checkbox + ' ', - text: checkbox + ' ', - escaped: true, - }); - } - } - else { - itemBody += checkbox + ' '; - } - } - itemBody += this.parser.parse(item.tokens, !!item.loose); - return `
        10. ${itemBody}
        11. \n`; - } - checkbox({ checked }) { - return ''; - } - paragraph({ tokens }) { - return `

          ${this.parser.parseInline(tokens)}

          \n`; - } - table(token) { - let header = ''; - // header - let cell = ''; - for (let j = 0; j < token.header.length; j++) { - cell += this.tablecell(token.header[j]); - } - header += this.tablerow({ text: cell }); - let body = ''; - for (let j = 0; j < token.rows.length; j++) { - const row = token.rows[j]; - cell = ''; - for (let k = 0; k < row.length; k++) { - cell += this.tablecell(row[k]); - } - body += this.tablerow({ text: cell }); - } - if (body) - body = `${body}`; - return '\n' - + '\n' - + header - + '\n' - + body - + '
          \n'; - } - tablerow({ text }) { - return `\n${text}\n`; - } - tablecell(token) { - const content = this.parser.parseInline(token.tokens); - const type = token.header ? 'th' : 'td'; - const tag = token.align - ? `<${type} align="${token.align}">` - : `<${type}>`; - return tag + content + `\n`; - } - /** - * span level renderer - */ - strong({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - em({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - codespan({ text }) { - return `${escape(text, true)}`; - } - br(token) { - return '
          '; - } - del({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - link({ href, title, tokens }) { - const text = this.parser.parseInline(tokens); - const cleanHref = cleanUrl(href); - if (cleanHref === null) { - return text; - } - href = cleanHref; - let out = '
          '; - return out; - } - image({ href, title, text, tokens }) { - if (tokens) { - text = this.parser.parseInline(tokens, this.parser.textRenderer); - } - const cleanHref = cleanUrl(href); - if (cleanHref === null) { - return escape(text); - } - href = cleanHref; - let out = `${text} { - const tokens = genericToken[childTokens].flat(Infinity); - values = values.concat(this.walkTokens(tokens, callback)); - }); - } - else if (genericToken.tokens) { - values = values.concat(this.walkTokens(genericToken.tokens, callback)); - } - } - } - } - return values; - } - use(...args) { - const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; - args.forEach((pack) => { - // copy options to new object - const opts = { ...pack }; - // set async to true if it was set to true before - opts.async = this.defaults.async || opts.async || false; - // ==-- Parse "addon" extensions --== // - if (pack.extensions) { - pack.extensions.forEach((ext) => { - if (!ext.name) { - throw new Error('extension name required'); - } - if ('renderer' in ext) { // Renderer extensions - const prevRenderer = extensions.renderers[ext.name]; - if (prevRenderer) { - // Replace extension with func to run new extension but fall back if false - extensions.renderers[ext.name] = function (...args) { - let ret = ext.renderer.apply(this, args); - if (ret === false) { - ret = prevRenderer.apply(this, args); - } - return ret; - }; - } - else { - extensions.renderers[ext.name] = ext.renderer; - } - } - if ('tokenizer' in ext) { // Tokenizer Extensions - if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { - throw new Error("extension level must be 'block' or 'inline'"); - } - const extLevel = extensions[ext.level]; - if (extLevel) { - extLevel.unshift(ext.tokenizer); - } - else { - extensions[ext.level] = [ext.tokenizer]; - } - if (ext.start) { // Function to check for start of token - if (ext.level === 'block') { - if (extensions.startBlock) { - extensions.startBlock.push(ext.start); - } - else { - extensions.startBlock = [ext.start]; - } - } - else if (ext.level === 'inline') { - if (extensions.startInline) { - extensions.startInline.push(ext.start); - } - else { - extensions.startInline = [ext.start]; - } - } - } - } - if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens - extensions.childTokens[ext.name] = ext.childTokens; - } - }); - opts.extensions = extensions; - } - // ==-- Parse "overwrite" extensions --== // - if (pack.renderer) { - const renderer = this.defaults.renderer || new _Renderer(this.defaults); - for (const prop in pack.renderer) { - if (!(prop in renderer)) { - throw new Error(`renderer '${prop}' does not exist`); - } - if (['options', 'parser'].includes(prop)) { - // ignore options property - continue; - } - const rendererProp = prop; - const rendererFunc = pack.renderer[rendererProp]; - const prevRenderer = renderer[rendererProp]; - // Replace renderer with func to run extension, but fall back if false - renderer[rendererProp] = (...args) => { - let ret = rendererFunc.apply(renderer, args); - if (ret === false) { - ret = prevRenderer.apply(renderer, args); - } - return ret || ''; - }; - } - opts.renderer = renderer; - } - if (pack.tokenizer) { - const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); - for (const prop in pack.tokenizer) { - if (!(prop in tokenizer)) { - throw new Error(`tokenizer '${prop}' does not exist`); - } - if (['options', 'rules', 'lexer'].includes(prop)) { - // ignore options, rules, and lexer properties - continue; - } - const tokenizerProp = prop; - const tokenizerFunc = pack.tokenizer[tokenizerProp]; - const prevTokenizer = tokenizer[tokenizerProp]; - // Replace tokenizer with func to run extension, but fall back if false - // @ts-expect-error cannot type tokenizer function dynamically - tokenizer[tokenizerProp] = (...args) => { - let ret = tokenizerFunc.apply(tokenizer, args); - if (ret === false) { - ret = prevTokenizer.apply(tokenizer, args); - } - return ret; - }; - } - opts.tokenizer = tokenizer; - } - // ==-- Parse Hooks extensions --== // - if (pack.hooks) { - const hooks = this.defaults.hooks || new _Hooks(); - for (const prop in pack.hooks) { - if (!(prop in hooks)) { - throw new Error(`hook '${prop}' does not exist`); - } - if (['options', 'block'].includes(prop)) { - // ignore options and block properties - continue; - } - const hooksProp = prop; - const hooksFunc = pack.hooks[hooksProp]; - const prevHook = hooks[hooksProp]; - if (_Hooks.passThroughHooks.has(prop)) { - // @ts-expect-error cannot type hook function dynamically - hooks[hooksProp] = (arg) => { - if (this.defaults.async) { - return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { - return prevHook.call(hooks, ret); - }); - } - const ret = hooksFunc.call(hooks, arg); - return prevHook.call(hooks, ret); - }; - } - else { - // @ts-expect-error cannot type hook function dynamically - hooks[hooksProp] = (...args) => { - let ret = hooksFunc.apply(hooks, args); - if (ret === false) { - ret = prevHook.apply(hooks, args); - } - return ret; - }; - } - } - opts.hooks = hooks; - } - // ==-- Parse WalkTokens extensions --== // - if (pack.walkTokens) { - const walkTokens = this.defaults.walkTokens; - const packWalktokens = pack.walkTokens; - opts.walkTokens = function (token) { - let values = []; - values.push(packWalktokens.call(this, token)); - if (walkTokens) { - values = values.concat(walkTokens.call(this, token)); - } - return values; - }; - } - this.defaults = { ...this.defaults, ...opts }; - }); - return this; - } - setOptions(opt) { - this.defaults = { ...this.defaults, ...opt }; - return this; - } - lexer(src, options) { - return _Lexer.lex(src, options ?? this.defaults); - } - parser(tokens, options) { - return _Parser.parse(tokens, options ?? this.defaults); - } - parseMarkdown(blockType) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const parse = (src, options) => { - const origOpt = { ...options }; - const opt = { ...this.defaults, ...origOpt }; - const throwError = this.onError(!!opt.silent, !!opt.async); - // throw error if an extension set async to true but parse was called with async: false - if (this.defaults.async === true && origOpt.async === false) { - return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); - } - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - return throwError(new Error('marked(): input parameter is undefined or null')); - } - if (typeof src !== 'string') { - return throwError(new Error('marked(): input parameter is of type ' - + Object.prototype.toString.call(src) + ', string expected')); - } - if (opt.hooks) { - opt.hooks.options = opt; - opt.hooks.block = blockType; - } - const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); - const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); - if (opt.async) { - return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) - .then(src => lexer(src, opt)) - .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) - .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) - .then(tokens => parser(tokens, opt)) - .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) - .catch(throwError); - } - try { - if (opt.hooks) { - src = opt.hooks.preprocess(src); - } - let tokens = lexer(src, opt); - if (opt.hooks) { - tokens = opt.hooks.processAllTokens(tokens); - } - if (opt.walkTokens) { - this.walkTokens(tokens, opt.walkTokens); - } - let html = parser(tokens, opt); - if (opt.hooks) { - html = opt.hooks.postprocess(html); - } - return html; - } - catch (e) { - return throwError(e); - } - }; - return parse; - } - onError(silent, async) { - return (e) => { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - if (silent) { - const msg = '

          An error occurred:

          '
          -                    + escape(e.message + '', true)
          -                    + '
          '; - if (async) { - return Promise.resolve(msg); - } - return msg; - } - if (async) { - return Promise.reject(e); - } - throw e; - }; - } -} - -const markedInstance = new Marked(); -function marked(src, opt) { - return markedInstance.parse(src, opt); -} -/** - * Sets the default options. - * - * @param options Hash of options - */ -marked.options = - marked.setOptions = function (options) { - markedInstance.setOptions(options); - marked.defaults = markedInstance.defaults; - changeDefaults(marked.defaults); - return marked; - }; -/** - * Gets the original marked default options. - */ -marked.getDefaults = _getDefaults; -marked.defaults = _defaults; -/** - * Use Extension - */ -marked.use = function (...args) { - markedInstance.use(...args); - marked.defaults = markedInstance.defaults; - changeDefaults(marked.defaults); - return marked; -}; -/** - * Run callback for every token - */ -marked.walkTokens = function (tokens, callback) { - return markedInstance.walkTokens(tokens, callback); -}; -/** - * Compiles markdown to HTML without enclosing `p` tag. - * - * @param src String of markdown source to be compiled - * @param options Hash of options - * @return String of compiled HTML - */ -marked.parseInline = markedInstance.parseInline; -/** - * Expose - */ -marked.Parser = _Parser; -marked.parser = _Parser.parse; -marked.Renderer = _Renderer; -marked.TextRenderer = _TextRenderer; -marked.Lexer = _Lexer; -marked.lexer = _Lexer.lex; -marked.Tokenizer = _Tokenizer; -marked.Hooks = _Hooks; -marked.parse = marked; -const options = marked.options; -const setOptions = marked.setOptions; -const use = marked.use; -const walkTokens = marked.walkTokens; -const parseInline = marked.parseInline; -const parse = marked; -const parser = _Parser.parse; -const lexer = _Lexer.lex; - -export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; -