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

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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * API Presets for Posts Entity
3
+ */
4
+
5
+ import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
6
+
7
+ export default defineApiEndpoint({
8
+ summary: 'Manage blog posts with content builder and publication workflow',
9
+ presets: [
10
+ {
11
+ id: 'list-all',
12
+ title: 'List All Posts',
13
+ description: 'Get all posts with pagination',
14
+ method: 'GET',
15
+ params: {
16
+ limit: 20
17
+ },
18
+ tags: ['read', 'list']
19
+ },
20
+ {
21
+ id: 'list-published',
22
+ title: 'List Published Posts',
23
+ description: 'Get all published posts',
24
+ method: 'GET',
25
+ params: {
26
+ status: 'published'
27
+ },
28
+ tags: ['read', 'filter']
29
+ },
30
+ {
31
+ id: 'list-drafts',
32
+ title: 'List Draft Posts',
33
+ description: 'Get all draft posts',
34
+ method: 'GET',
35
+ params: {
36
+ status: 'draft'
37
+ },
38
+ tags: ['read', 'filter']
39
+ },
40
+ {
41
+ id: 'list-featured',
42
+ title: 'List Featured Posts',
43
+ description: 'Get all featured posts',
44
+ method: 'GET',
45
+ params: {
46
+ featured: 'true'
47
+ },
48
+ tags: ['read', 'filter']
49
+ },
50
+ {
51
+ id: 'search-by-title',
52
+ title: 'Search by Title',
53
+ description: 'Search posts by title',
54
+ method: 'GET',
55
+ params: {
56
+ search: '{{title}}'
57
+ },
58
+ tags: ['read', 'search']
59
+ },
60
+ {
61
+ id: 'create-draft',
62
+ title: 'Create Draft Post',
63
+ description: 'Create a new draft post',
64
+ method: 'POST',
65
+ payload: {
66
+ title: 'New Post Title',
67
+ slug: 'new-post-title',
68
+ excerpt: 'Brief description of the post',
69
+ status: 'draft'
70
+ },
71
+ tags: ['write', 'create']
72
+ },
73
+ {
74
+ id: 'publish-post',
75
+ title: 'Publish Post',
76
+ description: 'Publish a draft post',
77
+ method: 'PATCH',
78
+ pathParams: {
79
+ id: '{{id}}'
80
+ },
81
+ payload: {
82
+ status: 'published',
83
+ publishedAt: '{{publishedAt}}'
84
+ },
85
+ tags: ['write', 'update']
86
+ },
87
+ {
88
+ id: 'unpublish-post',
89
+ title: 'Unpublish Post',
90
+ description: 'Revert a post to draft',
91
+ method: 'PATCH',
92
+ pathParams: {
93
+ id: '{{id}}'
94
+ },
95
+ payload: {
96
+ status: 'draft'
97
+ },
98
+ tags: ['write', 'update']
99
+ },
100
+ {
101
+ id: 'set-featured',
102
+ title: 'Set as Featured',
103
+ description: 'Mark a post as featured',
104
+ method: 'PATCH',
105
+ pathParams: {
106
+ id: '{{id}}'
107
+ },
108
+ payload: {
109
+ featured: true
110
+ },
111
+ tags: ['write', 'update']
112
+ },
113
+ {
114
+ id: 'update-content',
115
+ title: 'Update Content',
116
+ description: 'Update post content blocks',
117
+ method: 'PATCH',
118
+ pathParams: {
119
+ id: '{{id}}'
120
+ },
121
+ payload: {
122
+ content: '{{content}}'
123
+ },
124
+ tags: ['write', 'update']
125
+ },
126
+ {
127
+ id: 'delete-post',
128
+ title: 'Delete Post',
129
+ description: 'Delete a post record',
130
+ method: 'DELETE',
131
+ pathParams: {
132
+ id: '{{id}}'
133
+ },
134
+ tags: ['write', 'delete']
135
+ }
136
+ ]
137
+ })
@@ -0,0 +1,52 @@
1
+ import DOMPurify from 'dompurify'
2
+
3
+ /**
4
+ * A post body is HTML written by anyone who can create a post, a team member
5
+ * and not only an owner, so every place that turns it back into DOM sanitises
6
+ * it first: the public page, the editor and the editor's preview.
7
+ *
8
+ * DOMPurify needs a DOM. Where there is none (a server render pass) this
9
+ * returns '' rather than the raw string, failing closed.
10
+ */
11
+ export function sanitizePostHtml(html: string | null | undefined): string {
12
+ if (!html) return ''
13
+ if (!DOMPurify.isSupported) return ''
14
+ return DOMPurify.sanitize(html)
15
+ }
16
+
17
+ /**
18
+ * Whether sanitising changes the markup beyond what parsing it already does.
19
+ *
20
+ * Parsing alone rewrites markup that is not unsafe: the browser's editing
21
+ * commands leave a list inside a <p>, and the parser moves it out. So the
22
+ * original and the sanitised result are both parsed and compared as trees; any
23
+ * difference is something the sanitiser took out or altered. It does not read
24
+ * DOMPurify.removed, which DOMPurify documents as unfit for security decisions
25
+ * and which misses changes such as an attribute it rewrites.
26
+ *
27
+ * Both are parsed as a fragment, the way the editable area receives a paste. A
28
+ * document parse moves a leading <script> or <base> into a <head> that a
29
+ * comparison of bodies never sees. A template's content is inert, so nothing in
30
+ * the unsanitised markup runs or loads while it is inspected. Where there is no
31
+ * DOM the answer is true, sending callers down the sanitising path.
32
+ */
33
+ export function postHtmlHasUnsafeMarkup(html: string | null | undefined): boolean {
34
+ if (!html) return false
35
+ if (!DOMPurify.isSupported || typeof document === 'undefined') return true
36
+ return !sameMarkup(parseFragment(html), parseFragment(DOMPurify.sanitize(html)))
37
+ }
38
+
39
+ function parseFragment(markup: string): DocumentFragment {
40
+ const template = document.createElement('template')
41
+ template.innerHTML = markup
42
+ return template.content
43
+ }
44
+
45
+ /** Equal trees, including what each <template> holds: isEqualNode does not look inside one. */
46
+ function sameMarkup(a: DocumentFragment, b: DocumentFragment): boolean {
47
+ if (!a.isEqualNode(b)) return false
48
+ const templatesA = Array.from(a.querySelectorAll('template'))
49
+ const templatesB = Array.from(b.querySelectorAll('template'))
50
+ return templatesA.length === templatesB.length
51
+ && templatesA.every((template, i) => sameMarkup(template.content, templatesB[i].content))
52
+ }
package/lib/selectors.ts CHANGED
@@ -9,8 +9,7 @@
9
9
  * - Cypress tests (via tests/cypress/src/selectors.ts)
