diff --git a/.github/actions/apply-feed-app/action.yml b/.github/actions/apply-feed-app/action.yml new file mode 100644 index 0000000..641c3a6 --- /dev/null +++ b/.github/actions/apply-feed-app/action.yml @@ -0,0 +1,103 @@ +--- +name: Apply feed app config +description: > + Turns screenly.yml into the manifest for one feed app from deployed-apps.yml, + so the shared edge-apps-actions can deploy it like any other Edge App. The + code is the same for every feed app; only these manifest fields differ. + +inputs: + app_key: + description: Key of the app under `apps:` in deployed-apps.yml (e.g. bbc-news) + required: true + environment: + description: stage or production, to look up the app's id for it + required: true + override_description: + description: Use instead of the app's description (optional) + required: false + default: '' + override_icon_url: + description: Use instead of the app's icon_url (optional) + required: false + default: '' + override_rss_title: + description: Use instead of the app's rss_title (optional) + required: false + default: '' + override_rss_url: + description: Use instead of the app's rss_url (optional) + required: false + default: '' + +outputs: + app_name: + description: The app's app_name, used as the Edge App name and instance title + value: ${{ steps.apply.outputs.app_name }} + edge_app_id: + description: The app's id for this environment, or empty if it has none yet + value: ${{ steps.apply.outputs.edge_app_id }} + +runs: + using: composite + steps: + - name: Setup yq + uses: ./.github/actions/setup-yq + + - name: Apply ${{ inputs.app_key }} to screenly.yml + id: apply + shell: bash + # Inputs arrive as environment variables, never pasted into the script, + # so quotes or $ in a description can't break the step + env: + APP_KEY: ${{ inputs.app_key }} + ENVIRONMENT: ${{ inputs.environment }} + OVERRIDE_DESCRIPTION: ${{ inputs.override_description }} + OVERRIDE_ICON_URL: ${{ inputs.override_icon_url }} + OVERRIDE_RSS_TITLE: ${{ inputs.override_rss_title }} + OVERRIDE_RSS_URL: ${{ inputs.override_rss_url }} + run: | + set -euo pipefail + + APP_JSON=$(yq -o=json '.apps' deployed-apps.yml | jq -c --arg key "$APP_KEY" '.[$key] // empty') + if [ -z "$APP_JSON" ]; then + echo "::error::No app '$APP_KEY' in deployed-apps.yml." + exit 1 + fi + + field() { + jq -r --arg f "$1" '.[$f] // "" | tostring' <<<"$APP_JSON" + } + + APP_NAME=$(field app_name) + DESCRIPTION="${OVERRIDE_DESCRIPTION:-$(field description)}" + ICON_URL="${OVERRIDE_ICON_URL:-$(field icon_url)}" + RSS_TITLE="${OVERRIDE_RSS_TITLE:-$(field rss_title)}" + RSS_URL="${OVERRIDE_RSS_URL:-$(field rss_url)}" + EDGE_APP_ID=$(jq -r --arg env "$ENVIRONMENT" '(.ids // {})[$env] // "" | tostring' <<<"$APP_JSON") + + for name in APP_NAME DESCRIPTION ICON_URL RSS_TITLE RSS_URL; do + if [ -z "${!name}" ]; then + echo "::error::'$APP_KEY' in deployed-apps.yml is missing $(tr '[:upper:]' '[:lower:]' <<<"$name")." + exit 1 + fi + done + + # The shared actions take the id as an input, so the manifest must + # not carry one + export DESCRIPTION ICON_URL RSS_TITLE RSS_URL + yq -i ' + del(.id) | + .description = strenv(DESCRIPTION) | + .icon = strenv(ICON_URL) | + .settings.rss_title.default_value = strenv(RSS_TITLE) | + .settings.rss_url.default_value = strenv(RSS_URL) + ' screenly.yml + + { + echo "app_name=$APP_NAME" + echo "edge_app_id=$EDGE_APP_ID" + } >> "$GITHUB_OUTPUT" + + - name: Show manifest for ${{ inputs.app_key }} + shell: bash + run: cat screenly.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c4cc3b..8c3b773 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,7 +19,8 @@ updates: - package-ecosystem: 'github-actions' directory: '/' schedule: - interval: 'monthly' + interval: 'weekly' + target-branch: 'development' groups: github-actions: patterns: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c969846..91799b7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,16 +1,21 @@ +--- name: Checks on: push: branches: + - development - main + - master pull_request: branches: + - development - main + - master + +permissions: + contents: read jobs: checks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: Screenly/edge-apps-actions/checks@v1 + uses: Screenly/edge-apps-actions/.github/workflows/checks.yml@v26.10.2 diff --git a/.github/workflows/initialize-edge-app.yml b/.github/workflows/initialize-edge-app.yml index ffe2927..b769391 100644 --- a/.github/workflows/initialize-edge-app.yml +++ b/.github/workflows/initialize-edge-app.yml @@ -21,20 +21,35 @@ on: required: true type: string +permissions: + contents: read + run-name: Initializing ${{ inputs.edge_app_name }} in ${{ inputs.environment }} jobs: - initialize: - name: Initializing ${{ inputs.edge_app_name }} in ${{ inputs.environment }} + # The reusable workflow builds whatever ref this run was started from, so + # refuse anything but the default branch rather than initializing an app + # (possibly in production) from unreleased code. This fails loudly instead + # of skipping, so a run from the wrong branch can't look like a success. + verify-ref: + name: Verify default branch runs-on: ubuntu-latest - environment: ${{ inputs.environment }} steps: - - uses: actions/checkout@v7 - with: - ref: main - - uses: Screenly/edge-apps-actions/initialize@v1 - with: - screenly_api_token: ${{ secrets.SCREENLY_API_TOKEN }} - edge_app_name: ${{ inputs.edge_app_name }} - edge_app_title: ${{ inputs.edge_app_title }} - environment: ${{ inputs.environment }} + - name: Require the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Initialize Edge App must be run from $DEFAULT_BRANCH, not $GITHUB_REF_NAME." + exit 1 + fi + + initialize: + needs: verify-ref + uses: Screenly/edge-apps-actions/.github/workflows/initialize.yml@v26.10.2 + with: + environment: ${{ inputs.environment }} + edge_app_name: ${{ inputs.edge_app_name }} + edge_app_title: ${{ inputs.edge_app_title }} + secrets: + SCREENLY_API_TOKEN: ${{ secrets.SCREENLY_API_TOKEN }} diff --git a/.github/workflows/initialize-rss-reader-app.yml b/.github/workflows/initialize-rss-reader-app.yml index c835bb5..99fd954 100644 --- a/.github/workflows/initialize-rss-reader-app.yml +++ b/.github/workflows/initialize-rss-reader-app.yml @@ -1,11 +1,15 @@ --- +# Creates a feed app (e.g. BBC News) in one environment. Add the app to +# deployed-apps.yml first; this reads its name, description, icon and feed +# from there, then runs the shared edge-apps-actions initialize. Afterwards, +# put the new id in deployed-apps.yml so Update RSS Reader Apps deploys it. name: Initialize Custom RSS Reader App on: workflow_dispatch: inputs: environment: - description: 'Environment to initialize in (stage or production)' + description: 'Environment to initialize in' required: true default: 'stage' type: choice @@ -13,104 +17,78 @@ on: - stage - production - app_name: - description: 'App Name (used for instance title)' + app_key: + description: "App key in deployed-apps.yml (e.g. 'bbc-news')" required: true type: string - default: 'BBC News' - app_description: - description: 'App Description (used in manifest description)' - required: true - type: string - default: 'Latest news from BBC World Service' - - rss_title: - description: 'RSS Title (title to display in the app)' - required: true - type: string - default: 'BBC News' - - icon_url: - description: 'Icon URL (publicly accessible icon image)' - required: true - type: string - default: 'https://playground.srly.io/edge-apps/rss-reader/static/img/icon.svg' - - rss_url: - description: 'RSS Feed URL' - required: true - type: string - default: 'http://feeds.bbci.co.uk/news/rss.xml' +permissions: + contents: read -run-name: Initialize RSS Reader - ${{ inputs.app_name }} in ${{ inputs.environment }} +run-name: Initialize RSS Reader - ${{ inputs.app_key }} in ${{ inputs.environment }} jobs: - deploy: - name: Initializing ${{ inputs.app_name }} in ${{ inputs.environment }} + initialize: + name: Initialize ${{ inputs.app_key }} in ${{ inputs.environment }} runs-on: ubuntu-latest environment: ${{ inputs.environment }} - env: - API_BASE_URL: ${{ inputs.environment == 'stage' && 'https://api.screenlyappstage.com' || 'https://api.screenlyapp.com' }} - APP_NAME: ${{ inputs.app_name }} - SCREENLY_API_TOKEN: ${{ secrets.SCREENLY_API_TOKEN }} - MANIFEST_FILE_NAME: ${{ inputs.environment == 'stage' && 'screenly_qc.yml' || 'screenly.yml' }} + concurrency: + group: feed-app-${{ inputs.environment }}-${{ inputs.app_key }} + cancel-in-progress: false steps: - - name: Checkout Repository - uses: actions/checkout@v7 - with: - ref: main - - - name: Setup yq - uses: ./.github/actions/setup-yq - - - name: Update manifest file with custom values + # The app is built from the ref this run starts on, so refuse anything + # but the default branch rather than creating it from unreleased code + - name: Require the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - yq -i 'del(.id)' "$MANIFEST_FILE_NAME" - yq -i '.description = "${{ inputs.app_description }}"' "$MANIFEST_FILE_NAME" - yq -i '.icon = "${{ inputs.icon_url }}"' "$MANIFEST_FILE_NAME" - yq -i '.settings.rss_url.default_value = "${{ inputs.rss_url }}"' "$MANIFEST_FILE_NAME" - yq -i '.settings.rss_title.default_value = "${{ inputs.rss_title }}"' "$MANIFEST_FILE_NAME" - - - name: Show updated manifest - run: cat "$MANIFEST_FILE_NAME" - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Create Edge App - uses: screenly/cli@master - with: - screenly_api_token: ${{ env.SCREENLY_API_TOKEN }} - cli_commands: edge-app create --name="${{ env.APP_NAME }}" --in-place - - - name: Build - run: bun run build - - - name: Deploy Edge App - uses: screenly/cli@master - with: - screenly_api_token: ${{ env.SCREENLY_API_TOKEN }} - cli_commands: edge-app deploy --path="./dist" + if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Run this from $DEFAULT_BRANCH, not $GITHUB_REF_NAME." + exit 1 + fi - - name: Log manifest after creation - run: cat "dist/$MANIFEST_FILE_NAME" + - uses: actions/checkout@v7 - - name: Create Instance - uses: screenly/cli@master + - name: Apply ${{ inputs.app_key }} config + id: config + uses: ./.github/actions/apply-feed-app with: - screenly_api_token: ${{ env.SCREENLY_API_TOKEN }} - cli_commands: edge-app instance create --name="${{ inputs.app_name }}" + app_key: ${{ inputs.app_key }} + environment: ${{ inputs.environment }} - - name: Update Instance - uses: screenly/cli@master + # Refuses to run if deployed-apps.yml already has an id for this + # environment, or if an app with this name already exists + - name: Initialize ${{ inputs.app_key }} + uses: Screenly/edge-apps-actions/initialize@v26.10.2 with: - screenly_api_token: ${{ env.SCREENLY_API_TOKEN }} - cli_commands: edge-app instance update - - - name: Log instance.yml - run: cat instance.yml + screenly_api_token: ${{ secrets.SCREENLY_API_TOKEN }} + environment: ${{ inputs.environment }} + edge_app_name: ${{ steps.config.outputs.app_name }} + edge_app_title: ${{ steps.config.outputs.app_name }} + edge_app_id: ${{ steps.config.outputs.edge_app_id }} + + # The shared action's summary says to set a repo variable; feed apps + # keep their ids in deployed-apps.yml instead. Runs even if a later + # step failed, as long as the app was created. + - name: Report where the id goes + if: always() + env: + APP_KEY: ${{ inputs.app_key }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + ID="$(grep -E '^id:[[:space:]]*' screenly.yml | head -1 | awk '{print $2}' | tr -d "\"'" || true)" + [ -n "$ID" ] || exit 0 + echo "::notice::Feed app id: set apps.$APP_KEY.ids.$ENVIRONMENT to $ID in deployed-apps.yml (not a repo variable)." + { + echo "## Feed app id" + echo "" + echo "This is a feed app, so ignore the note above about repository variables. Set this in \`deployed-apps.yml\` and merge it:" + echo "" + echo '```yaml' + echo "apps:" + echo " $APP_KEY:" + echo " ids:" + echo " $ENVIRONMENT: '$ID'" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/update-edge-app.yml b/.github/workflows/update-edge-app.yml index be145f4..4688ae8 100644 --- a/.github/workflows/update-edge-app.yml +++ b/.github/workflows/update-edge-app.yml @@ -2,37 +2,21 @@ name: Update Edge App on: + workflow_dispatch: push: branches: - main + - master tags: - - 'v*' + - 'v[0-9]*' -run-name: Updating RSS Reader in ${{ startsWith(github.ref, 'refs/tags/v') && 'production' || 'stage' }} +permissions: + contents: read -jobs: - deploy-stage: - name: Updating RSS Reader in stage - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - environment: stage - steps: - - uses: actions/checkout@v7 - - uses: Screenly/edge-apps-actions/update@v1 - with: - screenly_api_token: ${{ secrets.SCREENLY_API_TOKEN }} - environment: stage - delete_missing_settings: 'true' +run-name: Updating RSS Reader in ${{ (github.event_name == 'push' && !github.event.deleted && startsWith(github.ref, 'refs/tags/v')) && 'production' || 'stage' }} - deploy-production: - name: Updating RSS Reader in production - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - environment: production - steps: - - uses: actions/checkout@v7 - - uses: Screenly/edge-apps-actions/update@v1 - with: - screenly_api_token: ${{ secrets.SCREENLY_API_TOKEN }} - environment: production - delete_missing_settings: 'true' +jobs: + update: + uses: Screenly/edge-apps-actions/.github/workflows/update.yml@v26.10.2 + secrets: + SCREENLY_API_TOKEN: ${{ secrets.SCREENLY_API_TOKEN }} diff --git a/.github/workflows/update-rss-reader-apps.yml b/.github/workflows/update-rss-reader-apps.yml index 51c705c..dc813d6 100644 --- a/.github/workflows/update-rss-reader-apps.yml +++ b/.github/workflows/update-rss-reader-apps.yml @@ -1,12 +1,17 @@ --- +# Deploys every feed app in deployed-apps.yml (BBC News, Fox News, ...). They +# all run this repo's code; each is the shared edge-apps-actions update with +# its own id and a manifest made from its deployed-apps.yml entry. The main +# RSS Reader app deploys separately, through update-edge-app.yml. name: Update RSS Reader Apps on: push: branches: - main + # Numeric CalVer tags only, matching update-edge-app.yml tags: - - 'v*' + - 'v[0-9]*' workflow_dispatch: inputs: app_key: @@ -14,11 +19,6 @@ on: required: false type: string - override_app_name: - description: 'Override app name (optional, leave empty to use deployed-apps.yml)' - required: false - type: string - override_description: description: 'Override app description (optional, leave empty to use deployed-apps.yml)' required: false @@ -39,148 +39,136 @@ on: required: false type: string +permissions: + contents: read + run-name: Update RSS Reader${{ inputs.app_key && format(' - {0}', inputs.app_key) || 's (ALL)' }} in ${{ startsWith(github.ref, 'refs/tags/v') && 'production' || 'stage' }} jobs: prepare: name: Prepare App Matrix runs-on: ubuntu-latest + # Deleting a tag also sends a push event; never deploy on that. + if: ${{ !github.event.deleted }} outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} - app_count: ${{ steps.set-matrix.outputs.app_count }} environment: ${{ steps.set-env.outputs.environment }} - api_base_url: ${{ steps.set-env.outputs.api_base_url }} - manifest_file_name: ${{ steps.set-env.outputs.manifest_file_name }} steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Only numeric CalVer tags (v26.11.0) are releases, matching the tag + # filter above and update-edge-app.yml. This is the one place that + # decides; a manual run on any other ref, including a tag such as vrc1, + # stays on stage. - name: Set environment id: set-env run: | - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9] ]]; then echo "environment=production" >> "$GITHUB_OUTPUT" - echo "api_base_url=https://api.screenlyapp.com" >> "$GITHUB_OUTPUT" - echo "manifest_file_name=screenly.yml" >> "$GITHUB_OUTPUT" else echo "environment=stage" >> "$GITHUB_OUTPUT" - echo "api_base_url=https://api.screenlyappstage.com" >> "$GITHUB_OUTPUT" - echo "manifest_file_name=screenly_qc.yml" >> "$GITHUB_OUTPUT" + fi + + # Same rules as the shared update workflow: production only from tags on + # the default branch, stage only from the default branch itself. + - name: Verify ref + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + ENVIRONMENT: ${{ steps.set-env.outputs.environment }} + run: | + if [ "$ENVIRONMENT" = production ]; then + git fetch --no-tags origin "$DEFAULT_BRANCH" + if ! git merge-base --is-ancestor HEAD FETCH_HEAD; then + echo "::error::Tag $GITHUB_REF_NAME is not on $DEFAULT_BRANCH; refusing to deploy to production." + exit 1 + fi + elif [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Stage deploys run from $DEFAULT_BRANCH only, not $GITHUB_REF_NAME." + exit 1 fi - name: Setup yq uses: ./.github/actions/setup-yq + # An app without an id for this environment (e.g. not yet initialized in + # production) is skipped, not deployed with an empty id - name: Generate App Matrix id: set-matrix + env: + APP_KEY: ${{ inputs.app_key }} + ENVIRONMENT: ${{ steps.set-env.outputs.environment }} run: | - CONFIG_FILE="./deployed-apps.yml" - APP_KEY_INPUT="${{ inputs.app_key }}" - ENVIRONMENT="${{ steps.set-env.outputs.environment }}" - - if [ -n "$APP_KEY_INPUT" ]; then - if ! yq ".apps.$APP_KEY_INPUT" "$CONFIG_FILE" | grep -q "ids:" || \ - [ "$(yq -r ".apps.$APP_KEY_INPUT.ids | has(\"$ENVIRONMENT\")" "$CONFIG_FILE")" != "true" ]; then - echo "Error: App key '$APP_KEY_INPUT' missing 'ids.$ENVIRONMENT'" - exit 1 - fi + set -euo pipefail + APPS=$(yq -o=json '.apps' deployed-apps.yml) - MATRIX=$(yq -o=json -I=0 '.apps | to_entries | map(select(.key == "'"$APP_KEY_INPUT"'") | {"key": .key, "id": .value.ids.'"$ENVIRONMENT"'})' "$CONFIG_FILE") - APP_COUNT=1 - else - MATRIX=$(yq -o=json -I=0 '.apps | to_entries | map(select(.value.ids and (.value.ids | has("'"$ENVIRONMENT"'"))) | {"key": .key, "id": .value.ids.'"$ENVIRONMENT"'})' "$CONFIG_FILE") - APP_COUNT=$(echo "$MATRIX" | yq 'length') + if [ -n "$APP_KEY" ] && [ "$(jq --arg key "$APP_KEY" 'has($key)' <<<"$APPS")" != true ]; then + echo "::error::No app '$APP_KEY' in deployed-apps.yml." + exit 1 + fi - if [ "$APP_COUNT" -eq 0 ]; then - echo "No apps found with IDs configured for environment: $ENVIRONMENT" - exit 1 - fi + MATRIX=$(jq -c --arg env "$ENVIRONMENT" --arg key "$APP_KEY" ' + to_entries + | map(select($key == "" or .key == $key)) + | map({key, id: ((.value.ids // {})[$env] // "" | tostring)}) + | map(select(.id != "")) + ' <<<"$APPS") + + SKIPPED=$(jq -r --arg env "$ENVIRONMENT" --arg key "$APP_KEY" ' + to_entries + | map(select(($key == "" or .key == $key) and (((.value.ids // {})[$env] // "") | tostring) == "")) + | map(.key) | join(", ") + ' <<<"$APPS") + if [ -n "$SKIPPED" ]; then + echo "::notice::No $ENVIRONMENT id in deployed-apps.yml, skipping: $SKIPPED" + fi + + if [ "$(jq length <<<"$MATRIX")" -eq 0 ]; then + echo "::error::No apps to deploy: none selected have an id for $ENVIRONMENT." + exit 1 fi echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" - echo "app_count=$APP_COUNT" >> "$GITHUB_OUTPUT" + { + echo "### Deploying to $ENVIRONMENT" + jq -r '.[] | "- \(.key): \(.id)"' <<<"$MATRIX" + } >> "$GITHUB_STEP_SUMMARY" deploy: name: Update ${{ matrix.app.key }} needs: prepare runs-on: ubuntu-latest - if: needs.prepare.outputs.app_count > 0 environment: ${{ needs.prepare.outputs.environment }} + # One deploy at a time per app and environment; a running one is never + # cancelled, matching the shared workflows + concurrency: + group: feed-app-${{ needs.prepare.outputs.environment }}-${{ matrix.app.key }} + cancel-in-progress: false strategy: matrix: app: ${{ fromJson(needs.prepare.outputs.matrix) }} fail-fast: false - env: - API_BASE_URL: ${{ needs.prepare.outputs.api_base_url }} - MANIFEST_FILE_NAME: ${{ needs.prepare.outputs.manifest_file_name }} - SCREENLY_API_TOKEN: ${{ secrets.SCREENLY_API_TOKEN }} steps: - uses: actions/checkout@v7 - - name: Setup yq - uses: ./.github/actions/setup-yq - - - name: Update manifest from config - run: | - CONFIG_FILE="./deployed-apps.yml" - APP_ID="${{ matrix.app.id }}" - APP_KEY="${{ matrix.app.key }}" - - CONFIG_APP_NAME=$(yq ".apps.$APP_KEY.app_name" "$CONFIG_FILE") - CONFIG_DESC=$(yq ".apps.$APP_KEY.description" "$CONFIG_FILE") - CONFIG_RSS_TITLE=$(yq ".apps.$APP_KEY.rss_title" "$CONFIG_FILE") - CONFIG_ICON=$(yq ".apps.$APP_KEY.icon_url" "$CONFIG_FILE") - CONFIG_RSS_URL=$(yq ".apps.$APP_KEY.rss_url" "$CONFIG_FILE") - - FINAL_APP_NAME="${{ inputs.override_app_name }}" - [ -z "$FINAL_APP_NAME" ] && FINAL_APP_NAME="$CONFIG_APP_NAME" - - FINAL_DESC="${{ inputs.override_description }}" - [ -z "$FINAL_DESC" ] && FINAL_DESC="$CONFIG_DESC" - - FINAL_RSS_TITLE="${{ inputs.override_rss_title }}" - [ -z "$FINAL_RSS_TITLE" ] && FINAL_RSS_TITLE="$CONFIG_RSS_TITLE" - - FINAL_ICON="${{ inputs.override_icon_url }}" - [ -z "$FINAL_ICON" ] && FINAL_ICON="$CONFIG_ICON" - - FINAL_RSS_URL="${{ inputs.override_rss_url }}" - [ -z "$FINAL_RSS_URL" ] && FINAL_RSS_URL="$CONFIG_RSS_URL" - - yq -i '.id = "'"$APP_ID"'"' "$MANIFEST_FILE_NAME" - yq -i '.description = "'"$FINAL_DESC"'"' "$MANIFEST_FILE_NAME" - yq -i '.icon = "'"$FINAL_ICON"'"' "$MANIFEST_FILE_NAME" - yq -i '.settings.rss_url.default_value = "'"$FINAL_RSS_URL"'"' "$MANIFEST_FILE_NAME" - yq -i '.settings.rss_title.default_value = "'"$FINAL_RSS_TITLE"'"' "$MANIFEST_FILE_NAME" - - - name: Show updated manifest - run: cat "$MANIFEST_FILE_NAME" - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build - run: bun run build - - - name: Deploy Edge App - uses: screenly/cli@master + - name: Apply ${{ matrix.app.key }} config + uses: ./.github/actions/apply-feed-app with: - screenly_api_token: ${{ env.SCREENLY_API_TOKEN }} - cli_commands: edge-app deploy --path="./dist" --delete-missing-settings=true - - summary: - name: Deployment Summary - needs: [prepare, deploy] - runs-on: ubuntu-latest - if: always() - - steps: - - name: Summary - run: | - echo "Environment: ${{ needs.prepare.outputs.environment }}" - echo "Apps processed: ${{ needs.prepare.outputs.app_count }}" - echo "Deployment status: ${{ needs.deploy.result }}" + app_key: ${{ matrix.app.key }} + environment: ${{ needs.prepare.outputs.environment }} + override_description: ${{ inputs.override_description }} + override_icon_url: ${{ inputs.override_icon_url }} + override_rss_title: ${{ inputs.override_rss_title }} + override_rss_url: ${{ inputs.override_rss_url }} + + - name: Deploy ${{ matrix.app.key }} + uses: Screenly/edge-apps-actions/update@v26.10.2 + with: + screenly_api_token: ${{ secrets.SCREENLY_API_TOKEN }} + environment: ${{ needs.prepare.outputs.environment }} + edge_app_id: ${{ matrix.app.id }} + # Settings removed from screenly.yml are removed from the app too + delete_missing_settings: 'true' diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d6c08ce..be1aaa5 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,43 +1,11 @@ -# Custom RSS Reader Deployment Guide +# Feed App Deployment Guide -Deploy multiple custom RSS reader apps from a single template using GitHub Actions. +This repo's code runs several Screenly Edge Apps. Besides the main RSS Reader app, each **feed app** (BBC News, Fox News, ...) is its own Edge App built from the same code, with its own name, icon and default feed. -## Workflows +- The **main RSS Reader app** deploys through `update-edge-app.yml`, using the repo variables `STAGE_EDGE_APP_ID` and `PRODUCTION_EDGE_APP_ID`, like every other Edge App. +- **Feed apps** are listed in `deployed-apps.yml`, which holds each one's settings and its stage and production app ids. `update-rss-reader-apps.yml` deploys them. -### Initialize Custom RSS Reader App - -Creates a new RSS reader app. - -**Inputs:** - -- Environment (stage/production) -- App Name (instance title) -- App Description (manifest description) -- RSS Title (source label displayed on each card) -- Icon URL -- RSS Feed URL - -**Steps:** - -1. Run workflow with inputs -2. Note the generated App ID from logs -3. Add app to `deployed-apps.yml` - -### Update RSS Reader Apps - -Updates one or all RSS reader apps. - -**Inputs:** - -- Environment (required) -- App key (empty = all apps, specific key = one app) -- Override fields (optional) - -**Behavior:** - -- Reads settings from `deployed-apps.yml` -- Applies overrides if provided -- Updates specified app(s) +Both use the shared [edge-apps-actions](https://github.com/Screenly/edge-apps-actions). For each feed app, the workflow writes its `deployed-apps.yml` entry into `screenly.yml` (description, icon and the `rss_title` and `rss_url` defaults), then calls the shared `update` action with the app's id. All other settings come from `screenly.yml` unchanged, so a new setting reaches every feed app on the next deploy. ## Configuration @@ -45,44 +13,23 @@ Updates one or all RSS reader apps. ```yaml apps: - app-key: - app_name: 'Instance Title' - description: 'Manifest Description' - rss_title: 'UI Title' - icon_url: 'https://...' - rss_url: 'https://...' + bbc-news: # app key, used to pick an app in the workflows + app_name: 'BBC News' # Edge App name and instance title + description: 'Latest news from BBC World Service' # manifest description + rss_title: 'BBC News' # rss_title default, shown in the header + icon_url: 'https://...' # manifest icon + rss_url: 'http://feeds.bbci.co.uk/news/rss.xml' # rss_url default ids: - stage: 'STAGE_APP_ID' - production: 'PROD_APP_ID' -``` - -Note: Common settings are shared; only App IDs differ per environment. - -## Examples - -**Create new app:** - -1. Run "Initialize" workflow -2. Copy App ID from logs -3. Add to `deployed-apps.yml` - -**Update one app:** - -```yml -App key: bbc-news -(overrides: optional) + stage: '01K7...' + production: '01K8...' # leave empty until the app exists there ``` -**Update all apps:** +An app with no id for an environment is skipped there, not deployed with an empty id. -```yml -App key: (empty) -(overrides: optional) -``` +## Releasing, adding and updating feed apps -**Change RSS URL:** +The steps are in the README: -```yml -App key: bbc-news -Override RSS URL: https://new-url.com -``` +- [Releasing](README.md#releasing): one tag releases the main app and every feed app. +- [Publishing a new feed app](README.md#publishing-a-new-feed-app) +- [Updating a feed app](README.md#updating-a-feed-app) diff --git a/README.md b/README.md index fc80680..d950361 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Version](https://img.shields.io/github/v/release/Screenly/rss-reader-app)](https://github.com/Screenly/rss-reader-app/releases/latest) + # RSS Reader Displays the latest entries from any RSS feed on your Screenly digital signage screens. @@ -38,23 +40,82 @@ bun run build ## Deployment +This code runs several Edge Apps: + +- **The main RSS Reader app**, which deploys through `update-edge-app.yml` like every other Edge App, using the repo variables `STAGE_EDGE_APP_ID` and `PRODUCTION_EDGE_APP_ID`. +- **Feed apps** (BBC News, Fox News, ...): each is its own Edge App with its own name, icon and default feed, listed in [`deployed-apps.yml`](deployed-apps.yml) and deployed by `update-rss-reader-apps.yml`. All other settings come from `screenly.yml`, so a code or setting change reaches every feed app on the next release. + +All of them release together, through the shared [edge-apps-actions](https://github.com/Screenly/edge-apps-actions). [DEPLOYMENT.md](DEPLOYMENT.md) covers how the feed-app workflows work. + +### Releasing + +Every deploy waits for approval in its GitHub environment. + +1. **Stage:** merge to `main`. The push deploys the main app and every feed app with a stage id to stage. +2. **Production:** after checking stage, tag that commit on `main` with the next CalVer version, `vYY.M.PATCH` (for example `v26.11.0`, then `v26.11.1`; check `git tag --sort=-v:refname | head -1` for the latest), and push the tag. This deploys the main app and every feed app with a production id to production. Tags not on `main` are refused, and tags that aren't `v` followed by a digit (such as `vrc1`) are ignored. + + > **Any** tag that starts with `v` and a digit deploys to production, including pre-release tags such as `v26.11.0-rc.1`. Don't push one to test; use stage. (This comes from the shared edge-apps-actions tag filter, so it applies to every Edge App.) + ```bash -screenly edge-app create --name rss-reader --in-place -bun run deploy -screenly edge-app instance create +git checkout main && git pull +git tag vYY.M.PATCH +git push origin vYY.M.PATCH ``` +3. **Release notes:** publish a GitHub Release listing the pull requests merged since the previous release. Add `--draft` to review the notes before publishing: + +```bash +gh release create vYY.M.PATCH --verify-tag --generate-notes --title vYY.M.PATCH +``` + +### Publishing a new feed app + +A feed app needs no code: it is an entry in `deployed-apps.yml`. + +1. **Host an icon** at a public URL. The existing icons are under `https://playground.srly.io/edge-apps/helpers/rss-reader/app-icons/`. +2. **Add an entry** to `deployed-apps.yml` with empty ids, and merge it to `main`: + + ```yaml + apps: + example-news: # app key: lowercase, used to pick the app in the workflows + app_name: 'Example News' # Edge App name and instance title; must be unique + description: 'Latest news from Example News' + rss_title: 'Example News' # shown in the header + icon_url: 'https://.../example-news.svg' + rss_url: 'https://example.com/feed.xml' # check it loads first + ids: + stage: '' + production: '' + ``` + + Apps without an id are skipped by deploys, so merging this changes nothing yet. + +3. **Create it on stage:** run **Actions → Initialize Custom RSS Reader App** from `main`, with environment `stage` and the app key. It refuses to run if the app already has a stage id or an Edge App with that name exists. +4. **Record the id:** copy the id from the run's summary into `ids.stage` and merge. From now on every stage deploy includes the app. +5. **Check it on stage**, then repeat steps 3 and 4 with `production` to create it there and fill in `ids.production`. The next release tag deploys it with everything else. + +The initialize summary also says to set a repo variable. Ignore that for feed apps; their ids live only in `deployed-apps.yml`. + +### Updating a feed app + +- **Its name, description, icon, header title or feed URL:** edit its entry in `deployed-apps.yml` and release as usual. Merging deploys the change to stage, and the next tag deploys it to production. (Changing `app_name` after the app exists doesn't rename it in Screenly; it is only used when the app is created.) +- **Code or other settings:** change the code or `screenly.yml`. Every feed app picks it up on the next release, with no change to `deployed-apps.yml`. +- **A one-off redeploy or test:** run **Actions → Update RSS Reader Apps**, from `main` for stage or from a release tag for production. Leave the app key empty for every app, or enter one such as `bbc-news`. The optional overrides (description, header title, icon URL, feed URL) apply to that run only, so set an app key when you use one; with the key empty, an override changes every feed app. To keep a change, put it in `deployed-apps.yml`. +- **Redeploying the main RSS Reader app:** a manual run of **Update Edge App** from `main` redeploys stage. For production, re-run the workflow run of the latest release tag; a manual run from a tag does nothing for the main app. +- **Removing an app from deploys:** clear its id for that environment in `deployed-apps.yml`. This stops deploying to it but doesn't delete the app in Screenly. + ## Configuration -| Setting | Description | Type | Default | -| ------------------- | -------------------------------------------------------------------- | -------- | -------------------------------------- | -| `bypass_cors` | Route the RSS feed URL through the CORS proxy | optional | `true` | -| `cache_interval` | How often (in seconds) to refresh the feed | required | `1800` | -| `display_errors` | Show errors on screen for debugging purposes | optional | `false` | -| `override_locale` | Override the locale used for date formatting (e.g. `en`, `fr`, `de`) | optional | `en` | -| `override_timezone` | Override the timezone for date display (e.g. `Europe/London`) | optional | system timezone | -| `rss_title` | Source label displayed on each card | required | `BBC News` | -| `rss_url` | URL of the RSS feed to display | required | `http://feeds.bbci.co.uk/news/rss.xml` | +| Setting | Description | Type | Default | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------- | +| `bypass_cors` | Route the RSS feed URL through the CORS proxy | optional | `true` | +| `cache_interval` | How often (in seconds) to refresh the feed, from 60 to 86400; values outside that range are clamped. If a refresh fails or the feed is empty, the last stories from this feed stay up for up to 24 hours | required | `1800` | +| `display_errors` | Show errors on screen for debugging purposes | optional | `false` | +| `mode` | Layout: `headlines` (lead story with image, plus a list) or `list` (headlines only, for maximum readability) | optional | `headlines` | +| `override_locale` | Override the locale used for date formatting (e.g. `en`, `fr`, `de`) | optional | `en` | +| `override_timezone` | Override the timezone for date display (e.g. `Europe/London`) | optional | system timezone | +| `rss_title` | Source name shown in the header | required | `BBC News` | +| `rss_url` | URL of the RSS feed to display | required | `http://feeds.bbci.co.uk/news/rss.xml` | ## Example Feeds diff --git a/deployed-apps.yml b/deployed-apps.yml index 28b5bf4..cea61e5 100644 --- a/deployed-apps.yml +++ b/deployed-apps.yml @@ -1,16 +1,18 @@ -# Deployed RSS Reader Apps +# Feed apps: Edge Apps that all run this repo's code, each with its own +# settings and ids. update-rss-reader-apps.yml deploys them; see the README +# ("Publishing a new feed app") for adding one. # # Format: # apps: # app-key: -# app_name: "Instance Title" -# description: "Manifest Description" -# rss_title: "UI Title" -# icon_url: "https://..." -# rss_url: "https://..." +# app_name: 'Edge App name and instance title' +# description: 'Manifest description' +# rss_title: 'rss_title default, shown in the header' +# icon_url: 'https://...' +# rss_url: 'https://...' # ids: -# stage: "STAGE_APP_ID" -# production: "PROD_APP_ID" +# stage: 'STAGE_APP_ID' +# production: 'PROD_APP_ID' # empty until initialized there; skipped apps: bbc-news: diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 4af214d..903a405 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -9,9 +9,16 @@ import { import path from 'path' const RSS_URL = 'http://feeds.bbci.co.uk/news/rss.xml' +const MOCK_IMAGE_URL_PATTERN = 'https://mock-images.example/**' + +const MOCK_IMAGE_SVG = ` + + + +` const MOCK_RSS_XML = ` - + BBC News https://www.bbc.co.uk/news @@ -20,16 +27,19 @@ const MOCK_RSS_XML = ` Global leaders gather for climate summit in Geneva World leaders are meeting in Geneva to discuss urgent measures to address climate change, with new emissions targets expected to be announced. Mon, 08 Dec 2025 09:00:00 GMT + Tech giants face new antitrust regulations across Europe The European Commission has unveiled a sweeping set of antitrust measures targeting major technology companies operating within the EU. Mon, 08 Dec 2025 08:30:00 GMT + Scientists discover new species deep in the Amazon rainforest A team of researchers has identified over a dozen previously unknown species during an expedition into a remote region of the Amazon. Mon, 08 Dec 2025 07:45:00 GMT + Global markets rally as inflation data shows signs of easing @@ -40,6 +50,7 @@ const MOCK_RSS_XML = ` Historic peace agreement signed in the Middle East Diplomats have announced a landmark peace agreement following months of negotiations, raising hopes for long-term stability in the region. Mon, 08 Dec 2025 06:30:00 GMT + Space agency reveals plans for crewed Mars mission by 2035 @@ -49,49 +60,65 @@ const MOCK_RSS_XML = ` ` -const { screenlyJsContent } = createMockScreenlyForScreenshots( - { - coordinates: [51.5074, -0.1278], - location: 'London, UK', - }, - { - bypass_cors: 'false', - cache_interval: '1800', - display_errors: 'false', - enable_analytics: 'false', - override_locale: 'en', - override_timezone: 'Europe/London', - rss_title: 'BBC News', - rss_url: RSS_URL, - }, -) +const VARIANTS = [ + { prefix: '', mode: 'headlines' }, + { prefix: 'list-', mode: 'list' }, +] + +for (const { prefix, mode } of VARIANTS) { + const { screenlyJsContent } = createMockScreenlyForScreenshots( + { + coordinates: [51.5074, -0.1278], + location: 'London, UK', + }, + { + bypass_cors: 'false', + cache_interval: '1800', + display_errors: 'false', + enable_analytics: 'false', + mode, + override_locale: 'en', + override_timezone: 'Europe/London', + rss_title: 'BBC News', + rss_url: RSS_URL, + }, + ) -for (const { width, height } of RESOLUTIONS) { - test(`screenshot ${width}x${height}`, async ({ browser }) => { - const screenshotsDir = getScreenshotsDir() + for (const { width, height } of RESOLUTIONS) { + test(`screenshot ${prefix}${width}x${height}`, async ({ browser }) => { + const screenshotsDir = getScreenshotsDir() - const context = await browser.newContext({ viewport: { width, height } }) - const page = await context.newPage() + const context = await browser.newContext({ viewport: { width, height } }) + const page = await context.newPage() - await setupClockMock(page) - await setupScreenlyJsMock(page, screenlyJsContent) + await setupClockMock(page) + await setupScreenlyJsMock(page, screenlyJsContent) - await page.route(RSS_URL, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/rss+xml', - body: MOCK_RSS_XML, + await page.route(RSS_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/rss+xml', + body: MOCK_RSS_XML, + }) + }) + + await page.route(MOCK_IMAGE_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: MOCK_IMAGE_SVG, + }) }) - }) - await page.goto('/') - await page.waitForLoadState('networkidle') + await page.goto('/') + await page.waitForLoadState('networkidle') - await page.screenshot({ - path: path.join(screenshotsDir, `${width}x${height}.png`), - fullPage: false, - }) + await page.screenshot({ + path: path.join(screenshotsDir, `${prefix}${width}x${height}.png`), + fullPage: false, + }) - await context.close() - }) + await context.close() + }) + } } diff --git a/index.html b/index.html index 71a55be..5bf8868 100644 --- a/index.html +++ b/index.html @@ -12,28 +12,25 @@ reference-height="1080" orientation="auto" > -
- - - - -

