diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index e51410a4..8180db55 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -4,21 +4,29 @@ on: name: Deploy Prod permissions: - contents: write + contents: read + pull-requests: write jobs: deploy: - name: Merge main to production + name: Open production pull request runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Merge main to production - uses: devmasx/merge-branch@854d3ac71ed1e9deb668e0074781b81fdd6e771f # v1.4.0 - with: - type: now - from_branch: main - target_branch: production - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + - name: Open a pull request from main to production + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ahead=$(gh api "repos/${GITHUB_REPOSITORY}/compare/production...main" --jq '.ahead_by') + if [ "$ahead" = "0" ]; then + echo "production already contains main" + exit 0 + fi + existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base production --head main --state open --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + echo "pull request #$existing is already open" + exit 0 + fi + gh pr create --repo "$GITHUB_REPOSITORY" --base production --head main \ + --title "Deploy main to production" \ + --body "Opened by the deploy workflow. Merging publishes main to production." diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml new file mode 100644 index 00000000..5e479835 --- /dev/null +++ b/.github/workflows/polinrider-scan.yml @@ -0,0 +1,19 @@ +name: PolinRider scan + +# Fails the build on editor autorun tasks, fake font files, and known loader markers. +# The scanner is ChurchApps/.github/.github/workflows/polinrider-scan.yml. +on: + pull_request: + push: + branches: [main, master, production, release] + workflow_dispatch: + +permissions: + contents: read + +jobs: + polinrider-scan: + name: polinrider-scan + permissions: + contents: read + uses: ChurchApps/.github/.github/workflows/polinrider-scan.yml@main diff --git a/next-sitemap.config.js b/next-sitemap.config.js index da6e5eff..7c16895a 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -1,11 +1,18 @@ // Signed-in and debug areas: keep crawlers out and keep them out of the sitemap. const privatePaths = ["/admin$", "/admin/", "/portal$", "/portal/", "/login$", "/b1$", "/b1/"]; +// Named AI crawlers get the same access as everyone else; listing them states that intent explicitly. +const aiCrawlers = ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-SearchBot", "Claude-User", "PerplexityBot", "Google-Extended", "Applebot-Extended", "CCBot"]; + /** @type {import('next-sitemap').IConfig} */ module.exports = { siteUrl: "https://lessons.church", generateRobotsTxt: true, - exclude: ["/admin", "/admin/*", "/portal", "/portal/*", "/login", "/b1", "/b1/*", "*/alt", "/sitemap-content.xml"], + // Google ignores changefreq and priority, and a build-time lastmod on every URL is not a real modification date. + autoLastmod: false, + changefreq: null, + priority: null, + exclude: ["/admin", "/admin/*", "/portal", "/portal/*", "/login", "/b1", "/b1/*", "*/alt", "/sitemap-content.xml", "/search"], additionalPaths: async (config) => [ await config.transform(config, "/compare/answers-in-genesis"), await config.transform(config, "/compare/think-orange"), @@ -16,14 +23,9 @@ module.exports = { additionalSitemaps: ["https://lessons.church/sitemap-content.xml"], policies: [ { userAgent: '*', allow: '/', disallow: privatePaths }, - { userAgent: 'GPTBot', allow: '/', disallow: privatePaths }, - { userAgent: 'ChatGPT-User', allow: '/', disallow: privatePaths }, - { userAgent: 'Claude-Web', allow: '/', disallow: privatePaths }, - { userAgent: 'Anthropic-AI', allow: '/', disallow: privatePaths }, - { userAgent: 'PerplexityBot', allow: '/', disallow: privatePaths }, - { userAgent: 'Bytespider', allow: '/', disallow: privatePaths }, - { userAgent: 'CCBot', allow: '/', disallow: privatePaths }, - { userAgent: 'Google-Extended', allow: '/', disallow: privatePaths }, + ...aiCrawlers.map(userAgent => ({ userAgent, allow: '/', disallow: privatePaths })), ], + // Drop next-sitemap's "Host:" line; it is a retired Yandex directive that Google and Bing ignore. + transformRobotsTxt: async (_config, robotsTxt) => robotsTxt.replace(/# Host\nHost: .*\n\n?/, ""), }, }; diff --git a/next.config.js b/next.config.js index 9a724810..fd155157 100644 --- a/next.config.js +++ b/next.config.js @@ -21,7 +21,8 @@ const config = { const common = [ { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" }, { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" } + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" } ]; // Embeds and classroom pages are iframed on church sites; only the signed-in areas refuse framing. const noFraming = [{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" }, { key: "X-Frame-Options", value: "SAMEORIGIN" }]; diff --git a/package.json b/package.json index 7d29e517..92db2487 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ } }, "dependencies": { - "@churchapps/apphelper": "^1.10.0", - "@churchapps/helpers": "^2.12.0", + "@churchapps/apphelper": "^1.10.11", + "@churchapps/helpers": "^2.15.0", "@mui/icons-material": "^7.3.11", "@mui/material": "^7.3.11", "@mui/material-nextjs": "^9.0.1", diff --git a/public/images/home/apps.webp b/public/images/home/apps.webp new file mode 100644 index 00000000..f88722b8 Binary files /dev/null and b/public/images/home/apps.webp differ diff --git a/public/images/home/elementary.webp b/public/images/home/elementary.webp new file mode 100644 index 00000000..34d932da Binary files /dev/null and b/public/images/home/elementary.webp differ diff --git a/public/images/home/hero-adult.webp b/public/images/home/hero-adult.webp new file mode 100644 index 00000000..d026514f Binary files /dev/null and b/public/images/home/hero-adult.webp differ diff --git a/public/images/home/hero-elementary.webp b/public/images/home/hero-elementary.webp new file mode 100644 index 00000000..6f881f37 Binary files /dev/null and b/public/images/home/hero-elementary.webp differ diff --git a/public/images/home/hero-preschool.webp b/public/images/home/hero-preschool.webp new file mode 100644 index 00000000..58b3454b Binary files /dev/null and b/public/images/home/hero-preschool.webp differ diff --git a/public/images/home/hero-teen.webp b/public/images/home/hero-teen.webp new file mode 100644 index 00000000..bcef9680 Binary files /dev/null and b/public/images/home/hero-teen.webp differ diff --git a/public/images/home/home-hero.webp b/public/images/home/home-hero.webp new file mode 100644 index 00000000..92ff146a Binary files /dev/null and b/public/images/home/home-hero.webp differ diff --git a/public/llms.txt b/public/llms.txt index 5a95a9b7..ae9dd0d6 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -11,6 +11,27 @@ Lessons.church is a free, open-source hub for Christian church curriculum. We ag - **Next Level** - Adult Bible study curriculum - **Partner Programs** - Curriculum from contributing church partners +## Programs + +- [Ark Kids Junior](https://lessons.church/ark-preschool): Preschool curriculum from The Ark Church, with lessons for 2-year-olds through Kindergarten +- [Ark Kids Elementary](https://lessons.church/ark): Elementary curriculum from The Ark Church +- [High Voltage Elementary](https://lessons.church/high-voltage): Elementary curriculum from High Voltage Kids +- [Faith Kidz](https://lessons.church/faith-kidz): Elementary curriculum from FaithKidz +- [West Ridge](https://lessons.church/west-ridge): Family-focused elementary series from West Ridge Church +- [For Ministry Resources Youth Series](https://lessons.church/forministryresources): Monthly youth sermon series with graphics and small group guides +- [NextLevel Online](https://lessons.church/next-level): Adult Bible courses from Ozark Christian College +- [Following Jesus Together](https://lessons.church/following-jesus-together): Adult small group studies + +## Key Pages + +- [Home](https://lessons.church/): Overview of all free curriculum +- [FAQ](https://lessons.church/faq): Common questions about cost, licensing and how Lessons.church works +- [Create a free account](https://lessons.church/login?action=register): Register to schedule lessons for classrooms +- [Lessons.church vs Answers in Genesis](https://lessons.church/compare/answers-in-genesis) +- [Lessons.church vs Think Orange](https://lessons.church/compare/think-orange) +- [Lessons.church vs RightNow Media](https://lessons.church/compare/rightnow-media) +- [Lessons.church vs Grow Curriculum](https://lessons.church/compare/grow-curriculum) + ## Why Lessons.church Exists Every year churches spend millions of dollars on curriculum. We believe: @@ -47,6 +68,8 @@ Every year churches spend millions of dollars on curriculum. We believe: ## Pricing +Competitor prices below are approximate list prices and may have changed; check each publisher for current pricing. + | Feature | Lessons.church | Answers in Genesis | Think Orange | RightNow Media | |---------|---------------|-------------------|--------------|----------------| | Curriculum Access | FREE | $450-600/year | $1,800+/year | $7/person/month | @@ -90,7 +113,7 @@ Group Publishing curriculum ranges $200-800 per curriculum set. Lessons.church p ## Getting Started -1. Visit https://lessons.church/register +1. Create a free account at https://lessons.church/login?action=register 2. Browse available curriculum 3. Schedule lessons for your classrooms 4. Download FreePlay for classroom displays (https://freeplay.church) @@ -117,7 +140,7 @@ All tools are free and work together seamlessly. - Main Site: https://lessons.church - Ark Kids Landing: https://lessons.church/landing/ark -- Registration: https://lessons.church/register +- Registration: https://lessons.church/login?action=register - Parent Organization: https://churchapps.org ## Open Source diff --git a/src/app/ClientLayout.tsx b/src/app/ClientLayout.tsx index 1689c1f6..8501921a 100644 --- a/src/app/ClientLayout.tsx +++ b/src/app/ClientLayout.tsx @@ -28,13 +28,24 @@ function ClientLayout({ children }: { children: React.ReactNode }) { // Embeds, B1 webviews and classroom displays run on other sites and screens; keep the marketing tracker off them. if (/^\/(embed|b1|classroom)(\/|$)/.test(window.location.pathname)) return; - const script = document.createElement("script"); - script.type = "text/javascript"; - script.id = "hs-script-loader"; - script.async = true; - script.defer = true; - script.src = "//js.hs-scripts.com/20077299.js"; - document.body.appendChild(script); + // Load HubSpot after the first interaction or once the page is idle, so it stays off the critical path. + let loaded = false; + const events = ["pointerdown", "keydown", "scroll", "touchstart"]; + const loadHubSpot = () => { + if (loaded) return; + loaded = true; + events.forEach(e => window.removeEventListener(e, loadHubSpot)); + const script = document.createElement("script"); + script.type = "text/javascript"; + script.id = "hs-script-loader"; + script.async = true; + script.defer = true; + script.src = "//js.hs-scripts.com/20077299.js"; + document.body.appendChild(script); + }; + events.forEach(e => window.addEventListener(e, loadHubSpot, { once: true, passive: true })); + const timer = window.setTimeout(loadHubSpot, 5000); + return () => { window.clearTimeout(timer); events.forEach(e => window.removeEventListener(e, loadHubSpot)); }; }, []); const getErrorAppData = () => { diff --git a/src/app/[programSlug]/[studySlug]/[lessonSlug]/page.tsx b/src/app/[programSlug]/[studySlug]/[lessonSlug]/page.tsx index aa85b7e1..a3f0873f 100644 --- a/src/app/[programSlug]/[studySlug]/[lessonSlug]/page.tsx +++ b/src/app/[programSlug]/[studySlug]/[lessonSlug]/page.tsx @@ -1,15 +1,22 @@ import { Metadata } from "next"; import { unstable_cache } from "next/cache"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import React from "react"; import { ApiHelper } from "@churchapps/apphelper"; -import Error from "@/components/Error"; import { EnvironmentHelper } from "@/helpers"; import { MetaHelper } from "@/helpers/MetaHelper"; +import { SITE_URL, SeoHelper } from "@/helpers/SeoHelper"; import LessonClient from "./components/LessonClient"; type PageParams = { programSlug: string; studySlug: string; lessonSlug: string }; +// Render each lesson on first request, then serve it from the cache and refresh hourly. +export const revalidate = 3600; +export async function generateStaticParams(): Promise { return []; } + +// Lesson slugs are always lowercase, and the API matches slugs case-insensitively. +const getCanonicalPath = (venue: any, lessonSlug: string) => "/" + venue.programSlug + "/" + venue.studySlug + "/" + lessonSlug.toLowerCase(); + // A lesson with no venues has nothing to show, so it 404s like a missing one. const loadData = async (params: PageParams): Promise<{ lessonData?: any; errorMessage: string }> => { EnvironmentHelper.init(); @@ -32,43 +39,56 @@ export async function generateMetadata({ params }: { params: Promise if (props.errorMessage || !props.lessonData || !props.lessonData.venues || props.lessonData.venues.length === 0) return MetaHelper.getMetaData("Lesson Not Found - Lessons.church", "The requested lesson could not be found."); - const { programSlug, studySlug, lessonSlug } = await params; + const { lessonSlug } = await params; const selectedVenue = props.lessonData.venues[0]; - const title = selectedVenue?.programName + ": " + selectedVenue?.lessonName + " - Free Church Curriculum"; - return MetaHelper.getMetaData(title, selectedVenue?.lessonDescription, selectedVenue?.lessonImage, undefined, "/" + programSlug + "/" + studySlug + "/" + lessonSlug); + const title = SeoHelper.lessonTitle(selectedVenue?.programName, selectedVenue?.lessonName); + return MetaHelper.getMetaData(title, SeoHelper.lessonDescription(selectedVenue), selectedVenue?.lessonImage, undefined, getCanonicalPath(selectedVenue, lessonSlug)); } -const getJsonLd = (venue: any, path: PageParams) => { - const site = "https://lessons.church"; - const programUrl = site + "/" + path.programSlug; - const studyUrl = programUrl + "/" + path.studySlug; - const result: Record = { - "@context": "https://schema.org", +const getJsonLd = (venue: any, lessonSlug: string) => { + const programUrl = SITE_URL + "/" + venue.programSlug; + const studyUrl = programUrl + "/" + venue.studySlug; + const url = SITE_URL + getCanonicalPath(venue, lessonSlug); + const programName = SeoHelper.cleanName(venue.programName); + const studyName = SeoHelper.cleanName(venue.studyName); + const name = SeoHelper.cleanName(venue.lessonName); + const lesson: Record = { "@type": "LearningResource", - name: venue.lessonName, - url: studyUrl + "/" + path.lessonSlug, + "@id": url + "#lesson", + name, + url, + description: SeoHelper.lessonDescription(venue), + learningResourceType: "Lesson plan", + ...SeoHelper.common(SeoHelper.publisherFromAbout(venue.programAbout)), isPartOf: { "@type": "Course", - name: venue.studyName, + "@id": studyUrl + "#course", + name: studyName, url: studyUrl, - isPartOf: { "@type": "Course", name: venue.programName, url: programUrl } - }, - provider: { "@type": "Organization", name: "Lessons.church", sameAs: site } + isPartOf: { "@type": "Course", "@id": programUrl + "#course", name: programName, url: programUrl } + } + }; + if (venue.bottomLine) lesson.teaches = SeoHelper.plainText(venue.bottomLine); + if (venue.lessonImage?.startsWith("http")) lesson.image = venue.lessonImage; + return { + "@context": "https://schema.org", + "@graph": [lesson, SeoHelper.breadcrumbs([{ name: programName, url: programUrl }, { name: studyName, url: studyUrl }, { name, url }])] }; - if (venue.lessonDescription) result.description = venue.lessonDescription; - if (venue.lessonImage?.startsWith("http")) result.image = venue.lessonImage; - return result; }; export default async function LessonsPage({ params }: { params: Promise }) { const { lessonData, errorMessage } = await loadSharedData(params); - if (!errorMessage && !lessonData) notFound(); - if (errorMessage) return ; + // Throw rather than render an error page, so a failed API call is never cached as a 200. + if (errorMessage) throw new Error(errorMessage); + if (!lessonData) notFound(); const { programSlug, studySlug, lessonSlug } = await params; + const venue = lessonData.venues[0]; + const canonicalPath = getCanonicalPath(venue, lessonSlug); + if (venue.programSlug && venue.studySlug && canonicalPath !== "/" + programSlug + "/" + studySlug + "/" + lessonSlug) permanentRedirect(canonicalPath); return ( <> - -