domma-cms 0.6.13 → 0.6.15
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.
- package/admin/css/admin.css +1 -1
- package/admin/js/templates/pages.html +5 -1
- package/admin/js/views/pages.js +2 -2
- package/config/plugins.json +10 -0
- package/config/site.json +2 -2
- package/package.json +2 -2
- package/plugins/analytics/stats.json +3 -3
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +45 -0
- package/plugins/example-analytics/public/inject-body.html +14 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +24 -0
- package/plugins/form-builder/data/forms/contacts.json +66 -0
- package/plugins/form-builder/data/forms/enquiries.json +103 -0
- package/plugins/form-builder/data/forms/feedback.json +131 -0
- package/plugins/form-builder/data/forms/notes.json +79 -0
- package/plugins/form-builder/data/forms/to-do.json +100 -0
- package/plugins/form-builder/data/submissions/contacts.json +1 -0
- package/plugins/form-builder/data/submissions/enquiries.json +1 -0
- package/plugins/form-builder/data/submissions/feedback.json +1 -0
- package/plugins/form-builder/data/submissions/notes.json +1 -0
- package/plugins/form-builder/data/submissions/to-do.json +1 -0
- package/plugins/site-search/admin/templates/site-search.html +67 -0
- package/plugins/site-search/admin/views/site-search.js +45 -0
- package/plugins/site-search/config.js +10 -0
- package/plugins/site-search/plugin.js +188 -0
- package/plugins/site-search/plugin.json +37 -0
- package/plugins/site-search/public/inject-body.html +17 -0
- package/plugins/site-search/public/inject-head.html +1 -0
- package/plugins/site-search/public/search.css +1 -0
- package/plugins/site-search/public/search.js +1 -0
- package/public/css/site.css +1 -1
- package/server/server.js +1 -0
- package/server/services/markdown.js +35 -6
- package/server/services/userTypes.js +227 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "feedback",
|
|
3
|
+
"title": "Feedback",
|
|
4
|
+
"description": "Share your feedback with us",
|
|
5
|
+
"fields": [
|
|
6
|
+
{
|
|
7
|
+
"name": "name",
|
|
8
|
+
"type": "string",
|
|
9
|
+
"label": "Your Name",
|
|
10
|
+
"required": true,
|
|
11
|
+
"placeholder": "Please enter your full name",
|
|
12
|
+
"helper": "Please enter your full name",
|
|
13
|
+
"validation": {
|
|
14
|
+
"min": 2,
|
|
15
|
+
"max": 100
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "email",
|
|
20
|
+
"type": "email",
|
|
21
|
+
"label": "Email Address",
|
|
22
|
+
"required": true,
|
|
23
|
+
"placeholder": "your@email.com",
|
|
24
|
+
"helper": "Please enter your email address"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "rating",
|
|
28
|
+
"type": "select",
|
|
29
|
+
"label": "Overall Rating",
|
|
30
|
+
"required": true,
|
|
31
|
+
"helper": "Tell us how we are doing!",
|
|
32
|
+
"options": [
|
|
33
|
+
{
|
|
34
|
+
"value": "none",
|
|
35
|
+
"label": "Please Choose"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"value": "excellent",
|
|
39
|
+
"label": "Excellent"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"value": "good",
|
|
43
|
+
"label": "Good"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"value": "average",
|
|
47
|
+
"label": "Average"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"value": "poor",
|
|
51
|
+
"label": "Poor"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "category",
|
|
57
|
+
"type": "select",
|
|
58
|
+
"label": "Category",
|
|
59
|
+
"required": true,
|
|
60
|
+
"placeholder": "Please select a category",
|
|
61
|
+
"helper": "",
|
|
62
|
+
"options": [
|
|
63
|
+
{
|
|
64
|
+
"value": "general",
|
|
65
|
+
"label": "General"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"value": "bug-report",
|
|
69
|
+
"label": "Bug Report"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"value": "feature-request",
|
|
73
|
+
"label": "Feature Request"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"value": "praise",
|
|
77
|
+
"label": "Praise"
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "subject",
|
|
83
|
+
"type": "string",
|
|
84
|
+
"label": "Subject",
|
|
85
|
+
"required": true,
|
|
86
|
+
"placeholder": "Brief summary of your feedback",
|
|
87
|
+
"helper": "",
|
|
88
|
+
"validation": {
|
|
89
|
+
"max": 200
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "message",
|
|
94
|
+
"type": "textarea",
|
|
95
|
+
"label": "Your Feedback",
|
|
96
|
+
"required": true,
|
|
97
|
+
"placeholder": "Please share your thoughts in detail…",
|
|
98
|
+
"helper": "Please share your thoughts in detail…",
|
|
99
|
+
"rows": 4,
|
|
100
|
+
"validation": {
|
|
101
|
+
"min": 10,
|
|
102
|
+
"max": 2000
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
"settings": {
|
|
107
|
+
"submitText": "Submit Feedback",
|
|
108
|
+
"successMessage": "Thank you for your feedback! We appreciate you taking the time.",
|
|
109
|
+
"layout": "stacked",
|
|
110
|
+
"honeypot": true,
|
|
111
|
+
"rateLimitPerMinute": 3
|
|
112
|
+
},
|
|
113
|
+
"actions": {
|
|
114
|
+
"email": {
|
|
115
|
+
"enabled": true,
|
|
116
|
+
"recipients": "",
|
|
117
|
+
"subjectPrefix": "[feedback]"
|
|
118
|
+
},
|
|
119
|
+
"webhook": {
|
|
120
|
+
"enabled": false,
|
|
121
|
+
"url": "",
|
|
122
|
+
"method": "POST"
|
|
123
|
+
},
|
|
124
|
+
"collection": {
|
|
125
|
+
"enabled": true,
|
|
126
|
+
"slug": "feedback"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"createdAt": "2026-03-17T12:35:44.569Z",
|
|
130
|
+
"updatedAt": "2026-03-17T12:35:44.569Z"
|
|
131
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "notes",
|
|
3
|
+
"title": "Notes",
|
|
4
|
+
"description": "Free-form notes with categories and tags.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{
|
|
7
|
+
"name": "title",
|
|
8
|
+
"type": "string",
|
|
9
|
+
"label": "Title",
|
|
10
|
+
"required": true,
|
|
11
|
+
"placeholder": "Note title"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "content",
|
|
15
|
+
"type": "textarea",
|
|
16
|
+
"label": "Content",
|
|
17
|
+
"required": true,
|
|
18
|
+
"placeholder": "Write your note here…",
|
|
19
|
+
"rows": 5
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "category",
|
|
23
|
+
"type": "select",
|
|
24
|
+
"label": "Category",
|
|
25
|
+
"required": false,
|
|
26
|
+
"options": [
|
|
27
|
+
{
|
|
28
|
+
"value": "general",
|
|
29
|
+
"label": "General"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"value": "idea",
|
|
33
|
+
"label": "Idea"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"value": "reminder",
|
|
37
|
+
"label": "Reminder"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"value": "reference",
|
|
41
|
+
"label": "Reference"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "tags",
|
|
47
|
+
"type": "string",
|
|
48
|
+
"label": "Tags",
|
|
49
|
+
"required": false,
|
|
50
|
+
"placeholder": "Comma-separated tags",
|
|
51
|
+
"helper": "Separate tags with commas"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"settings": {
|
|
55
|
+
"submitText": "Save Note",
|
|
56
|
+
"successMessage": "Note saved successfully.",
|
|
57
|
+
"layout": "stacked",
|
|
58
|
+
"honeypot": true,
|
|
59
|
+
"rateLimitPerMinute": 3
|
|
60
|
+
},
|
|
61
|
+
"actions": {
|
|
62
|
+
"email": {
|
|
63
|
+
"enabled": false,
|
|
64
|
+
"recipients": "",
|
|
65
|
+
"subjectPrefix": "[notes]"
|
|
66
|
+
},
|
|
67
|
+
"webhook": {
|
|
68
|
+
"enabled": false,
|
|
69
|
+
"url": "",
|
|
70
|
+
"method": "POST"
|
|
71
|
+
},
|
|
72
|
+
"collection": {
|
|
73
|
+
"enabled": true,
|
|
74
|
+
"slug": "notes"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"createdAt": "2026-03-17T12:35:44.569Z",
|
|
78
|
+
"updatedAt": "2026-03-17T12:35:44.569Z"
|
|
79
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "to-do",
|
|
3
|
+
"title": "To-Do",
|
|
4
|
+
"description": "Task tracking with status, priority, and due dates.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{
|
|
7
|
+
"name": "title",
|
|
8
|
+
"type": "string",
|
|
9
|
+
"label": "Title",
|
|
10
|
+
"required": true,
|
|
11
|
+
"placeholder": "Task title"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "description",
|
|
15
|
+
"type": "textarea",
|
|
16
|
+
"label": "Description",
|
|
17
|
+
"required": false,
|
|
18
|
+
"placeholder": "Task details…",
|
|
19
|
+
"rows": 3
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "status",
|
|
23
|
+
"type": "select",
|
|
24
|
+
"label": "Status",
|
|
25
|
+
"required": true,
|
|
26
|
+
"options": [
|
|
27
|
+
{
|
|
28
|
+
"value": "pending",
|
|
29
|
+
"label": "Pending"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"value": "in-progress",
|
|
33
|
+
"label": "In Progress"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"value": "done",
|
|
37
|
+
"label": "Done"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "priority",
|
|
43
|
+
"type": "select",
|
|
44
|
+
"label": "Priority",
|
|
45
|
+
"required": false,
|
|
46
|
+
"options": [
|
|
47
|
+
{
|
|
48
|
+
"value": "low",
|
|
49
|
+
"label": "Low"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"value": "medium",
|
|
53
|
+
"label": "Medium"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"value": "high",
|
|
57
|
+
"label": "High"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "due_date",
|
|
63
|
+
"type": "date",
|
|
64
|
+
"label": "Due Date",
|
|
65
|
+
"required": false
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "assigned_to",
|
|
69
|
+
"type": "string",
|
|
70
|
+
"label": "Assigned To",
|
|
71
|
+
"required": false,
|
|
72
|
+
"placeholder": "Name or email"
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"settings": {
|
|
76
|
+
"submitText": "Add Task",
|
|
77
|
+
"successMessage": "Task added successfully.",
|
|
78
|
+
"layout": "stacked",
|
|
79
|
+
"honeypot": true,
|
|
80
|
+
"rateLimitPerMinute": 3
|
|
81
|
+
},
|
|
82
|
+
"actions": {
|
|
83
|
+
"email": {
|
|
84
|
+
"enabled": false,
|
|
85
|
+
"recipients": "",
|
|
86
|
+
"subjectPrefix": "[to-do]"
|
|
87
|
+
},
|
|
88
|
+
"webhook": {
|
|
89
|
+
"enabled": false,
|
|
90
|
+
"url": "",
|
|
91
|
+
"method": "POST"
|
|
92
|
+
},
|
|
93
|
+
"collection": {
|
|
94
|
+
"enabled": true,
|
|
95
|
+
"slug": "to-do"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"createdAt": "2026-03-17T12:35:44.569Z",
|
|
99
|
+
"updatedAt": "2026-03-17T12:35:44.569Z"
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<div class="view-header">
|
|
2
|
+
<h1><span data-icon="search"></span> Site Search</h1>
|
|
3
|
+
<div>
|
|
4
|
+
<button id="save-settings-btn" class="btn btn-primary">
|
|
5
|
+
<span data-icon="save"></span> Save
|
|
6
|
+
</button>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="card mb-4">
|
|
11
|
+
<div class="card-header"><h2>Settings</h2></div>
|
|
12
|
+
<div class="card-body">
|
|
13
|
+
|
|
14
|
+
<div class="row mb-3">
|
|
15
|
+
<div class="col">
|
|
16
|
+
<label class="form-label">Search placeholder text</label>
|
|
17
|
+
<input id="field-placeholder" type="text" class="form-input" placeholder="Search pages...">
|
|
18
|
+
<span class="form-hint">Displayed inside the search input when empty.</span>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="row mb-3">
|
|
23
|
+
<div class="col">
|
|
24
|
+
<label class="form-check-label">
|
|
25
|
+
<input id="field-keyboard-shortcut" type="checkbox">
|
|
26
|
+
Enable keyboard shortcut (Ctrl+K / ⌘K)
|
|
27
|
+
</label>
|
|
28
|
+
<span class="form-hint">Allows visitors to open the search overlay using a keyboard shortcut.</span>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="row mb-3">
|
|
33
|
+
<div class="col-6">
|
|
34
|
+
<label class="form-label">Max results</label>
|
|
35
|
+
<input id="field-max-results" type="number" class="form-input" min="1" max="50" placeholder="10">
|
|
36
|
+
<span class="form-hint">Maximum number of results to return per search (1–50).</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="col-6">
|
|
39
|
+
<label class="form-label">Minimum query length</label>
|
|
40
|
+
<input id="field-min-query-length" type="number" class="form-input" min="1" max="5" placeholder="2">
|
|
41
|
+
<span class="form-hint">Minimum characters required before searching (1–5).</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="row">
|
|
46
|
+
<div class="col-6">
|
|
47
|
+
<label class="form-label">Debounce delay (ms)</label>
|
|
48
|
+
<input id="field-debounce-ms" type="number" class="form-input" min="100" max="1000" placeholder="300">
|
|
49
|
+
<span class="form-hint">Delay after typing stops before search fires (100–1000ms).</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="card mb-4">
|
|
57
|
+
<div class="card-header"><h2>How it works</h2></div>
|
|
58
|
+
<div class="card-body">
|
|
59
|
+
<p class="text-muted mb-2">Site Search adds a search icon to the public navbar. Visitors can click it or use
|
|
60
|
+
the keyboard shortcut to open a full-screen overlay with live results.</p>
|
|
61
|
+
<ul class="text-muted" style="padding-left:1.25rem;line-height:1.8;">
|
|
62
|
+
<li>Results are weighted: title matches score highest, followed by tags, description, and page content.</li>
|
|
63
|
+
<li>Draft pages and private pages are excluded from results.</li>
|
|
64
|
+
<li>Use <kbd>↑</kbd> <kbd>↓</kbd> to navigate results, <kbd>↵</kbd> to open, <kbd>Esc</kbd> to close.</li>
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Search Plugin — Admin Settings View
|
|
3
|
+
*/
|
|
4
|
+
import {apiRequest} from '/admin/js/api.js';
|
|
5
|
+
|
|
6
|
+
export const siteSearchView = {
|
|
7
|
+
templateUrl: '/plugins/site-search/admin/templates/site-search.html',
|
|
8
|
+
|
|
9
|
+
async onMount($container) {
|
|
10
|
+
let settings = {};
|
|
11
|
+
try {
|
|
12
|
+
settings = await apiRequest('/plugins/site-search/settings');
|
|
13
|
+
} catch {
|
|
14
|
+
E.toast('Could not load settings.', {type: 'error'});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
$container.find('#field-placeholder').val(settings.placeholder || 'Search pages...');
|
|
18
|
+
$container.find('#field-keyboard-shortcut').prop('checked', settings.keyboardShortcut !== false);
|
|
19
|
+
$container.find('#field-max-results').val(settings.maxResults ?? 10);
|
|
20
|
+
$container.find('#field-min-query-length').val(settings.minQueryLength ?? 2);
|
|
21
|
+
$container.find('#field-debounce-ms').val(settings.debounceMs ?? 300);
|
|
22
|
+
|
|
23
|
+
$container.find('#save-settings-btn').off('click').on('click', async () => {
|
|
24
|
+
const data = {
|
|
25
|
+
placeholder: $container.find('#field-placeholder').val() || 'Search pages...',
|
|
26
|
+
keyboardShortcut: $container.find('#field-keyboard-shortcut').prop('checked'),
|
|
27
|
+
maxResults: parseInt($container.find('#field-max-results').val(), 10) || 10,
|
|
28
|
+
minQueryLength: parseInt($container.find('#field-min-query-length').val(), 10) || 2,
|
|
29
|
+
debounceMs: parseInt($container.find('#field-debounce-ms').val(), 10) || 300
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await apiRequest('/plugins/site-search/settings', {
|
|
34
|
+
method: 'PUT',
|
|
35
|
+
body: JSON.stringify(data)
|
|
36
|
+
});
|
|
37
|
+
E.toast('Settings saved.', {type: 'success'});
|
|
38
|
+
} catch {
|
|
39
|
+
E.toast('Failed to save settings.', {type: 'error'});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
Domma.icons.scan();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Search Plugin — Server
|
|
3
|
+
*
|
|
4
|
+
* Endpoints (prefix: /api/plugins/site-search):
|
|
5
|
+
* GET /search?q=term — public: full-text search across all published pages
|
|
6
|
+
* GET /settings — public: return plugin settings (used by inject script)
|
|
7
|
+
* PUT /settings — admin-auth: save user overrides
|
|
8
|
+
*/
|
|
9
|
+
import {listPages} from '../../server/services/content.js';
|
|
10
|
+
import {getPluginSettings, savePluginState} from '../../server/services/plugins.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip markdown syntax and shortcodes from content for plain-text searching.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} content
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function stripMarkdown(content) {
|
|
19
|
+
if (!content) return '';
|
|
20
|
+
return content
|
|
21
|
+
// Remove shortcodes (self-closing and wrapping)
|
|
22
|
+
.replace(/\[[a-z][^\]]*\/\]/gi, '')
|
|
23
|
+
.replace(/\[[a-z][^\]]*\][\s\S]*?\[\/[a-z]+\]/gi, '')
|
|
24
|
+
.replace(/\[[a-z][^\]]*\]/gi, '')
|
|
25
|
+
// Remove HTML tags
|
|
26
|
+
.replace(/<[^>]+>/g, '')
|
|
27
|
+
// Remove headings (keep text)
|
|
28
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
29
|
+
// Remove bold / italic
|
|
30
|
+
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
|
|
31
|
+
.replace(/_{1,3}([^_]+)_{1,3}/g, '$1')
|
|
32
|
+
// Remove images
|
|
33
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
34
|
+
// Remove links (keep text)
|
|
35
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
36
|
+
// Remove code blocks
|
|
37
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
38
|
+
.replace(/`[^`]+`/g, '')
|
|
39
|
+
// Remove blockquotes marker
|
|
40
|
+
.replace(/^>\s+/gm, '')
|
|
41
|
+
// Collapse whitespace
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a snippet of ~length chars around the first occurrence of term in content.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} rawContent - raw markdown content
|
|
50
|
+
* @param {string} term
|
|
51
|
+
* @param {number} length
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function buildSnippet(rawContent, term, length = 120) {
|
|
55
|
+
const text = stripMarkdown(rawContent);
|
|
56
|
+
if (!text) return '';
|
|
57
|
+
|
|
58
|
+
const lower = text.toLowerCase();
|
|
59
|
+
const idx = lower.indexOf(term.toLowerCase());
|
|
60
|
+
|
|
61
|
+
if (idx === -1) {
|
|
62
|
+
return text.length > length ? text.slice(0, length) + '…' : text;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const half = Math.floor(length / 2);
|
|
66
|
+
const start = Math.max(0, idx - half);
|
|
67
|
+
const end = Math.min(text.length, start + length);
|
|
68
|
+
const snippet = text.slice(start, end);
|
|
69
|
+
|
|
70
|
+
return (start > 0 ? '…' : '') + snippet + (end < text.length ? '…' : '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Score a page against a search query using weighted field matching.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} page
|
|
77
|
+
* @param {string} query
|
|
78
|
+
* @param {string[]} words - query split into words
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
function scorePage(page, query, words) {
|
|
82
|
+
let score = 0;
|
|
83
|
+
const title = (page.title || '').toLowerCase();
|
|
84
|
+
const description = (page.description || '').toLowerCase();
|
|
85
|
+
const tagsRaw = Array.isArray(page.tags) ? page.tags : [];
|
|
86
|
+
const tags = tagsRaw.map(t => t.toLowerCase());
|
|
87
|
+
const stripped = stripMarkdown(page.content || '').toLowerCase();
|
|
88
|
+
const q = query.toLowerCase();
|
|
89
|
+
|
|
90
|
+
// Title scoring
|
|
91
|
+
if (title === q) {
|
|
92
|
+
score += 100;
|
|
93
|
+
} else if (title.includes(q)) {
|
|
94
|
+
score += 60;
|
|
95
|
+
} else if (words.every(w => title.includes(w))) {
|
|
96
|
+
score += 40;
|
|
97
|
+
} else if (words.some(w => title.includes(w))) {
|
|
98
|
+
score += 20;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tags scoring
|
|
102
|
+
if (tags.includes(q)) {
|
|
103
|
+
score += 30;
|
|
104
|
+
} else if (tags.some(t => t.includes(q))) {
|
|
105
|
+
score += 20;
|
|
106
|
+
} else if (words.every(w => tags.some(t => t.includes(w)))) {
|
|
107
|
+
score += 15;
|
|
108
|
+
} else if (words.some(w => tags.some(t => t.includes(w)))) {
|
|
109
|
+
score += 8;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Description scoring
|
|
113
|
+
if (description.includes(q)) {
|
|
114
|
+
score += 25;
|
|
115
|
+
} else if (words.some(w => description.includes(w))) {
|
|
116
|
+
score += 10;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Content scoring
|
|
120
|
+
if (stripped.includes(q)) {
|
|
121
|
+
score += 10;
|
|
122
|
+
} else if (words.every(w => stripped.includes(w))) {
|
|
123
|
+
score += 5;
|
|
124
|
+
} else if (words.some(w => stripped.includes(w))) {
|
|
125
|
+
score += 2;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return score;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default async function siteSearchPlugin(fastify, options) {
|
|
132
|
+
const {authenticate, requireAdmin} = options.auth;
|
|
133
|
+
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Public search endpoint
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
fastify.get('/search', async (request, reply) => {
|
|
139
|
+
const settings = getPluginSettings('site-search');
|
|
140
|
+
const minLen = settings.minQueryLength ?? 2;
|
|
141
|
+
const maxResults = settings.maxResults ?? 10;
|
|
142
|
+
|
|
143
|
+
const q = (request.query.q || '').trim();
|
|
144
|
+
if (q.length < minLen) {
|
|
145
|
+
return reply.status(400).send({error: `Query must be at least ${minLen} characters`});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pages = await listPages();
|
|
149
|
+
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
150
|
+
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const page of pages) {
|
|
153
|
+
// Filter out drafts and private pages
|
|
154
|
+
if (page.status === 'draft') continue;
|
|
155
|
+
if (page.visibility === 'private') continue;
|
|
156
|
+
|
|
157
|
+
const score = scorePage(page, q, words);
|
|
158
|
+
if (score === 0) continue;
|
|
159
|
+
|
|
160
|
+
results.push({
|
|
161
|
+
title: page.title || 'Untitled',
|
|
162
|
+
url: page.urlPath,
|
|
163
|
+
description: page.description || '',
|
|
164
|
+
tags: Array.isArray(page.tags) ? page.tags : [],
|
|
165
|
+
snippet: buildSnippet(page.content || '', q),
|
|
166
|
+
score
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
results.sort((a, b) => b.score - a.score);
|
|
171
|
+
|
|
172
|
+
return {results: results.slice(0, maxResults)};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
// Settings routes
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
fastify.get('/settings', async () => {
|
|
180
|
+
return getPluginSettings('site-search');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
fastify.put('/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
|
|
184
|
+
const body = request.body || {};
|
|
185
|
+
savePluginState('site-search', {settings: body});
|
|
186
|
+
return {ok: true};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "site-search",
|
|
3
|
+
"displayName": "Site Search",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Full-text search for the public site. Search icon in navbar, Cmd+K shortcut, weighted results.",
|
|
6
|
+
"author": "Darryl Waterhouse",
|
|
7
|
+
"date": "2026-03-19",
|
|
8
|
+
"icon": "search",
|
|
9
|
+
"admin": {
|
|
10
|
+
"sidebar": [
|
|
11
|
+
{
|
|
12
|
+
"id": "site-search",
|
|
13
|
+
"text": "Site Search",
|
|
14
|
+
"icon": "search",
|
|
15
|
+
"url": "#/plugins/site-search",
|
|
16
|
+
"section": "#/plugins/site-search"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"routes": [
|
|
20
|
+
{
|
|
21
|
+
"path": "/plugins/site-search",
|
|
22
|
+
"view": "plugin-site-search",
|
|
23
|
+
"title": "Site Search - Domma CMS"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"views": {
|
|
27
|
+
"plugin-site-search": {
|
|
28
|
+
"entry": "site-search/admin/views/site-search.js",
|
|
29
|
+
"exportName": "siteSearchView"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"inject": {
|
|
34
|
+
"headLate": "public/inject-head.html",
|
|
35
|
+
"bodyEnd": "public/inject-body.html"
|
|
36
|
+
}
|
|
37
|
+
}
|