comfy_middle_seat 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d22803345977cfaec60811a1301ce6ad6ada6ab242d41c5c390b4c5aa6da645
4
- data.tar.gz: 537cd86f6e7685b7a771dcd80e5c0c1bc01544f92e88fc3939bc956bc2a08db2
3
+ metadata.gz: 9433cede5d17f15d7c30a4a6bb30e9951ddb944129c317dc8b9a008f8e8d69f0
4
+ data.tar.gz: 6f30614dae87dfc9735ee436c640f5431ef66e58a084483549fb105ab43bffa2
5
5
  SHA512:
6
- metadata.gz: d0785ac0b03f9f9ce49112a9b8ce41332d077e3535afd1fd46f6baab5ced8316cd95a6daef480c2e67c254d4bedcf1707f2b11ee1df980907a546443ce4a2958
7
- data.tar.gz: e36f136f038d6bbdd043c306306c4d2c9fa8fc4cac7a5b0ad7021c717858eaa354c529516d0642a4d09c501fc892b7c871d0749e4388bad4d22b474a5a581302
6
+ metadata.gz: fba6ed160d2930727df376eb665f184df898ad3bf732b45a734abbee4f4edbe0f063f056db66c8f95adcba9111b9fd43a0640f8f5aebb1860c36b451ddcc09d5
7
+ data.tar.gz: f0801cb516d4d98bd8adc25c8e51bf73c2fc13b25f8930a6597c854abd216dd7a98ba5b08dd214cd45f85f73139844a7e5a6a3bb7771e8bd6c66954dd026c187
data/CHANGELOG.md CHANGED
@@ -12,15 +12,57 @@ This changelog follows the project's lineage: **Comfortable Mexican Sofa → Com
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ### Added
16
+
17
+ - Add CMS comment blocks and short notes to hide content without processing enclosed tags.
18
+ - Expand CMS tags with editor shortcuts and searchable completion.
19
+ - Open the shortcut selector with `;;` + Tab and filter with fuzzy search.
20
+
21
+ ## [v4.2.0] - 2026-10-13
22
+
23
+ ### Changed
24
+
25
+ - Keep Page editing actions together in a sticky top toolbar.
26
+ - Save edits and publish or unpublish a page with one button.
27
+ - Show unpublished status below the heading and save hints as button tooltips.
28
+ - Simplify the save confirmation to “Page updated.”
29
+ - Fade success and informational notices after five seconds; pause on hover or focus.
30
+ - Keep warnings and errors visible and respect reduced-motion preferences.
31
+
32
+ ### Fixed
33
+
34
+ - Keep editing buttons aligned with the page heading above publication status.
35
+ - Keep editing controls usable after Preview.
36
+ - Restore dismissal of admin flash notices.
37
+ - Keep Add Child Layout on one line when space allows.
38
+
39
+ ## [v4.1.0] - 2026-10-12
40
+
15
41
  ### Changed
16
42
 
43
+ - Refresh the admin with the Harbor navy and teal theme.
17
44
  - Hide pages beneath unpublished ancestors without changing their publication settings.
18
- - Show inherited page visibility and clarify descendant publishing actions.
45
+ - Show when a page is hidden by an unpublished ancestor.
46
+ - Group descendant publishing actions in the Child pages menu.
19
47
  - Gray out descendant publishing actions when no changes are needed.
20
48
 
21
49
  ### Fixed
22
50
 
23
- - Refresh page and translation navigation when branch visibility changes.
51
+ - Refresh page and translation navigation when publication changes.
52
+
53
+ ## [v4.0.4] - 2026-10-10
54
+
55
+ No functional changes from v4.0.3.
56
+
57
+ ## [v4.0.3] - 2026-10-10
58
+
59
+ ### Added
60
+
61
+ - Publish or unpublish pages directly from the Page index.
62
+
63
+ ### Fixed
64
+
65
+ - Keep Add Child Page buttons on one line.
24
66
 
25
67
  ## [v4.0.2] - 2026-09-21
26
68
 
