@nextsparkjs/theme-blog 0.1.0-beta.19 → 0.1.0-beta.190

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.
Files changed (72) hide show
  1. package/api/authors/[username]/route.ts +5 -2
  2. package/api/authors/docs.md +135 -0
  3. package/api/authors/presets.ts +45 -0
  4. package/api/authors/route.ts +4 -1
  5. package/api/posts/public/docs.md +124 -0
  6. package/api/posts/public/presets.ts +65 -0
  7. package/api/posts/public/route.ts +4 -1
  8. package/components/ExportPostsButton.tsx +2 -1
  9. package/components/ImportPostsDialog.tsx +2 -1
  10. package/components/editor/FeaturedImageUpload.tsx +2 -1
  11. package/components/editor/WysiwygEditor.tsx +56 -7
  12. package/components/public/AuthorBio.tsx +3 -2
  13. package/components/public/AuthorCard.tsx +3 -2
  14. package/components/public/BlogFooter.tsx +2 -1
  15. package/components/public/PostCard.tsx +8 -7
  16. package/components/public/RelatedPosts.tsx +2 -1
  17. package/config/app.config.ts +8 -15
  18. package/config/billing.config.ts +4 -7
  19. package/config/dashboard.config.ts +13 -0
  20. package/config/permissions.config.ts +11 -0
  21. package/entities/categories/api/docs.md +119 -0
  22. package/entities/categories/api/presets.ts +67 -0
  23. package/entities/posts/api/docs.md +174 -0
  24. package/entities/posts/api/presets.ts +137 -0
  25. package/lib/sanitize-post-html.ts +52 -0
  26. package/lib/selectors.ts +2 -3
  27. package/migrations/090_billing_sample_data.sql +99 -0
  28. package/migrations/999_theme_sample_data.sql +13 -13
  29. package/nextsparkjs-theme-blog-0.1.0-beta.137.tgz +0 -0
  30. package/package.json +13 -5
  31. package/styles/globals.css +58 -0
  32. package/templates/(public)/author/[username]/page.tsx +6 -5
  33. package/templates/(public)/authors/page.tsx +3 -2
  34. package/templates/(public)/page.tsx +2 -1
  35. package/templates/(public)/posts/[slug]/page.tsx +7 -5
  36. package/templates/dashboard/(main)/page.tsx +2 -1
  37. package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +4 -3
  38. package/templates/dashboard/(main)/posts/create/page.tsx +2 -1
  39. package/templates/dashboard/(main)/posts/page.tsx +7 -6
  40. package/tests/cypress/e2e/README.md +170 -0
  41. package/tests/cypress/e2e/categories/categories-crud.cy.ts +322 -0
  42. package/tests/cypress/e2e/categories/categories-crud.md +73 -0
  43. package/tests/cypress/e2e/posts/posts-crud.cy.ts +460 -0
  44. package/tests/cypress/e2e/posts/posts-crud.md +115 -0
  45. package/tests/cypress/e2e/posts/posts-editor.cy.ts +290 -0
  46. package/tests/cypress/e2e/posts/posts-editor.md +139 -0
  47. package/tests/cypress/e2e/posts/posts-status-workflow.cy.ts +302 -0
  48. package/tests/cypress/e2e/posts/posts-status-workflow.md +83 -0
  49. package/tests/cypress/fixtures/blocks.json +9 -0
  50. package/tests/cypress/fixtures/entities.json +51 -0
  51. package/tests/cypress/src/FeaturedImageUpload.js +131 -0
  52. package/tests/cypress/src/PostEditor.js +386 -0
  53. package/tests/cypress/src/PostsList.js +350 -0
  54. package/tests/cypress/src/WysiwygEditor.js +373 -0
  55. package/tests/cypress/src/components/EntityForm.ts +378 -0
  56. package/tests/cypress/src/components/EntityList.ts +378 -0
  57. package/tests/cypress/src/components/PostEditorPOM.ts +447 -0
  58. package/tests/cypress/src/components/PostsPOM.ts +362 -0
  59. package/tests/cypress/src/components/index.ts +18 -0
  60. package/tests/cypress/src/index.js +33 -0
  61. package/tests/cypress/src/selectors.ts +49 -0
  62. package/tests/cypress/src/session-helpers.ts +151 -0
  63. package/tests/cypress/support/e2e.ts +90 -0
  64. package/tests/cypress.config.ts +154 -0
  65. package/tests/jest/__mocks__/jose.js +22 -0
  66. package/tests/jest/__mocks__/next-server.js +56 -0
  67. package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
  68. package/tests/jest/jest.config.cjs +148 -0
  69. package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
  70. package/tests/jest/setup.ts +170 -0
  71. package/tests/jest/tsconfig.jest.json +6 -0
  72. package/tests/tsconfig.json +15 -0
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { NextRequest, NextResponse } from 'next/server'
11
11
  import { queryWithRLS } from '@nextsparkjs/core/lib/db'
