Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
62cf975
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
a5483ff
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8caad53
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
a3f02c0
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8083c2c
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
55967a4
page: add "test page" (en)
Infi-Knight Feb 9, 2026
0dccd37
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
f186c18
page: add "test page" (en)
Infi-Knight Feb 9, 2026
384169e
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
9ba6677
page: add "test page" (en)
Infi-Knight Feb 9, 2026
ff793b8
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
90dfc40
page: add "test page" (en)
Infi-Knight Feb 9, 2026
c3ba778
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
3c29210
page: add "test page" (en)
Infi-Knight Feb 9, 2026
792388d
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
d062943
page: add "test page" (en)
Infi-Knight Feb 9, 2026
4f67873
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
c704cbd
page: add "test page" (en)
Infi-Knight Feb 9, 2026
19be915
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
07f6cb2
page: add "test page" (en)
Infi-Knight Feb 9, 2026
fe9e1d2
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
b636e2b
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8dfb330
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
542748d
page: add "test page" (en)
Infi-Knight Feb 9, 2026
9e16a79
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
4ab8a86
page: add "test page" (en)
Infi-Knight Feb 9, 2026
91830ad
wip: blockquote
Infi-Knight Feb 9, 2026
0f7798d
update readme
Infi-Knight Feb 9, 2026
1dd5d46
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
f221c60
page: add "test page" (en)
Infi-Knight Feb 10, 2026
1c5244a
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
50547db
page: add "test page" (en)
Infi-Knight Feb 10, 2026
b40755c
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
0c43d07
page: add "test page" (en)
Infi-Knight Feb 10, 2026
14f3751
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
04bf7ca
page: add "test page" (en)
Infi-Knight Feb 10, 2026
55e89f9
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
f08b729
page: add "test page" (en)
Infi-Knight Feb 10, 2026
29de6a3
use non-rich text for quote src, styling fixes
Infi-Knight Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions cms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ The token must have read access to the content types you want to preview (includ
2. Create an SSR page in `src/pages/` (e.g. `my-type-preview.astro`) with `export const prerender = false`
3. In that page, use `fetchStrapi()` with `status=draft` to fetch the draft content and render it

#### Adding a new block to the page dynamic zone

When you add a new block component to the page content dynamic zone, you **must** also add it to the populate params in `src/pages/page-preview.astro`. In Strapi v5, using `on` (component-specific population) for a dynamic zone acts as a filter — only block types listed in `on` clauses are returned in the API response. Unlisted types are silently excluded.

For blocks with only scalar fields (richtext, string, enum):

```js
'populate[content][on][blocks.my-block][populate]': '*'
```

For blocks with nested components or relations:

```js
'populate[content][on][blocks.my-block][populate][myRelation][populate]': '*'
```

If you forget this step, the block will not appear in the preview even though it exists in Strapi.

#### Component architecture: presentational vs block components

Every Strapi dynamic zone block type needs a corresponding **block component** in `src/components/blocks/` so that the `DynamicZone` component can render it during preview. Whether you also need a separate **presentational component** depends on the data shape.
Expand All @@ -142,6 +160,33 @@ For published content, the MDX lifecycle hook in Strapi handles these transforma

**Rule of thumb:** If you need to transform Strapi's API response before rendering (flatten nested objects, convert markdown to HTML, resolve relations), create a block adapter in `src/components/blocks/` that does the transformation and delegates to a presentational component. Otherwise, a single component in `src/components/blocks/` is fine.

#### Styling rendered HTML from `set:html`

Components that render Strapi richtext fields use `set:html` to inject HTML converted from markdown. Since this injected HTML doesn't receive Astro's scoped data attributes, child elements can inherit unwanted styles from page-level prose selectors (e.g. `[&_strong]:text-primary`).

There are two approaches to control styling of `set:html` content:

**Option A — Tailwind arbitrary variants on container elements:**

```html
<blockquote class="[&_strong]:text-inherit [&_p]:mb-0 [&_em]:italic">
```

Consistent with the pattern used in `[...page].astro` and `Paragraph.astro`. Keeps everything in the template but can get verbose with many overrides.

**Option B — Astro scoped `<style>` with `:global()`:**

```css
<style>
blockquote :global(strong) { color: inherit; }
blockquote :global(p) { margin-bottom: 0; }
</style>
```

The parent selector (`blockquote`) retains Astro's scoped attribute, so styles only apply within that component — they won't leak to other parts of the page. `:global()` removes scoping from the child selector so it can reach the injected HTML. Cleaner when there are multiple overrides.

See `Blockquote.astro` for an example using Option B.

## Development Workflow

1. **Start the CMS**: `cd cms && npm run develop`
Expand Down
36 changes: 36 additions & 0 deletions cms/src/api/page/content-types/page/lifecycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ interface AmbassadorsGridBlock {
ambassadors?: AmbassadorRef[]
}

interface BlockquoteBlock {
__component: 'blocks.blockquote'
id: number
quote: string
source?: string
}

type ContentBlock =
| CardsGrid
| CardLinksGrid
Expand All @@ -148,6 +155,7 @@ type ContentBlock =
| ImageRow
| AmbassadorBlock
| AmbassadorsGridBlock
| BlockquoteBlock

interface Page {
id: number
Expand Down Expand Up @@ -425,6 +433,22 @@ function serializeAmbassadorsGrid(block: AmbassadorsGridBlock): string {
return lines.join('\n')
}

/**
* Serializes a blockquote block to MDX
*/
function serializeBlockquote(block: BlockquoteBlock): string {
const attrs = [
`quote="${escapeQuotes(block.quote)}"`,
block.source
? `source="${escapeQuotes(htmlToMarkdown(block.source))}"`
: null
]
.filter(Boolean)
.join(' ')

return `<Blockquote ${attrs} />`
}

/**
* Serializes the content dynamic zone to MDX
*/
Expand Down Expand Up @@ -459,6 +483,9 @@ function serializeContent(content: ContentBlock[] | undefined): string {
case 'blocks.ambassadors-grid':
blocks.push(serializeAmbassadorsGrid(block as AmbassadorsGridBlock))
break
case 'blocks.blockquote':
blocks.push(serializeBlockquote(block as BlockquoteBlock))
break
default:
console.warn(
`Unknown block component: ${(block as ContentBlock).__component}`
Expand Down Expand Up @@ -551,6 +578,15 @@ function generateMDX(page: Page): string {
)
}

const hasBlockquote = page.content?.some(
(block) => block.__component === 'blocks.blockquote'
)
if (hasBlockquote) {
imports.push(
'import Blockquote from "../../components/blockquote/Blockquote.astro"'
)
}

const importBlock = imports.length > 0 ? imports.join('\n') + '\n\n' : ''

return `---\n${frontmatter}\n---\n\n${importBlock}${content}\n`
Expand Down
3 changes: 2 additions & 1 deletion cms/src/api/page/content-types/page/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"blocks.carousel",
"blocks.cta-banner",
"blocks.ambassador",
"blocks.ambassadors-grid"
"blocks.ambassadors-grid",
"blocks.blockquote"
]
}
}
Expand Down
18 changes: 18 additions & 0 deletions cms/src/components/blocks/blockquote.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"collectionName": "components_blocks_blockquotes",
"info": {
"displayName": "Blockquote",
"icon": "quote",
"description": "A styled blockquote with optional attribution"
},
"options": {},
"attributes": {
"quote": {
"type": "text",
"required": true
},
"source": {
"type": "richtext"
}
}
}
14 changes: 14 additions & 0 deletions cms/types/generated/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ export interface BlocksAmbassadorsGrid extends Struct.ComponentSchema {
}
}