@@ -220,7 +262,11 @@ First release of `comfortable_media_surfer`. This new gem is a revival of [Comfo
220
262
 
221
263
  - Rebranded **ComfortableMexicanSofa** as **ComfortableMediaSurfer** in order to publish new gem (database table names and schema have not changed).
222
264
 
223
- [Unreleased]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.0.2...master
265
+ [Unreleased]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.2.0...master
266
+ [v4.2.0]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.1.0...v4.2.0
267
+ [v4.1.0]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.0.4...v4.1.0
268
+ [v4.0.4]: https://github.com/avonderluft/comfy-middle-seat/compare/ea2bb99c0398e1f4d6e850a25e0c13afc577c3ad...v4.0.4
269
+ [v4.0.3]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.0.2...ea2bb99c0398e1f4d6e850a25e0c13afc577c3ad
224
270
  [v4.0.2]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.0.1...v4.0.2
225
271
  [v4.0.1]: https://github.com/avonderluft/comfy-middle-seat/compare/v4.0.0...v4.0.1
226
272
  [v4.0.0]: https://github.com/avonderluft/comfy-middle-seat/compare/v3.2.1...v4.0.0
data/README.md CHANGED
@@ -114,6 +114,70 @@ A Site defines a hostname, path, and language. Layouts define page structure and
114
114
 
115
115
  When an editor creates a Page using this Layout, the admin automatically presents fields for `title` and `content`. Content tags can also provide Markdown, files, images, dates, numbers, checkboxes, snippets, partials, helpers, navigation, and other reusable content.
116
116
 
117
+ ### Editor shortcuts
118
+
119
+ In HTML and Markdown code editors, type a shortcut and press **Tab** to insert a CMS tag. The parameter placeholder is selected so you can replace it immediately. For example, `;md` inserts `{{ cms:markdown content }}` with `content` selected.
120
+
121
+ Type **`;;` then Tab**, or press **Ctrl+Space**, for a searchable shortcut list. Search by shortcut or tag name: `c` prioritizes comment, children, and other `c` shortcuts; `p fl` finds `page_file_link`. Exact and prefix matches appear before fuzzy matches.
122
+
123
+ Use the arrow keys and **Enter** or **Tab** to choose. The tag replaces the entire trigger and search text. **Escape** closes the list without changing your text. Full names such as `;markdown` also work for direct expansion. One **Undo** reverses the expansion without removing what you typed.
124
+
125
+ | Shortcut | Inserts | Selected placeholder |
126
+ | --- | --- | --- |
127
+ | `;md` | `markdown` | `content` |
128
+ | `;tx` | `text` | `field` |
129
+ | `;ta` | `textarea` | `content` |
130
+ | `;wys` | `wysiwyg` | `content` |
131
+ | `;da` | `date` | `field` |
132
+ | `;dt` | `datetime` | `field` |
133
+ | `;num` | `number` | `field` |
134
+ | `;cb` | `checkbox` | `field` |
135
+ | `;fi` | `file`, as a link | `attachment` |
136
+ | `;fs` | `files`, as links | `attachments` |
137
+ | `;im` | `image` | `image label` |
138
+ | `;au` | `audio` | `path/to/audio` |
139
+ | `;fl` | `file_link`, as a link | `file_id` |
140
+ | `;pfl` | `page_file_link`, as a link | `attachment` |
141
+ | `;sn` | `snippet` | `identifier` |
142
+ | `;as`, `;css` | CSS `asset` tag | `layout_identifier` |
143
+ | `;js` | JavaScript `asset` tag | `layout_identifier` |
144
+ | `;hlp` | `helper` | `method_name` |
145
+ | `;par` | `partial` | `path/to/partial` |
146
+ | `;tpl` | `template` | `path/to/template` |
147
+ | `;cr` | `breadcrumbs` | — |
148
+ | `;ch` | `children` | — |
149
+ | `;si` | `siblings` | — |
150
+ | `;com` | Complete multiline comment block | `Comment here` |
151
+ | `;nt` | `comment "Note"` (also `;note`) | `Note` |
152
+ | `;ec` | `endcomment` | — |
153
+ | `;end` | `end` | — |
154
+
155
+ Placeholders are examples: replace them with your field names, identifiers, paths, or IDs. `;asset` inserts the CSS version; `;comment` inserts a full comment block.
156
+
157
+ Shortcuts work at the start of a line, after whitespace, or after `>`, outside an existing CMS tag. Unrecognized shortcuts retain normal Tab behavior. CSS, JavaScript, ordinary text fields, and visual editors are unchanged. Saved content uses the standard `{{ cms:... }}` syntax.
158
+
159
+ ### CMS comments
160
+
161
+ Use a quoted note for a short comment:
162
+
163
+ ```html
164
+ {{cms:comment "Replace this banner in December"}}
165
+ ```
166
+
167
+ Use a block to hide a section, including any CMS tags inside it:
168
+
169
+ ```html
170
+ {{cms:comment}}
171
+ Internal note: this section is not ready.
172
+ {{cms:markdown old_content}}
173
+ {{cms:snippet seasonal_banner}}
174
+ {{cms:endcomment}}
175
+ ```
176
+
177
+ Comments produce no rendered output and do not create editor fields. Unlike HTML comments, enclosed CMS tags are not processed. Source text and existing field values remain stored; removing a comment restores normal processing.
178
+
179
+ Blocks may be nested and must end with `{{cms:endcomment}}`, not `{{cms:end}}`. Short notes accept single or double quotes, with backslash-escaped quotes. Unclosed blocks and malformed comment tags raise a clear syntax error. Comments work in layouts, snippets, and rendered fragment content, including Markdown. They are not a place for secrets: original source remains available to editors and may be exposed by APIs that include raw content.
180
+
117
181
  ### Publishing pages
118
182
 
119
183
  A page is publicly visible only when it and all its ancestors are published. Unpublishing a parent hides its branch without changing descendants’ individual publication settings. Republishing the parent restores only descendants whose full ancestor chain is published; drafts stay unpublished. Translations must also satisfy their own publication settings.
@@ -4,6 +4,7 @@ import ujs from "@rails/ujs";
4
4
  window.jQuery = window.$ = jQuery;
5
5
  ujs.start();
6
6
 
7
+ import "bootstrap/js/src/alert";
7
8
  import "bootstrap/js/src/tab";
8
9
  import "bootstrap/js/src/collapse";
9
10
  import "bootstrap/js/src/dropdown";
@@ -20,6 +21,7 @@ import "./draft_forms";
20
21
  import "./file_link";
21
22
  import "./file_upload";
22
23
  import "./files_modal";
24
+ import "./flash_notices";
23
25
  import "./page_fragments";
24
26
  import "./sortable_list";
25
27
  import "./slugify";
@@ -32,9 +32,11 @@
32
32
  CMS.fileUpload.init();
33
33
  CMS.diff();
34
34
  CMS.draftForms.init();
35
+ CMS.flashNotices.init();
35
36
  };