12
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
12
13
 
13
14
  interface Author {
14
15
  id: string
@@ -39,10 +40,10 @@ interface RouteContext {
39
40
  }>
40
41
  }
41
42
 
42
- export async function GET(
43
+ const getHandler = async (
43
44
  request: NextRequest,
44
45
  context: RouteContext
45
- ) {
46
+ ) => {
46
47
  try {
47
48
  const { username } = await context.params
48
49
 
@@ -148,3 +149,5 @@ export async function GET(
148
149
  )
149
150
  }
150
151
  }
152
+
153
+ export const GET = withRateLimitTier(getHandler, 'read')
@@ -0,0 +1,135 @@
1
+ # Authors API
2
+
3
+ Public author profiles and post listings. No authentication required.
4
+
5
+ ## Overview
6
+
7
+ The Authors API provides read-only access to author profiles and their published posts. This endpoint is designed for public consumption and does not require authentication.
8
+
9
+ ## Authentication
10
+
11
+ **No authentication required** - This is a public endpoint.
12
+
13
+ ## Endpoints
14
+
15
+ ### List Authors
16
+ `GET /api/v1/theme/blog/authors`
17
+
18
+ Returns a list of all authors who have published posts.
19
+
20
+ **Example Request:**
21
+ ```bash
22
+ curl https://example.com/api/v1/theme/blog/authors
23
+ ```
24
+
25
+ **Example Response:**
26
+ ```json
27
+ {
28
+ "data": [
29
+ {
30
+ "id": "user_abc123",
31
+ "name": "John Doe",
32
+ "username": "johndoe",
33
+ "bio": "Full-stack developer and tech writer",
34
+ "image": "https://example.com/avatar.jpg",
35
+ "postCount": 15
36
+ },
37
+ {
38
+ "id": "user_def456",
39
+ "name": "Jane Smith",
40
+ "username": "janesmith",
41
+ "bio": "Product designer and UX enthusiast",
42
+ "image": "https://example.com/avatar2.jpg",
43
+ "postCount": 8
44
+ }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ ### Get Author Profile
50
+ `GET /api/v1/theme/blog/authors/[username]`
51
+
52
+ Returns an author's profile with their published posts.
53
+
54
+ **Path Parameters:**
55
+ - `username` (string, required): Author's username
56
+
57
+ **Example Request:**
58
+ ```bash
59
+ curl https://example.com/api/v1/theme/blog/authors/johndoe
60
+ ```
61
+
62
+ **Example Response:**
63
+ ```json
64
+ {
65
+ "author": {
66
+ "id": "user_abc123",
67
+ "name": "John Doe",
68
+ "username": "johndoe",
69
+ "bio": "Full-stack developer and tech writer",
70
+ "image": "https://example.com/avatar.jpg"
71
+ },
72
+ "posts": [
73
+ {
74
+ "id": "post_123",
75
+ "title": "Getting Started with Next.js",
76
+ "slug": "getting-started-with-nextjs",
77
+ "excerpt": "A comprehensive guide...",
78
+ "featuredImage": "https://example.com/image.jpg",
79
+ "publishedAt": "2024-01-15T10:30:00Z",
80
+ "categories": [
81
+ {
82
+ "id": "cat_123",
83
+ "name": "Technology",
84
+ "slug": "technology"
85
+ }
86
+ ]
87
+ }
88
+ ],
89
+ "stats": {
90
+ "totalPosts": 15
91
+ }
92
+ }
93
+ ```
94
+
95
+ ## Response Fields
96
+
97
+ ### Author Object
98
+
99
+ | Field | Type | Description |
100
+ |-------|------|-------------|
101
+ | id | string | Author user ID |
102
+ | name | string | Author display name |
103
+ | username | string | Author username (URL-friendly) |
104
+ | bio | string | Author biography |
105
+ | image | string | Author avatar URL |
106
+ | postCount | number | Number of published posts (list endpoint only) |
107
+
108
+ ### Author Profile Response
109
+
110
+ | Field | Type | Description |
111
+ |-------|------|-------------|
112
+ | author | object | Author profile information |
113
+ | posts | array | Array of published posts |
114
+ | stats.totalPosts | number | Total number of published posts |
115
+
116
+ ## Features
117
+
118
+ - **No Auth Required**: Accessible without authentication
119
+ - **Rate Limited**: 100 requests per minute per IP
120
+ - **Cached**: Responses cached for 60 seconds
121
+ - **Sorted by Post Count**: Authors with more posts appear first
122
+
123
+ ## Error Responses
124
+
125
+ | Status | Description |
126
+ |--------|-------------|
127
+ | 400 | Bad Request - Invalid parameters |
128
+ | 404 | Not Found - Author not found |
129
+ | 429 | Too Many Requests - Rate limit exceeded |
130
+ | 500 | Internal Server Error |
131
+
132
+ ## Related APIs
133
+
134
+ - **[Public Posts](/api/v1/theme/blog/posts/public)** - Public post feed
135
+ - **[Posts](/api/v1/posts)** - Authenticated CRUD operations
@@ -0,0 +1,45 @@
1
+ /**
2
+ * API Presets for Authors Route
3
+ *
4
+ * Public author profiles and their published posts
5
+ */
6
+
7
+ import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
8
+
9
+ export default defineApiEndpoint({
10
+ endpoint: '/api/v1/theme/blog/authors',
11
+ summary: 'Public author profiles with published posts',
12
+ presets: [
13
+ {
14
+ id: 'list-all',
15
+ title: 'List All Authors',
16
+ description: 'Get all authors with published posts',
17
+ method: 'GET',
18
+ params: {
19
+ limit: 50
20
+ },
21
+ tags: ['read', 'list', 'public']
22
+ },
23
+ {
24
+ id: 'get-by-username',
25
+ title: 'Get Author by Username',
26
+ description: 'Get author profile with their posts',
27
+ method: 'GET',
28
+ pathParams: {
29
+ username: '{{username}}'
30
+ },
31
+ tags: ['read', 'detail', 'public']
32
+ },
33
+ {
34
+ id: 'list-with-post-count',
35
+ title: 'List with Post Count',
36
+ description: 'Get authors sorted by number of posts',
37
+ method: 'GET',
38
+ params: {
39
+ sortBy: 'postCount',
40
+ sortOrder: 'desc'
41
+ },
42
+ tags: ['read', 'list', 'public']
43
+ }
44
+ ]
45
+ })
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { NextRequest, NextResponse } from 'next/server'
11
11
  import { queryWithRLS } from '@nextsparkjs/core/lib/db'
12
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
12
13
 
13
14
  interface AuthorWithCount {
14
15
  id: string
@@ -19,7 +20,7 @@ interface AuthorWithCount {
19
20
  postCount: number
20
21
  }
21
22
 
22
- export async function GET(request: NextRequest) {
23
+ const getHandler = async (request: NextRequest) => {
23
24
  try {
24
25
  // Get all authors who have at least one published post
25
26
  const authorsQuery = `
@@ -61,3 +62,5 @@ export async function GET(request: NextRequest) {
61
62
  )
62
63
  }
