Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [使用技術](#使用技術)
- [アーキテクチャ](#アーキテクチャ)
- [環境構築](#環境構築)
- [スポンサー記事](#スポンサー記事)
- [Terraform](#terraform)
- [テスト](#テスト)
- [ディレクトリ構成](#ディレクトリ構成)
Expand Down Expand Up @@ -132,6 +133,24 @@ docker compose down

<p align="right">(<a href="#top">トップへ</a>)</p>

## スポンサー記事

microCMSの`blog` APIに以下のフィールドを追加する。

| フィールドID | 種類 | 設定 |
| ------------- | -------- | --------------------- |
| `isSponsored` | 真偽値 | 初期値を`false`にする |
| `sponsorName` | テキスト | 広告主の正式名称 |
| `sponsorUrl` | テキスト | 広告主の公式URL |

`isSponsored`を有効にした記事では、一覧と記事上部にPR表示が追加される。本文中のリンクは、`sponsorUrl`と同じドメインまたはそのサブドメインに限り`rel="sponsored"`が付与される。

スポンサー記事では`sponsorName`と有効な`sponsorUrl`が必須となり、不足している場合はビルドが失敗する。

スポンサー記事は、初回公開時のOneSignalプッシュ通知から自動的に除外される。

<p align="right">(<a href="#top">トップへ</a>)</p>

## Terraform

```
Expand Down
6 changes: 4 additions & 2 deletions e2e/fixtures/content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@ const createArticle = (index, baseUrl) => {
],
content_blocks: [
{
rich_text:
'<h2 id="main-flow">主要導線</h2><p>記事一覧、カテゴリ、タグ、検索を確認します。</p><h3 id="stable-ci">安定したCI</h3><p>外部サービスはモックして、ブラウザ上の振る舞いを検証します。</p>',
rich_text: `<h2 id="main-flow">主要導線</h2><p>記事一覧、カテゴリ、タグ、検索を確認します。</p><h3 id="stable-ci">安定したCI</h3><p>外部サービスはモックして、ブラウザ上の振る舞いを検証します。</p>${index === 1 ? '<p><a href="https://shop.sponsor.example/product">スポンサーリンク</a><a href="https://reference.example/source">参考リンク</a></p>' : ''}`,
},
],
isSponsored: index === 1,
sponsorName: index === 1 ? 'Example Sponsor' : undefined,
sponsorUrl: index === 1 ? 'https://sponsor.example' : undefined,
createdAt: publishedAt,
publishedAt,
revisedAt: publishedAt,
Expand Down
23 changes: 23 additions & 0 deletions e2e/sponsored.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';

test('discloses sponsored articles and marks only sponsor links', async ({ page }) => {
await page.goto('/');

const sponsoredCard = page.locator('a[href="/articles/e2e-article-1"]').first();
await expect(sponsoredCard.getByText('PR')).toBeVisible();

await sponsoredCard.click();

await expect(page.getByText('PR', { exact: true })).toBeVisible();
await expect(
page.getByText('本記事は、Example Sponsorから依頼を受けて制作した広告です。'),
).toBeVisible();
await expect(page.getByRole('link', { name: 'スポンサーリンク' })).toHaveAttribute(
'rel',
'sponsored',
);
await expect(page.getByRole('link', { name: '参考リンク' })).not.toHaveAttribute(
'rel',
/sponsored/,
);
});
2 changes: 1 addition & 1 deletion pkg/api/contentops/onesignal.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ func NotifyExternalArticlesFirstPublishWithOneSignal(ctx context.Context, articl
}

func notifyMicroCMSFirstPublishWithOneSignal(ctx context.Context, config s3BackupConfig, credentials awsCredentials, payload microCMSWebhookPayload, now time.Time) (oneSignalNotificationResult, error) {
if !isMicroCMSFirstPublishWebhook(payload) {
if !isMicroCMSFirstPublishWebhook(payload) || isMicroCMSSponsoredArticle(payload) {
return oneSignalNotificationResult{}, nil
}

Expand Down
31 changes: 31 additions & 0 deletions pkg/api/contentops/onesignal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ func TestOneSignalIdempotencyKey(t *testing.T) {
}
}

func TestNotifyMicroCMSFirstPublishWithOneSignalSkipsSponsoredArticle(t *testing.T) {
payload := microCMSWebhookPayload{
API: "blog",
ID: "article-a",
Type: "new",
Contents: &microCMSWebhookPayloadState{
New: &microCMSWebhookContentState{
Status: []string{"PUBLISH"},
PublishValue: map[string]interface{}{
"title": "Sponsored Article",
"isSponsored": true,
},
},
},
}

result, err := notifyMicroCMSFirstPublishWithOneSignal(
t.Context(),
s3BackupConfig{},
awsCredentials{},
payload,
time.Now(),
)
if err != nil {
t.Fatalf("notifyMicroCMSFirstPublishWithOneSignal() error = %v", err)
}
if result != (oneSignalNotificationResult{}) {
t.Fatalf("result = %#v, want empty result", result)
}
}

func assertOneSignalPushTargeting(t *testing.T, body map[string]interface{}) {
t.Helper()

Expand Down
10 changes: 10 additions & 0 deletions pkg/api/contentops/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ func microCMSWebhookArticleTitle(payload microCMSWebhookPayload) string {
return strings.TrimSpace(microCMSBackupStringValue(publishedValue["title"]))
}

func isMicroCMSSponsoredArticle(payload microCMSWebhookPayload) bool {
publishedValue := microCMSWebhookPublishedValue(payload)
if publishedValue == nil {
return false
}

isSponsored, ok := publishedValue["isSponsored"].(bool)
return ok && isSponsored
}

func expectedMicroCMSWebhookSignature(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
Expand Down
29 changes: 29 additions & 0 deletions pkg/api/contentops/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,32 @@ func TestIsMicroCMSFirstPublishWebhook(t *testing.T) {
})
}
}

func TestIsMicroCMSSponsoredArticle(t *testing.T) {
for _, testCase := range []struct {
name string
value interface{}
want bool
}{
{name: "sponsored article", value: true, want: true},
{name: "regular article", value: false, want: false},
{name: "missing flag", value: nil, want: false},
{name: "invalid flag", value: "true", want: false},
} {
t.Run(testCase.name, func(t *testing.T) {
publishValue := map[string]interface{}{"title": "Article A"}
if testCase.value != nil {
publishValue["isSponsored"] = testCase.value
}
payload := microCMSWebhookPayload{
Contents: &microCMSWebhookPayloadState{
New: &microCMSWebhookContentState{PublishValue: publishValue},
},
}

if got := isMicroCMSSponsoredArticle(payload); got != testCase.want {
t.Fatalf("isMicroCMSSponsoredArticle() = %t, want %t", got, testCase.want)
}
})
}
}
15 changes: 15 additions & 0 deletions src/components/Common/ArticleCard/__tests__/ArticleCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import ArticleCard from '@/components/Common/ArticleCard';
import { createArticle } from '@/test/factories';

describe('ArticleCard', () => {
it('shows a PR badge only for sponsored articles', () => {
const { rerender } = render(<ArticleCard article={createArticle({ isSponsored: true })} />);

expect(screen.getByText('PR')).toBeInTheDocument();

rerender(<ArticleCard article={createArticle({ isSponsored: false })} />);
expect(screen.queryByText('PR')).not.toBeInTheDocument();
});
});
4 changes: 4 additions & 0 deletions src/components/Common/ArticleCard/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
overflow-wrap: anywhere;
}

.content > :first-child + .title {
margin-top: 0.5rem;
}

@media (max-width: 1023px) and (min-width: 641px) {
.image {
height: auto;
Expand Down
2 changes: 2 additions & 0 deletions src/components/Common/ArticleCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import styles from './index.module.css';
import DoubleDate from '../DoubleDate';
import WebpImage from '../Elements/WebpImage';
import { getThemeClassName, surfaceClassNames } from '@/styles/designTokens';
import SponsoredDisclosure from '../SponsoredDisclosure';

type Props = {
article: Article;
Expand All @@ -26,6 +27,7 @@ export default function ArticleCard({ article, priority = false }: Props) {
>
<WebpImage article={article} card={true} priority={priority} />
<div className={styles.content}>
{article.isSponsored && <SponsoredDisclosure compact />}
<div className={styles.title}>{article.title}</div>
<div className={styles.description}>{article.description}</div>
<DoubleDate article={article} />
Expand Down
32 changes: 32 additions & 0 deletions src/components/Common/SponsoredDisclosure/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.badge {
display: inline-flex;
width: fit-content;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
height: 1.4rem;
padding: 0 0.5rem;
}

.disclosure {
display: flex;
align-items: center;
gap: 0.625rem;
border-width: 1px;
font-size: 0.875rem;
line-height: 1.6;
margin-bottom: 1.5rem;
padding: 0.65rem 0.75rem;
}

.disclosure .badge {
flex: 0 0 auto;
}

@media (max-width: 640px) {
.disclosure {
margin-bottom: 1.25rem;
}
}
40 changes: 40 additions & 0 deletions src/components/Common/SponsoredDisclosure/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';

import { useTheme } from 'next-themes';
import styles from './index.module.css';
import {
colorClassNames,
getThemeVariantClassName,
radiusClassNames,
themeVariantClassNames,
transitionClassNames,
} from '@/styles/designTokens';

type Props = {
sponsorName?: string;
compact?: boolean;
};

export default function SponsoredDisclosure({ sponsorName, compact = false }: Props) {
const { theme } = useTheme();
const badgeClassName = `${styles.badge} ${radiusClassNames.control} ${colorClassNames.accentBadge}`;

if (compact) {
return <span className={badgeClassName}>PR</span>;
}

const disclosureClassName = [
styles.disclosure,
radiusClassNames.control,
transitionClassNames.color,
getThemeVariantClassName(theme, themeVariantClassNames.borderedText),
getThemeVariantClassName(theme, themeVariantClassNames.subtleSurface),
].join(' ');

return (
<aside className={disclosureClassName} aria-label="広告に関する表示">
<span className={badgeClassName}>PR</span>
<span>本記事は、{sponsorName}から依頼を受けて制作した広告です。</span>
</aside>
);
}
2 changes: 2 additions & 0 deletions src/components/Common/UnifiedArticleCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SingleDate from '@/components/Common/SingleDate';
import doubleDateStyles from '@/components/Common/DoubleDate/index.module.css';
import WebpImage from '../Elements/WebpImage';
import { getThemeClassName, surfaceClassNames } from '@/styles/designTokens';
import SponsoredDisclosure from '../SponsoredDisclosure';

type Props = {
article: UnifiedArticle;
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function UnifiedArticleCard({ article, priority = false }: Props)
/>
)}
<div className={styles.content}>
{article.isSponsored && <SponsoredDisclosure compact />}
<div className={styles.title}>{article.title}</div>
<div className={styles.description}>{article.description}</div>
<div className={doubleDateStyles.date}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { memo, useEffect, useMemo, useRef } from 'react';
import { usePathname } from 'next/navigation';
import styles from './index.module.css';
import { applyTargetBlankToLinks } from './links';
import { applySponsoredRelToLinks, applyTargetBlankToLinks } from './links';
import { setupMoshimoEasyLinkFallback, syncMoshimoEasyLinkArrows } from './moshimoEasyLinkFallback';
import { runCustomHtmlScripts } from './scripts';
import { useIframelyEmbeds } from '@/hooks/useIframelyEmbeds';
Expand All @@ -12,11 +12,12 @@ import { useCodeBlockCopyButtons } from '@/hooks/useCodeBlockCopyButtons';

type Props = {
html: string;
sponsorUrl?: string;
};

const SCRIPT_REPLAY_DELAY_MS = 100;

function CustomHtml({ html }: Props) {
function CustomHtml({ html, sponsorUrl }: Props) {
const pathname = usePathname();
const contentRef = useRef<HTMLDivElement>(null);
const dangerouslySetInnerHTML = useMemo(() => ({ __html: html }), [html]);
Expand All @@ -34,6 +35,7 @@ function CustomHtml({ html }: Props) {

const syncCustomHtmlEnhancements = () => {
applyTargetBlankToLinks(content);
applySponsoredRelToLinks(content, sponsorUrl);
syncMoshimoEasyLinkArrows(content);
};

Expand Down Expand Up @@ -62,7 +64,7 @@ function CustomHtml({ html }: Props) {
observer.disconnect();
window.clearTimeout(timer);
};
}, [html, pathname]);
}, [html, pathname, sponsorUrl]);

return (
<div
Expand All @@ -75,5 +77,5 @@ function CustomHtml({ html }: Props) {
}

export default memo(CustomHtml, (prevProps, nextProps) => {
return prevProps.html === nextProps.html;
return prevProps.html === nextProps.html && prevProps.sponsorUrl === nextProps.sponsorUrl;
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseUrl } from '@/utils/urlSafety';
import { isSponsorLink, mergeSponsoredRel } from '@/utils/sponsored';

const SAFE_TARGET_BLANK_REL_VALUES = ['noopener', 'noreferrer'];
const SAME_PAGE_FRAGMENT_BASE = 'https://example.invalid/';
Expand Down Expand Up @@ -41,3 +42,17 @@ export const applyTargetBlankToLinks = (content: HTMLElement) => {
anchor.setAttribute('rel', mergeRelValues(anchor.getAttribute('rel')));
});
};

export const applySponsoredRelToLinks = (content: HTMLElement, sponsorUrl?: string) => {
if (!sponsorUrl) {
return;
}

content.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((anchor) => {
const href = anchor.getAttribute('href');

if (href && isSponsorLink(href, sponsorUrl)) {
anchor.setAttribute('rel', mergeSponsoredRel(anchor.getAttribute('rel')));
}
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Props = {
demerit?: boolean;
common?: boolean;
point?: boolean;
sponsorUrl?: string;
};

export default function TabBox({
Expand All @@ -22,13 +23,14 @@ export default function TabBox({
demerit = false,
point = false,
common = false,
sponsorUrl,
}: Props) {
const renderHtml = (html: string | undefined) => {
if (!html) {
return null;
}

return <div dangerouslySetInnerHTML={{ __html: sanitizeCmsHtml(html) }} />;
return <div dangerouslySetInnerHTML={{ __html: sanitizeCmsHtml(html, sponsorUrl) }} />;
};

return (
Expand Down
Loading
Loading