From a45d3814cf1731c2040c5f9a3cc676ad9eebfa3c Mon Sep 17 00:00:00 2001 From: Arkadiusz Lachowicz Date: Sun, 21 Jun 2026 17:35:37 +0200 Subject: [PATCH] Add runtime LinkedIn feed sync --- .dockerignore | 2 + .github/workflows/docker-publish.yml | 52 +++ .gitignore | 3 + Caddyfile.site | 1 + Dockerfile.linkedin-sync | 11 + app/components/LinkedInUpdatesSection.tsx | 456 ++++++++++++++++++++-- app/globals.css | 439 ++++++++++++++++++++- scripts/sync-linkedin-feed.mjs | 391 ++++++++++++++++++- 8 files changed, 1302 insertions(+), 53 deletions(-) create mode 100644 Dockerfile.linkedin-sync diff --git a/.dockerignore b/.dockerignore index 6cece8e..4f8fa3d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,8 @@ dist tmp npm-debug.log .npmrc +.env* +public/social/ .git .gitignore Dockerfile diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c3921de..76a110b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,6 +13,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: "www-website" + LINKEDIN_SYNC_IMAGE_NAME: "www-website-linkedin-sync" jobs: build: @@ -41,8 +42,11 @@ jobs: REPO_NAME="${GITHUB_REPOSITORY##*/}" IMAGE_NAME="${IMAGE_NAME:-$REPO_NAME}" IMAGE_NAME="$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" + LINKEDIN_SYNC_IMAGE_NAME="$(echo "${LINKEDIN_SYNC_IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" echo "IMAGE=${REGISTRY}/${OWNER}/${IMAGE_NAME}" >> "$GITHUB_ENV" + echo "LINKEDIN_SYNC_IMAGE=${REGISTRY}/${OWNER}/${LINKEDIN_SYNC_IMAGE_NAME}" >> "$GITHUB_ENV" echo "Image namespace: ${REGISTRY}/${OWNER}/${IMAGE_NAME}" + echo "LinkedIn sync image namespace: ${REGISTRY}/${OWNER}/${LINKEDIN_SYNC_IMAGE_NAME}" - name: Compute build info id: build_info @@ -67,6 +71,16 @@ jobs: type=ref,event=branch,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + - name: Docker metadata for LinkedIn sync + id: meta_linkedin_sync + uses: docker/metadata-action@v6 + with: + images: ${{ env.LINKEDIN_SYNC_IMAGE }} + tags: | + type=sha + type=ref,event=branch,enable=${{ github.ref == 'refs/heads/master' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} + - name: Set up Buildx uses: docker/setup-buildx-action@v4 @@ -95,6 +109,20 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max,ignore-error=true + - name: Build LinkedIn sync image for tests + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile.linkedin-sync + push: false + load: true + tags: | + ${{ env.LINKEDIN_SYNC_IMAGE }}:ci + ${{ steps.meta_linkedin_sync.outputs.tags }} + labels: ${{ steps.meta_linkedin_sync.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max,ignore-error=true + - name: Run smoke test env: IMAGE_UNDER_TEST: ${{ env.IMAGE }}:ci @@ -145,8 +173,11 @@ jobs: REPO_NAME="${GITHUB_REPOSITORY##*/}" IMAGE_NAME="${IMAGE_NAME:-$REPO_NAME}" IMAGE_NAME="$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" + LINKEDIN_SYNC_IMAGE_NAME="$(echo "${LINKEDIN_SYNC_IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" echo "IMAGE=${REGISTRY}/${OWNER}/${IMAGE_NAME}" >> "$GITHUB_ENV" + echo "LINKEDIN_SYNC_IMAGE=${REGISTRY}/${OWNER}/${LINKEDIN_SYNC_IMAGE_NAME}" >> "$GITHUB_ENV" echo "Image namespace: ${REGISTRY}/${OWNER}/${IMAGE_NAME}" + echo "LinkedIn sync image namespace: ${REGISTRY}/${OWNER}/${LINKEDIN_SYNC_IMAGE_NAME}" - name: Compute build info id: build_info @@ -171,6 +202,16 @@ jobs: type=ref,event=branch type=raw,value=latest + - name: Docker metadata for LinkedIn sync + id: meta_linkedin_sync + uses: docker/metadata-action@v6 + with: + images: ${{ env.LINKEDIN_SYNC_IMAGE }} + tags: | + type=sha + type=ref,event=branch + type=raw,value=latest + - name: Set up Buildx uses: docker/setup-buildx-action@v4 @@ -216,6 +257,17 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max,ignore-error=true + - name: Build and push LinkedIn sync + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile.linkedin-sync + push: true + tags: ${{ steps.meta_linkedin_sync.outputs.tags }} + labels: ${{ steps.meta_linkedin_sync.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max,ignore-error=true + update-size-badge: needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/.gitignore b/.gitignore index 5572673..08ffb52 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ tmp/ # Generated static documents /public/pricing.pdf + +# Runtime-generated social feed +/public/social/ diff --git a/Caddyfile.site b/Caddyfile.site index b4898dd..3d8c62d 100644 --- a/Caddyfile.site +++ b/Caddyfile.site @@ -3,6 +3,7 @@ encode zstd gzip respond /healthz 200 header /build-info.json Cache-Control "no-store" + header /social/linkedin-posts.json Cache-Control "no-store" header /_next/static/* Cache-Control "public, max-age=31536000, immutable" header /optimized/* Cache-Control "public, max-age=604800" header /partners/* Cache-Control "public, max-age=604800" diff --git a/Dockerfile.linkedin-sync b/Dockerfile.linkedin-sync new file mode 100644 index 0000000..b10a287 --- /dev/null +++ b/Dockerfile.linkedin-sync @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine +WORKDIR /app + +ENV NODE_ENV=production + +COPY scripts/sync-linkedin-feed.mjs ./scripts/sync-linkedin-feed.mjs + +USER node +CMD ["node", "scripts/sync-linkedin-feed.mjs"] diff --git a/app/components/LinkedInUpdatesSection.tsx b/app/components/LinkedInUpdatesSection.tsx index 35c45f2..d784b15 100644 --- a/app/components/LinkedInUpdatesSection.tsx +++ b/app/components/LinkedInUpdatesSection.tsx @@ -1,13 +1,37 @@ "use client"; +import Image from "next/image"; import { useEffect, useMemo, useState } from "react"; +type LinkedInPostMedia = { + type: "image"; + url: string; + remoteUrl?: string; + alt?: string; +}; + +type LinkedInQuotedPost = { + title: string; + excerpt: string; + url: string; +}; + +type LinkedInTextSegment = { + text: string; + href?: string; +}; + type LinkedInPost = { id: string; title: string; excerpt: string; + text?: string; + textSegments?: LinkedInTextSegment[]; url: string; publishedAt: string; + media?: LinkedInPostMedia[]; + reshared?: boolean; + quoted?: LinkedInQuotedPost; }; type LinkedInFeed = { @@ -19,7 +43,11 @@ type LinkedInFeed = { const FEED_URL = "/social/linkedin-posts.json"; const LINKEDIN_PROFILE_URL = "https://www.linkedin.com/company/devsh-graphics-programming/"; const MAX_POSTS = 3; -const LINKEDIN_FEED_ENABLED = false; +const LINKEDIN_FEED_ENABLED = true; +const SPOTLIGHT_HEADLINE_WORDS = 15; +const TIMELINE_TITLE_WORDS = 8; +const BODY_FADE_CHARACTERS = 260; +const WEAK_TEXT_BOUNDARY_WORDS = new Set(["a", "an", "and", "for", "in", "of", "on", "or", "the", "to", "with"]); function isPost(value: unknown): value is LinkedInPost { if (!value || typeof value !== "object") { @@ -78,60 +106,382 @@ function LinkedInIcon({ className = "h-4 w-4" }: { className?: string }) { ); } -function ExternalArrow() { +function ExternalArrow({ className = "h-4 w-4" }: { className?: string }) { return ( -
+ +
+

+ DevSH Graphics Programming +

+ {date ?

{date}

: null} +
+
+ ); +} + +function getPostSegments(post: LinkedInPost) { + const fallbackText = (post.text || post.excerpt || post.title).trim(); + + return post.textSegments && post.textSegments.length > 0 ? post.textSegments : [{ text: fallbackText }]; +} + +function getPostText(post: LinkedInPost) { + return getPostSegments(post) + .map((segment) => segment.text) + .join("") + .trim(); +} + +function clipWords(text: string, maxWords: number) { + const words = text.trim().split(/\s+/).filter(Boolean); + + if (words.length <= maxWords) { + return text.trim(); + } + + const clipped = words.slice(0, maxWords); + const cleanWord = (word: string) => word.replace(/[^\p{L}\p{N}#++-]/gu, "").toLowerCase(); + + if (words[maxWords] && WEAK_TEXT_BOUNDARY_WORDS.has(cleanWord(words[maxWords]))) { + clipped.push(...words.slice(maxWords, Math.min(words.length, maxWords + 2))); + } + + while (clipped.length > Math.max(4, maxWords - 3)) { + const last = cleanWord(clipped[clipped.length - 1]); + + if (!WEAK_TEXT_BOUNDARY_WORDS.has(last)) { + break; + } + + clipped.pop(); + } + + return clipped.join(" "); +} + +function getFirstTextBlock(text: string) { + return text + .split(/\n+/) + .map((line) => line.trim()) + .find(Boolean); +} + +function getPostHeadline(post: LinkedInPost) { + const text = getPostText(post); + const fallback = post.title.replace(/\s*\.\.\.$/, "").trim(); + const source = getFirstTextBlock(text) || fallback; + + return clipWords(source, SPOTLIGHT_HEADLINE_WORDS); +} + +function getTimelineTitle(post: LinkedInPost) { + return clipWords(getPostHeadline(post), TIMELINE_TITLE_WORDS); +} + +function getPostBodySegments(post: LinkedInPost) { + const segments = getPostSegments(post); + const joined = segments.map((segment) => segment.text).join(""); + const headline = getPostHeadline(post); + const headlineStart = joined.indexOf(headline); + + if (headlineStart < 0) { + return segments; + } + + let skip = headlineStart + headline.length; + + while (skip < joined.length && /\s/.test(joined[skip])) { + skip += 1; + } + + while (skip < joined.length && /[,.;:]/.test(joined[skip])) { + skip += 1; + } + + while (skip < joined.length && /\s/.test(joined[skip])) { + skip += 1; + } + + const bodySegments: LinkedInTextSegment[] = []; + let remainingSkip = skip; + + for (const segment of segments) { + if (remainingSkip >= segment.text.length) { + remainingSkip -= segment.text.length; + continue; + } + + if (remainingSkip > 0) { + bodySegments.push({ ...segment, text: segment.text.slice(remainingSkip) }); + remainingSkip = 0; + continue; + } + + bodySegments.push(segment); + } + + if (bodySegments.length === 0) { + return segments; + } + + bodySegments[0] = { + ...bodySegments[0], + text: bodySegments[0].text.replace(/^([a-z])/, (match) => match.toUpperCase()), + }; + + return bodySegments; +} + +function preparePreviewSegments(segments: LinkedInTextSegment[]) { + const text = segments.map((segment) => segment.text).join(""); + + return { + segments, + faded: text.trim().length > BODY_FADE_CHARACTERS, + }; +} + +function shouldFadePostBody(post: LinkedInPost) { + return preparePreviewSegments(getPostBodySegments(post)).faded; +} + +function PostPreviewText({ post }: { post: LinkedInPost }) { + const preview = preparePreviewSegments(getPostBodySegments(post)); + + return ( +
+

+ {preview.segments.map((segment, index) => + segment.href ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + )} +

+
+ ); +} + +function QuotedPost({ post }: { post: LinkedInPost }) { + if (!post.reshared && !post.quoted) { + return null; + } + + const quoted = post.quoted ?? { + title: "Referenced LinkedIn post", + excerpt: "Open the original update to view the full referenced post, media, and discussion.", + url: post.url, + }; return ( -
-
- {publishedAt} - - - LinkedIn - - +
+
+ +
- -

- {post.title} -

-

- {post.excerpt} +

+
+

+ Referenced post +

+

+ {quoted.title}

+ {quoted.excerpt ? ( +

+ {quoted.excerpt} +

+ ) : null}
); } -function LoadingCard() { +function SpotlightMedia({ post }: { post: LinkedInPost }) { + const media = post.media?.filter((item) => item.type === "image" && item.url) ?? []; + + if (media.length === 0) { + return null; + } + + if (media.length === 1) { + const image = media[0]; + + return ( +
+ {image.alt +
+ ); + } + + const [primary, secondary, ...rest] = media; + const hero = secondary ?? primary; + const inset = secondary ? primary : undefined; + return ( -