63
64
  }
65
+
66
+ export const GET = withRateLimitTier(getHandler, 'read')
@@ -0,0 +1,124 @@
1
+ # Public Posts API
2
+
3
+ Public feed of published blog posts. No authentication required.
4
+
5
+ ## Overview
6
+
7
+ The Public Posts API provides read-only access to published blog posts. This endpoint is designed for public consumption by blog visitors and does not require authentication.
8
+
9
+ ## Authentication
10
+
11
+ **No authentication required** - This is a public endpoint.
12
+
13
+ ## Endpoints
14
+
15
+ ### List Published Posts
16
+ `GET /api/v1/theme/blog/posts/public`
17
+
18
+ Returns a paginated list of published posts.
19
+
20
+ **Query Parameters:**
21
+ - `limit` (number, optional): Maximum records to return. Default: 20, Max: 100
22
+ - `offset` (number, optional): Number of records to skip. Default: 0
23
+ - `category` (string, optional): Filter by category slug
24
+
25
+ **Example Request:**
26
+ ```bash
27
+ curl https://example.com/api/v1/theme/blog/posts/public?limit=10&category=technology
28
+ ```
29
+
30
+ **Example Response:**
31
+ ```json
32
+ {
33
+ "data": [
34
+ {
35
+ "id": "post_abc123",
36
+ "title": "Getting Started with Next.js",
37
+ "slug": "getting-started-with-nextjs",
38
+ "excerpt": "A comprehensive guide to building modern web apps",
39
+ "featuredImage": "https://example.com/image.jpg",
40
+ "featured": true,
41
+ "publishedAt": "2024-01-15T10:30:00Z",
42
+ "author": {
43
+ "id": "user_123",
44
+ "name": "John Doe",
45
+ "username": "johndoe",
46
+ "image": "https://example.com/avatar.jpg"
47
+ },
48
+ "categories": [
49
+ {
50
+ "id": "cat_123",
51
+ "name": "Technology",
52
+ "slug": "technology"
53
+ }
54
+ ]
55
+ }
56
+ ],
57
+ "pagination": {
58
+ "total": 25,
59
+ "limit": 10,
60
+ "offset": 0,
61
+ "hasMore": true
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Response Fields
67
+
68
+ | Field | Type | Description |
69
+ |-------|------|-------------|
70
+ | id | string | Post unique identifier |
71
+ | title | string | Post title |
72
+ | slug | string | URL-friendly slug |
73
+ | excerpt | string | Brief description |
74
+ | featuredImage | string | Featured image URL |
75
+ | featured | boolean | Whether post is featured |
76
+ | publishedAt | datetime | Publication timestamp |
77
+ | author | object | Author information |
78
+ | author.id | string | Author user ID |
79
+ | author.name | string | Author display name |
80
+ | author.username | string | Author username |
81
+ | author.image | string | Author avatar URL |
82
+ | categories | array | Associated categories |
83
+
84
+ ## Features
85
+
86
+ - **No Auth Required**: Accessible without authentication
87
+ - **Rate Limited**: 100 requests per minute per IP
88
+ - **Cached**: Responses cached for 60 seconds
89
+ - **Category Filter**: Filter posts by category slug
90
+
91
+ ## Pagination
92
+
93
+ The API uses offset-based pagination:
94
+
95
+ ```json
96
+ {
97
+ "pagination": {
98
+ "total": 100,
99
+ "limit": 20,
100
+ "offset": 0,
101
+ "hasMore": true
102
+ }
103
+ }
104
+ ```
105
+
106
+ To get the next page:
107
+ ```
108
+ GET /api/v1/theme/blog/posts/public?offset=20&limit=20
109
+ ```
110
+
111
+ ## Error Responses
112
+
113
+ | Status | Description |
114
+ |--------|-------------|
115
+ | 400 | Bad Request - Invalid query parameters |
116
+ | 404 | Not Found - Category not found |
117
+ | 429 | Too Many Requests - Rate limit exceeded |
118
+ | 500 | Internal Server Error |
119
+
120
+ ## Related APIs
121
+
122
+ - **[Posts](/api/v1/posts)** - Authenticated CRUD operations
123
+ - **[Authors](/api/v1/theme/blog/authors)** - Author profiles
124
+ - **[Categories](/api/v1/categories)** - Category management
@@ -0,0 +1,65 @@
1
+ /**
2
+ * API Presets for Public Posts
3
+ *
4
+ * Public feed of published blog posts (no authentication required)
5
+ */
6
+
7
+ import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
8
+
9
+ export default defineApiEndpoint({
10
+ endpoint: '/api/v1/theme/blog/posts/public',
11
+ summary: 'Public feed of published blog posts',
12
+ presets: [
13
+ {
14
+ id: 'list-recent',
15
+ title: 'List Recent Posts',
16
+ description: 'Get recent published posts',
17
+ method: 'GET',
18
+ params: {
19
+ limit: 20
20
+ },
21
+ tags: ['read', 'list', 'public']
22
+ },
23
+ {
24
+ id: 'list-with-limit',
25
+ title: 'List with Custom Limit',
26
+ description: 'Get posts with specific limit',
27
+ method: 'GET',
28
+ params: {
29
+ limit: '{{limit}}'
30
+ },
31
+ tags: ['read', 'list', 'public']
32
+ },
33
+ {
34
+ id: 'list-by-category',
35
+ title: 'List by Category',
36
+ description: 'Get posts filtered by category slug',
37
+ method: 'GET',
38
+ params: {
39
+ category: '{{category}}'
40
+ },
41
+ tags: ['read', 'filter', 'public']
42
+ },
43
+ {
44
+ id: 'list-paginated',
45
+ title: 'List Paginated',
46
+ description: 'Get paginated posts',
47
+ method: 'GET',
48
+ params: {
49
+ limit: '{{limit}}',
50
+ offset: '{{offset}}'
51
+ },
52
+ tags: ['read', 'list', 'public']
53
+ },
54
+ {
55
+ id: 'list-featured',
56
+ title: 'List Featured Posts',
57
+ description: 'Get featured published posts',
58
+ method: 'GET',
59
+ params: {
60
+ featured: 'true'
61
+ },
62
+ tags: ['read', 'filter', 'public']
63
+ }
64
+ ]
65
+ })
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { NextRequest, NextResponse } from 'next/server'
12
12
  import { queryWithRLS } from '@nextsparkjs/core/lib/db'
13
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
13
14
 
14
15
  interface Post {
15
16
  id: string
@@ -31,7 +32,7 @@ interface Post {
31
32
  authorImage: string | null
32
33
  }
33
34
 
34
- export async function GET(request: NextRequest) {
35
+ const getHandler = async (request: NextRequest) => {
35
36
  try {
36
37
  const { searchParams } = new URL(request.url)
37
38
 
@@ -149,3 +150,5 @@ export async function GET(request: NextRequest) {
149
150
  )
150
151
  }
151
152
  }
153
+
154
+ export const GET = withRateLimitTier(getHandler, 'read')
@@ -4,6 +4,7 @@ import { useState } from 'react'
4
4
  import { Button } from '@nextsparkjs/core/components/ui/button'
5
5
  import { Download, Loader2 } from 'lucide-react'
6
6
  import { useToast } from '@nextsparkjs/core/hooks/useToast'
7
+ import { withBasePath } from '@nextsparkjs/core/lib/base-path'
7
8
 
8
9
  interface ExportPostsButtonProps {
9
10
  className?: string
@@ -17,7 +18,7 @@ export function ExportPostsButton({ className }: ExportPostsButtonProps) {
17
18
  setIsExporting(true)
18
19
 
19
20
  try {
20
- const response = await fetch('/api/v1/posts?limit=1000', {
21
+ const response = await fetch(withBasePath('/api/v1/posts?limit=1000'), {
21
22
  method: 'GET',
22
23
  headers: {
23
24
  'Content-Type': 'application/json',
@@ -16,6 +16,7 @@ import { Badge } from '@nextsparkjs/core/components/ui/badge'
16
16
  import { ScrollArea } from '@nextsparkjs/core/components/ui/scroll-area'
17
17
  import { Upload, FileJson, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
18
18
  import { useToast } from '@nextsparkjs/core/hooks/useToast'
19
+ import { withBasePath } from '@nextsparkjs/core/lib/base-path'
19
20
 
20
21
  interface PostImport {
21
22
  title: string
@@ -108,7 +109,7 @@ export function ImportPostsDialog({ className, onImportComplete }: ImportPostsDi
108
109
  const post = posts[i]
109
110
 
110
111
  try {
111
- const response = await fetch('/api/v1/posts', {
112
+ const response = await fetch(withBasePath('/api/v1/posts'), {
112
113
  method: 'POST',
113
114
  headers: {
114
115
  'Content-Type': 'application/json',
@@ -12,6 +12,7 @@ import NextImage from 'next/image'
12
12
  import { X, Upload, Image as ImageIcon } from 'lucide-react'
13
13
  import { Button } from '@nextsparkjs/core/components/ui/button'
14
14
  import { cn } from '@nextsparkjs/core/lib/utils'
15
+ import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
15
16
 
16
17
  interface FeaturedImageUploadProps {
17
18
  value: string // URL string
@@ -111,7 +112,7 @@ export function FeaturedImageUpload({
111
112
  // Image Preview
112
113
  <div className="relative aspect-video rounded-lg overflow-hidden border border-border bg-muted" data-cy="featured-image-preview">
113
114
  <NextImage
114
- src={value}
115
+ src={withBasePathIfInApp(value)}
115
116
  alt="Featured image preview"
116
117
  fill
117
118
  className="object-cover"
@@ -6,12 +6,15 @@
6
6
  * A rich text editor built with native contentEditable.
7
7
  * Provides formatting, markdown shortcuts, and a clean interface.
8
8
  *
9
- * No external dependencies - uses document.execCommand for formatting.
9
+ * Formatting uses document.execCommand. Stored HTML, and markup pasted or
10
+ * dropped in, is sanitised before it reaches the DOM, in the editable area and
11
+ * in the preview.
10
12
  */
11
13
 
12
14
  import { useRef, useCallback, useEffect, useState } from 'react'
13
15
  import { cn } from '@nextsparkjs/core/lib/utils'
14
16
  import { Button } from '@nextsparkjs/core/components/ui/button'
17
+ import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
15
18
  import {
16
19
  Bold,
17
20
  Italic,
@@ -78,6 +81,22 @@ const TOOLBAR_GROUPS: ToolbarButton[][] = [
78
81
  ],
79
82
  ]
80
83
 
84
+ /** The caret position under a point, so a drop lands where the pointer is. */
85
+ function rangeAtPoint(x: number, y: number): Range | null {
86
+ const doc = document as Document & {
87
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
88
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
89
+ }
90
+ const position = doc.caretPositionFromPoint?.(x, y)
91
+ if (position) {
92
+ const range = document.createRange()
93
+ range.setStart(position.offsetNode, position.offset)
94
+ range.collapse(true)
95
+ return range
96
+ }
97
+ return doc.caretRangeFromPoint?.(x, y) ?? null
98
+ }
99
+
81
100
  export function WysiwygEditor({
82
101
  value,
83
102
  onChange,
@@ -91,15 +110,18 @@ export function WysiwygEditor({
91
110
  const [isFocused, setIsFocused] = useState(false)
92
111
  const isComposing = useRef(false)
93
112
 
94
- // Initialize content
113
+ // Fill the editable area. A value from outside (a loaded post) always goes in
114
+ // sanitised, and so does the value when leaving the preview mounts a new,
115
+ // empty area. One the area produced itself (typing, a paste, a command) is
116
+ // replaced only when sanitising would change it: rewriting it for a mere
117
+ // re-serialisation would move the caret to the start.
95
118
  useEffect(() => {
96
119
  if (editorRef.current && !isComposing.current) {
97
- const currentContent = editorRef.current.innerHTML
98
- if (currentContent !== value) {
99
- editorRef.current.innerHTML = value || ''
120
+ if (editorRef.current.innerHTML !== value || postHtmlHasUnsafeMarkup(value)) {
121
+ editorRef.current.innerHTML = sanitizePostHtml(value)
100
122
  }
101
123
  }
102
- }, [value])
124
+ }, [value, isPreview])
103
125
 
104
126
  // Auto focus
105
127
  useEffect(() => {
@@ -115,6 +137,31 @@ export function WysiwygEditor({
115
137
  }
116
138
  }, [onChange])
117
139
 
140
+ // Markup pasted or dropped in that sanitising would change goes in sanitised,
141
+ // in place of the browser's own insertion. Replacing it once it is in the
142
+ // editable area is too late: an attribute such as onerror fires as soon as
143
+ // the element is in the document. Anything else keeps the browser's paste
144
+ // and drop, which is also how text moves within the editor.
145
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
146
+ const html = e.clipboardData.getData('text/html')
147
+ if (!postHtmlHasUnsafeMarkup(html)) return
148
+ e.preventDefault()
149
+ document.execCommand('insertHTML', false, sanitizePostHtml(html))
150
+ }, [])
151
+
152
+ const handleDrop = useCallback((e: React.DragEvent) => {
153
+ const html = e.dataTransfer.getData('text/html')
154
+ if (!postHtmlHasUnsafeMarkup(html)) return
155
+ e.preventDefault()
156
+ const range = rangeAtPoint(e.clientX, e.clientY)
157
+ if (range) {
158
+ const selection = window.getSelection()
159
+ selection?.removeAllRanges()
160
+ selection?.addRange(range)
161
+ }
162
+ document.execCommand('insertHTML', false, sanitizePostHtml(html))
163
+ }, [])
164
+
118
165
  const execCommand = useCallback((command: string, value?: string) => {
119
166
  if (command === 'createLink') {
120
167
  const url = prompt('Enter URL:')
@@ -275,7 +322,7 @@ export function WysiwygEditor({
275
322
  <div
276
323
  className="flex-1 min-h-0 prose prose-sm max-w-none p-4 overflow-auto"
277
324
  style={{ minHeight }}
278
- dangerouslySetInnerHTML={{ __html: value || '<p class="text-muted-foreground">Nothing to preview...</p>' }}
325
+ dangerouslySetInnerHTML={{ __html: value ? sanitizePostHtml(value) : '<p class="text-muted-foreground">Nothing to preview...</p>' }}
279
326
  data-cy="wysiwyg-preview"
280
327
  />
281
328
  ) : (
@@ -302,6 +349,8 @@ export function WysiwygEditor({
302
349
  )}
303
350
  style={{ minHeight }}
304
351
  onInput={handleInput}
352
+ onPaste={handlePaste}
353
+ onDrop={handleDrop}
305
354
  onKeyDown={handleKeyDown}
306
355
  onFocus={() => setIsFocused(true)}
307
356
  onBlur={() => setIsFocused(false)}
@@ -9,6 +9,7 @@
9
9
  import Image from 'next/image'
10
10
  import Link from 'next/link'
11
11
  import { Twitter, Github, Linkedin, Globe, User } from 'lucide-react'
12
+ import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
12
13
 
13
14
  interface SocialLink {
14
15
  type: 'twitter' | 'github' | 'linkedin' | 'website'
@@ -43,7 +44,7 @@ export function AuthorBio({
43
44
  <div data-cy="author-bio-avatar" className="flex-shrink-0">
44
45
  {avatar ? (
45
46
  <Image
46
- src={avatar}
47
+ src={withBasePathIfInApp(avatar)}
47
48
  alt={name}
48
49
  width={64}
49
50
  height={64}
@@ -84,7 +85,7 @@ export function AuthorBio({
84
85
  return (
85
86
  <a
86
87
  key={link.type}
87
- href={link.url}
88
+ href={withBasePathIfInApp(link.url)}
88
89
  data-cy={`author-bio-social-${link.type}`}
89
90
  target="_blank"
90
91
  rel="noopener noreferrer"
@@ -11,6 +11,7 @@ import Link from 'next/link'
11
11
  import Image from 'next/image'
12
12
  import { User } from 'lucide-react'
13
13
  import { cn } from '@nextsparkjs/core/lib/utils'
14
+ import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
14
15
 
15
16
  interface AuthorCardProps {
16
17
  username: string
@@ -48,7 +49,7 @@ export function AuthorCard({
48
49
  <div data-cy={`author-card-avatar-${username}`} className="flex-shrink-0">
49
50
  {avatar ? (
50
51
  <Image
51
- src={avatar}
52
+ src={withBasePathIfInApp(avatar)}
52
53
  alt={name}
53
54
  width={48}
54
55
  height={48}
@@ -89,7 +90,7 @@ export function AuthorCard({
89
90
  <div data-cy={`author-card-avatar-${username}`} className="flex justify-center mb-4">
90
91
  {avatar ? (
91
92
  <Image
92
- src={avatar}
93
+ src={withBasePathIfInApp(avatar)}
93
94
  alt={name}
94
95
  width={96}
95
96
  height={96}
@@ -8,6 +8,7 @@
8
8
 
9
9
  import Link from 'next/link'
10
10
  import { Github, Twitter, Linkedin, Mail, Heart } from 'lucide-react'
11
+ import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
11
12
 
12
13
  interface SocialLink {
13
14
  name: string
@@ -117,7 +118,7 @@ export function BlogFooter({
117
118
  return (
118
119
  <a
119
120
  key={link.name}
120
- href={link.href}
121
+ href={withBasePathIfInApp(link.href)}
121
122
  data-cy={`blog-footer-social-${link.icon}`}
122
123
  target="_blank"
123
124
  rel="noopener noreferrer"