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..f937c83e8 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
@@ -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)
@@ -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 ad321c2de..b8982beb2 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -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
@@ -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)
@@ -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/card-perma.css b/app/assets/stylesheets/card-perma.css
index 3b9cbdcc3..4ab5d307f 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -88,7 +88,7 @@
@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);
}
}
@@ -110,12 +110,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,
@@ -172,12 +184,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;
@@ -187,7 +199,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);
}
}
@@ -195,7 +207,6 @@
.card__column-name {
@media (max-width: 639px) {
- --btn-border-radius: 0;
justify-content: center;
}
}
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index e05562635..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 {
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/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/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/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/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb
index 95acc7511..1fc675b84 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)
+ @card.read_by(Current.user)
record_board_access
end
def destroy
- @notifications = @card.unread_by(Current.user)
+ @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/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/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/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/card/readable.rb b/app/models/card/readable.rb
index 9c272dd75..0168f00a9 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)&.read
end
def unread_by(user)
- all_notifications_for(user).tap do |notifications|
- notifications.each(&:unread)
- end
+ user.notifications.find_by(card: self)&.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..f00987ab8 100644
--- a/app/models/notifier.rb
+++ b/app/models/notifier.rb
@@ -16,7 +16,17 @@ 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?
+ 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/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/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb
index 080f53bdd..0f42afdc0 100644
--- a/app/views/cards/comments/_comment.html.erb
+++ b/app/views/cards/comments/_comment.html.erb
@@ -32,7 +32,7 @@
<% end %>
-
+
<%= 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/_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/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
deleted file mode 100644
index 5d7fe7801..000000000
--- a/app/views/cards/readings/create.turbo_stream.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<% @notifications.each do |notification| %>
- <%= turbo_stream.remove notification %>
-<% end %>
diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb
index 28ca0be36..1545f8598 100644
--- a/app/views/mailers/notification/bundle_mailer/notification.html.erb
+++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb
@@ -9,7 +9,7 @@
<%= link_to board.name, board %>
<% board_notifications.group_by(&:card).each do |card, notifications| %>
- <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %>
+ <%= 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/_notification.html.erb b/app/views/notifications/_notification.html.erb
index 02f547c4e..6bed2aa56 100644
--- a/app/views/notifications/_notification.html.erb
+++ b/app/views/notifications/_notification.html.erb
@@ -2,7 +2,7 @@
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
- <%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
+ <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder
index ba27c5425..d2fadfa1b 100644
--- a/app/views/notifications/_notification.json.jbuilder
+++ b/app/views/notifications/_notification.json.jbuilder
@@ -1,5 +1,5 @@
json.cache! notification do
- json.(notification, :id)
+ json.(notification, :id, :unread_count)
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb
index 1f5aae469..74d492049 100644
--- a/app/views/notifications/_tray.html.erb
+++ b/app/views/notifications/_tray.html.erb
@@ -8,9 +8,8 @@
data-navigable-list-reverse-navigation-value="true"
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
- controller: "notifications-tray frame-reloader",
- notifications_tray_grouped_class: "notification--grouped",
- action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
+ controller: "frame-reloader",
+ action: "focus@window->frame-reloader#reload" } %>
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/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/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/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/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/schema.rb b/db/schema.rb
index 8bc5f5c7d..a46444460 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_02_11_122517) do
+ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) 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
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) 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
diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb
index 248bf97d4..4254c30f1 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_02_11_122517) do
+ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) 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
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 "what
aboutthis", 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/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: /^,
- endAngleBracket: />$/,
- 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]*?(?:\\1>[^\\n]*\\n+|$)' // (1)
- + '|comment[^\\n]*(\\n+|$)' // (2)
- + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3)
- + '|\\n*|$)' // (4)
- + '|\\n*|$)' // (5)
- + '|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6)
- + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag
- + '|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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]+?\\1> *(?:\\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: /^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\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'
- + '|^[a-zA-Z][\\w:-]*\\s*>' // 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 + '' + type + '>\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 `${itemBody} \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 + `${type}>\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 = '' + text + ' ';
- 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 = ` ';
- return out;
- }
- text(token) {
- return 'tokens' in token && token.tokens
- ? this.parser.parseInline(token.tokens)
- : ('escaped' in token && token.escaped ? token.text : escape(token.text));
- }
-}
-
-/**
- * TextRenderer
- * returns only the textual part of the token
- */
-class _TextRenderer {
- // no need for block level renderers
- strong({ text }) {
- return text;
- }
- em({ text }) {
- return text;
- }
- codespan({ text }) {
- return text;
- }
- del({ text }) {
- return text;
- }
- html({ text }) {
- return text;
- }
- text({ text }) {
- return text;
- }
- link({ text }) {
- return '' + text;
- }
- image({ text }) {
- return '' + text;
- }
- br() {
- return '';
- }
-}
-
-/**
- * Parsing & Compiling
- */
-class _Parser {
- options;
- renderer;
- textRenderer;
- constructor(options) {
- this.options = options || _defaults;
- this.options.renderer = this.options.renderer || new _Renderer();
- this.renderer = this.options.renderer;
- this.renderer.options = this.options;
- this.renderer.parser = this;
- this.textRenderer = new _TextRenderer();
- }
- /**
- * Static Parse Method
- */
- static parse(tokens, options) {
- const parser = new _Parser(options);
- return parser.parse(tokens);
- }
- /**
- * Static Parse Inline Method
- */
- static parseInline(tokens, options) {
- const parser = new _Parser(options);
- return parser.parseInline(tokens);
- }
- /**
- * Parse Loop
- */
- parse(tokens, top = true) {
- let out = '';
- for (let i = 0; i < tokens.length; i++) {
- const anyToken = tokens[i];
- // Run any renderer extensions
- if (this.options.extensions?.renderers?.[anyToken.type]) {
- const genericToken = anyToken;
- const ret = this.options.extensions.renderers[genericToken.type].call({ parser: this }, genericToken);
- if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(genericToken.type)) {
- out += ret || '';
- continue;
- }
- }
- const token = anyToken;
- switch (token.type) {
- case 'space': {
- out += this.renderer.space(token);
- continue;
- }
- case 'hr': {
- out += this.renderer.hr(token);
- continue;
- }
- case 'heading': {
- out += this.renderer.heading(token);
- continue;
- }
- case 'code': {
- out += this.renderer.code(token);
- continue;
- }
- case 'table': {
- out += this.renderer.table(token);
- continue;
- }
- case 'blockquote': {
- out += this.renderer.blockquote(token);
- continue;
- }
- case 'list': {
- out += this.renderer.list(token);
- continue;
- }
- case 'html': {
- out += this.renderer.html(token);
- continue;
- }
- case 'paragraph': {
- out += this.renderer.paragraph(token);
- continue;
- }
- case 'text': {
- let textToken = token;
- let body = this.renderer.text(textToken);
- while (i + 1 < tokens.length && tokens[i + 1].type === 'text') {
- textToken = tokens[++i];
- body += '\n' + this.renderer.text(textToken);
- }
- if (top) {
- out += this.renderer.paragraph({
- type: 'paragraph',
- raw: body,
- text: body,
- tokens: [{ type: 'text', raw: body, text: body, escaped: true }],
- });
- }
- else {
- out += body;
- }
- continue;
- }
- default: {
- const errMsg = 'Token with "' + token.type + '" type was not found.';
- if (this.options.silent) {
- console.error(errMsg);
- return '';
- }
- else {
- throw new Error(errMsg);
- }
- }
- }
- }
- return out;
- }
- /**
- * Parse Inline Tokens
- */
- parseInline(tokens, renderer = this.renderer) {
- let out = '';
- for (let i = 0; i < tokens.length; i++) {
- const anyToken = tokens[i];
- // Run any renderer extensions
- if (this.options.extensions?.renderers?.[anyToken.type]) {
- const ret = this.options.extensions.renderers[anyToken.type].call({ parser: this }, anyToken);
- if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(anyToken.type)) {
- out += ret || '';
- continue;
- }
- }
- const token = anyToken;
- switch (token.type) {
- case 'escape': {
- out += renderer.text(token);
- break;
- }
- case 'html': {
- out += renderer.html(token);
- break;
- }
- case 'link': {
- out += renderer.link(token);
- break;
- }
- case 'image': {
- out += renderer.image(token);
- break;
- }
- case 'strong': {
- out += renderer.strong(token);
- break;
- }
- case 'em': {
- out += renderer.em(token);
- break;
- }
- case 'codespan': {
- out += renderer.codespan(token);
- break;
- }
- case 'br': {
- out += renderer.br(token);
- break;
- }
- case 'del': {
- out += renderer.del(token);
- break;
- }
- case 'text': {
- out += renderer.text(token);
- break;
- }
- default: {
- const errMsg = 'Token with "' + token.type + '" type was not found.';
- if (this.options.silent) {
- console.error(errMsg);
- return '';
- }
- else {
- throw new Error(errMsg);
- }
- }
- }
- }
- return out;
- }
-}
-
-class _Hooks {
- options;
- block;
- constructor(options) {
- this.options = options || _defaults;
- }
- static passThroughHooks = new Set([
- 'preprocess',
- 'postprocess',
- 'processAllTokens',
- ]);
- /**
- * Process markdown before marked
- */
- preprocess(markdown) {
- return markdown;
- }
- /**
- * Process HTML after marked is finished
- */
- postprocess(html) {
- return html;
- }
- /**
- * Process all tokens before walk tokens
- */
- processAllTokens(tokens) {
- return tokens;
- }
- /**
- * Provide function to tokenize markdown
- */
- provideLexer() {
- return this.block ? _Lexer.lex : _Lexer.lexInline;
- }
- /**
- * Provide function to parse tokens
- */
- provideParser() {
- return this.block ? _Parser.parse : _Parser.parseInline;
- }
-}
-
-class Marked {
- defaults = _getDefaults();
- options = this.setOptions;
- parse = this.parseMarkdown(true);
- parseInline = this.parseMarkdown(false);
- Parser = _Parser;
- Renderer = _Renderer;
- TextRenderer = _TextRenderer;
- Lexer = _Lexer;
- Tokenizer = _Tokenizer;
- Hooks = _Hooks;
- constructor(...args) {
- this.use(...args);
- }
- /**
- * Run callback for every token
- */
- walkTokens(tokens, callback) {
- let values = [];
- for (const token of tokens) {
- values = values.concat(callback.call(this, token));
- switch (token.type) {
- case 'table': {
- const tableToken = token;
- for (const cell of tableToken.header) {
- values = values.concat(this.walkTokens(cell.tokens, callback));
- }
- for (const row of tableToken.rows) {
- for (const cell of row) {
- values = values.concat(this.walkTokens(cell.tokens, callback));
- }
- }
- break;
- }
- case 'list': {
- const listToken = token;
- values = values.concat(this.walkTokens(listToken.items, callback));
- break;
- }
- default: {
- const genericToken = token;
- if (this.defaults.extensions?.childTokens?.[genericToken.type]) {
- this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => {
- 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 };
-
- <%= 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.title %> + <%= card_html_title(card) %>
- <%= card.title %> + <%= card_html_title(card) %>
<%= link_to board.name, board %>
<% board_notifications.group_by(&:card).each do |card, notifications| %> - <%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> + <%= 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/_notification.html.erb b/app/views/notifications/_notification.html.erb index 02f547c4e..6bed2aa56 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -2,7 +2,7 @@ <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%= notification_tag notification do %> <%= render "notifications/notification/header", notification: notification do %> - <%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %> + <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %> <% end %> <%= render "notifications/notification/body", notification: notification %> <% end %> diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index ba27c5425..d2fadfa1b 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -1,5 +1,5 @@ json.cache! notification do - json.(notification, :id) + json.(notification, :id, :unread_count) json.read notification.read? json.read_at notification.read_at&.utc json.created_at notification.created_at.utc diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb index 1f5aae469..74d492049 100644 --- a/app/views/notifications/_tray.html.erb +++ b/app/views/notifications/_tray.html.erb @@ -8,9 +8,8 @@ data-navigable-list-reverse-navigation-value="true" data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside"> <%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: { - controller: "notifications-tray frame-reloader", - notifications_tray_grouped_class: "notification--grouped", - action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %> + controller: "frame-reloader", + action: "focus@window->frame-reloader#reload" } %>Previously seen
- <%= tag.span card.title, class: "card__title-link" %> + <%= tag.span card_html_title(card), class: "card__title-link" %>
-bugin 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(/fooandbar", 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/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\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 + '' + type + '>\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 `
${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 '${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 = '' + text + ''; - 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 = `An error occurred:
'; - 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 }; -