10
10
  */
11
11
 
12
- import { createSelectorHelpers } from '@nextsparkjs/core/lib/test/selector-factory'
13
- import { CORE_SELECTORS } from '@nextsparkjs/core/lib/test/core-selectors'
12
+ import { createSelectorHelpers, CORE_SELECTORS } from '@nextsparkjs/core/selectors'
14
13
 
15
14
  // =============================================================================
16
15
  // BLOCK SELECTORS
@@ -175,5 +174,5 @@ export const entitySelectors = helpers.entitySelectors
175
174
  export type ThemeSelectorsType = typeof THEME_SELECTORS
176
175
  export type BlockSelectorsType = typeof BLOCK_SELECTORS
177
176
  export type EntitySelectorsType = typeof ENTITY_SELECTORS
178
- export type { Replacements } from '@nextsparkjs/core/lib/test/selector-factory'
177
+ export type { Replacements } from '@nextsparkjs/core/selectors'
179
178
  export { CORE_SELECTORS }
@@ -0,0 +1,99 @@
1
+ -- Migration: 090_billing_sample_data.sql
2
+ -- Description: Sample billing plans for the blog theme (free, pro, enterprise)
3
+ -- Theme: blog
4
+ -- Phase: Theme sample data - runs before 999_theme_sample_data.sql, whose
5
+ -- subscriptions reference these plans by slug.
6
+ --
7
+ -- Mirrors themes/blog/config/billing.config.ts (slugs, prices, features,
8
+ -- limits) so the seeded plans match what the app's billing config declares.
9
+
10
+ -- ============================================
11
+ -- SAMPLE PLANS
12
+ -- ============================================
13
+
14
+ -- Plan: Free
15
+ INSERT INTO public."plans" (
16
+ id,
17
+ slug,
18
+ name,
19
+ description,
20
+ type,
21
+ visibility,
22
+ "priceMonthly",
23
+ "priceYearly",
24
+ "trialDays",
25
+ features,
26
+ limits,
27
+ "sortOrder"
28
+ ) VALUES (
29
+ 'plan_blog_free',
30
+ 'free',
31
+ 'Free',
32
+ 'Free plan to get started',
33
+ 'free',
34
+ 'public',
35
+ 0,
36
+ 0,
37
+ 0,
38
+ '["basic_analytics"]'::jsonb,
39
+ '{"posts": 10, "monthly_views": 10000, "storage_gb": 1, "authors": 1}'::jsonb,
40
+ 1
41
+ ) ON CONFLICT (slug) DO NOTHING;
42
+
43
+ -- Plan: Pro
44
+ INSERT INTO public."plans" (
45
+ id,
46
+ slug,
47
+ name,
48
+ description,
49
+ type,
50
+ visibility,
51
+ "priceMonthly",
52
+ "priceYearly",
53
+ "trialDays",
54
+ features,
55
+ limits,
56
+ "sortOrder"
57
+ ) VALUES (
58
+ 'plan_blog_pro',
59
+ 'pro',
60
+ 'Pro',
61
+ 'Professional plan for bloggers',
62
+ 'paid',
63
+ 'public',
64
+ 2900,
65
+ 29000,
66
+ 14,
67
+ '["basic_analytics", "custom_domain", "advanced_seo"]'::jsonb,
68
+ '{"posts": -1, "monthly_views": 100000, "storage_gb": 10, "authors": 5}'::jsonb,
69
+ 2
70
+ ) ON CONFLICT (slug) DO NOTHING;
71
+
72
+ -- Plan: Enterprise
73
+ INSERT INTO public."plans" (
74
+ id,
75
+ slug,
76
+ name,
77
+ description,
78
+ type,
79
+ visibility,
80
+ "priceMonthly",
81
+ "priceYearly",
82
+ "trialDays",
83
+ features,
84
+ limits,
85
+ "sortOrder"
86
+ ) VALUES (
87
+ 'plan_blog_enterprise',
88
+ 'enterprise',
89
+ 'Enterprise',
90
+ 'Custom enterprise plan with unlimited features',
91
+ 'enterprise',
92
+ 'hidden',
93
+ NULL,
94
+ NULL,
95
+ 30,
96
+ '["*"]'::jsonb,
97
+ '{"posts": -1, "monthly_views": -1, "storage_gb": -1, "authors": -1}'::jsonb,
98
+ 3
99
+ ) ON CONFLICT (slug) DO NOTHING;
@@ -662,15 +662,15 @@ INSERT INTO public."subscriptions" (
662
662
  "externalSubscriptionId", "externalCustomerId", "createdAt"
663
663
  ) VALUES