36
37
 
37
38
  CMS.dispose = () => {
39
+ CMS.flashNotices.dispose();
38
40
  CMS.pageFragments.dispose();
39
41
  CMS.draftForms.dispose();
40
42
  CMS.codemirror.dispose();
@@ -0,0 +1,177 @@
1
+ import CodeMirror from "codemirror";
2
+ import "codemirror/addon/hint/show-hint";
3
+
4
+ const tag = (shortcut, name, params = "", placeholder = null, aliases = []) => ({
5
+ shortcut,
6
+ name,
7
+ aliases: [shortcut, name, ...aliases],
8
+ text: `{{ cms:${name}${params ? ` ${params}` : ""} }}`,
9
+ placeholder,
10
+ });
11
+
12
+ const completions = [
13
+ tag("md", "markdown", "content", "content"),
14
+ tag("tx", "text", "field", "field"),
15
+ tag("ta", "textarea", "content", "content"),
16
+ tag("wys", "wysiwyg", "content", "content"),
17
+ tag("da", "date", "field", "field"),
18
+ tag("dt", "datetime", "field", "field"),
19
+ tag("num", "number", "field", "field"),
20
+ tag("cb", "checkbox", "field", "field"),
21
+ tag("fi", "file", "attachment, as: link", "attachment"),
22
+ tag("fs", "files", "attachments, as: link", "attachments"),
23
+ tag("im", "image", '"image label"', "image label"),
24
+ tag("au", "audio", '"path/to/audio"', "path/to/audio"),
25
+ tag("fl", "file_link", "file_id, as: link", "file_id"),
26
+ tag("pfl", "page_file_link", "attachment, as: link", "attachment"),
27
+ tag("sn", "snippet", "identifier", "identifier"),
28
+ tag("as", "asset", "layout_identifier, type: css, as: tag", "layout_identifier"),
29
+ tag("css", "asset", "layout_identifier, type: css, as: tag", "layout_identifier"),
30
+ tag("js", "asset", "layout_identifier, type: js, as: tag", "layout_identifier"),
31
+ tag("hlp", "helper", "method_name", "method_name"),
32
+ tag("par", "partial", '"path/to/partial"', "path/to/partial"),
33
+ tag("tpl", "template", '"path/to/template"', "path/to/template"),
34
+ tag("cr", "breadcrumbs"),
35
+ tag("ch", "children"),
36
+ tag("si", "siblings"),
37
+ {
38
+ ...tag("com", "comment", "", "Comment here"),
39
+ text: "{{ cms:comment }}\nComment here\n{{ cms:endcomment }}",
40
+ },
41
+ tag("nt", "comment", '"Note"', "Note", ["note"]),
42
+ tag("ec", "endcomment"),
43
+ tag("end", "end"),
44
+ ];
45
+
46
+ const byAlias = new Map();
47
+ for (const completion of completions) {
48
+ for (const alias of completion.aliases) {
49
+ // Shared full names use the primary entry: asset => ;as, comment => ;com.
50
+ if (!byAlias.has(alias)) byAlias.set(alias, completion);
51
+ }
52
+ }
53
+
54
+ const completionContext = (editor, requireSemicolon = false, start = null) => {
55
+ if (editor.getOption("readOnly") || editor.somethingSelected() ||
56
+ editor.listSelections().length !== 1) return null;
57
+
58
+ const to = editor.getCursor();
59
+ const line = editor.getLine(to.line);
60
+ if (/\w/.test(line.charAt(to.ch))) return null;
61
+
62
+ if (start && (start.line !== to.line || start.ch > to.ch)) return null;
63
+ // Keep the menu's original start so spaces in a fuzzy query are not lost.
64
+ const match = start
65
+ ? /^(;{0,2}[\w ]*)$/.exec(line.slice(start.ch, to.ch))
66
+ : /(?:^|[\s>])(;;[\w ]*|;?\w*)$/.exec(line.slice(0, to.ch));
67
+ if (!match || (requireSemicolon && !match[1].startsWith(";"))) return null;
68
+
69
+ const from = start || CodeMirror.Pos(to.line, to.ch - match[1].length);
70
+ const before = editor.getRange(CodeMirror.Pos(0, 0), from);
71
+ if (before.lastIndexOf("{{") > before.lastIndexOf("}}")) return null;
72
+
73
+ return {
74
+ from,
75
+ to,
76
+ selector: match[1].startsWith(";;"),
77
+ query: match[1].replace(/^;{1,2}/, "").toLowerCase().replace(/ /g, ""),
78
+ };
79
+ };
80
+
81
+ const insertCompletion = (editor, completion, from, to) => {
82
+ const indent = editor.getLine(from.line).match(/^\s*/)[0];
83
+ const text = completion.text.replace(/\n/g, `\n${indent}`);
84
+ const start = editor.indexFromPos(from);
85
+
86
+ editor.operation(() => {
87
+ // A distinct history origin keeps the expansion separate from preceding typing.
88
+ editor.replaceRange(text, from, to, "cms-completion");
89
+ if (completion.placeholder) {
90
+ const offset = text.indexOf(completion.placeholder);
91
+ editor.setSelection(
92
+ editor.posFromIndex(start + offset),
93
+ editor.posFromIndex(start + offset + completion.placeholder.length)
94
+ );
95
+ } else {
96
+ editor.setCursor(editor.posFromIndex(start + text.length));
97
+ }
98
+ });
99
+ };
100
+
101
+ const matchRank = (alias, query) => {
102
+ if (alias === query) return 0;
103
+ if (alias.startsWith(query)) return 1;
104
+ if (alias.includes(query)) return 2;
105
+
106
+ let position = 0;
107
+ for (const character of query) {
108
+ position = alias.indexOf(character, position);
109
+ if (position === -1) return Infinity;
110
+ position += 1;
111
+ }
112
+ return 3;
113
+ };
114
+
115
+ const displayNames = {
116
+ as: "asset (CSS)",
117
+ css: "asset (CSS)",
118
+ js: "asset (JavaScript)",
119
+ com: "comment (block)",
120
+ nt: "comment (note)",
121
+ };
122
+
123
+ const hints = (editor, start) => {
124
+ const context = completionContext(editor, false, start);
125
+ if (!context) return null;
126
+
127
+ return {
128
+ from: context.from,
129
+ to: context.to,
130
+ list: completions
131
+ .map((completion) => ({
132
+ completion,
133
+ rank: Math.min(...completion.aliases.map((alias) => matchRank(alias, context.query))),
134
+ }))
135
+ .filter(({ rank }) => Number.isFinite(rank))
136
+ .sort((a, b) => a.rank - b.rank)
137
+ .map(({ completion }) => ({
138
+ text: completion.text,
139
+ displayText: `;${completion.shortcut} ${displayNames[completion.shortcut] || completion.name}`,
140
+ hint(cm, data) {
141
+ insertCompletion(cm, completion, data.from, data.to);
142
+ },
143
+ })),
144
+ };
145
+ };
146
+
147
+ const showCompletions = (editor, context) => {
148
+ editor.showHint({
149
+ hint: (cm) => hints(cm, context.from),
150
+ completeSingle: false,
151
+ closeCharacters: /[\t\r\n()[\]{}:>,]/,
152
+ });
153
+ };
154
+
155
+ export const cmsTagCompletionKeys = (mode) => {
156
+ if (!["text/html", "htmlmixed", "text/x-markdown", "markdown"].includes(mode)) return {};
157
+
158
+ return {
159
+ Tab(editor) {
160
+ const context = completionContext(editor, true);
161
+ if (context?.selector) {
162
+ showCompletions(editor, context);
163
+ return;
164
+ }
165
+ const completion = context && byAlias.get(context.query);
166
+ if (!completion) return CodeMirror.Pass;
167
+
168
+ insertCompletion(editor, completion, context.from, context.to);
169
+ },
170
+ "Ctrl-Space"(editor) {
171
+ const context = completionContext(editor);
172
+ if (!context) return CodeMirror.Pass;
173
+
174
+ showCompletions(editor, context);
175
+ },
176
+ };
177
+ };
@@ -1,5 +1,6 @@
1
1
  import jQuery from "jquery";
2
2
  import CodeMirror from "codemirror";
3
+ import { cmsTagCompletionKeys } from "./cms_tag_completions";
3
4
  import "codemirror/mode/markdown/markdown";
4
5
  import "codemirror/mode/htmlmixed/htmlmixed";
5
6
 
@@ -12,6 +13,7 @@ import "codemirror/mode/htmlmixed/htmlmixed";
12
13
  )) {
13
14
  const codemirror = CodeMirror.fromTextArea(textarea, {
14
15
  mode: textarea.dataset.cmsCmMode,
16
+ extraKeys: cmsTagCompletionKeys(textarea.dataset.cmsCmMode),
15
17
  tabSize: 2,
16
18
  lineWrapping: true,
17
19
  autoCloseTags: true,
@@ -52,6 +54,7 @@ import "codemirror/mode/htmlmixed/htmlmixed";
52
54
  },
53
55
  dispose() {
54
56
  for (const codemirror of codeMirrorInstances) {
57
+ codemirror.closeHint();
55
58
  codemirror.toTextArea();
56
59
  }
57
60
  codeMirrorInstances.length = 0;
@@ -0,0 +1,73 @@
1
+ import $ from "jquery";
2
+ import Alert from "bootstrap/js/src/alert";
3
+
4
+ (() => {
5
+ const DELAY = 5000;
6
+ let cleanups = [];
7
+
8
+ const watch = (notice) => {
9
+ let remaining = DELAY;
10
+ let startedAt;
11
+ let timer = null;
12
+ let hovered = notice.matches(":hover");
13
+ let focused = notice.contains(document.activeElement);
14
+
15
+ const pause = () => {
16
+ if (timer === null) return;
17
+ window.clearTimeout(timer);
18
+ timer = null;
19
+ remaining = Math.max(0, remaining - (performance.now() - startedAt));
20
+ };
21
+
22
+ const resume = () => {
23
+ if (timer !== null || hovered || focused || !notice.isConnected) return;
24
+ startedAt = performance.now();
25
+ timer = window.setTimeout(() => {
26
+ timer = null;
27
+ cleanup();
28
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
29
+ notice.classList.remove("fade");
30
+ }
31
+ new Alert(notice).close();
32
+ }, remaining);
33
+ };
34
+
35
+ const onMouseEnter = () => { hovered = true; pause(); };
36
+ const onMouseLeave = () => { hovered = false; resume(); };
37
+ const onFocusIn = () => { focused = true; pause(); };
38
+ const onFocusOut = (event) => {
39
+ focused = notice.contains(event.relatedTarget);
40
+ if (!focused) resume();
41
+ };
42
+ const cleanup = () => {
43
+ pause();
44
+ notice.removeEventListener("mouseenter", onMouseEnter);
45
+ notice.removeEventListener("mouseleave", onMouseLeave);
46
+ notice.removeEventListener("focusin", onFocusIn);
47
+ notice.removeEventListener("focusout", onFocusOut);
48
+ $(notice).off("close.bs.alert", cleanup);
49
+ };
50
+
51
+ notice.addEventListener("mouseenter", onMouseEnter);
52
+ notice.addEventListener("mouseleave", onMouseLeave);
53
+ notice.addEventListener("focusin", onFocusIn);
54
+ notice.addEventListener("focusout", onFocusOut);
55
+ $(notice).on("close.bs.alert", cleanup);
56
+ cleanups.push(cleanup);
57
+ resume();
58
+ };
59
+
60
+ CMS.flashNotices = {
61
+ init() {
62
+ this.dispose();
63
+ document.querySelectorAll(
64
+ "[data-cms-flash].alert-success, [data-cms-flash].alert-info"
65
+ ).forEach(watch);
66
+ },
67
+
68
+ dispose() {
69
+ cleanups.forEach((cleanup) => cleanup());
70
+ cleanups = [];
71
+ },
72
+ };
73
+ })();
@@ -2,6 +2,7 @@
2
2
  @import "comfy/vendor/bootstrap/bootstrap"
3
3
 
4
4
  @import "codemirror/lib/codemirror"
5
+ @import "codemirror/addon/hint/show-hint"
5
6
  @import "comfy/vendor/fontawesome"
6
7
  @import "comfy/vendor/redactor"
7
8
  @import "flatpickr/dist/flatpickr.min"
@@ -12,6 +12,17 @@ body#comfy
12
12
  .CodeMirror-scroll
13
13
  min-height: 160px
14
14
 
15
+ .CodeMirror-hints
16
+ z-index: 1060
17
+ max-width: calc(100vw - 1rem)
18
+ border-color: $gray-400
19
+ background-color: $white
20
+ .CodeMirror-hint
21
+ color: $body-color
22
+ .CodeMirror-hint-active
23
+ background-color: $primary
24
+ color: $white
25
+
15
26
  .CodeMirror-focused
16
27
  border-color: rgba(82, 168, 236, 0.8)
17
28
  outline: 0
@@ -42,6 +42,36 @@ body#comfy
42
42
  #cms-main
43
43
  .page-header
44
44
  color: $harbor-navy
45
+ .page-editor-toolbar
46
+ position: sticky
47
+ top: 0
48
+ z-index: 1000
49
+ display: grid
50
+ grid-template-columns: max-content minmax(0, 1fr)
51
+ align-items: center
52
+ gap: .5rem 1rem
53
+ padding: .75rem 0
54
+ background-color: $body-bg
55
+ .page-editor-heading h2
56
+ margin-bottom: 0
57
+ .page-publication-state
58
+ grid-column: 1 / -1
59
+ .page-editor-actions
60
+ display: flex
61
+ flex-wrap: wrap
62
+ align-items: center
63
+ justify-content: flex-end
64
+ gap: .5rem
65
+ min-width: 0
66
+
67
+ @media (max-width: map-get($grid-breakpoints, lg))
68
+ top: 3rem
69
+ @include media-breakpoint-down(sm)
70
+ grid-template-columns: minmax(0, 1fr)
71
+ .page-editor-actions
72
+ justify-content: flex-start
73
+ .layout-actions .btn-group
74
+ max-width: 100%
45
75
  ul.list
46
76
  li .row
47
77
  @include transition(background-color .15s ease-in-out)
@@ -64,12 +64,28 @@ class Comfy::Admin::Cms::PagesController < Comfy::Admin::Cms::BaseController
64
64
  end
65
65
 
66
66
  def update
67
- @page.save!
68
- update_family
67
+ publication_action = params[:publication_action].presence_in(%w[publish unpublish])
68
+ original_publication = @page.is_published_in_database
69
+ @page.is_published = publication_action == 'publish' if publication_action
70
+
71
+ @page.class.transaction do
72
+ @page.save!
73
+ update_family
74
+ end
69
75
  acknowledge_draft_save
70
- flash[:success] = I18n.t('comfy.admin.cms.pages.updated')
76
+ flash[:success] = case publication_action
77
+ when 'publish'
78
+ I18n.t('comfy.admin.cms.pages.saved_and_published', default: 'Page saved and published')
79
+ when 'unpublish'
80
+ I18n.t('comfy.admin.cms.pages.saved_and_unpublished', default: 'Page saved and unpublished')
81
+ else
82
+ I18n.t('comfy.admin.cms.pages.updated')
83
+ end
71
84
  redirect_to action: :edit, id: @page
72
- rescue ActiveRecord::RecordInvalid
85
+ rescue ActiveRecord::RecordInvalid => e
86
+ # Keep submitted edits and errors, but show the publication state that was actually saved.
87
+ @page.is_published = original_publication if publication_action
88
+ @page.errors.add(:base, e.message) unless e.record == @page
73
89
  flash.now[:danger] = I18n.t('comfy.admin.cms.pages.update_failure')
74
90
  render action: :edit
75
91
  end
@@ -3,7 +3,7 @@
3
3
 
4
4
  %li{data: {id: layout.id}}
5
5
  .row
6
- .col-md-8.item
6
+ .col-12.col-md.item
7
7
  .item-controls.d-none.d-md-block
8
8
  - if has_siblings
9
9
  .dragger
@@ -15,7 +15,7 @@
15
15
  .item-meta
16
16
  = layout.identifier
17
17
 
18
- .col-md-4.d-flex.align-items-center.justify-content-md-end
18
+ .col-12.col-md-auto.layout-actions.d-flex.align-items-center.justify-content-md-end
19
19
  .btn-group.btn-group-sm
20
20
  = link_to t('.add_child_layout'), new_comfy_admin_cms_site_layout_path(@site, parent_id: layout.id), class: "btn btn-outline-secondary"
21
21
  = link_to t('.edit'), edit_comfy_admin_cms_site_layout_path(@site, layout), class: "btn btn-outline-secondary"
@@ -22,11 +22,13 @@
22
22
  - if (options = ::Comfy::Cms::Page.options_for_select(site: @site, current_page: @page)).present?
23
23
  = form.select :target_page_id, [["---- #{t('.select_target_page')} ----", nil]] + options, bootstrap: {custom_control: true}
24
24
 
25
- = form.check_box :is_published
25
+ - if @page.new_record?
26
+ = form.check_box :is_published
26
27
 
27
28
  = comfy_admin_partial "comfy/admin/cms/partials/page_form_after", form: form
28
29
 
29
- = form.form_actions do
30
- = submit_tag t(".preview"), name: "preview", formtarget: "comfy-cms-preview", id: nil, class: "btn btn-secondary"
31
- = submit_tag t(@page.new_record?? ".create" : ".update"), class: "btn btn-primary ml-sm-1"
32
- = link_to t(".cancel"), comfy_admin_cms_site_pages_path, class: "btn btn-link"
30
+ - if @page.new_record?
31
+ = form.form_actions do
32
+ = submit_tag t(".preview"), name: "preview", formtarget: "comfy-cms-preview", id: nil, class: "btn btn-secondary"
33
+ = submit_tag t(".create"), class: "btn btn-primary ml-sm-1"
34
+ = link_to t(".cancel"), comfy_admin_cms_site_pages_path, class: "btn btn-link"
@@ -3,32 +3,50 @@
3
3
  = render "comfy/admin/cms/revisions/sidebar", record: @page, link: link
4
4
  = render "comfy/admin/cms/translations/sidebar"
5
5
 
6
- .page-header.d-flex.justify-content-between.align-items-center
7
- %h2= t('.title')
8
- - if @page.children.exists?
9
- - publication_states = descendant_publication_states(@page)
10
- .dropdown
11
- %button#child-pages-menu.btn.btn-sm.btn-outline-secondary.dropdown-toggle.text-nowrap{type: 'button', data: {toggle: 'dropdown'}, aria: {haspopup: 'true', expanded: 'false', controls: 'child-pages-actions'}}
12
- = t('.child_pages', default: 'Child pages')
13
- #child-pages-actions.dropdown-menu.dropdown-menu-right{aria: {labelledby: 'child-pages-menu'}}
14
- - if publication_states.include?(false)
15
- = link_to t('.publish_descendants', default: 'Publish descendants'), publish_children_comfy_admin_cms_site_page_path(@site, @page), method: :patch, data: {confirm: t('.publish_descendants_confirmation', default: 'Publish all descendant pages?')}, class: 'dropdown-item'
16
- - else
17
- %button.dropdown-item.disabled{type: 'button', disabled: true, aria: {disabled: 'true'}}= t('.publish_descendants', default: 'Publish descendants')
18
- - if publication_states.include?(true)
19
- = link_to t('.unpublish_descendants', default: 'Unpublish descendants'), unpublish_children_comfy_admin_cms_site_page_path(@site, @page), method: :patch, data: {confirm: t('.unpublish_descendants_confirmation', default: 'Unpublish all descendant pages?')}, class: 'dropdown-item'
20
- - else
21
- %button.dropdown-item.disabled{type: 'button', disabled: true, aria: {disabled: 'true'}}= t('.unpublish_descendants', default: 'Unpublish descendants')
22
-
23
- - unless @page.published_with_ancestors?
24
- .alert.alert-info.publication-status{role: 'status'}
25
- - if @page.is_published?
26
- = t('.hidden_by_ancestor', default: 'Hidden by unpublished ancestor. Publish its ancestors to make this page visible.')
27
- - else
28
- = t('.unpublished_notice', default: 'This page is unpublished.')
29
- = t('.branch_visibility_notice', default: 'Descendants are visible only when they and all their ancestors are published. Their publication settings are preserved when an ancestor is unpublished.')
30
-
31
6
  - draft_data = cms_draft_form_data('site', @site.id, 'page', @page.id, record: @page)
32
7
  = comfy_form_with model: @page, scope: :page, url: {action: :update}, html: {multipart: true, data: draft_data} do |form|
33
8
  = hidden_field_tag :cms_draft_key, draft_data[:cms_draft_key]
9
+ .page-header.page-editor-toolbar
10
+ .page-editor-heading
11
+ %h2= t('.title')
12
+
13
+ .page-editor-actions
14
+ = submit_tag t('comfy.admin.cms.pages.form.preview'), name: 'preview', formtarget: 'comfy-cms-preview', id: nil, class: 'btn btn-sm btn-outline-secondary text-nowrap', data: {disable_with: false}
15
+ = submit_tag t('comfy.admin.cms.pages.form.update'), class: 'btn btn-sm btn-primary text-nowrap', data: {disable_with: false}
16
+ - publication_action = @page.is_published? ? 'unpublish' : 'publish'
17
+ - publication_label = @page.is_published? ? t('.unpublish', default: 'Unpublish') : t('.publish', default: 'Publish')
18
+ - publication_hint = @page.is_published? ? t('.save_and_unpublish_hint', default: 'Save and unpublish page') : t('.save_and_publish_hint', default: 'Save and publish page')
19
+ = button_tag publication_label, name: 'publication_action', value: publication_action, class: 'btn btn-sm btn-outline-secondary text-nowrap', title: publication_hint, aria: {describedby: 'page-publication-hint'}
20
+ - if @page.children.exists?
21
+ - publication_states = descendant_publication_states(@page)
22
+ .dropdown
23
+ %button#child-pages-menu.btn.btn-sm.btn-outline-secondary.dropdown-toggle.text-nowrap{type: 'button', data: {toggle: 'dropdown'}, aria: {haspopup: 'true', expanded: 'false', controls: 'child-pages-actions'}}
24
+ = t('.child_pages', default: 'Child pages')
25
+ #child-pages-actions.dropdown-menu.dropdown-menu-right{aria: {labelledby: 'child-pages-menu'}}
26
+ - if publication_states.include?(false)
27
+ = link_to t('.publish_descendants', default: 'Publish descendants'), publish_children_comfy_admin_cms_site_page_path(@site, @page), method: :patch, data: {confirm: t('.publish_descendants_confirmation', default: 'Publish all descendant pages?')}, class: 'dropdown-item'
28
+ - else
29
+ %button.dropdown-item.disabled{type: 'button', disabled: true, aria: {disabled: 'true'}}= t('.publish_descendants', default: 'Publish descendants')
30
+ - if publication_states.include?(true)
31
+ = link_to t('.unpublish_descendants', default: 'Unpublish descendants'), unpublish_children_comfy_admin_cms_site_page_path(@site, @page), method: :patch, data: {confirm: t('.unpublish_descendants_confirmation', default: 'Unpublish all descendant pages?')}, class: 'dropdown-item'
32
+ - else
33
+ %button.dropdown-item.disabled{type: 'button', disabled: true, aria: {disabled: 'true'}}= t('.unpublish_descendants', default: 'Unpublish descendants')
34
+ .dropdown-divider
35
+ .px-4.py-1.small.text-muted= t('.descendants_hint', default: 'Changes descendants only; does not save this page’s edits.')
36
+ = link_to t('comfy.admin.cms.pages.form.cancel'), comfy_admin_cms_site_pages_path(@site), class: 'btn btn-sm btn-link text-nowrap'
37
+ .page-publication-state.small.text-muted{role: 'status'}
38
+ - if !@page.is_published?
39
+ = t('.unpublished_notice', default: 'Unpublished.')
40
+ = t('.branch_visibility_notice', default: 'Descendants hidden, with their publication settings unchanged.')
41
+ - elsif !@page.published_with_ancestors?
42
+ = t('.hidden_status', default: 'Hidden by unpublished ancestor')
43
+ - else
44
+ = t('.published', default: 'Published')
45
+ %span#page-publication-hint{hidden: true}= publication_hint
46
+
47
+ - if @page.is_published? && !@page.published_with_ancestors?
48
+ .alert.alert-info.publication-status{role: 'status'}
49
+ = t('.hidden_by_ancestor', default: 'Hidden by unpublished ancestor.')
50
+ = t('.branch_visibility_notice', default: 'Descendants hidden, with their publication settings unchanged.')
51
+
34
52
  = render form
@@ -1,4 +1,4 @@
1
1
  - flash.to_hash.except('cms_saved_draft_key').each do |type, message|
2
- .alert{:class => "alert-#{type}"}
2
+ .alert.fade.show{class: "alert-#{type}", data: {cms_flash: true}, role: %w[success info].include?(type) ? 'status' : 'alert'}
3
3
  %button.close{:type => 'button', :class => "close", :data => {:dismiss => 'alert'}} &times;
4
4
  = message
@@ -123,7 +123,7 @@ en:
123
123
  pages:
124
124
  created: Page created, siblings, and parent updated
125
125
  creation_failure: Failed to create page
126
- updated: Page, siblings, and parent updated
126
+ updated: Page updated
127
127
  update_failure: Failed to update page
128
128
  children_published: Descendant pages published
129
129
  children_unpublished: Descendant pages unpublished
@@ -22,6 +22,9 @@ class ComfyMiddleSeat::Content::Renderer
22
22
 
23
23
  # tags are in this format: {{ cms:tag_class params }}
24
24
  TAG_REGEX = %r{\{\{\s*?cms:(?<class>\w+)(?<params>.*?)\}\}}
25
+ COMMENT_START_REGEX = %r{\{\{\s*cms:(?<comment>comment|endcomment)(?=\s|\}\}|\z)}
26
+ TOKEN_REGEX = %r{#{COMMENT_START_REGEX}|#{TAG_REGEX}}
27
+ COMMENT_NOTE_REGEX = %r{(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|“(?:\\.|[^”\\])*”|‘(?:\\.|[^’\\])*’)\s*\}\}}m
25
28
 
26
29
  class << self
27
30
  # @return [Hash<String, Class<ComfyMiddleSeat::Content::Tag>>]
@@ -73,23 +76,35 @@ class ComfyMiddleSeat::Content::Renderer
73
76
 
74
77
  # Splitting text with tags into tokens we can process down the line
75
78
  # @return [Array<String, {Symbol => String}>]
76
- def tokenize(string)
79
+ def tokenize(string, preserve_whitespace: false)
77
80
  tokens = []
78
81
  ss = StringScanner.new(string.to_s)
79
- while (string = ss.scan_until(TAG_REGEX))
82
+ while (string = ss.scan_until(TOKEN_REGEX))
80
83
  text = string.sub(ss[0], '')
81
84
  tokens << text unless text.empty?
82
- tokens << {
83
- tag_class: ss[:class],
84
- tag_params: ss[:params].strip,
85
- source: ss[0]
86
- }
85
+ case ss[:comment]
86
+ when 'comment'
87
+ skip_comment(ss)
88
+ when 'endcomment'
89
+ raise SyntaxError, 'Unexpected {{cms:endcomment}} without {{cms:comment}}'
90
+ else
91
+ tokens << {
92
+ tag_class: ss[:class],
93
+ tag_params: ss[:params].strip,
94
+ source: ss[0]
95
+ }
96
+ end
87
97
  end
88
98
  text = ss.rest
89
- tokens << text if text.present?
99
+ tokens << text if text.present? || (preserve_whitespace && !text.empty?)
90
100
  tokens
91
101
  end
92
102
 
103
+ # Remove comments before formatters such as Markdown can transform their contents.
104
+ def strip_comments(string)
105
+ tokenize(string, preserve_whitespace: true).map { |token| token.is_a?(Hash) ? token[:source] : token }.join
106
+ end
107
+
93
108
  # Constructing node tree for content. It's a list of strings and tags with
94
109
  # their own `nodes` method that has array of strings and tags with their own
95
110
  # `nodes` method that... you get the idea.
@@ -143,4 +158,34 @@ class ComfyMiddleSeat::Content::Renderer
143
158
 
144
159
  nodes.flatten
145
160
  end
161
+
162
+ private
163
+
164
+ # Comments are lexical: their contents must never become tag instances or form fields.
165
+ def skip_comment(scanner)
166
+ return unless comment_block?(scanner)
167
+
168
+ depth = 1
169
+ while scanner.scan_until(COMMENT_START_REGEX)
170
+ if scanner[:comment] == 'comment'
171
+ depth += 1 if comment_block?(scanner)
172
+ else
173
+ unless scanner.skip(%r{\s*\}\}})
174
+ raise SyntaxError, 'Invalid CMS comment closing tag: use {{cms:endcomment}} without parameters'
175
+ end
176
+
177
+ depth -= 1
178
+ return if depth.zero?
179
+ end
180
+ end
181
+ raise SyntaxError, 'Unclosed CMS comment: expected {{cms:endcomment}}'
182
+ end
183
+
184
+ def comment_block?(scanner)
185
+ scanner.skip(%r{\s*})
186
+ return true if scanner.skip(%r{\}\}})
187
+ return false if scanner.skip(COMMENT_NOTE_REGEX)
188
+
189
+ raise SyntaxError, 'Invalid CMS comment: use {{cms:comment "note"}} or {{cms:comment}}...{{cms:endcomment}}'
190
+ end
146
191
  end