- -
- - + + diff --git a/screenly.yml b/screenly.yml index 19a81ea..89d6bbe 100644 --- a/screenly.yml +++ b/screenly.yml @@ -1,6 +1,5 @@ --- syntax: manifest_v1 -id: 01HKT5KJPAHVGYJSFQF80BCJYQ description: Screenly RSS Reader App icon: https://playground.srly.io/edge-apps/icons/rss-reader.svg author: Screenly, Inc. @@ -22,7 +21,9 @@ settings: title: Cache Interval optional: false help_text: | - Specify the interval (in seconds) used to periodically cache feeds. + How often, in seconds, to refresh the feed (60 to 86400; values + outside that range are clamped). If a refresh fails, the last stories + from this feed stay on screen for up to 24 hours. display_errors: type: string default_value: 'false' @@ -41,6 +42,21 @@ settings: optional: true help_text: Whether to enable analytics or not is_global: true + mode: + type: string + default_value: headlines + title: Mode + optional: true + help_text: + properties: + help_text: Choose the layout for where the screen is placed. + options: + - label: Headlines (lead story and list) + value: headlines + - label: List (headlines only, maximum readability) + value: list + type: select + schema_version: 1 override_locale: type: string default_value: en diff --git a/screenly_qc.yml b/screenly_qc.yml deleted file mode 100644 index c94e8e3..0000000 --- a/screenly_qc.yml +++ /dev/null @@ -1,85 +0,0 @@ ---- -syntax: manifest_v1 -id: 01JZHNYXGAR1SN3876MZNQFDXJ -description: Screenly RSS Reader App -icon: https://playground.srly.io/edge-apps/icons/rss-reader.svg -author: Screenly, Inc. -categories: - - Utilities -ready_signal: true -settings: - bypass_cors: - type: string - default_value: 'true' - title: Bypass CORS - optional: true - help_text: | - Some RSS sources require you to bypass CORS policies. - Setting this to 'true' enables this feature. - cache_interval: - type: string - default_value: '1800' - title: Cache Interval - optional: false - help_text: | - Specify the interval (in seconds) used to periodically cache feeds. - display_errors: - type: string - default_value: 'false' - title: Display Errors - optional: true - help_text: - schema_version: 1 - properties: - advanced: true - help_text: For debugging purposes to display errors on the screen. - type: boolean - enable_analytics: - type: string - default_value: 'true' - title: Enable Analytics - optional: true - help_text: Whether to enable analytics or not - is_global: true - override_locale: - type: string - default_value: en - title: Override Locale - optional: true - help_text: | - Override the default locale with a supported language code (e.g., en, fr, de). Defaults to English if not specified. - You can find the available locales here: https://momentjs.com/ - override_timezone: - type: string - default_value: '' - title: Override Timezone - optional: true - help_text: | - Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). Defaults to the system timezone if left blank. - You can find the available timezones here: https://momentjs.com/timezone/ - rss_title: - type: string - default_value: BBC News - title: RSS Title - optional: false - help_text: The source label displayed on each card, default is `BBC News`. - rss_url: - type: string - default_value: http://feeds.bbci.co.uk/news/rss.xml - title: RSS URL - optional: false - help_text: | - Enter the URL of the RSS feed you want to display, default is `BBC News`. - tag_manager_id: - type: string - default_value: GTM-P98SPZ9Z - title: Google Tag Manager ID - optional: true - help_text: Specify a Google Tag Manager ID - is_global: true - sentry_dsn: - type: secret - title: Sentry Client Key - optional: true - help_text: Sentry Client Key from Sentry SDK for error capturing. - is_global: true diff --git a/screenshots/1080x1920.webp b/screenshots/1080x1920.webp index da7626d..ad42b73 100644 Binary files a/screenshots/1080x1920.webp and b/screenshots/1080x1920.webp differ diff --git a/screenshots/1280x720.webp b/screenshots/1280x720.webp index ff06757..47cc400 100644 Binary files a/screenshots/1280x720.webp and b/screenshots/1280x720.webp differ diff --git a/screenshots/1920x1080.webp b/screenshots/1920x1080.webp index 5b4827c..9a9f9c5 100644 Binary files a/screenshots/1920x1080.webp and b/screenshots/1920x1080.webp differ diff --git a/screenshots/2160x3840.webp b/screenshots/2160x3840.webp index 4039dc9..7ac66dd 100644 Binary files a/screenshots/2160x3840.webp and b/screenshots/2160x3840.webp differ diff --git a/screenshots/2160x4096.webp b/screenshots/2160x4096.webp index ea73596..ed6cf09 100644 Binary files a/screenshots/2160x4096.webp and b/screenshots/2160x4096.webp differ diff --git a/screenshots/3840x2160.webp b/screenshots/3840x2160.webp index 26d1dfb..e33fd01 100644 Binary files a/screenshots/3840x2160.webp and b/screenshots/3840x2160.webp differ diff --git a/screenshots/4096x2160.webp b/screenshots/4096x2160.webp index 7beb87c..aa8417d 100644 Binary files a/screenshots/4096x2160.webp and b/screenshots/4096x2160.webp differ diff --git a/screenshots/480x800.webp b/screenshots/480x800.webp index 6a64bb8..8907e59 100644 Binary files a/screenshots/480x800.webp and b/screenshots/480x800.webp differ diff --git a/screenshots/720x1280.webp b/screenshots/720x1280.webp index 4df9c95..b94f02f 100644 Binary files a/screenshots/720x1280.webp and b/screenshots/720x1280.webp differ diff --git a/screenshots/800x480.webp b/screenshots/800x480.webp index d91d4fc..142a107 100644 Binary files a/screenshots/800x480.webp and b/screenshots/800x480.webp differ diff --git a/screenshots/list-1080x1920.webp b/screenshots/list-1080x1920.webp new file mode 100644 index 0000000..615f551 Binary files /dev/null and b/screenshots/list-1080x1920.webp differ diff --git a/screenshots/list-1280x720.webp b/screenshots/list-1280x720.webp new file mode 100644 index 0000000..9881869 Binary files /dev/null and b/screenshots/list-1280x720.webp differ diff --git a/screenshots/list-1920x1080.webp b/screenshots/list-1920x1080.webp new file mode 100644 index 0000000..d264504 Binary files /dev/null and b/screenshots/list-1920x1080.webp differ diff --git a/screenshots/list-2160x3840.webp b/screenshots/list-2160x3840.webp new file mode 100644 index 0000000..768884a Binary files /dev/null and b/screenshots/list-2160x3840.webp differ diff --git a/screenshots/list-2160x4096.webp b/screenshots/list-2160x4096.webp new file mode 100644 index 0000000..a5f6065 Binary files /dev/null and b/screenshots/list-2160x4096.webp differ diff --git a/screenshots/list-3840x2160.webp b/screenshots/list-3840x2160.webp new file mode 100644 index 0000000..f551c2b Binary files /dev/null and b/screenshots/list-3840x2160.webp differ diff --git a/screenshots/list-4096x2160.webp b/screenshots/list-4096x2160.webp new file mode 100644 index 0000000..912cac7 Binary files /dev/null and b/screenshots/list-4096x2160.webp differ diff --git a/screenshots/list-480x800.webp b/screenshots/list-480x800.webp new file mode 100644 index 0000000..eac659e Binary files /dev/null and b/screenshots/list-480x800.webp differ diff --git a/screenshots/list-720x1280.webp b/screenshots/list-720x1280.webp new file mode 100644 index 0000000..5c5199b Binary files /dev/null and b/screenshots/list-720x1280.webp differ diff --git a/screenshots/list-800x480.webp b/screenshots/list-800x480.webp new file mode 100644 index 0000000..8316c30 Binary files /dev/null and b/screenshots/list-800x480.webp differ diff --git a/src/css/style.css b/src/css/style.css index e8e83ff..9ef039d 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1,49 +1,34 @@ @import '@screenly/edge-apps/styles'; -:root { - --card-bg: - linear-gradient(90deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.4) 100%), - linear-gradient( - 142deg, - rgba(255, 255, 255, 0.08) 0%, - rgba(255, 255, 255, 0.02) 100% - ); - --card-border: rgba(255, 255, 255, 0.1); - --card-radius: 1.667rem; - --card-padding: 1.5rem; - --card-gap: 1.25rem; - --grid-gap: 1.25rem; - --text-primary: #ffffff; - --text-muted: rgba(255, 255, 255, 0.8); -} - -* { - box-sizing: border-box; -} - -#feed-grid[hidden], -#feed-error[hidden] { - display: none; -} - body { - margin: 0; - padding: 0; - overflow: hidden; - color: var(--text-primary); - font-family: 'Inter', system-ui, sans-serif; - background: url('/static/images/bg.webp') no-repeat center center; - background-size: cover; + --news-accent: var(--theme-color-primary, #972eff); + --news-canvas: #08080d; + --news-ink: #fff; + --news-ink-dim: rgb(255 255 255 / 55%); + --news-rule: rgb(255 255 255 / 14%); + --news-accent-ink: color-mix( + in srgb, + var(--news-accent) 65%, + var(--news-ink) + ); + --news-hero-size: 6rem; + --news-item-size: 2.25rem; + --news-item-date-size: 1.4rem; + + color: var(--news-ink); + background-color: var(--news-canvas); + background-image: radial-gradient( + ellipse 75% 50% at 50% 0%, + color-mix(in srgb, var(--news-accent) 40%, transparent), + transparent + ); } -body::before { - content: ''; - position: fixed; - inset: 0; - z-index: var(--z-index-backdrop); - pointer-events: none; - background: rgba(0, 0, 0, 0.2); - backdrop-filter: blur(3px); +body.is-light-brand { + --news-canvas: #f6f6f8; + --news-ink: #0a0a0f; + --news-ink-dim: rgb(10 10 15 / 58%); + --news-rule: rgb(10 10 15 / 14%); } auto-scaler { @@ -51,171 +36,294 @@ auto-scaler { z-index: var(--z-index-content); } -#app { +.news { display: flex; flex-direction: column; + gap: 3rem; width: 100%; height: 100%; + padding: 3rem 3.5rem; +} + +.news-body[hidden], +.news-error[hidden] { + display: none; +} + +.news-header { + display: flex; + align-items: center; + gap: 1.75rem; +} + +.news-logo { + max-width: 12rem; + max-height: 3.5rem; + object-fit: contain; } -.header { - border-radius: 1.042rem; - padding-right: 1.25rem; - background: rgba(0, 0, 0, 0.2); - box-shadow: - inset 0 0.052rem 0 rgba(255, 255, 255, 0.15), - inset 0 -0.052rem 0 rgba(255, 255, 255, 0.05); +.news-logo.is-hidden { + display: none; } -.feed-date { +.news-source { margin: 0; - padding: 5.25rem 10rem 0; - font-size: 2.344rem; - font-weight: 400; - line-height: 3.073rem; - color: var(--text-primary); - text-align: center; - text-shadow: 0 0.104rem 0.313rem rgba(0, 0, 0, 0.25); - white-space: nowrap; + font-size: 2rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.news-date { + margin: 0 0 0 auto; + font-size: 2rem; + color: var(--news-ink-dim); + font-variant-numeric: tabular-nums; } -.feed-grid { +.news-body { flex: 1; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--grid-gap); - padding: 3.75rem 10rem 7.5rem; - overflow: hidden; + min-height: 0; + display: flex; + gap: 4.5rem; } -.feed-card { +.news-hero { + flex: 1 1 55%; + min-width: 0; display: flex; flex-direction: column; - gap: var(--card-gap); - padding: var(--card-padding); - background: var(--card-bg); - border: 0.75px solid var(--card-border); - border-radius: var(--card-radius); - backdrop-filter: blur(10px); + justify-content: center; + gap: 2.5rem; + padding-left: 4rem; + border-left: 0.5rem solid var(--news-accent-ink); +} + +/* Clamped text boxes have no minimum height, so flex would squeeze them and + cut lines through the middle. The clamps below are sized to fit instead. */ +.news-hero > * { + flex-shrink: 0; +} + +.news-hero-image { + width: 100%; + height: 15rem; + object-fit: cover; + border-radius: 1.25rem; + border: 0.0625rem solid var(--news-rule); +} + +.news-hero-image.is-hidden, +.news-item-image.is-hidden { + display: none; +} + +.news-hero-date { + margin: 0; + font-size: 1.6rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--news-accent-ink); +} + +.news-hero-title { + margin: 0; + font-size: var(--news-hero-size); + font-weight: 600; + line-height: 1.05; + letter-spacing: -0.02em; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; overflow: hidden; - box-shadow: - inset 0 0.75px 0 rgba(255, 255, 255, 0.15), - inset 0 -0.75px 0 rgba(255, 255, 255, 0.05); + /* The tight line-height leaves no room under the last clamped line, so + descenders (g, p, y) would be cut off without this */ + padding-bottom: 0.12em; } -.feed-card-meta { - display: flex; - flex-direction: column; - gap: 0.375rem; +.news-hero-title.is-long { + font-size: 4.75rem; + -webkit-line-clamp: 4; } -.feed-card-title { +.news-hero-title.is-very-long { + font-size: 3.75rem; + -webkit-line-clamp: 5; +} + +.news-hero-excerpt { margin: 0; - font-size: 1.458rem; - font-weight: 700; - line-height: 1.929rem; - color: var(--text-primary); + max-width: 56ch; + font-size: 2.1rem; + line-height: 1.5; + color: var(--news-ink-dim); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } -.feed-card-date { - margin-top: 0.5rem; - font-size: 1.042rem; - font-weight: 400; - line-height: 1.563rem; - color: var(--text-muted); - white-space: nowrap; +/* Without a lead image there is room for more of the story */ +@media (orientation: landscape) { + .news-hero-image.is-hidden ~ .news-hero-excerpt { + -webkit-line-clamp: 4; + } } -.feed-card-source { - font-size: 0.833rem; - font-weight: 600; - line-height: 1.25rem; - color: var(--text-muted); - letter-spacing: 0.08em; - text-transform: uppercase; +.news-list { + flex: 1 1 45%; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + justify-content: space-evenly; + overflow: hidden; +} + +.news-item { + display: flex; + align-items: center; + gap: 2rem; + padding: 1.25rem 0; + border-top: 0.0625rem solid var(--news-rule); } -.feed-card-excerpt { +.news-item-text { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.news-item-image { + flex: 0 0 auto; + width: 9rem; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 0.75rem; + border: 0.0625rem solid var(--news-rule); +} + +.news-item:first-child { + border-top: 0; +} + +.news-item-title { margin: 0; - font-size: 1.042rem; - font-weight: 500; - line-height: 1.563rem; - color: var(--text-primary); + font-size: var(--news-item-size); + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 4; + -webkit-line-clamp: 2; overflow: hidden; } -.feed-error { +.news-item-date { + margin: 0; + font-size: var(--news-item-date-size); + color: var(--news-ink-dim); + font-variant-numeric: tabular-nums; +} + +.news-error { flex: 1; display: flex; align-items: center; justify-content: center; - padding-bottom: 10%; - font-size: 1.5rem; - line-height: 3rem; - color: var(--text-muted); + margin: 0; + font-size: 3.5rem; + line-height: 1.4; text-align: center; + color: var(--news-ink-dim); +} + +body.mode-list { + --news-item-size: 3.4rem; + --news-item-date-size: 1.75rem; +} + +body.mode-list .news-hero { + display: none; +} + +body.mode-list .news-item { + padding: 2rem 0; +} + +body.mode-list .news-item-text { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: 3rem; +} + +body.mode-list .news-item-title { + flex: 1; + display: block; + white-space: nowrap; + text-overflow: ellipsis; +} + +body.mode-list .news-item-date { + white-space: nowrap; } @media (orientation: portrait) { - .header { - padding: 0.938rem 1.25rem; - border-radius: 1.25rem; - font-size: 1.5rem; + body { + --news-hero-size: 5rem; + --news-item-size: 2.8rem; + --news-item-date-size: 1.7rem; } - .feed-date { - padding: 10rem 2.5rem 0; - font-size: 5.625rem; - line-height: 7.375rem; - white-space: normal; + .news { + padding: 3.5rem 3rem; + } + + .news-body { + flex-direction: column; + gap: 3.5rem; + } + + .news-hero { + flex: 0 0 auto; + gap: 2rem; + padding-left: 3rem; } - .feed-grid { - flex: unset; - margin-top: auto; - grid-template-columns: 1fr; - grid-auto-rows: 1fr; - align-content: start; - padding: 3.75rem 2.5rem 7.5rem; + .news-hero-image { + height: 12rem; } - .feed-card { - gap: 2.5rem; - padding: 3.25rem; + .news-hero-excerpt { + -webkit-line-clamp: 3; } - .feed-card:nth-child(n + 4) { + body.mode-headlines .news-item:nth-child(n + 5) { display: none; } - .feed-card-title { - font-size: 2rem; - line-height: 2.625rem; - -webkit-line-clamp: 2; + body.mode-list { + --news-item-size: 3rem; } - .feed-card-date { - margin-top: 0.75rem; - font-size: 1.5rem; - line-height: 2rem; + body.mode-list .news-item { + padding: 1.75rem 0; } - .feed-card-source { - font-size: 1.125rem; - line-height: 1.5rem; + body.mode-list .news-item-text { + flex-direction: column; + gap: 0.75rem; } - .feed-card-excerpt { - font-size: 1.5rem; - line-height: 2rem; - -webkit-line-clamp: 3; + body.mode-list .news-item-title { + display: -webkit-box; + white-space: normal; } } diff --git a/src/main.ts b/src/main.ts index 364d42b..60e4767 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ import './css/style.css' + +// Side-effect import: registers as a custom element import '@screenly/edge-apps/components' import { parseFeed } from '@rowanmanning/feed-parser' import { @@ -7,24 +9,82 @@ import { getLocale, getSettingWithDefault, getTimeZone, + isLightColor, + setupBrandingLogo, setupErrorHandling, + setupTheme, signalReady, } from '@screenly/edge-apps' -import { loadCache, saveCache, stripHtml, type RssEntry } from './utils' +import { getNewsMode, type NewsMode } from './mode' +import { + getTitleSizeClass, + getRefreshIntervalMs, + loadCache, + resizeImageUrl, + saveCache, + stripHtml, + type RssEntry, +} from './utils' const MAX_ENTRIES = 6 +const DATE_UPDATE_INTERVAL_MS = 60 * 1000 + +// The lead image never spans more than the screen's shorter side in either +// orientation, and thumbnails stay well under a quarter of it +function heroImageWidth(): number { + return Math.min(window.innerWidth, window.innerHeight) +} + +function thumbnailImageWidth(): number { + return heroImageWidth() / 4 +} + +// Loads the resized image, falling back to the feed's own URL if the resized +// one fails, and hiding the image only if that fails too +function setFeedImage(image: HTMLImageElement, url: string, width: number) { + const resized = resizeImageUrl(url, width) + image.onerror = () => { + if (image.src === resized && resized !== url) { + image.src = url + } else { + image.classList.add('is-hidden') + } + } + image.src = resized +} + +interface FeedConfig { + rssUrl: string + rssTitle: string + locale: string + timezone: string +} + +function queryElements() { + return { + source: document.querySelector('[data-source]'), + date: document.querySelector('[data-date]'), + body: document.querySelector('[data-body]'), + heroDate: document.querySelector('[data-hero-date]'), + heroTitle: document.querySelector('[data-hero-title]'), + heroExcerpt: document.querySelector('[data-hero-excerpt]'), + heroImage: document.querySelector('[data-hero-image]'), + list: document.querySelector('[data-list]'), + error: document.querySelector('[data-error]'), + logo: document.querySelector('[data-logo]'), + } +} -async function fetchFeed( - rssUrl: string, - locale: string, - timezone: string, - source: string, -): Promise { +type NewsElements = ReturnType + +async function fetchFeed(config: FeedConfig): Promise { const bypassCors = getSettingWithDefault('bypass_cors', 'true') === 'true' - const isAbsolute = /^https?:/.test(rssUrl) + const isAbsolute = /^https?:/.test(config.rssUrl) const url = - bypassCors && isAbsolute ? `${getCorsProxyUrl()}/${rssUrl}` : rssUrl + bypassCors && isAbsolute + ? `${getCorsProxyUrl()}/${config.rssUrl}` + : config.rssUrl const response = await fetch(url) if (!response.ok) { @@ -37,11 +97,12 @@ async function fetchFeed( const rawContent = item.content ?? item.description ?? '' return { title: item.title ?? '', - source, + source: config.rssTitle, content: rawContent.includes('<') ? stripHtml(rawContent) : rawContent, + imageUrl: item.image?.url ?? '', formattedDate: item.published - ? formatLocalizedDate(item.published, locale, { - timeZone: timezone, + ? formatLocalizedDate(item.published, config.locale, { + timeZone: config.timezone, weekday: 'short', month: 'short', day: 'numeric', @@ -54,84 +115,160 @@ async function fetchFeed( }) } -function renderCards(entries: RssEntry[]) { - const grid = document.getElementById('feed-grid')! - const template = document.getElementById( - 'feed-card-template', - ) as HTMLTemplateElement - - grid.innerHTML = '' - - for (const entry of entries) { - const clone = template.content.cloneNode(true) as DocumentFragment - clone.querySelector('.feed-card-title')!.textContent = - entry.title - clone.querySelector('.feed-card-date')!.textContent = - entry.formattedDate - clone.querySelector('.feed-card-source')!.textContent = - entry.source - clone.querySelector('.feed-card-excerpt')!.textContent = - entry.content - grid.appendChild(clone) +function createRow(entry: RssEntry, withImage: boolean): HTMLLIElement { + const item = document.createElement('li') + item.className = 'news-item' + + const text = document.createElement('div') + text.className = 'news-item-text' + + const title = document.createElement('p') + title.className = 'news-item-title' + title.textContent = entry.title + + const date = document.createElement('p') + date.className = 'news-item-date' + date.textContent = entry.formattedDate + + text.append(title, date) + item.appendChild(text) + + if (withImage && entry.imageUrl) { + const image = document.createElement('img') + image.className = 'news-item-image' + image.alt = '' + setFeedImage(image, entry.imageUrl, thumbnailImageWidth()) + item.appendChild(image) + } + + return item +} + +function renderHero(els: NewsElements, entry: RssEntry) { + if (els.heroImage) { + els.heroImage.classList.toggle('is-hidden', !entry.imageUrl) + if (entry.imageUrl) { + setFeedImage(els.heroImage, entry.imageUrl, heroImageWidth()) + } } + if (els.heroDate) { + els.heroDate.textContent = entry.formattedDate + } + if (els.heroTitle) { + els.heroTitle.className = [ + 'news-hero-title', + getTitleSizeClass(entry.title), + ] + .filter(Boolean) + .join(' ') + els.heroTitle.textContent = entry.title + } + if (els.heroExcerpt) { + els.heroExcerpt.textContent = entry.content + } +} + +function render(els: NewsElements, mode: NewsMode, entries: RssEntry[]) { + const listEntries = mode === 'headlines' ? entries.slice(1) : entries + // The list mode is the text-first layout, so it skips thumbnails + els.list?.replaceChildren( + ...listEntries.map((entry) => createRow(entry, mode === 'headlines')), + ) + + // The list mode never shows the lead story, so skip rendering it + if (mode !== 'headlines') return + renderHero(els, entries[0]) } -function showGrid() { - document.getElementById('feed-grid')!.hidden = false - document.getElementById('feed-error')!.hidden = true +function showBody(els: NewsElements) { + if (els.body) els.body.hidden = false + if (els.error) els.error.hidden = true +} + +function showError(els: NewsElements) { + if (els.body) els.body.hidden = true + if (els.error) els.error.hidden = false +} + +async function loadAndRender( + els: NewsElements, + mode: NewsMode, + config: FeedConfig, +) { + try { + const entries = await fetchFeed(config) + if (entries.length > 0) { + saveCache(config.rssUrl, entries) + render(els, mode, entries) + showBody(els) + return + } + // An empty feed is usually a hiccup at the source; keep showing the last + // stories rather than blanking the screen + console.warn('RSS feed returned no entries') + } catch (err) { + console.error('RSS fetch failed:', err) + } + + const cached = loadCache(config.rssUrl) + if (cached.length === 0) { + showError(els) + return + } + render(els, mode, cached) + showBody(els) } -function showError() { - document.getElementById('feed-grid')!.hidden = true - document.getElementById('feed-error')!.hidden = false +function updateDate(els: NewsElements, config: FeedConfig) { + if (!els.date) return + + els.date.textContent = formatLocalizedDate(new Date(), config.locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: config.timezone, + }) } document.addEventListener('DOMContentLoaded', async () => { setupErrorHandling() - const rssUrl = getSettingWithDefault( - 'rss_url', - 'http://feeds.bbci.co.uk/news/rss.xml', - ) - const rssTitle = getSettingWithDefault('rss_title', 'RSS Feed') - const cacheInterval = - getSettingWithDefault('cache_interval', 1800) * 1000 - - const loadAndRender = async () => { - try { - const entries = await fetchFeed(rssUrl, locale, timezone, rssTitle) - saveCache(entries) - renderCards(entries) - showGrid() - } catch (err) { - console.error('RSS fetch failed:', err) - const cached = loadCache() - if (cached.length === 0) { - showError() - return - } - renderCards(cached) - showGrid() - } + const { primary } = setupTheme() + document.body.classList.toggle('is-light-brand', isLightColor(primary)) + + const mode = getNewsMode() + document.body.classList.add(`mode-${mode}`) + + const els = queryElements() + + const logo = els.logo + if (logo) { + logo.onerror = () => logo.classList.add('is-hidden') + logo.src = await setupBrandingLogo() } - const updateDate = () => { - const dateEl = document.getElementById('feed-date')! - dateEl.textContent = formatLocalizedDate(new Date(), locale, { - day: 'numeric', - month: 'long', - year: 'numeric', - timeZone: timezone, - }) + const rssTitle = getSettingWithDefault('rss_title', 'RSS Feed') + if (els.source) { + els.source.textContent = rssTitle } - const locale = await getLocale() - const timezone = await getTimeZone() + const config: FeedConfig = { + rssUrl: getSettingWithDefault( + 'rss_url', + 'http://feeds.bbci.co.uk/news/rss.xml', + ), + rssTitle, + locale: await getLocale(), + timezone: await getTimeZone(), + } + const cacheInterval = getRefreshIntervalMs( + getSettingWithDefault('cache_interval', 1800), + ) - updateDate() - setInterval(updateDate, 60000) + updateDate(els, config) + setInterval(() => updateDate(els, config), DATE_UPDATE_INTERVAL_MS) - await loadAndRender() + await loadAndRender(els, mode, config) signalReady() - setInterval(loadAndRender, cacheInterval) + setInterval(() => loadAndRender(els, mode, config), cacheInterval) }) diff --git a/src/mode.test.ts b/src/mode.test.ts new file mode 100644 index 0000000..6c3947f --- /dev/null +++ b/src/mode.test.ts @@ -0,0 +1,21 @@ +import '@screenly/edge-apps/test' +import { describe, expect, test } from 'bun:test' +import { DEFAULT_MODE, resolveMode } from './mode' + +describe('resolveMode', () => { + test('returns known modes exactly', () => { + expect(resolveMode('headlines')).toBe('headlines') + expect(resolveMode('list')).toBe('list') + }) + + test('normalises casing and whitespace', () => { + expect(resolveMode(' List ')).toBe('list') + expect(resolveMode('HEADLINES')).toBe('headlines') + }) + + test('falls back to the default for unknown values', () => { + expect(resolveMode('grid')).toBe(DEFAULT_MODE) + expect(resolveMode('')).toBe(DEFAULT_MODE) + expect(resolveMode(undefined)).toBe(DEFAULT_MODE) + }) +}) diff --git a/src/mode.ts b/src/mode.ts new file mode 100644 index 0000000..6d63327 --- /dev/null +++ b/src/mode.ts @@ -0,0 +1,18 @@ +import { getSettingWithDefault } from '@screenly/edge-apps' + +export const MODES = ['headlines', 'list'] as const + +export type NewsMode = (typeof MODES)[number] + +export const DEFAULT_MODE: NewsMode = 'headlines' + +// Settings are free text at the API level, so an instance can hold a value +// this build has never heard of +export function resolveMode(value: string | undefined): NewsMode { + const normalized = value?.trim().toLowerCase() as NewsMode + return MODES.includes(normalized) ? normalized : DEFAULT_MODE +} + +export function getNewsMode(): NewsMode { + return resolveMode(getSettingWithDefault('mode', DEFAULT_MODE)) +} diff --git a/src/utils.test.ts b/src/utils.test.ts index a295052..99e46a5 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,17 +1,41 @@ import { describe, test, expect, beforeEach } from 'bun:test' -import { stripHtml, loadCache, saveCache } from './utils' +import { + CACHE_MAX_AGE_MS, + getRefreshIntervalMs, + getTitleSizeClass, + loadCache, + resizeImageUrl, + saveCache, + stripHtml, +} from './utils' import type { RssEntry } from './utils' const localStorageMock = (() => { let store: Record = {} + let quota = Infinity return { getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { + if (!(key in store) && Object.keys(store).length >= quota) { + throw new DOMException('quota exceeded', 'QuotaExceededError') + } store[key] = value }, + setQuota: (keys: number) => { + quota = keys + }, + removeItem: (key: string) => { + delete store[key] + }, + key: (index: number) => Object.keys(store)[index] ?? null, + get length() { + return Object.keys(store).length + }, clear: () => { store = {} + quota = Infinity }, + keys: () => Object.keys(store), } })() @@ -23,12 +47,14 @@ const ENTRIES: RssEntry[] = [ source: 'BBC News', content: 'Content 1', formattedDate: 'Mon, Dec 8, 2025', + imageUrl: 'https://example.com/image-1.jpg', }, { title: 'Title 2', source: 'BBC News', content: 'Content 2', formattedDate: 'Mon, Dec 8, 2025', + imageUrl: '', }, ] @@ -50,28 +76,204 @@ describe('stripHtml', () => { }) }) +const BBC = 'http://feeds.bbci.co.uk/news/rss.xml' +const NPR = 'https://feeds.npr.org/1004/rss.xml' +const NOW = 1_800_000_000_000 + describe('loadCache / saveCache', () => { beforeEach(() => { localStorageMock.clear() }) test('returns empty array when cache is empty', () => { - expect(loadCache()).toEqual([]) + expect(loadCache(BBC, NOW)).toEqual([]) + }) + + test('returns saved entries for the same feed', () => { + saveCache(BBC, ENTRIES, NOW) + expect(loadCache(BBC, NOW)).toEqual(ENTRIES) + }) + + test('overwrites previous cache for the same feed', () => { + saveCache(BBC, ENTRIES, NOW) + saveCache(BBC, [ENTRIES[0]], NOW) + expect(loadCache(BBC, NOW)).toEqual([ENTRIES[0]]) + }) + + test("never returns another feed's stories", () => { + // e.g. rss_url changed and the first fetch of the new feed failed + saveCache(BBC, ENTRIES, NOW) + expect(loadCache(NPR, NOW)).toEqual([]) + }) + + test('keeps separate caches per feed', () => { + saveCache(BBC, ENTRIES, NOW) + saveCache(NPR, [ENTRIES[1]], NOW) + expect(loadCache(BBC, NOW)).toEqual(ENTRIES) + expect(loadCache(NPR, NOW)).toEqual([ENTRIES[1]]) + }) + + test('expires after CACHE_MAX_AGE_MS', () => { + saveCache(BBC, ENTRIES, NOW) + expect(loadCache(BBC, NOW + CACHE_MAX_AGE_MS)).toEqual(ENTRIES) + expect(loadCache(BBC, NOW + CACHE_MAX_AGE_MS + 1)).toEqual([]) + }) + + test('ignores the legacy shared cache and removes it on save', () => { + localStorage.setItem( + 'rssStore', + JSON.stringify({ entries: ENTRIES, timestamp: NOW }), + ) + expect(loadCache(BBC, NOW)).toEqual([]) + saveCache(BBC, [ENTRIES[0]], NOW) + expect(localStorageMock.keys()).not.toContain('rssStore') + }) + + test("prunes other feeds' expired caches on save, keeps fresh ones", () => { + saveCache(NPR, ENTRIES, NOW) + saveCache(BBC, ENTRIES, NOW + 1000) + expect(loadCache(NPR, NOW + 1000)).toEqual(ENTRIES) + + saveCache(BBC, ENTRIES, NOW + CACHE_MAX_AGE_MS + 1) + expect(localStorageMock.keys()).toEqual([`rssStore:${BBC}`]) + }) +}) + +describe('cache recovery and validation', () => { + beforeEach(() => { + localStorageMock.clear() + }) + + test('recovers when storage is full of expired caches', () => { + saveCache(NPR, ENTRIES, NOW) + localStorageMock.setQuota(1) + saveCache(BBC, ENTRIES, NOW + CACHE_MAX_AGE_MS + 1) + expect(loadCache(BBC, NOW + CACHE_MAX_AGE_MS + 1)).toEqual(ENTRIES) + }) + + test('treats a cache saved in the future as stale', () => { + // e.g. saved while the player's clock was ahead, before it synced + saveCache(BBC, ENTRIES, NOW + 60_000) + expect(loadCache(BBC, NOW)).toEqual([]) }) - test('returns saved entries', () => { - saveCache(ENTRIES) - expect(loadCache()).toEqual(ENTRIES) + test('rejects a cache with any malformed entry', () => { + const store = (entries: unknown) => + localStorage.setItem( + `rssStore:${BBC}`, + JSON.stringify({ feedUrl: BBC, timestamp: NOW, entries }), + ) + store([null]) + expect(loadCache(BBC, NOW)).toEqual([]) + store([ENTRIES[0], { content: 'no title' }]) + expect(loadCache(BBC, NOW)).toEqual([]) + store(['just a string']) + expect(loadCache(BBC, NOW)).toEqual([]) }) - test('overwrites previous cache', () => { - saveCache(ENTRIES) - saveCache([ENTRIES[0]]) - expect(loadCache()).toEqual([ENTRIES[0]]) + test('fills in missing optional fields of a cached entry', () => { + localStorage.setItem( + `rssStore:${BBC}`, + JSON.stringify({ + feedUrl: BBC, + timestamp: NOW, + entries: [{ title: 'T' }], + }), + ) + expect(loadCache(BBC, NOW)).toEqual([ + { title: 'T', content: '', formattedDate: '', imageUrl: '', source: '' }, + ]) }) test('returns empty array for malformed cache', () => { - localStorage.setItem('rssStore', 'not-json') - expect(loadCache()).toEqual([]) + localStorage.setItem(`rssStore:${BBC}`, 'not-json') + expect(loadCache(BBC, NOW)).toEqual([]) + localStorage.setItem(`rssStore:${BBC}`, JSON.stringify({ feedUrl: BBC })) + expect(loadCache(BBC, NOW)).toEqual([]) + localStorage.setItem(`rssStore:${BBC}`, 'null') + expect(loadCache(BBC, NOW)).toEqual([]) + }) +}) + +describe('getRefreshIntervalMs', () => { + test('uses a normal value as is', () => { + expect(getRefreshIntervalMs(1800)).toBe(1_800_000) + expect(getRefreshIntervalMs(60)).toBe(60_000) + }) + + test('raises values that would refetch in a tight loop', () => { + expect(getRefreshIntervalMs(0)).toBe(60_000) + expect(getRefreshIntervalMs(-5)).toBe(60_000) + expect(getRefreshIntervalMs(1)).toBe(60_000) + expect(getRefreshIntervalMs(NaN)).toBe(60_000) + }) + + test('caps values that would overflow setInterval', () => { + // setInterval fires immediately for delays above 2^31 - 1 ms (~24.8 days) + expect(getRefreshIntervalMs(Infinity)).toBe(86_400_000) + expect(getRefreshIntervalMs(10_000_000)).toBe(86_400_000) + }) +}) + +describe('getTitleSizeClass', () => { + test('returns no class for a short title', () => { + expect(getTitleSizeClass('Markets rally')).toBe('') + }) + + test('returns is-long past 70 characters', () => { + expect(getTitleSizeClass('a'.repeat(71))).toBe('is-long') + }) + + test('returns is-very-long past 130 characters', () => { + expect(getTitleSizeClass('a'.repeat(131))).toBe('is-very-long') + }) + + test('treats the thresholds as exclusive', () => { + expect(getTitleSizeClass('a'.repeat(70))).toBe('') + expect(getTitleSizeClass('a'.repeat(130))).toBe('is-long') + }) +}) + +describe('resizeImageUrl', () => { + const ACE = + 'https://ichef.bbci.co.uk/ace/standard/240/cpsprodpb/50a9/live/ece0.jpg' + const aceAt = (size: number) => + `https://ichef.bbci.co.uk/ace/standard/${size}/cpsprodpb/50a9/live/ece0.jpg` + const NEWS = 'https://ichef.bbci.co.uk/news/240/cpsprodpb/50a9/live/ece0.jpg' + const newsAt = (size: number) => + `https://ichef.bbci.co.uk/news/${size}/cpsprodpb/50a9/live/ece0.jpg` + const IC = 'https://ichef.bbci.co.uk/images/ic/240x135/p0l7jnbt.jpg' + const icAt = (size: string) => + `https://ichef.bbci.co.uk/images/ic/${size}/p0l7jnbt.jpg` + + test('rounds /ace/ URLs up to a width the CDN serves', () => { + // 1080 itself is a 404 on the CDN + expect(resizeImageUrl(ACE, 1080)).toBe(aceAt(1280)) + expect(resizeImageUrl(ACE, 480)).toBe(aceAt(480)) + expect(resizeImageUrl(ACE, 270)).toBe(aceAt(480)) + expect(resizeImageUrl(ACE, 120)).toBe(aceAt(240)) + expect(resizeImageUrl(ACE, 4096)).toBe(aceAt(2048)) + }) + + test('skips the widths /news/ URLs do not serve', () => { + // 1280 is a 404 on this shape + expect(resizeImageUrl(NEWS, 1080)).toBe(newsAt(1536)) + expect(resizeImageUrl(NEWS, 270)).toBe(newsAt(480)) + expect(resizeImageUrl(NEWS, 4096)).toBe(newsAt(2048)) + }) + + test('resizes /images/ic/ URLs by their width-by-height segment', () => { + expect(resizeImageUrl(IC, 1080)).toBe(icAt('1280x720')) + expect(resizeImageUrl(IC, 270)).toBe(icAt('480x270')) + // 2048x1152 is a 403, so this shape tops out lower + expect(resizeImageUrl(IC, 4096)).toBe(icAt('1920x1080')) + }) + + test('leaves other feeds untouched', () => { + const nyt = 'https://static01.nyt.com/images/2026/11/03/240/photo.jpg' + expect(resizeImageUrl(nyt, 1080)).toBe(nyt) + const otherBbc = 'https://ichef.bbci.co.uk/onesport/cps/240/photo.jpg' + expect(resizeImageUrl(otherBbc, 1080)).toBe(otherBbc) + expect(resizeImageUrl('', 1080)).toBe('') }) }) diff --git a/src/utils.ts b/src/utils.ts index d342f18..8fdb75f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,17 +1,81 @@ -const CACHE_KEY = 'rssStore' +// One cache per feed URL, so a changed rss_url (or another app on the same +// origin) never shows a different feed's stories +const CACHE_KEY_PREFIX = 'rssStore:' +// The single shared key used before caches were per feed +const LEGACY_CACHE_KEY = 'rssStore' +// Past this, cached stories are too old to show during an outage, and the +// error screen is more honest than yesterday's news +export const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +const MIN_REFRESH_SECONDS = 60 +const MAX_REFRESH_SECONDS = 24 * 60 * 60 export interface RssEntry { title: string content: string formattedDate: string + imageUrl: string source: string } interface AppCache { + feedUrl: string entries: RssEntry[] timestamp: number } +const LONG_TITLE_CHARS = 70 +const VERY_LONG_TITLE_CHARS = 130 + +// News titles vary wildly in length; a long one steps down in size instead +// of wrapping the lead story off the canvas +export function getTitleSizeClass(title: string): string { + if (title.length > VERY_LONG_TITLE_CHARS) return 'is-very-long' + if (title.length > LONG_TITLE_CHARS) return 'is-long' + return '' +} + +// BBC feeds only advertise 240px thumbnails, which blur on a screen-sized lead +// story. Their image CDN serves the same image at other sizes, but each URL +// shape accepts its own set (/ace/ 404s on 1080, /news/ on 1280, /images/ic/ +// 403s on 2048x1152), so round up to a size each shape is known to serve. +// Other feeds' URLs are left untouched. +const BBC_IMAGE_SHAPES: { pattern: RegExp; sizes: string[] }[] = [ + { + // https://ichef.bbci.co.uk/ace/standard/240/cpsprodpb/... + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/ace\/[^/]+\/)\d+\//, + sizes: ['240', '480', '640', '976', '1280', '1536', '2048'], + }, + { + // https://ichef.bbci.co.uk/news/240/cpsprodpb/... + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/news\/)\d+\//, + sizes: ['240', '480', '640', '976', '1536', '2048'], + }, + { + // https://ichef.bbci.co.uk/images/ic/240x135/p0l7jnbt.jpg (video items) + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/images\/ic\/)\d+x\d+\//, + sizes: [ + '240x135', + '480x270', + '640x360', + '976x549', + '1280x720', + '1536x864', + '1920x1080', + ], + }, +] + +export function resizeImageUrl(url: string, width: number): string { + const shape = BBC_IMAGE_SHAPES.find(({ pattern }) => pattern.test(url)) + if (!shape) return url + const { pattern, sizes } = shape + const size = + sizes.find((candidate) => parseInt(candidate, 10) >= width) ?? + sizes[sizes.length - 1] + return url.replace(pattern, `$1${size}/`) +} + export function stripHtml(html: string): string { return html .replace(/<[^>]*>/g, '') @@ -19,24 +83,119 @@ export function stripHtml(html: string): string { .trim() } -export function loadCache(): RssEntry[] { +function cacheKey(feedUrl: string): string { + return `${CACHE_KEY_PREFIX}${feedUrl}` +} + +function readCache(key: string): Partial | null { + const raw = localStorage.getItem(key) + if (!raw) return null try { - const raw = localStorage.getItem(CACHE_KEY) - if (!raw) return [] - const parsed: AppCache = JSON.parse(raw) - // parsed.entries is undefined if the stored value is a legacy plain array; - // falling back to [] causes a fresh fetch on first run after upgrade. - return parsed.entries ?? [] + const parsed: unknown = JSON.parse(raw) + return parsed && typeof parsed === 'object' + ? (parsed as Partial) + : null + } catch { + return null + } +} + +// A cache saved in the future means the clock was wrong when it was saved +// (players often boot with a bad date until they sync), so its real age is +// unknown; treat it as stale rather than letting it outlive the 24 hours +function isFresh(cache: Partial, now: number): boolean { + if (typeof cache.timestamp !== 'number') return false + const age = now - cache.timestamp + return age >= 0 && age <= CACHE_MAX_AGE_MS +} + +// Storage can hold anything (an older build's format, a partial write), and +// rendering trusts these fields, so check every entry. Optional text fields +// default to empty; an entry without a title is unusable. +function toEntry(value: unknown): RssEntry | null { + if (!value || typeof value !== 'object') return null + const v = value as Record + if (typeof v.title !== 'string') return null + const text = (field: unknown) => (typeof field === 'string' ? field : '') + return { + title: v.title, + content: text(v.content), + formattedDate: text(v.formattedDate), + imageUrl: text(v.imageUrl), + source: text(v.source), + } +} + +// Returns the cached entries for this feed, or [] if there are none, they +// belong to another feed, they are older than CACHE_MAX_AGE_MS, or any of +// them is malformed +export function loadCache(feedUrl: string, now = Date.now()): RssEntry[] { + try { + const cache = readCache(cacheKey(feedUrl)) + if ( + !cache || + cache.feedUrl !== feedUrl || + !Array.isArray(cache.entries) || + !isFresh(cache, now) + ) { + return [] + } + const entries = cache.entries.map(toEntry) + return entries.every((entry) => entry !== null) + ? (entries as RssEntry[]) + : [] } catch { return [] } } -export function saveCache(entries: RssEntry[]) { +export function saveCache( + feedUrl: string, + entries: RssEntry[], + now = Date.now(), +) { try { - const data: AppCache = { entries, timestamp: Date.now() } - localStorage.setItem(CACHE_KEY, JSON.stringify(data)) + // Prune first: if storage is full of expired caches, the write below + // would otherwise fail every time and the cache would never recover + pruneCaches(feedUrl, now) + const data: AppCache = { feedUrl, entries, timestamp: now } + localStorage.setItem(cacheKey(feedUrl), JSON.stringify(data)) } catch (err) { console.warn('Failed to save cache:', err) } } + +// Drops the legacy shared cache and other feeds' expired caches, so a screen +// whose rss_url changes over time doesn't collect them forever +function pruneCaches(currentFeedUrl: string, now: number) { + localStorage.removeItem(LEGACY_CACHE_KEY) + const stale: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if ( + !key || + !key.startsWith(CACHE_KEY_PREFIX) || + key === cacheKey(currentFeedUrl) + ) { + continue + } + const cache = readCache(key) + if (!cache || !isFresh(cache, now)) stale.push(key) + } + stale.forEach((key) => localStorage.removeItem(key)) +} + +// cache_interval is free text: 0 or a tiny value would refetch the feed in a +// tight loop, and a huge one (or Infinity) overflows setInterval, which then +// fires immediately. Clamp it to a sane range. +export function getRefreshIntervalMs(seconds: number): number { + const clamped = Number.isNaN(seconds) + ? MIN_REFRESH_SECONDS + : Math.min(Math.max(seconds, MIN_REFRESH_SECONDS), MAX_REFRESH_SECONDS) + if (clamped !== seconds) { + console.warn( + `cache_interval ${seconds} is outside ${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS} seconds; using ${clamped}`, + ) + } + return clamped * 1000 +} diff --git a/static/images/bg.webp b/static/images/bg.webp deleted file mode 100644 index 202cb6f..0000000 Binary files a/static/images/bg.webp and /dev/null differ