664
664
  -- Marcos Tech → Pro Plan $29/mo (active, monthly)
665
- ('sub-blog-marcos', 'team-blog-marcos', 'usr-blog-marcos', 'plan_pro', 'active',
665
+ ('sub-blog-marcos', 'team-blog-marcos', 'usr-blog-marcos', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
666
666
  NOW() - INTERVAL '30 days', NOW() + INTERVAL '30 days', 'monthly', 'stripe',
667
667
  'sub_stripe_marcos', 'cus_marcos', NOW() - INTERVAL '6 months'),
668
668
  -- Lucia Lifestyle → Pro Plan $29/mo (active, monthly)
669
- ('sub-blog-lucia', 'team-blog-lucia', 'usr-blog-lucia', 'plan_pro', 'active',
669
+ ('sub-blog-lucia', 'team-blog-lucia', 'usr-blog-lucia', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
670
670
  NOW() - INTERVAL '15 days', NOW() + INTERVAL '15 days', 'monthly', 'stripe',
671
671
  'sub_stripe_lucia', 'cus_lucia', NOW() - INTERVAL '5 months'),
672
672
  -- Carlos Finance → Pro Plan (trialing, monthly)
673
- ('sub-blog-carlos', 'team-blog-carlos', 'usr-blog-carlos', 'plan_pro', 'trialing',
673
+ ('sub-blog-carlos', 'team-blog-carlos', 'usr-blog-carlos', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'trialing',
674
674
  NOW(), NOW() + INTERVAL '14 days', 'monthly', NULL, NULL, NULL, NOW() - INTERVAL '4 months')
675
675
  ON CONFLICT (id) DO NOTHING;
676
676
 
@@ -843,64 +843,64 @@ INSERT INTO public."subscriptions" (
843
843
  "createdAt", "canceledAt", "cancelAtPeriodEnd", metadata
844
844
  ) VALUES
845
845
  -- 1. Diana Design - Pro Monthly ACTIVE (8 months customer)
846
- ('sub-blog-diana', 'team-blog-diana', 'usr-blog-diana', 'plan_pro', 'active',
846
+ ('sub-blog-diana', 'team-blog-diana', 'usr-blog-diana', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
847
847
  NOW() - INTERVAL '25 days', NOW() + INTERVAL '5 days', 'monthly',
848
848
  'stripe', 'sub_stripe_diana', 'cus_diana',
849
849
  NOW() - INTERVAL '8 months', NULL, false, '{}'::jsonb),
850
850
 
851
851
  -- 2. Elena Eco - Pro Monthly ACTIVE (7 months customer)
852
- ('sub-blog-elena', 'team-blog-elena', 'usr-blog-elena', 'plan_pro', 'active',
852
+ ('sub-blog-elena', 'team-blog-elena', 'usr-blog-elena', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
853
853
  NOW() - INTERVAL '20 days', NOW() + INTERVAL '10 days', 'monthly',
854
854
  'stripe', 'sub_stripe_elena', 'cus_elena',
855
855
  NOW() - INTERVAL '7 months', NULL, false, '{}'::jsonb),
856
856
 
857
857
  -- 3. Felix Fitness - Pro Yearly ACTIVE (14 months = 2nd year)
858
- ('sub-blog-felix', 'team-blog-felix', 'usr-blog-felix', 'plan_pro', 'active',
858
+ ('sub-blog-felix', 'team-blog-felix', 'usr-blog-felix', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
859
859
  NOW() - INTERVAL '60 days', NOW() + INTERVAL '305 days', 'yearly',
860
860
  'stripe', 'sub_stripe_felix', 'cus_felix',
861
861
  NOW() - INTERVAL '14 months', NULL, false, '{}'::jsonb),
862
862
 
863
863
  -- 4. Gloria Gourmet - Pro Yearly ACTIVE (12 months)
864
- ('sub-blog-gloria', 'team-blog-gloria', 'usr-blog-gloria', 'plan_pro', 'active',
864
+ ('sub-blog-gloria', 'team-blog-gloria', 'usr-blog-gloria', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
865
865
  NOW() - INTERVAL '30 days', NOW() + INTERVAL '335 days', 'yearly',
866
866
  'stripe', 'sub_stripe_gloria', 'cus_gloria',
867
867
  NOW() - INTERVAL '12 months', NULL, false, '{}'::jsonb),
868
868
 
869
869
  -- 5. Hugo Health - Pro Monthly ACTIVE (6 months customer)
870
- ('sub-blog-hugo', 'team-blog-hugo', 'usr-blog-hugo', 'plan_pro', 'active',
870
+ ('sub-blog-hugo', 'team-blog-hugo', 'usr-blog-hugo', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
871
871
  NOW() - INTERVAL '18 days', NOW() + INTERVAL '12 days', 'monthly',
872
872
  'stripe', 'sub_stripe_hugo', 'cus_hugo',
873
873
  NOW() - INTERVAL '6 months', NULL, false, '{}'::jsonb),
874
874
 
875
875
  -- 6. Iris Inspire - Pro Monthly PAST_DUE (failed payment)
876
- ('sub-blog-iris', 'team-blog-iris', 'usr-blog-iris', 'plan_pro', 'past_due',
876
+ ('sub-blog-iris', 'team-blog-iris', 'usr-blog-iris', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'past_due',
877
877
  NOW() - INTERVAL '35 days', NOW() - INTERVAL '5 days', 'monthly',
878
878
  'stripe', 'sub_stripe_iris', 'cus_iris',
879
879
  NOW() - INTERVAL '5 months', NULL, false,
880
880
  '{"lastPaymentAttempt": "failed", "retryCount": 2}'::jsonb),
881
881
 
882
882
  -- 7. Jorge Journey - Pro Monthly CANCELED (voluntary - user decided to cancel)
883
- ('sub-blog-jorge', 'team-blog-jorge', 'usr-blog-jorge', 'plan_pro', 'canceled',
883
+ ('sub-blog-jorge', 'team-blog-jorge', 'usr-blog-jorge', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'canceled',
884
884
  NOW() - INTERVAL '45 days', NOW() - INTERVAL '15 days', 'monthly',
885
885
  'stripe', 'sub_stripe_jorge', 'cus_jorge',
886
886
  NOW() - INTERVAL '9 months', NOW() - INTERVAL '15 days', false,
887
887
  '{"cancelReason": "voluntary", "canceledBy": "user", "feedback": "taking a break"}'::jsonb),
888
888
 
889
889
  -- 8. Karen Kitchen - Pro Yearly CANCELED (payment failed 4 times)
890
- ('sub-blog-karen', 'team-blog-karen', 'usr-blog-karen', 'plan_pro', 'canceled',
890
+ ('sub-blog-karen', 'team-blog-karen', 'usr-blog-karen', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'canceled',
891
891
  NOW() - INTERVAL '400 days', NOW() - INTERVAL '35 days', 'yearly',
892
892
  'stripe', 'sub_stripe_karen', 'cus_karen',
893
893
  NOW() - INTERVAL '15 months', NOW() - INTERVAL '35 days', false,
894
894
  '{"cancelReason": "payment_failed", "failedAttempts": 4, "lastCardEnding": "4242"}'::jsonb),
895
895
 
896
896
  -- 9. Leo Learn - Free Plan ACTIVE (no billing)
897
- ('sub-blog-leo', 'team-blog-leo', 'usr-blog-leo', 'plan_free', 'active',
897
+ ('sub-blog-leo', 'team-blog-leo', 'usr-blog-leo', (SELECT id FROM public."plans" WHERE slug = 'free'), 'active',
898
898
  NOW() - INTERVAL '2 months', NOW() + INTERVAL '100 years', 'monthly',
899
899
  NULL, NULL, NULL,
900
900
  NOW() - INTERVAL '2 months', NULL, false, '{}'::jsonb),
901
901
 
902
902
  -- 10. Maria Music - Pro Monthly TRIALING (5 days in)
903
- ('sub-blog-maria', 'team-blog-maria', 'usr-blog-maria', 'plan_pro', 'trialing',
903
+ ('sub-blog-maria', 'team-blog-maria', 'usr-blog-maria', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'trialing',
904
904
  NOW() - INTERVAL '5 days', NOW() + INTERVAL '25 days', 'monthly',
905
905
  NULL, NULL, NULL,
906
906
  NOW() - INTERVAL '5 days', NULL, false, '{}'::jsonb)
package/package.json CHANGED
@@ -1,22 +1,30 @@
1
1
  {
2
2
  "name": "@nextsparkjs/theme-blog",
3
- "version": "0.1.0-beta.19",
3
+ "version": "0.1.0-beta.191",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./config/theme.config.ts",
7
7
  "requiredPlugins": [],
8
- "dependencies": {},
8
+ "dependencies": {
9
+ "@tailwindcss/container-queries": "^0.1.1",
10
+ "dompurify": "^3.2.7",
11
+ "next-themes": "^0.4.6"
12
+ },
9
13
  "peerDependencies": {
14
+ "@nextsparkjs/core": ">=0.1.0-0",
15
+ "@nextsparkjs/testing": ">=0.1.0-0",
10
16
  "lucide-react": "^0.539.0",
11
- "next": "^15.0.0",
17
+ "next": "^15.0.0 || ^16.0.0",
12
18
  "next-intl": "^4.0.0",
13
19
  "react": "^19.0.0",
14
20
  "react-dom": "^19.0.0",
15
- "zod": "^4.0.0",
16
- "@nextsparkjs/core": "0.1.0-beta.19"
21
+ "zod": "^4.0.0"
17
22
  },
18
23
  "nextspark": {
19
24
  "type": "theme",
20
25
  "name": "blog"
26
+ },
27
+ "devDependencies": {
28
+ "@types/dompurify": "^3.2.0"
21
29
  }
22
30
  }
@@ -3,8 +3,54 @@
3
3
  *
4
4
  * Warm amber/orange palette with editorial typography.
5
5
  * Fonts: Oxanium (sans), Merriweather (serif), Fira Code (mono)
6
+ *
7
+ * ÚNICA FUENTE DE VERDAD para estilos del theme.
8
+ * Editar este archivo para customizar el design system.
6
9
  */
7
10
 
11
+ /* =============================================
12
+ IMPORTS
13
+ ============================================= */
14
+ @import "tailwindcss";
15
+
16
+ /* =============================================
17
+ TAILWIND v4 DARK MODE CONFIGURATION
18
+
19
+ Por defecto Tailwind v4 usa @media (prefers-color-scheme: dark)
20
+ para las variantes dark:. Esto causa problemas porque:
21
+ - next-themes usa la clase .dark en el HTML
22
+ - El sistema operativo puede tener dark mode aunque la app use light
23
+
24
+ Esta configuración hace que Tailwind use el selector de clase .dark
25
+ en lugar de la media query, sincronizándose con next-themes.
26
+ ============================================= */
27
+ @custom-variant dark (&:where(.dark, .dark *));
28
+
29
+ @plugin "@tailwindcss/container-queries";
30
+ /* @nextsparkjs/core/styles/ui.css is intentionally NOT imported here: it is
31
+ pre-compiled by packages/core/scripts/build/build-ui-css.mjs with a plain
32
+ `@import "tailwindcss"` that has no knowledge of this theme's
33
+ `@custom-variant dark` override above, so it bakes every dark: utility it
34
+ contains (including ThemeToggle's own dark:-rotate-90/scale-0 icon
35
+ classes) using Tailwind's default `@media (prefers-color-scheme: dark)`
36
+ strategy. Importing that already-compiled CSS re-declares the same
37
+ classes with that conflicting strategy, so they stop following
38
+ next-themes' `.dark`/`.light` class and instead silently track the
39
+ visitor's OS/browser color-scheme preference (issue #110). The @source
40
+ globs below already scan core's source (monorepo) and its compiled dist
41
+ output (npm) directly into THIS compilation, so they pick up the same
42
+ classes correctly scoped to `@custom-variant dark` — the separate ui.css
43
+ import is redundant. */
44
+ @import "@nextsparkjs/core/styles/utilities.css";
45
+ @import "@nextsparkjs/core/styles/docs.css";
46
+ @source "../../../**/*.{js,ts,jsx,tsx}";
47
+ /* Core package: monorepo (short path) + npm projects (long path) */
48
+ @source "../node_modules/@nextsparkjs/core/dist/**/*.js";
49
+ @source "../../../../node_modules/@nextsparkjs/core/dist/**/*.js";
50
+
51
+ /* =============================================
52
+ BLOG THEME - LIGHT MODE
53
+ ============================================= */
8
54
  :root {
9
55
  --background: oklch(0.9885 0.0057 84.5659);
10
56
  --foreground: oklch(0.3660 0.0251 49.6085);
@@ -152,6 +198,18 @@
152
198
  --shadow-2xl: var(--shadow-2xl);
153
199
  }
154
200
 
201
+ /* =============================================
202
+ BASE STYLES
203
+ ============================================= */
204
+ * {
205
+ border-color: var(--border);
206
+ }
207
+
208
+ body {
209
+ background-color: var(--background);
210
+ color: var(--foreground);
211
+ }
212
+
155
213
  /* ============================================================================
156
214
  TYPOGRAPHY
157
215
  ============================================================================ */
@@ -13,6 +13,7 @@ import Image from 'next/image'
13
13
  import { Twitter, Linkedin, Globe, User } from 'lucide-react'
14
14
  import { PostCard } from '@/themes/blog/components/public/PostCard'
15
15
  import { cn } from '@nextsparkjs/core/lib/utils'
16
+ import { withBasePath, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
16
17
 
17
18
  interface Author {
18
19
  id: string
@@ -60,7 +61,7 @@ export default function AuthorPage({ params }: PageProps) {
60
61
  setLoading(true)
61
62
  setError(null)
62
63
 
63
- const response = await fetch(`/api/v1/theme/blog/authors/${username}`, {
64
+ const response = await fetch(withBasePath(`/api/v1/theme/blog/authors/${username}`), {
64
65
  method: 'GET',
65
66
  headers: {
66
67
  'Content-Type': 'application/json',
@@ -137,7 +138,7 @@ export default function AuthorPage({ params }: PageProps) {
137
138
  <div className="flex-shrink-0">
138
139
  {author.image ? (
139
140
  <Image
140
- src={author.image}
141
+ src={withBasePathIfInApp(author.image)}
141
142
  alt={author.name}
142
143
  width={128}
143
144
  height={128}
@@ -165,7 +166,7 @@ export default function AuthorPage({ params }: PageProps) {
165
166
  <div className="flex items-center gap-4">
166
167
  {author.socialTwitter && (
167
168
  <a
168
- href={author.socialTwitter}
169
+ href={withBasePathIfInApp(author.socialTwitter)}
169
170
  target="_blank"
170
171
  rel="noopener noreferrer"
171
172
  className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -176,7 +177,7 @@ export default function AuthorPage({ params }: PageProps) {
176
177
  )}
177
178
  {author.socialLinkedin && (
178
179
  <a
179
- href={author.socialLinkedin}
180
+ href={withBasePathIfInApp(author.socialLinkedin)}
180
181
  target="_blank"
181
182
  rel="noopener noreferrer"
182
183
  className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -187,7 +188,7 @@ export default function AuthorPage({ params }: PageProps) {
187
188
  )}
188
189
  {author.socialWebsite && (
189
190
  <a
190
- href={author.socialWebsite}
191
+ href={withBasePathIfInApp(author.socialWebsite)}
191
192
  target="_blank"
192
193
  rel="noopener noreferrer"
193
194
  className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -10,6 +10,7 @@ import { useState, useEffect } from 'react'
10
10
  import Link from 'next/link'
11
11
  import Image from 'next/image'
12
12
  import { User } from 'lucide-react'
13
+ import { withBasePath, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
13
14
 
14
15
  interface Author {
15
16
  id: string
@@ -31,7 +32,7 @@ export default function AuthorsPage() {
31
32
  setLoading(true)
32
33
  setError(null)
33
34
 
34
- const response = await fetch('/api/v1/theme/blog/authors', {
35
+ const response = await fetch(withBasePath('/api/v1/theme/blog/authors'), {
35
36
  method: 'GET',
36
37
  headers: {
37
38
  'Content-Type': 'application/json',
@@ -115,7 +116,7 @@ export default function AuthorsPage() {
115
116
  <div className="flex justify-center mb-4">
116
117
  {author.image ? (
117
118
  <Image
118
- src={author.image}
119
+ src={withBasePathIfInApp(author.image)}
119
120
  alt={author.name}
120
121
  width={80}
121
122
  height={80}
@@ -14,6 +14,7 @@ import { useTranslations } from 'next-intl'
14
14
  import { PostCard } from '@/themes/blog/components/public/PostCard'
15
15
  import { cn } from '@nextsparkjs/core/lib/utils'
16
16
  import { Loader2 } from 'lucide-react'
17
+ import { withBasePath } from '@nextsparkjs/core/lib/base-path'
17
18
 
18
19
  interface Post {
19
20
  id: string
@@ -65,7 +66,7 @@ export default function BlogHomePage() {
65
66
 
66
67
  // Fetch published posts from ALL authors via public endpoint
67
68
  // This endpoint does not require authentication and aggregates posts cross-team
68
- const response = await fetch('/api/v1/theme/blog/posts/public?limit=20', {
69
+ const response = await fetch(withBasePath('/api/v1/theme/blog/posts/public?limit=20'), {
69
70
  method: 'GET',
70
71
  headers: {
71
72
  'Content-Type': 'application/json',
@@ -12,10 +12,12 @@ import Link from 'next/link'
12
12
  import Image from 'next/image'
13
13
  import { ArrowLeft, Calendar, Clock, Loader2, Share2, Bookmark } from 'lucide-react'
14
14
  import { use, useEffect, useState } from 'react'
15
+ import { sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
15
16
  import { ReadingProgress } from '@/themes/blog/components/public/ReadingProgress'
16
17
  import { AuthorBio } from '@/themes/blog/components/public/AuthorBio'
17
18
  import { RelatedPosts } from '@/themes/blog/components/public/RelatedPosts'
18
19
  import { Button } from '@nextsparkjs/core/components/ui/button'
20
+ import { withBasePath, withBasePathInHtml, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
19
21
 
20
22
  interface Post {
21
23
  id: string
@@ -57,7 +59,7 @@ async function fetchPost(identifier: string): Promise<Post | null> {
57
59
  try {
58
60
  const headers = buildHeaders()
59
61
 
60
- const byIdResponse = await fetch(`/api/v1/posts?ids=${encodeURIComponent(identifier)}&status=published&limit=1`, {
62
+ const byIdResponse = await fetch(withBasePath(`/api/v1/posts?ids=${encodeURIComponent(identifier)}&status=published&limit=1`), {
61
63
  credentials: 'include',
62
64
  headers,
63
65
  })
@@ -69,7 +71,7 @@ async function fetchPost(identifier: string): Promise<Post | null> {
69
71
  }
70
72
  }
71
73
 
72
- const bySlugResponse = await fetch(`/api/v1/posts?slug=${encodeURIComponent(identifier)}&status=published&limit=1`, {
74
+ const bySlugResponse = await fetch(withBasePath(`/api/v1/posts?slug=${encodeURIComponent(identifier)}&status=published&limit=1`), {
73
75
  credentials: 'include',
74
76
  headers,
75
77
  })
@@ -145,7 +147,7 @@ async function fetchRelatedPosts(currentPostId: string): Promise<RelatedPost[]>
145
147
  const headers = buildHeaders()
146
148
 
147
149
  // Fetch latest published posts
148
- const response = await fetch('/api/v1/posts?status=published&limit=4&sortBy=publishedAt&sortOrder=desc', {
150
+ const response = await fetch(withBasePath('/api/v1/posts?status=published&limit=4&sortBy=publishedAt&sortOrder=desc'), {
149
151
  credentials: 'include',
150
152
  headers,
151
153
  })
@@ -281,7 +283,7 @@ export default function PostPage({ params }: PageProps) {
281
283
  <figure className="mb-12">
282
284
  <div className="aspect-[21/9] relative overflow-hidden rounded-xl">
283
285
  <Image
284
- src={post.featuredImage}
286
+ src={withBasePathIfInApp(post.featuredImage)}
285
287
  alt={post.title}
286
288
  fill
287
289
  className="object-cover"
@@ -294,7 +296,7 @@ export default function PostPage({ params }: PageProps) {
294
296
 
295
297
  {/* Article Content */}
296
298
  <div className="article-content">
297
- <div dangerouslySetInnerHTML={{ __html: renderContent(post.content) }} />
299
+ <div dangerouslySetInnerHTML={{ __html: withBasePathInHtml(sanitizePostHtml(renderContent(post.content))) }} />
298
300
  </div>
299
301
 
300
302
  {/* Divider */}