export interface BlocksBlockquote extends Struct.ComponentSchema {
collectionName: 'components_blocks_blockquotes'
info: {
description: 'A styled blockquote with optional attribution'
displayName: 'Blockquote'
icon: 'quote'
}
attributes: {
quote: Schema.Attribute.Text & Schema.Attribute.Required
source: Schema.Attribute.RichText
}
}

export interface BlocksCard extends Struct.ComponentSchema {
collectionName: 'components_blocks_cards'
info: {
Expand Down Expand Up @@ -289,6 +302,7 @@ declare module '@strapi/strapi' {
export interface ComponentSchemas {
'blocks.ambassador': BlocksAmbassador
'blocks.ambassadors-grid': BlocksAmbassadorsGrid
'blocks.blockquote': BlocksBlockquote
'blocks.card': BlocksCard
'blocks.card-link': BlocksCardLink
'blocks.card-links-grid': BlocksCardLinksGrid
Expand Down
3 changes: 2 additions & 1 deletion cms/types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,8 @@ export interface ApiPagePage extends Struct.CollectionTypeSchema {
'blocks.carousel',
'blocks.cta-banner',
'blocks.ambassador',
'blocks.ambassadors-grid'
'blocks.ambassadors-grid',
'blocks.blockquote'
]
>
createdAt: Schema.Attribute.DateTime
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/markdown-it": "^14.1.2",
"@typescript-eslint/parser": "^8.48.0",
"astro-eslint-parser": "^1.2.2",
"eslint": "^9.39.1",
Expand Down
78 changes: 78 additions & 0 deletions src/components/blockquote/Blockquote.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
import MarkdownIt from 'markdown-it'

interface Props {
quote: string
source?: string
}

const { quote, source } = Astro.props

const md = new MarkdownIt({ html: true, linkify: true, typographer: true })
const htmlSource = source ? md.renderInline(source) : ''

// Strip any existing quotes and add curly quotes for consistent output
// Handles straight quotes ("), curly quotes (" " ' '), and single quotes (' ')
let trimmedQuote = quote.trim()

// Strip any leading quote (any type)
if (
trimmedQuote.startsWith('"') ||
trimmedQuote.startsWith('"') ||
trimmedQuote.startsWith("'") ||
trimmedQuote.startsWith("'")
) {
trimmedQuote = trimmedQuote.slice(1)
}

// Strip any trailing quote (any type)
if (
trimmedQuote.endsWith('"') ||
trimmedQuote.endsWith('"') ||
trimmedQuote.endsWith("'") ||
trimmedQuote.endsWith("'")
) {
trimmedQuote = trimmedQuote.slice(0, -1)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 20 lines can be cleaned up with regex

something like
trimmedQuote = trimmedQuote.replace(/^["'"']|["'"']$/g, '');


// Always use curly quotes for consistent styling
const formattedQuote = `“${trimmedQuote.trim()}”`
---

<div class="ps-space-s py-space-2xs border-l-4 border-primary mb-space-s">
<blockquote class="text-step-1">
<p>{formattedQuote}</p>
</blockquote>
{
htmlSource && (
<p>
— <cite class="text-step-1" set:html={htmlSource} />
</p>
)
}
</div>

<!--
Styling rendered HTML from set:html

Since set:html injects raw HTML that doesn't receive Astro's scoped data
attributes, we use :global() to target injected child elements while keeping
the parent selector scoped to this component.
-->
<style>
/* Reset inherited prose styles for rendered HTML inside cite */
cite {
font-weight: 200;
font-style: normal;
}

cite :global(strong) {
color: inherit;
font-weight: 400;
font-style: italic;
}

cite :global(em) {
font-style: italic;
}
</style>
20 changes: 20 additions & 0 deletions src/components/blocks/BlockquoteBlock.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
/**
* Blockquote Block for Dynamic Zones (SSR Preview)
* Passes quote as plain text, converts source markdown to HTML
*/

import Blockquote from '../blockquote/Blockquote.astro'
import { marked } from 'marked'

interface Props {
quote: string
source?: string
}

const { quote, source } = Astro.props

const htmlSource = source ? await marked.parseInline(source) : undefined
---

<Blockquote quote={quote} source={htmlSource} />
4 changes: 3 additions & 1 deletion src/components/blocks/DynamicZone.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Carousel from './Carousel.astro'
import CtaBanner from './CtaBanner.astro'
import AmbassadorBlock from './AmbassadorBlock.astro'
import AmbassadorsGridBlock from './AmbassadorsGridBlock.astro'
import BlockquoteBlock from './BlockquoteBlock.astro'

interface Block {
__component: string
Expand All @@ -34,7 +35,8 @@ const componentMap: Record<string, any> = {
'blocks.carousel': Carousel,
'blocks.cta-banner': CtaBanner,
'blocks.ambassador': AmbassadorBlock,
'blocks.ambassadors-grid': AmbassadorsGridBlock
'blocks.ambassadors-grid': AmbassadorsGridBlock,
'blocks.blockquote': BlockquoteBlock
}

const resolvedBlocks = blocks
Expand Down
5 changes: 4 additions & 1 deletion src/content/foundation-pages/test-page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ title: "test page"
---

import AmbassadorGrid from "../../components/ambassadors/AmbassadorGrid.astro"
import Blockquote from "../../components/blockquote/Blockquote.astro"

test paragraph new
For creators, publishers, and community-run platforms, building a sustainable future on the web has become increasingly difficult. Advertising revenues continue to decline, audiences are experiencing subscription fatigue, and smaller publishers are often left choosing between paywalls, platform dependence, or giving content away for.

<AmbassadorGrid slugs={["caroline-sinders","erica-hargreave"]} />

<Blockquote quote="This was a key milestone for the Interledger card initiative. We confirmed that EMV Kernel C-2 works seamlessly on MPOC-certified KaiOS POS devices and aligns fully with our custom Interledger card chip design. Confident, standards-aligned, and ready for the next phase." source="_Tadej_ looks back on the trip (He is all business here, but he also discovered a deep love for bubble tea. IYKYK.)" />
2 changes: 1 addition & 1 deletion src/pages/[...page].astro
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const pageTitle = title || page.data.slug
<section class="py-space-xl">
<div class="mx-auto max-w-content min-w-90 max-[1324px]:px-[5%]">
<div
class="[&_h2]:mt-space-l [&_h2]:mb-space-s [&_h2]:text-[1.75rem] [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:mt-space-m [&_h3]:mb-space-xs [&_h3]:text-[1.25rem] [&_h3]:font-semibold [&_p]:mb-space-s [&_p]:leading-[1.6] [&_ul]:mb-space-s [&_ul]:ps-space-m [&_li]:mb-space-xs [&_a]:text-primary [&_a]:underline [&_a:hover]:no-underline"
class="[&_h2]:mt-space-l [&_h2]:mb-space-s [&_h2]:text-[1.75rem] [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:mt-space-m [&_h3]:mb-space-xs [&_h3]:text-[1.25rem] [&_h3]:font-semibold [&>p]:mb-space-s [&>p]:leading-[1.6] [&_ul]:mb-space-s [&_ul]:ps-space-m [&_li]:mb-space-xs [&_a]:text-primary [&_a]:underline [&_a:hover]:no-underline"
>
<Content />
</div>
Expand Down
15 changes: 10 additions & 5 deletions src/pages/page-preview.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,22 @@ if (!documentId) {
}

// Build populate query for dynamic zone with nested relations
// Note: In Strapi v5, using `on` for any component in a dynamic zone
// requires listing ALL component types — unlisted types are excluded.
const populateParams = new URLSearchParams({
status: 'draft',
'populate[hero][populate]': '*',
'populate[seo][populate]': '*',
'populate[content][populate]': '*',
'populate[content][on][blocks.ambassadors-grid][populate][ambassadors][populate]':
'*',
'populate[content][on][blocks.paragraph][populate]': '*',
'populate[content][on][blocks.blockquote][populate]': '*',
'populate[content][on][blocks.cta-banner][populate]': '*',
'populate[content][on][blocks.cards-grid][populate][cards]': '*',
'populate[content][on][blocks.card-links-grid][populate][cards]': '*',
'populate[content][on][blocks.carousel][populate][items][populate]': '*',
'populate[content][on][blocks.ambassador][populate][ambassador][populate]':
'*',
'populate[content][on][blocks.cards-grid][populate][cards]': '*',
'populate[content][on][blocks.carousel][populate][items][populate]': '*'
'populate[content][on][blocks.ambassadors-grid][populate][ambassadors][populate]':
'*'
})

const response = await fetchStrapi(
Expand Down