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
20 changes: 11 additions & 9 deletions next-sitemap.config.js
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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?/, ""),
},
};
3 changes: 2 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }];
Expand Down
Binary file added public/images/home/apps.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/elementary.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/hero-adult.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/hero-elementary.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/hero-preschool.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/hero-teen.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/home/home-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
25 changes: 18 additions & 7 deletions src/app/ClientLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
66 changes: 43 additions & 23 deletions src/app/[programSlug]/[studySlug]/[lessonSlug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<PageParams[]> { 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();
Expand All @@ -32,43 +39,56 @@ export async function generateMetadata({ params }: { params: Promise<PageParams>

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<string, unknown> = {
"@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<string, unknown> = {
"@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<PageParams> }) {
const { lessonData, errorMessage } = await loadSharedData(params);
if (!errorMessage && !lessonData) notFound();
if (errorMessage) return <Error message={errorMessage} />;
// 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 (
<>
<LessonClient lessonData={lessonData} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(getJsonLd(lessonData.venues[0], { programSlug, studySlug, lessonSlug })).replace(/</g, "\\u003c") }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: SeoHelper.jsonLd(getJsonLd(venue, lessonSlug)) }} />
</>
);
}
63 changes: 40 additions & 23 deletions src/app/[programSlug]/[studySlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ import { Metadata } from "next";
import { unstable_cache } from "next/cache";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import Container from "@mui/material/Container";
import Grid from "@mui/material/Grid";
import { ApiHelper } from "@churchapps/apphelper";
import { HeaderWrapper } from "@/app/components/HeaderWrapper";
import { MarkdownWrapper } from "@/app/components/MarkdownWrapper";
import Error from "@/components/Error";
import { Layout } from "@/components/Layout";
import { Lessons } from "@/components/Lessons";
import { EnvironmentHelper } from "@/helpers";
import { MetaHelper } from "@/helpers/MetaHelper";
import { SITE_URL, SeoHelper } from "@/helpers/SeoHelper";
import { LessonInterface, ProgramInterface, StudyInterface } from "@/helpers/interfaces";

type PageParams = { programSlug: string; studySlug: string };

// Render each study on first request, then serve it from the cache and refresh hourly.
export const revalidate = 3600;
export async function generateStaticParams(): Promise<PageParams[]> { return []; }

type LoadDataResult = { program?: ProgramInterface; study?: StudyInterface; lessons?: LessonInterface[]; errorMessage: string };

const loadData = async (params: PageParams): Promise<LoadDataResult> => {
Expand All @@ -37,21 +41,50 @@ const loadSharedData = async (params: Promise<PageParams>): Promise<LoadDataResu
}
};

const getJsonLd = (program: ProgramInterface, study: StudyInterface, lessonCount: number) => {
const programUrl = SITE_URL + "/" + program.slug;
const url = programUrl + "/" + study.slug;
const programName = SeoHelper.cleanName(program.name);
const name = SeoHelper.cleanName(study.name);
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Course",
"@id": url + "#course",
name,
url,
description: SeoHelper.studyDescription(programName, name, lessonCount, study.description),
...(study.image ? { image: study.image } : {}),
...SeoHelper.common(SeoHelper.publisherFromAbout(program.aboutSection), program.age),
isPartOf: { "@type": "Course", "@id": programUrl + "#course", name: programName, url: programUrl }
},
SeoHelper.breadcrumbs([{ name: programName, url: programUrl }, { name, url }])
]
};
};

export async function generateMetadata({ params }: { params: Promise<PageParams> }): Promise<Metadata> {
const props = await loadSharedData(params);
if (!props.errorMessage && props.study?.id) {
const { programSlug, studySlug } = await params;
const title = props.program?.name + ": " + props.study?.name + " - Free Church Curriculum";
return MetaHelper.getMetaData(title, props.study?.description, props.study?.image, undefined, "/" + programSlug + "/" + studySlug);
const programName = SeoHelper.cleanName(props.program?.name);
const studyName = SeoHelper.cleanName(props.study.name);
const title = SeoHelper.studyTitle(programName, studyName);
const description = SeoHelper.studyDescription(programName, studyName, props.lessons?.length, props.study.description);
return MetaHelper.getMetaData(title, description, props.study.image, undefined, "/" + props.program?.slug + "/" + props.study.slug);
}
return MetaHelper.getMetaData();
}

export default async function StudyPage({ params }: { params: Promise<PageParams> }) {
const { program, study, lessons, errorMessage } = await loadSharedData(params);

if (errorMessage) return <Error message={errorMessage} />;
// 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 (!study?.id) notFound();
// Slug lookups are case-insensitive; redirect case variants to the one canonical URL.
const { programSlug, studySlug } = await params;
if (program?.slug && study.slug && (program.slug !== programSlug || study.slug !== studySlug)) permanentRedirect("/" + program.slug + "/" + study.slug);

return (
<>
Expand Down Expand Up @@ -95,23 +128,7 @@ export default async function StudyPage({ params }: { params: Promise<PageParams
</Container>
</div>

<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Course",
name: study!.name,
description: study!.description,
provider: {
"@type": "Organization",
name: "Lessons.church",
sameAs: "https://lessons.church"
},
isPartOf: { "@type": "Course", name: program!.name }
}).replace(/</g, "\\u003c")
}}
/>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: SeoHelper.jsonLd(getJsonLd(program!, study!, lessons?.length ?? 0)) }} />
</Layout>
</>
);
Expand Down
Loading
Loading