@@ -5,7 +5,10 @@
5
5
  #
6
6
  class ComfyMiddleSeat::Content::Tags::Markdown < ComfyMiddleSeat::Content::Tags::Fragment
7
7
  def render
8
- renderable ? Kramdown::Document.new(content.to_s).to_html : ''
8
+ return '' unless renderable
9
+
10
+ source = ComfyMiddleSeat::Content::Renderer.new(context).strip_comments(content)
11
+ Kramdown::Document.new(source).to_html
9
12
  end
10
13
 
11
14
  def form_field(object_name, view, index)
@@ -18,7 +18,8 @@ class ComfyMiddleSeat::Content::Tags::Snippet < ComfyMiddleSeat::Content::Tag
18
18
 
19
19
  def content
20
20
  if snippet.markdown
21
- Kramdown::Document.new(snippet.content.to_s).to_html
21
+ source = ComfyMiddleSeat::Content::Renderer.new(context).strip_comments(snippet.content)
22
+ Kramdown::Document.new(source).to_html
22
23
  else
23
24
  snippet.content
24
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ComfyMiddleSeat
4
- VERSION = '4.1.0'
4
+ VERSION = '4.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: comfy_middle_seat
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleg Khabarov
@@ -230,6 +230,7 @@ files:
230
230
  - app/assets/javascripts/comfy/admin/cms/application.js
231
231
  - app/assets/javascripts/comfy/admin/cms/base.js
232
232
  - app/assets/javascripts/comfy/admin/cms/categories.js
233
+ - app/assets/javascripts/comfy/admin/cms/cms_tag_completions.js
233
234
  - app/assets/javascripts/comfy/admin/cms/codemirror.js
234
235
  - app/assets/javascripts/comfy/admin/cms/custom.js
235
236
  - app/assets/javascripts/comfy/admin/cms/diff.js
@@ -237,6 +238,7 @@ files:
237
238
  - app/assets/javascripts/comfy/admin/cms/file_link.js
238
239
  - app/assets/javascripts/comfy/admin/cms/file_upload.js
239
240
  - app/assets/javascripts/comfy/admin/cms/files_modal.js
241
+ - app/assets/javascripts/comfy/admin/cms/flash_notices.js
240
242
  - app/assets/javascripts/comfy/admin/cms/page_fragments.js
241
243
  - app/assets/javascripts/comfy/admin/cms/slugify.js
242
244
  - app/assets/javascripts/comfy/admin/cms/sortable_list.js