├── .go-version ├── internal ├── blocko │ ├── testdata │ │ ├── empty.html │ │ ├── empty.md │ │ ├── hello-world.md │ │ ├── heading.md │ │ ├── table.html │ │ ├── hello-world.html │ │ ├── table.md │ │ ├── heading.html │ │ ├── special-chars.md │ │ ├── pre.md │ │ ├── special-chars.html │ │ ├── heading-ids.md │ │ ├── heading-ids.html │ │ ├── pre.html │ │ ├── siblings.md │ │ ├── list.md │ │ ├── siblings.html │ │ └── list.html │ ├── blocko_test.go │ └── minify.go ├── tableaux │ ├── testdata │ │ ├── empty.html │ │ ├── simple-1.json │ │ ├── thead-1.json │ │ ├── thead-2.json │ │ ├── simple.html │ │ └── thead.html │ └── table_test.go ├── mailchimp │ ├── testdata │ │ ├── OJ0tRvv-.req.txt │ │ ├── sendemail │ │ │ ├── 7KFHkvKo.req.txt │ │ │ ├── 6w3yqIMi.req.txt │ │ │ ├── 7KFHkvKo.res.txt │ │ │ └── uG_IW3uK.req.txt │ │ ├── yD8q9OUu.req.txt │ │ └── s0zjn.req.txt │ ├── doc.go │ ├── sendemail_test.go │ ├── emailservice.go │ └── v3.go ├── anf │ ├── doc.go │ ├── testdata │ │ ├── sample │ │ │ ├── header.png │ │ │ └── article.html │ │ ├── api │ │ │ └── FG_NChiX.req.txt │ │ ├── req.analytics.raw │ │ └── req.analytics.signed │ ├── template.go │ ├── convert_test.go │ └── fromdb_test.go ├── jsonfeed │ ├── doc.go │ └── feed.go ├── google │ ├── testdata │ │ ├── iV1Hrkzj.res.txt │ │ ├── iV1Hrkzj.req.txt │ │ ├── translate bLE0RXdg.req.txt │ │ ├── vhSTnwyq.req.txt │ │ ├── translate bLE0RXdg.res.txt │ │ ├── ixfeHnNf.req.txt │ │ └── vhSTnwyq.res.txt │ ├── docs.go │ ├── google-analytics_test.go │ └── translate_test.go ├── db │ ├── null.go │ ├── errs.go │ ├── option.sql.go │ ├── arc.sql.go │ ├── db.go │ ├── map.go │ └── domain-roles.go ├── must │ └── must.go ├── gdocs │ └── testdata │ │ ├── rich-person.md │ │ └── rich-person.html ├── lazy │ └── re.go ├── httpx │ ├── attachment.go │ └── middleware.go ├── slicex │ ├── unique.go │ └── unique_test.go ├── iterx │ ├── unique.go │ ├── iterx.go │ ├── error.go │ └── concat.go ├── stringx │ ├── count.go │ └── count_test.go ├── netlifyid │ ├── context.go │ └── mock.go ├── aws │ ├── gocloud.go │ └── md5_test.go └── jwthook │ └── testdata │ ├── signup-umd.txt │ ├── validate-umd.txt │ ├── login-spotlight.txt │ └── login-spotlight-tampered.txt ├── pkg ├── almanack │ ├── deploy-url.txt │ ├── testdata │ │ └── processDocHTML │ │ │ ├── abc │ │ │ ├── warnings.json │ │ │ ├── embeds.json │ │ │ ├── metadata.json │ │ │ ├── raw.html │ │ │ ├── rich.html │ │ │ └── article.md │ │ │ ├── toc │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── embeds.json │ │ │ ├── spltest1 │ │ │ ├── warnings.json │ │ │ ├── embeds.json │ │ │ └── metadata.json │ │ │ ├── Demo document │ │ │ ├── embeds.json │ │ │ ├── warnings.json │ │ │ └── metadata.json │ │ │ ├── SPLEVCLINK │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── embeds.json │ │ │ ├── Shortcode │ │ │ ├── warnings.json │ │ │ ├── embeds.json │ │ │ ├── raw.html │ │ │ ├── rich.html │ │ │ ├── article.md │ │ │ ├── metadata.json │ │ │ └── intermediate.html │ │ │ ├── table table │ │ │ ├── embeds.json │ │ │ ├── warnings.json │ │ │ └── metadata.json │ │ │ ├── Fake heading test │ │ │ ├── embeds.json │ │ │ ├── warnings.json │ │ │ └── metadata.json │ │ │ ├── Hash mark example │ │ │ ├── warnings.json │ │ │ ├── embeds.json │ │ │ └── metadata.json │ │ │ ├── OP2 │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ ├── embeds.json │ │ │ ├── rich.html │ │ │ └── raw.html │ │ │ ├── OP1 │ │ │ ├── warnings.json │ │ │ └── metadata.json │ │ │ ├── video │ │ │ ├── warnings.json │ │ │ └── embeds.json │ │ │ ├── SPLBALREJ │ │ │ └── warnings.json │ │ │ ├── SPLHAROLD │ │ │ ├── warnings.json │ │ │ └── embeds.json │ │ │ └── SPLEX23ERR │ │ │ └── warnings.json │ ├── doc.go │ ├── almanack.go │ ├── convert_test.go │ ├── service-gdocs_test.go │ ├── site-data.go │ └── imagestore_test.go ├── integration │ ├── testdata │ │ ├── gdoc byby │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc eyebrow │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ ├── article.md │ │ │ ├── raw.html │ │ │ ├── rich.html │ │ │ └── shared-article.json │ │ ├── gdoc hash │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc simple │ │ │ ├── warnings.json │ │ │ ├── smnIcGLZ.res.txt │ │ │ ├── google docs image yCPejdq2.res.txt │ │ │ ├── smnIcGLZ.req.txt │ │ │ ├── google docs image yCPejdq2.req.txt │ │ │ ├── rich.html │ │ │ ├── raw.html │ │ │ ├── article.md │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc spl │ │ │ ├── warnings.json │ │ │ ├── rich.html │ │ │ ├── raw.html │ │ │ ├── article.md │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc table │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc toc │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc empty embed │ │ │ ├── warnings.json │ │ │ ├── W6CL_zwW.req.txt │ │ │ ├── Ln3CfGqD.res.txt │ │ │ ├── sAuY-NIZ.req.txt │ │ │ ├── v-QOmOI8.req.txt │ │ │ ├── H1N4qOxH.req.txt │ │ │ ├── metadata.json │ │ │ ├── shared-article.json │ │ │ └── W6CL_zwW.res.txt │ │ ├── gdoc flourish │ │ │ └── warnings.json │ │ ├── gdoc image width │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ ├── shared-article.json │ │ │ ├── raw.html │ │ │ ├── rich.html │ │ │ └── article.md │ │ ├── blank.md │ │ ├── gdoc embed newlines │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc more │ │ │ ├── warnings.json │ │ │ ├── 9IbgK0Ed.req.txt │ │ │ ├── 9IbgK0Ed.res.txt │ │ │ ├── iV1Hrkzj.res.txt │ │ │ ├── smnIcGLZ.res.txt │ │ │ ├── iV1Hrkzj.req.txt │ │ │ ├── smnIcGLZ.req.txt │ │ │ ├── metadata.json │ │ │ ├── shared-article.json │ │ │ ├── rich.html │ │ │ └── raw.html │ │ ├── gdoc warning │ │ │ ├── warnings.json │ │ │ ├── W6CL_zwW.req.txt │ │ │ ├── VCrj3U0x.res.txt │ │ │ ├── sAuY-NIZ.req.txt │ │ │ ├── wbcx1aTw.req.txt │ │ │ ├── SUHw3r_B.req.txt │ │ │ ├── metadata.json │ │ │ ├── shared-article.json │ │ │ └── W6CL_zwW.res.txt │ │ ├── gdoc paren na │ │ │ ├── warnings.json │ │ │ ├── 9IbgK0Ed.req.txt │ │ │ ├── 9IbgK0Ed.res.txt │ │ │ ├── iV1Hrkzj.res.txt │ │ │ ├── iV1Hrkzj.req.txt │ │ │ ├── metadata.json │ │ │ ├── shared-article.json │ │ │ ├── rich.html │ │ │ └── raw.html │ │ ├── anf │ │ │ └── KcVMfQus.req.txt │ │ ├── gdoc na │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc fake heading │ │ │ ├── warnings.json │ │ │ ├── metadata.json │ │ │ └── shared-article.json │ │ ├── gdoc bad embed │ │ │ └── warnings.json │ │ ├── gdoc mark │ │ │ └── warnings.json │ │ ├── gdoc picture │ │ │ └── warnings.json │ │ ├── gdoc curly │ │ │ └── warnings.json │ │ ├── fm.md │ │ └── fm+body.md │ └── testdb_test.go ├── api │ └── doc.go └── almlog │ ├── testlog.go │ ├── context.go │ └── httplogger.go ├── public ├── robots.txt ├── favicon.ico └── docs │ └── Spotlight PA Content License.docx ├── sql ├── schema │ ├── prod.conf │ ├── tern.conf │ ├── 012_page_index.sql │ ├── 020_create_option.sql │ ├── 030_fix_shared.sql │ ├── 008_add_tiff.sql │ ├── 024_publication_date.sql │ ├── 031_soft_delete.sql │ ├── 011_page_path.sql │ ├── 016_iso_timestamp.sql │ ├── 022_webp.sql │ ├── 017_drop_spl_data.sql │ ├── 009_newsletter_articles.sql │ ├── 032_licensed_image.sql │ ├── 028_md5_bytes.sql │ ├── 029_image_fts.sql │ ├── 018_big-int.sql │ ├── 033_shared_blurb.sql │ ├── 010_page.sql │ ├── 005_file.sql │ ├── 007_address_roles.sql │ ├── 004_site_data.sql │ ├── 013_site_data_schedule.sql │ ├── 019_page_source.sql │ └── 014_citext.sql ├── queries │ ├── arc.sql │ ├── option.sql │ ├── address-roles.sql │ ├── domain-roles.sql │ └── file.sql ├── one-time │ ├── migrate-pages.sql │ ├── image-import.sql │ └── import-newsletters.sql └── schema-overrides │ └── 001.sql ├── src ├── assets │ └── img │ │ ├── ad-rail.png │ │ ├── ad-footer.png │ │ ├── ad-header.png │ │ ├── ad-river.png │ │ ├── ad-breaker.png │ │ ├── ad-featured.png │ │ ├── ad-headwater.png │ │ ├── pr-almanack.jpeg │ │ ├── sports-alamanc.jpeg │ │ └── circle-white-on-trans.svg ├── utils │ ├── cmp.js │ ├── fuzzy-match.js │ ├── getter.js │ ├── maybe-date.js │ ├── comma-and.js │ ├── image-size.js │ ├── human-size.js │ ├── use-scroll-to.js │ ├── link.js │ ├── sanitize-text.js │ ├── throttle.js │ ├── use-data.js │ ├── google-analytics.js │ └── use-props.js ├── components │ ├── AsyncSpinner.vue │ ├── ArcArticleDivider.vue │ ├── ArcArticleText.vue │ ├── ArcArticlePlaceholder.vue │ ├── ArcArticleHeader.vue │ ├── LinkButtons.vue │ ├── NoCopyTextArea.vue │ ├── TagDate.vue │ ├── DOMInnerHTML.vue │ ├── APILoader.vue │ ├── ViewUnauthorized.vue │ ├── ArcArticleHTML.vue │ ├── ArcArticleList.vue │ ├── ErrorSimple.vue │ ├── ViewError.vue │ ├── LinkButton.vue │ ├── LinkRoute.vue │ ├── ThumbnailArc.vue │ ├── LinkHref.vue │ ├── SpinnerProgress.vue │ ├── SiteParamsRailSticky.vue │ ├── BulmaModal.vue │ ├── ArcArticleOEmbed.vue │ ├── SiteParamsRailTop.vue │ ├── BulmaCharLimit.vue │ ├── TagStatus.vue │ ├── SiteParamsFeaturedHomepage.vue │ ├── HomepageEditorItem.vue │ ├── SiteParamsNewsletter.vue │ ├── ThumbnailS3.vue │ ├── GDocsDocWarnings.vue │ ├── BulmaPaste.vue │ ├── BulmaBreadcrumbs.vue │ ├── BulmaFieldCheckbox.vue │ └── ArcArticleImage.vue ├── css │ └── fonts │ │ └── raleway-v13 │ │ ├── raleway-v13-latin-ext_latin-100.woff │ │ ├── raleway-v13-latin-ext_latin-100.woff2 │ │ ├── raleway-v13-latin-ext_latin-200.woff │ │ ├── raleway-v13-latin-ext_latin-200.woff2 │ │ ├── raleway-v13-latin-ext_latin-300.woff │ │ ├── raleway-v13-latin-ext_latin-300.woff2 │ │ ├── raleway-v13-latin-ext_latin-500.woff │ │ ├── raleway-v13-latin-ext_latin-500.woff2 │ │ ├── raleway-v13-latin-ext_latin-600.woff │ │ ├── raleway-v13-latin-ext_latin-600.woff2 │ │ ├── raleway-v13-latin-ext_latin-700.woff │ │ ├── raleway-v13-latin-ext_latin-700.woff2 │ │ ├── raleway-v13-latin-ext_latin-800.woff │ │ ├── raleway-v13-latin-ext_latin-800.woff2 │ │ ├── raleway-v13-latin-ext_latin-900.woff │ │ ├── raleway-v13-latin-ext_latin-900.woff2 │ │ ├── raleway-v13-latin-ext_latin-italic.woff │ │ ├── raleway-v13-latin-ext_latin-italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-regular.woff │ │ ├── raleway-v13-latin-ext_latin-100italic.woff │ │ ├── raleway-v13-latin-ext_latin-200italic.woff │ │ ├── raleway-v13-latin-ext_latin-300italic.woff │ │ ├── raleway-v13-latin-ext_latin-500italic.woff │ │ ├── raleway-v13-latin-ext_latin-600italic.woff │ │ ├── raleway-v13-latin-ext_latin-700italic.woff │ │ ├── raleway-v13-latin-ext_latin-800italic.woff │ │ ├── raleway-v13-latin-ext_latin-900italic.woff │ │ ├── raleway-v13-latin-ext_latin-regular.woff2 │ │ ├── raleway-v13-latin-ext_latin-100italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-200italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-300italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-500italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-600italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-700italic.woff2 │ │ ├── raleway-v13-latin-ext_latin-800italic.woff2 │ │ └── raleway-v13-latin-ext_latin-900italic.woff2 ├── api │ ├── spotlightpa-all-pages-item.js │ ├── imgproxy-url.js │ └── gdocs.js └── main.js ├── postcss.config.cjs ├── SECURITY.md ├── jsconfig.json ├── .browserslistrc ├── babel.config.js ├── funcs └── almanack-api │ └── main.go ├── README.md ├── layouts └── layouts.go ├── .github └── workflows │ ├── go.yml │ ├── nodejs.yml │ └── govuln.yml ├── eslint.config.js └── functions └── schedule.mts /.go-version: -------------------------------------------------------------------------------- 1 | 1.24.1 2 | -------------------------------------------------------------------------------- /internal/blocko/testdata/empty.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/blocko/testdata/empty.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/tableaux/testdata/empty.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/almanack/deploy-url.txt: -------------------------------------------------------------------------------- 1 | http://localhost -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc byby/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc hash/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc table/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc toc/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /internal/blocko/testdata/hello-world.md: -------------------------------------------------------------------------------- 1 | Hello, world! 2 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/toc/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc flourish/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/spltest1/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/integration/testdata/blank.md: -------------------------------------------------------------------------------- 1 | +++ 2 | +++ 3 | 4 | 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc embed newlines/warnings.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Demo document/embeds.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Demo document/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLEVCLINK/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/table table/embeds.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/table table/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Fake heading test/embeds.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Hash mark example/warnings.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /internal/blocko/testdata/heading.md: -------------------------------------------------------------------------------- 1 | # Hello, world! 2 | 3 | Lorem ipsum 4 | -------------------------------------------------------------------------------- /internal/blocko/testdata/table.html: -------------------------------------------------------------------------------- 1 |

one

two

2 | -------------------------------------------------------------------------------- /sql/schema/prod.conf: -------------------------------------------------------------------------------- 1 | [database] 2 | conn_string={{ env "PG_PROD_URL"}} 3 | -------------------------------------------------------------------------------- /internal/blocko/testdata/hello-world.html: -------------------------------------------------------------------------------- 1 |

2 | Hello, world! 3 |

4 | -------------------------------------------------------------------------------- /internal/blocko/testdata/table.md: -------------------------------------------------------------------------------- 1 | one 2 | 3 |
4 | 5 | two 6 | -------------------------------------------------------------------------------- /sql/schema/tern.conf: -------------------------------------------------------------------------------- 1 | [database] 2 | host = 127.0.0.1 3 | database = almanack 4 | -------------------------------------------------------------------------------- /internal/blocko/testdata/heading.html: -------------------------------------------------------------------------------- 1 |

Hello, world!

2 |

Lorem ipsum

3 | -------------------------------------------------------------------------------- /pkg/almanack/doc.go: -------------------------------------------------------------------------------- 1 | // Package almanack has business logic. 2 | package almanack 3 | -------------------------------------------------------------------------------- /pkg/api/doc.go: -------------------------------------------------------------------------------- 1 | // Package api has CLI setup and routing handlers 2 | package api 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /internal/mailchimp/testdata/OJ0tRvv-.req.txt: -------------------------------------------------------------------------------- 1 | GET /hzazpP HTTP/1.1 2 | Host: eepurl.com 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Image embed #3 missing alt description." 3 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Unrecognized table type: \"zork\"" 3 | ] -------------------------------------------------------------------------------- /internal/anf/doc.go: -------------------------------------------------------------------------------- 1 | // Package anf contains tools for dealing with Apple News Format 2 | package anf 3 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Image embed #3 missing alt description." 3 | ] -------------------------------------------------------------------------------- /src/assets/img/ad-rail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-rail.png -------------------------------------------------------------------------------- /src/utils/cmp.js: -------------------------------------------------------------------------------- 1 | export default function cmp(a, b) { 2 | return a === b ? 0 : a < b ? -1 : 1; 3 | } 4 | -------------------------------------------------------------------------------- /internal/blocko/testdata/special-chars.md: -------------------------------------------------------------------------------- 1 | a b c 2 | 3 | a b 4 | 5 | a
b 6 | 7 | a
b 8 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP2/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Image embed #3 missing alt description." 3 | ] -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | let plugins = [require("autoprefixer")]; 2 | 3 | module.exports = { 4 | plugins, 5 | }; 6 | -------------------------------------------------------------------------------- /src/assets/img/ad-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-footer.png -------------------------------------------------------------------------------- /src/assets/img/ad-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-header.png -------------------------------------------------------------------------------- /src/assets/img/ad-river.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-river.png -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Email `webmaster@spotlightpa.org`. 6 | -------------------------------------------------------------------------------- /internal/mailchimp/doc.go: -------------------------------------------------------------------------------- 1 | // Package mailchimp has types for working with the Mailchimp API. 2 | package mailchimp 3 | -------------------------------------------------------------------------------- /pkg/integration/testdata/anf/KcVMfQus.req.txt: -------------------------------------------------------------------------------- 1 | GET /feeds/full.json HTTP/1.1 2 | Host: www.spotlightpa.org 3 | 4 | -------------------------------------------------------------------------------- /sql/queries/arc.sql: -------------------------------------------------------------------------------- 1 | -- name: GetArcByArcID :one 2 | SELECT 3 | * 4 | FROM 5 | arc 6 | WHERE 7 | arc_id = $1; 8 | -------------------------------------------------------------------------------- /src/assets/img/ad-breaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-breaker.png -------------------------------------------------------------------------------- /src/assets/img/ad-featured.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-featured.png -------------------------------------------------------------------------------- /src/assets/img/ad-headwater.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/ad-headwater.png -------------------------------------------------------------------------------- /src/assets/img/pr-almanack.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/pr-almanack.jpeg -------------------------------------------------------------------------------- /src/utils/fuzzy-match.js: -------------------------------------------------------------------------------- 1 | export default (str, substr) => 2 | str.toLowerCase().indexOf(substr.toLowerCase()) >= 0; 3 | -------------------------------------------------------------------------------- /internal/jsonfeed/doc.go: -------------------------------------------------------------------------------- 1 | // Package jsonfeed has tools for reading a jsonfeed.org feed of news stories 2 | package jsonfeed 3 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/9IbgK0Ed.req.txt: -------------------------------------------------------------------------------- 1 | GET /images/dither-320.png HTTP/1.1 2 | Host: carlmjohnson.net 3 | 4 | -------------------------------------------------------------------------------- /sql/queries/option.sql: -------------------------------------------------------------------------------- 1 | -- name: GetOption :one 2 | SELECT 3 | "value" 4 | FROM 5 | "option" 6 | WHERE 7 | key = $1; 8 | -------------------------------------------------------------------------------- /src/assets/img/sports-alamanc.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/assets/img/sports-alamanc.jpeg -------------------------------------------------------------------------------- /internal/blocko/testdata/pre.md: -------------------------------------------------------------------------------- 1 |
    don’t
2 |     break
3 |     me
4 | 
5 | 6 | don’t 7 | break 8 | me 9 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/9IbgK0Ed.req.txt: -------------------------------------------------------------------------------- 1 | GET /images/dither-320.png HTTP/1.1 2 | Host: carlmjohnson.net 3 | 4 | -------------------------------------------------------------------------------- /internal/anf/testdata/sample/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/internal/anf/testdata/sample/header.png -------------------------------------------------------------------------------- /internal/google/testdata/iV1Hrkzj.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/internal/google/testdata/iV1Hrkzj.res.txt -------------------------------------------------------------------------------- /internal/blocko/testdata/special-chars.html: -------------------------------------------------------------------------------- 1 |

 a b c 

2 |

 a 3 | b

4 |

a
b

5 |

a
b

6 | -------------------------------------------------------------------------------- /internal/blocko/testdata/heading-ids.md: -------------------------------------------------------------------------------- 1 | # Hello, world! 2 | 3 | Lorem ipsum 4 | 5 |

More headings

6 | 7 | Lorem ipsum 8 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | }, 6 | "jsx": "preserve" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc na/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Paragraph beginning \"[DELETE BELOW/…\" looks like a header, but does not use H-tag." 3 | ] -------------------------------------------------------------------------------- /public/docs/Spotlight PA Content License.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/public/docs/Spotlight PA Content License.docx -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 Chrome version 2 | last 2 Firefox version 3 | last 2 Safari version 4 | last 1 ChromeAndroid version 5 | last 1 iOS version 6 | -------------------------------------------------------------------------------- /internal/tableaux/testdata/simple-1.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "one", 4 | "two" 5 | ], 6 | [ 7 | "three", 8 | "four" 9 | ] 10 | ] -------------------------------------------------------------------------------- /internal/tableaux/testdata/thead-1.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "one", 4 | "two" 5 | ], 6 | [ 7 | "three", 8 | "four" 9 | ] 10 | ] -------------------------------------------------------------------------------- /internal/google/testdata/iV1Hrkzj.req.txt: -------------------------------------------------------------------------------- 1 | GET /drive/v3/files/1ssiQd8AKXHo99qkZZwYbHxfVJHY3RPnL?alt=media HTTP/1.1 2 | Host: www.googleapis.com 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc fake heading/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Paragraph beginning \"Heading: Lorem…\" looks like a header, but does not use H-tag." 3 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/9IbgK0Ed.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc more/9IbgK0Ed.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/iV1Hrkzj.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc more/iV1Hrkzj.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/smnIcGLZ.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc more/smnIcGLZ.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/W6CL_zwW.req.txt: -------------------------------------------------------------------------------- 1 | GET /u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7=w2048-h1322 HTTP/1.1 2 | Host: lh3.google.com 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/W6CL_zwW.req.txt: -------------------------------------------------------------------------------- 1 | GET /u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7=w2048-h1322 HTTP/1.1 2 | Host: lh3.google.com 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/smnIcGLZ.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc simple/smnIcGLZ.res.txt -------------------------------------------------------------------------------- /internal/blocko/testdata/heading-ids.html: -------------------------------------------------------------------------------- 1 |

Hello, world!

2 |

Lorem ipsum

3 |

More headings

4 |

Lorem ipsum

5 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Fake heading test/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Paragraph beginning \"Heading: Lorem…\" looks like a header, but does not use H-tag." 3 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/iV1Hrkzj.req.txt: -------------------------------------------------------------------------------- 1 | GET /drive/v3/files/1ssiQd8AKXHo99qkZZwYbHxfVJHY3RPnL?alt=media HTTP/1.1 2 | Host: www.googleapis.com 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/9IbgK0Ed.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc paren na/9IbgK0Ed.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/iV1Hrkzj.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc paren na/iV1Hrkzj.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/VCrj3U0x.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc warning/VCrj3U0x.res.txt -------------------------------------------------------------------------------- /src/components/AsyncSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /internal/blocko/testdata/pre.html: -------------------------------------------------------------------------------- 1 |
 2 |     don’t
 3 |     break
 4 |     me
 5 | 
6 | 7 | 8 | don’t 9 | break 10 | me 11 | 12 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/Ln3CfGqD.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc empty embed/Ln3CfGqD.res.txt -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/iV1Hrkzj.req.txt: -------------------------------------------------------------------------------- 1 | GET /drive/v3/files/1ssiQd8AKXHo99qkZZwYbHxfVJHY3RPnL?alt=media HTTP/1.1 2 | Host: www.googleapis.com 3 | 4 | -------------------------------------------------------------------------------- /sql/schema/012_page_index.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX page_published ON page ((frontmatter ->> 'published')); 2 | 3 | ---- create above / drop below ---- 4 | DROP INDEX page_published; 5 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/sendemail/7KFHkvKo.req.txt: -------------------------------------------------------------------------------- 1 | POST /3.0/campaigns/65f71f2588/actions/send HTTP/1.1 2 | Host: .api.mailchimp.com 3 | Authorization: Basic Og== 4 | 5 | -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-regular.woff -------------------------------------------------------------------------------- /internal/tableaux/testdata/thead-2.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "five", 4 | "six", 5 | "seven" 6 | ], 7 | [ 8 | "eight", 9 | "nine", 10 | "ten" 11 | ] 12 | ] -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900italic.woff -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-regular.woff2 -------------------------------------------------------------------------------- /internal/db/null.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/jackc/pgx/v5/pgtype" 5 | ) 6 | 7 | var ( 8 | NullTime = pgtype.Timestamptz{} 9 | NullText = pgtype.Text{} 10 | ) 11 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "partner-embed", 5 | "value": "" 6 | } 7 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/google docs image yCPejdq2.res.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/pkg/integration/testdata/gdoc simple/google docs image yCPejdq2.res.txt -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-100italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-200italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-300italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-500italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-600italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-700italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-800italic.woff2 -------------------------------------------------------------------------------- /src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotlightpa/almanack/HEAD/src/css/fonts/raleway-v13/raleway-v13-latin-ext_latin-900italic.woff2 -------------------------------------------------------------------------------- /src/components/ArcArticleDivider.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP1/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Image embed #3 missing alt description.", 3 | "Image embed #5 missing alt description.", 4 | "Image embed #6 missing alt description." 5 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc bad embed/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Embed #1 seems to contain unbalanced HTML.", 3 | "Paragraph beginning \"Our panelists …\" looks like a header, but does not use H-tag." 4 | ] -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/video/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Embed #1 seems to contain unbalanced HTML.", 3 | "Paragraph beginning \"Our panelists …\" looks like a header, but does not use H-tag." 4 | ] 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc mark/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 3 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/smnIcGLZ.req.txt: -------------------------------------------------------------------------------- 1 | GET /styles/news_large/s3/imagelibrary/T/TeenageEngineeringOP1_01-S7onF.JAz9Mm5jMOnoPrFKJJEdQPBwYP.jpg HTTP/1.1 2 | Host: dt7v1i9vyp3mf.cloudfront.net 3 | 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc picture/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 3 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/smnIcGLZ.req.txt: -------------------------------------------------------------------------------- 1 | GET /styles/news_large/s3/imagelibrary/T/TeenageEngineeringOP1_01-S7onF.JAz9Mm5jMOnoPrFKJJEdQPBwYP.jpg HTTP/1.1 2 | Host: dt7v1i9vyp3mf.cloudfront.net 3 | 4 | -------------------------------------------------------------------------------- /src/components/ArcArticleText.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLBALREJ/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 3 | ] -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLHAROLD/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 3 | ] -------------------------------------------------------------------------------- /internal/tableaux/testdata/simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
onetwo
threefour
11 | -------------------------------------------------------------------------------- /internal/anf/testdata/api/FG_NChiX.req.txt: -------------------------------------------------------------------------------- 1 | GET /channels/abc/ HTTP/1.1 2 | Host: news-api.apple.com 3 | Authorization: HHMAC; key="123"; signature="z8BELAmucEnq5YbXqAQCGsPxSWDCxXS2sCV5dqbv/L8="; date="2000-01-01T00:00:00Z" 4 | 5 | -------------------------------------------------------------------------------- /sql/schema/020_create_option.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "option" ( 2 | "id" bigserial PRIMARY KEY, 3 | "key" text NOT NULL, 4 | "value" text NOT NULL DEFAULT '' 5 | ); 6 | 7 | ---- create above / drop below ---- 8 | DROP TABLE "option"; 9 | -------------------------------------------------------------------------------- /sql/schema/030_fix_shared.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE shared_article 2 | DROP COLUMN lede_image_source; 3 | 4 | ---- create above / drop below ---- 5 | ALTER TABLE shared_article 6 | ADD COLUMN "lede_image_source" text NOT NULL DEFAULT '',; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@vue/cli-plugin-babel/preset", 5 | { 6 | targets: { esmodules: true }, 7 | polyfills: [], 8 | }, 9 | ], 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/sendemail/6w3yqIMi.req.txt: -------------------------------------------------------------------------------- 1 | PUT /3.0/campaigns/65f71f2588/content HTTP/1.1 2 | Host: .api.mailchimp.com 3 | Authorization: Basic Og== 4 | Content-Type: application/json 5 | 6 | {"plain_text":"Hello, World!"} -------------------------------------------------------------------------------- /funcs/almanack-api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spotlightpa/almanack/pkg/api" 7 | ) 8 | 9 | func main() { 10 | if err := api.CLI(os.Args[1:]); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/must/must.go: -------------------------------------------------------------------------------- 1 | package must 2 | 3 | func Do(err error) { 4 | if err != nil { 5 | panic(err) 6 | } 7 | } 8 | 9 | func Get[T any](v T, err error) T { 10 | if err != nil { 11 | panic(err) 12 | } 13 | return v 14 | } 15 | -------------------------------------------------------------------------------- /sql/schema/008_add_tiff.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO image_type (name, mime, extensions) 2 | VALUES 3 | -- 4 | ('tiff', 'image/tiff', '{tif,tiff}'); 5 | 6 | ---- create above / drop below ---- 7 | DELETE FROM image_type 8 | WHERE name = 'tiff'; 9 | -------------------------------------------------------------------------------- /src/components/ArcArticlePlaceholder.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /internal/gdocs/testdata/rich-person.md: -------------------------------------------------------------------------------- 1 | My name is Carlana Johnson. 2 | 3 | This is my test document. 4 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/rich.html: -------------------------------------------------------------------------------- 1 |

Blah blah blah

2 |

Lorem ipsum dolor

3 |

Embed #1

4 |

Some bold and italic partner text. Spotlight PA is blah blah. 5 |

6 | 7 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc curly/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Embed #2 contains unusual characters.", 3 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 4 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/raw.html: -------------------------------------------------------------------------------- 1 |

Blah blah blah

2 |

Lorem ipsum dolor

3 | 4 |

Some bold and italic partner text. Spotlight PA is blah blah. 5 |

6 | 7 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/raw.html: -------------------------------------------------------------------------------- 1 |

Blah blah blah

Lorem ipsum dolor

Some bold and italic partner text. Spotlight PA is blah blah. 2 |

3 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/rich.html: -------------------------------------------------------------------------------- 1 |

Blah blah blah

Lorem ipsum dolor

Embed #1

Some bold and italic partner text. Spotlight PA is blah blah. 2 |

3 | -------------------------------------------------------------------------------- /src/utils/getter.js: -------------------------------------------------------------------------------- 1 | export default function getProp(obj, pathStr, { fallback = null } = {}) { 2 | for (let prop of pathStr.split(".")) { 3 | if (!obj) { 4 | break; 5 | } 6 | obj = obj[prop]; 7 | } 8 | return obj ?? fallback; 9 | } 10 | -------------------------------------------------------------------------------- /internal/gdocs/testdata/rich-person.html: -------------------------------------------------------------------------------- 1 |

My name is Carlana Johnson. 2 |

3 |

This is my test document. 4 |

5 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLEX23ERR/warnings.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Embed #2 contains unusual characters.", 3 | "Document contains
line breaks. Are you sure you want to use a line break? In Google Docs, select View > Show non-printing characters to see them." 4 | ] -------------------------------------------------------------------------------- /src/utils/maybe-date.js: -------------------------------------------------------------------------------- 1 | export default function maybeDate(obj, pathStr = "") { 2 | let d = obj; 3 | for (let prop of pathStr.split(".")) { 4 | if (!d) { 5 | break; 6 | } 7 | d = d[prop]; 8 | } 9 | return d ? new Date(d) : null; 10 | } 11 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/sendemail/7KFHkvKo.res.txt: -------------------------------------------------------------------------------- 1 | HTTP/2.0 204 No Content 2 | Connection: close 3 | Content-Type: application/json; charset=utf-8 4 | Date: Tue, 09 May 2023 19:23:39 GMT 5 | Server: openresty 6 | X-Request-Id: 10b9218a-04d0-ae46-b208-97e8c7ccc3c3 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotlight PA Almanack [![Netlify Status](https://api.netlify.com/api/v1/badges/70bdbb1c-63cf-4141-b786-9eb6ba438914/deploy-status)](https://app.netlify.com/sites/spotlightpa-almanack/deploys) 2 | Predicts future sports scores 3 | 4 | 5 | -------------------------------------------------------------------------------- /internal/google/testdata/translate bLE0RXdg.req.txt: -------------------------------------------------------------------------------- 1 | POST /v3/projects/1:translateText HTTP/1.1 2 | Host: translate.googleapis.com 3 | Content-Type: application/json 4 | 5 | {"contents":["Hello, World!"],"sourceLanguageCode":"en-US","targetLanguageCode":"es","mimeType":"text/plain"} 6 | -------------------------------------------------------------------------------- /sql/one-time/migrate-pages.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO page (file_path, last_published) 2 | SELECT 3 | spotlightpa_path, 4 | CURRENT_TIMESTAMP 5 | FROM 6 | article 7 | WHERE 8 | article.spotlightpa_path IS NOT NULL 9 | AND article.last_published IS NOT NULL 10 | RETURNING 11 | *; 12 | -------------------------------------------------------------------------------- /sql/schema/024_publication_date.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "page" 2 | ADD COLUMN "publication_date" timestamptz GENERATED ALWAYS AS 3 | (iso_to_timestamptz (frontmatter ->> 'published')) STORED; 4 | 5 | ---- create above / drop below ---- 6 | ALTER TABLE "page" 7 | DROP COLUMN "publication_date"; 8 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/sAuY-NIZ.req.txt: -------------------------------------------------------------------------------- 1 | GET /ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7=w2048-h1322 4 | 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/sAuY-NIZ.req.txt: -------------------------------------------------------------------------------- 1 | GET /ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7=w2048-h1322 4 | 5 | -------------------------------------------------------------------------------- /internal/blocko/testdata/siblings.md: -------------------------------------------------------------------------------- 1 | Hello, world!. One, two, three. 2 | 3 | Link one and one 4 | 5 | Link 1Link 2 6 | 7 |
Don't touch that div!
8 | 9 |
Do not touch that div!
10 | -------------------------------------------------------------------------------- /pkg/almanack/almanack.go: -------------------------------------------------------------------------------- 1 | package almanack 2 | 3 | import ( 4 | _ "embed" 5 | "strings" 6 | 7 | "github.com/earthboundkid/versioninfo/v2" 8 | ) 9 | 10 | var ( 11 | BuildVersion = versioninfo.Revision 12 | //go:embed deploy-url.txt 13 | deployURL string 14 | DeployURL = strings.TrimSpace(deployURL) 15 | ) 16 | -------------------------------------------------------------------------------- /sql/schema/031_soft_delete.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE image 2 | ADD COLUMN deleted_at timestamptz; 3 | 4 | ALTER TABLE file 5 | ADD COLUMN deleted_at timestamptz; 6 | 7 | ---- create above / drop below ---- 8 | ALTER TABLE image 9 | DROP COLUMN deleted_at; 10 | 11 | ALTER TABLE file 12 | DROP COLUMN deleted_at; 13 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/yD8q9OUu.req.txt: -------------------------------------------------------------------------------- 1 | GET /3.0/campaigns?count=10&fields=campaigns.archive_url%2Ccampaigns.send_time%2Ccampaigns.settings.subject_line%2Ccampaigns.settings.title&list_id=&offset=0&sort_dir=desc&sort_field=send_time&status=sent HTTP/1.1 2 | Host: .api.mailchimp.com 3 | Authorization: Basic Og== 4 | 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/google docs image yCPejdq2.req.txt: -------------------------------------------------------------------------------- 1 | GET /docs/AG8NV2Y0FrXSefDcNXQNa-NdCiGs2BTkB1Tut4xxqkBQ0NecjCb9wWGYqZjigXbLBkQOc3HMb9G9rA8BERhgQJFfudKEZyEVoS3HyzrAf6gpbRUr0eH7kwLZrNIIq1X4___hRd8Y0Upm-N6htQCXSw1ZNyahUjhPHU5GUb2FWrP91GeoTLYY28MmWEc4tuiSwOeKNtY HTTP/1.1 2 | Host: lh3.googleusercontent.com 3 | 4 | -------------------------------------------------------------------------------- /src/components/ArcArticleHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/components/LinkButtons.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /src/components/NoCopyTextArea.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /internal/lazy/re.go: -------------------------------------------------------------------------------- 1 | // Package lazy has sync.Once wrappers 2 | package lazy 3 | 4 | import ( 5 | "regexp" 6 | "sync" 7 | ) 8 | 9 | // RE is a lazy regular expression. 10 | func RE(str string) func() *regexp.Regexp { 11 | return sync.OnceValue(func() *regexp.Regexp { 12 | return regexp.MustCompile(str) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/comma-and.js: -------------------------------------------------------------------------------- 1 | export default function commaAnd(a) { 2 | if (!a || !a.length) { 3 | return ""; 4 | } 5 | let ss = a.map((item) => item.toString()); 6 | if (ss.length < 3) { 7 | return ss.join(" and "); 8 | } 9 | let commas = a.slice(0, -1).join(", "); 10 | return `${commas} and ${ss[ss.length - 1]}`; 11 | } 12 | -------------------------------------------------------------------------------- /internal/google/testdata/vhSTnwyq.req.txt: -------------------------------------------------------------------------------- 1 | GET /drive/v3/files?corpora=drive&driveId=0AGhO1dVO-F_-Uk9PVA&fields=files%28id%2Cdescription%2ClastModifyingUser%2Cname%29&includeItemsFromAllDrives=true&orderBy=recency&pageSize=50&q=mimeType%3D%27application%2Fvnd.google-apps.document%27&supportsAllDrives=true HTTP/1.1 2 | Host: www.googleapis.com 3 | 4 | -------------------------------------------------------------------------------- /sql/schema/011_page_path.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "page" RENAME COLUMN "path" TO "file_path"; 2 | 3 | ALTER TABLE "page" 4 | ADD COLUMN "url_path" text, 5 | ADD UNIQUE ("url_path"); 6 | 7 | ---- create above / drop below ---- 8 | ALTER TABLE "page" RENAME COLUMN "file_path" TO "path"; 9 | 10 | ALTER TABLE "page" 11 | DROP COLUMN "url_path"; 12 | -------------------------------------------------------------------------------- /internal/google/docs.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | func (gsvc *Service) GDocsClient(ctx context.Context) (cl *http.Client, err error) { 9 | return gsvc.client(ctx, 10 | "https://www.googleapis.com/auth/documents.readonly", 11 | "https://www.googleapis.com/auth/drive.readonly", 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/s0zjn.req.txt: -------------------------------------------------------------------------------- 1 | GET /3.0/campaigns?count=10&fields=campaigns.archive_url%2Ccampaigns.send_time%2Ccampaigns.settings.subject_line%2Ccampaigns.settings.title%2Ccampaigns.settings.preview_text&list_id=&offset=0&sort_dir=desc&sort_field=send_time&status=sent HTTP/1.1 2 | Host: .api.mailchimp.com 3 | Authorization: Basic Og== 4 | 5 | -------------------------------------------------------------------------------- /sql/schema/016_iso_timestamp.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION iso_to_timestamptz (text) 2 | RETURNS timestamptz 3 | AS $$ 4 | SELECT 5 | to_timestamp($1, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')::timestamptz 6 | $$ 7 | LANGUAGE sql 8 | IMMUTABLE; 9 | 10 | ---- create above / drop below ---- 11 | DROP FUNCTION IF EXISTS iso_to_timestamptz (text); 12 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "image", 5 | "value": { 6 | "path": "abc/123.jpeg", 7 | "credit": "Cred 3", 8 | "caption": "Cap 1", 9 | "description": "Desc 2", 10 | "width": 640, 11 | "height": 480, 12 | "kind": "all" 13 | } 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /pkg/almlog/testlog.go: -------------------------------------------------------------------------------- 1 | package almlog 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | ) 7 | 8 | func UseTestLogger(t interface{ Output() io.Writer }) { 9 | opts := slog.HandlerOptions{ 10 | Level: Level, 11 | ReplaceAttr: removeTime, 12 | } 13 | Logger = slog.New(slog.NewTextHandler(t.Output(), &opts)) 14 | slog.SetDefault(Logger) 15 | } 16 | -------------------------------------------------------------------------------- /internal/mailchimp/testdata/sendemail/uG_IW3uK.req.txt: -------------------------------------------------------------------------------- 1 | POST /3.0/campaigns HTTP/1.1 2 | Host: .api.mailchimp.com 3 | Authorization: Basic Og== 4 | Content-Type: application/json 5 | 6 | {"type":"plaintext","recipients":{"segment_opts":{}},"settings":{"subject_line":"Test message","title":"Test message","from_name":"Spotlight PA","reply_to":"press@spotlightpa.org"}} -------------------------------------------------------------------------------- /sql/schema/022_webp.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO image_type (name, mime, extensions) 2 | VALUES 3 | -- 4 | ('webp', 'image/webp', '{webp}'), -- 5 | ('avif', 'image/avif', '{avif,avifs}'), -- 6 | ('heic', 'image/heic', '{heic,heif}'); 7 | 8 | ---- create above / drop below ---- 9 | DELETE FROM image_type 10 | WHERE name IN ('webp', 'heic', 'avif'); 11 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/rich.html: -------------------------------------------------------------------------------- 1 |

My name is Carlana Johnson.

2 |

This is my test document.

3 |

Some blocks

4 |

Embed #1

5 |

Embed #2

6 | -------------------------------------------------------------------------------- /src/utils/image-size.js: -------------------------------------------------------------------------------- 1 | export default function imageSize(url) { 2 | return new Promise((resolve, reject) => { 3 | let img = new Image(); 4 | img.onload = () => { 5 | resolve({ 6 | height: img.height, 7 | width: img.width, 8 | }); 9 | }; 10 | img.onerror = (e) => reject(e); 11 | img.src = url; 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/raw.html: -------------------------------------------------------------------------------- 1 |

My name is Carlana Johnson.

2 |

This is my test document.

3 |

Some blocks

4 | 5 |

Embed #2

6 | -------------------------------------------------------------------------------- /internal/anf/template.go: -------------------------------------------------------------------------------- 1 | package anf 2 | 3 | import ( 4 | _ "embed" 5 | "sync" 6 | 7 | "encoding/json" 8 | 9 | "github.com/spotlightpa/almanack/internal/must" 10 | ) 11 | 12 | //go:embed sample/article.json 13 | var templateJSON []byte 14 | 15 | var templateDoc = sync.OnceValue(func() Article { 16 | var a Article 17 | must.Do(json.Unmarshal(templateJSON, &a)) 18 | return a 19 | }) 20 | -------------------------------------------------------------------------------- /sql/schema/017_drop_spl_data.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "article" 2 | DROP COLUMN "spotlightpa_data", 3 | DROP COLUMN "schedule_for", 4 | DROP COLUMN "last_published"; 5 | 6 | ---- create above / drop below ---- 7 | ALTER TABLE "article" 8 | ADD COLUMN "spotlightpa_data" jsonb NOT NULL DEFAULT '{}'::jsonb, 9 | ADD COLUMN "schedule_for" timestamptz, 10 | ADD COLUMN "last_published" timestamptz; 11 | -------------------------------------------------------------------------------- /src/components/TagDate.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Hash mark example/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "raw", 5 | "value": "Blah blah\n\n###" 6 | }, 7 | { 8 | "n": 2, 9 | "type": "raw", 10 | "value": "\n\n
" 11 | } 12 | ] -------------------------------------------------------------------------------- /internal/blocko/testdata/list.md: -------------------------------------------------------------------------------- 1 | ##### header 5 2 | 3 | - one and one 4 | 5 | - two 6 | 7 | three 8 | 9 | - four 10 | 11 | - - five 12 | 13 | - six 14 | 15 | seven 16 | 17 | -
eight
18 | 19 | Lorem ipsum. 20 | 21 | 1. one 22 | 23 | 2. two 24 | 25 | three 26 | 27 | 3. four 28 | 29 | 1. five 30 | 31 | six 32 | 33 | 2. seven 34 | 35 | 3. eight 36 | 37 | Lorem ipsum. 38 | -------------------------------------------------------------------------------- /internal/blocko/testdata/siblings.html: -------------------------------------------------------------------------------- 1 |

2 | Hello, world!. 3 | One, two, three. 4 |

5 |

6 | Link one and one 7 |

8 |

9 | Link 1Link 2 10 |

11 |
12 | Don't touch that div! 13 |
14 |
15 | Do not touch that div! 16 |
17 | -------------------------------------------------------------------------------- /src/api/spotlightpa-all-pages-item.js: -------------------------------------------------------------------------------- 1 | export default class AllPagesItem { 2 | constructor(data) { 3 | this.id = data.id; 4 | this.filePath = data.file_path ?? ""; 5 | this.internalID = data.internal_id ?? ""; 6 | this.hed = data.hed ?? ""; 7 | this.authors = data.authors ?? []; 8 | this.filterableProps = `${this.internalID} ${this.hed} ${this.authors.join( 9 | " " 10 | )}`; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/httpx/attachment.go: -------------------------------------------------------------------------------- 1 | // Package httpx contains http utilities. 2 | package httpx 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | func SetAttachmentName(h http.Header, filename string) { 11 | h.Set("Content-Disposition", AttachmentName(filename)) 12 | } 13 | 14 | func AttachmentName(filename string) string { 15 | return fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/v-QOmOI8.req.txt: -------------------------------------------------------------------------------- 1 | GET /InteractiveLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneGEW7r2o5YuaxTfS05CwPp_g5VQ_ySwz1jYpHdrQY5zStqyOp8GpmfYSDq_P-eBa-rI4qqR HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://accounts.google.com/ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en 4 | 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/wbcx1aTw.req.txt: -------------------------------------------------------------------------------- 1 | GET /InteractiveLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneGls-BG2uxMs06OxfaHLGuEXg-ofq43KwFayWJXFgN17BJZJN0UbP_AfFIp4818N1rzPlC_Vw HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://accounts.google.com/ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en 4 | 5 | -------------------------------------------------------------------------------- /src/utils/human-size.js: -------------------------------------------------------------------------------- 1 | export default function humanSize(size) { 2 | if (size < 1024) { 3 | return `${size} B`; 4 | } 5 | const units = ["B", "KB", "MB", "GB", "TB", "PB"]; 6 | let unit; 7 | for (unit of units) { 8 | if (size < 1024) { 9 | break; 10 | } 11 | size /= 1024; 12 | } 13 | if (size < 10) { 14 | return `${size.toFixed(1)} ${unit}`; 15 | } 16 | return `${size.toFixed(0)} ${unit}`; 17 | } 18 | -------------------------------------------------------------------------------- /sql/schema/009_newsletter_articles.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "newsletter" 2 | ADD COLUMN "id" BIGSERIAL PRIMARY KEY, 3 | ADD COLUMN "description" text NOT NULL DEFAULT '', 4 | ADD COLUMN "blurb" text NOT NULL DEFAULT '', 5 | ADD COLUMN "spotlightpa_path" text UNIQUE; 6 | 7 | ---- create above / drop below ---- 8 | ALTER TABLE "newsletter" 9 | DROP COLUMN "id", 10 | DROP COLUMN "description", 11 | DROP COLUMN "blurb", 12 | DROP COLUMN "spotlightpa_path"; 13 | -------------------------------------------------------------------------------- /layouts/layouts.go: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | ) 7 | 8 | //go:embed *.html 9 | var FS embed.FS 10 | 11 | func makeTemplate(names ...string) *template.Template { 12 | baseName := names[0] 13 | return template.Must( 14 | template. 15 | New(baseName). 16 | Funcs(nil). 17 | ParseFS(FS, names...)) 18 | } 19 | 20 | var MailChimp = makeTemplate("mailchimp.html") 21 | 22 | var Error = makeTemplate("error.html") 23 | -------------------------------------------------------------------------------- /src/components/DOMInnerHTML.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /internal/httpx/middleware.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/earthboundkid/mid" 9 | ) 10 | 11 | func WithTimeout(d time.Duration) mid.Middleware { 12 | return func(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | ctx, stop := context.WithTimeout(r.Context(), d) 15 | defer stop() 16 | next.ServeHTTP(w, r.WithContext(ctx)) 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/article.md: -------------------------------------------------------------------------------- 1 | Blah blah blah 2 | 3 | Lorem ipsum dolor 4 | 5 | {{}} 6 | 7 | {{}} 13 | 14 | Some bold and italic Spotlight PA text. Spotlight PA is blah blah. 15 | 16 | -------------------------------------------------------------------------------- /src/components/APILoader.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/article.md: -------------------------------------------------------------------------------- 1 | Blah blah blah 2 | 3 | Lorem ipsum dolor 4 | 5 | {{}} 6 | 7 | {{}} 13 | 14 | Some bold and italic Spotlight PA text. Spotlight PA is blah blah. 15 | 16 | -------------------------------------------------------------------------------- /src/components/ViewUnauthorized.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "abc", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/toc/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/article.md: -------------------------------------------------------------------------------- 1 | My name is Carlana Johnson. 2 | 3 | This is my test document. 4 | 5 | # Some blocks 6 | 7 | {{}} 8 | 9 | {{}} 10 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Shortcode", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /sql/schema-overrides/001.sql: -------------------------------------------------------------------------------- 1 | -- DO NOT RUN THIS FILE. 2 | -- Hack to remove useless fulltextsearch column from page. 3 | -- See: 4 | -- https://github.com/kyleconroy/sqlc/issues/162 5 | -- https://github.com/kyleconroy/sqlc/issues/1380 6 | SELECT 7 | fail (); 8 | 9 | ALTER TABLE page 10 | DROP COLUMN fts_doc_en; 11 | 12 | ALTER TABLE page 13 | DROP COLUMN internal_id_fts; 14 | 15 | ALTER TABLE image 16 | DROP COLUMN fts; 17 | 18 | DROP TABLE newsletter; 19 | 20 | DROP TABLE newsletter_type; 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | permissions: 4 | contents: read 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version: "stable" 14 | - name: Download modules 15 | run: go mod download 16 | - name: Check go.mod 17 | run: go mod tidy -diff 18 | - name: Test 19 | run: ./run.sh test:backend 20 | -------------------------------------------------------------------------------- /internal/db/errs.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/earthboundkid/resperr/v2" 9 | "github.com/jackc/pgx/v5" 10 | ) 11 | 12 | func IsNotFound(err error) bool { 13 | return errors.Is(err, pgx.ErrNoRows) 14 | } 15 | 16 | func NoRowsAs404(err error, format string, a ...any) error { 17 | if !IsNotFound(err) { 18 | return err 19 | } 20 | prefix := fmt.Sprintf(format, a...) 21 | return resperr.New(http.StatusNotFound, "%s: %w", prefix, err) 22 | } 23 | -------------------------------------------------------------------------------- /internal/google/testdata/translate bLE0RXdg.res.txt: -------------------------------------------------------------------------------- 1 | HTTP/2.0 200 OK 2 | Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 3 | Content-Type: application/json; charset=UTF-8 4 | Date: Sat, 11 Oct 2025 16:12:08 GMT 5 | Server: ESF 6 | Vary: Origin 7 | Vary: X-Origin 8 | Vary: Referer 9 | X-Content-Type-Options: nosniff 10 | X-Frame-Options: SAMEORIGIN 11 | X-Xss-Protection: 0 12 | 13 | { 14 | "translations": [ 15 | { 16 | "translatedText": "¡Hola Mundo!" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/table table/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "abc", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc table/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Table table", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /sql/schema/032_licensed_image.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE image 2 | ADD COLUMN "is_licensed" boolean NOT NULL DEFAULT TRUE; 3 | 4 | ALTER TABLE image DISABLE TRIGGER row_updated_at_on_image_trigger_; 5 | 6 | UPDATE 7 | image 8 | SET 9 | "is_licensed" = FALSE 10 | WHERE 11 | image.fts @@ websearch_to_tsquery('english', 'inquirer'); 12 | 13 | ALTER TABLE image ENABLE TRIGGER row_updated_at_on_image_trigger_; 14 | 15 | ---- create above / drop below ---- 16 | ALTER TABLE image 17 | DROP COLUMN "is_licensed"; 18 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc hash/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Hash mark example", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc toc/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Table of Contents", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /src/components/ArcArticleHTML.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /internal/google/testdata/ixfeHnNf.req.txt: -------------------------------------------------------------------------------- 1 | POST /v1beta/properties/1:runReport HTTP/1.1 2 | Host: analyticsdata.googleapis.com 3 | Content-Type: application/json 4 | 5 | {"dateRanges":[{"startDate":"today","endDate":"today"}],"dimensions":[{"name":"pagePath"}],"metrics":[{"name":"screenPageViews"}],"orderBys":[{"metric":{"metricName":"screenPageViews"},"desc":true}],"dimensionFilter":{"filter":{"fieldName":"pagePath","stringFilter":{"matchType":"FULL_REGEXP","value":"^/(news|statecollege|berks)/\\d\\d\\d\\d/.*"}}},"limit":20} -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Fake heading test/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Hash mark example/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc fake heading/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Fake heading test", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/spltest1/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "toc", 5 | "value": "

Blah blah

" 6 | }, 7 | { 8 | "n": 2, 9 | "type": "raw", 10 | "value": "\n\n
" 11 | } 12 | ] -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc na/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Basic Story / Google to Alm Template", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "", 7 | "description": "", 8 | "lede_image": "", 9 | "lede_image_credit": "", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /src/components/ArcArticleList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /internal/anf/testdata/req.analytics.raw: -------------------------------------------------------------------------------- 1 | POST /v1beta/properties/1:runReport HTTP/1.1 2 | Host: analyticsdata.googleapis.com 3 | Content-Type: application/json 4 | Content-Length: 362 5 | 6 | {"dateRanges":[{"startDate":"today","endDate":"today"}],"dimensions":[{"name":"pagePath"}],"metrics":[{"name":"screenPageViews"}],"orderBys":[{"metric":{"metricName":"screenPageViews"},"desc":true}],"dimensionFilter":{"filter":{"fieldName":"pagePath","stringFilter":{"matchType":"FULL_REGEXP","value":"^/(news|statecollege|berks)/\\d\\d\\d\\d/.*"}}},"limit":20} 7 | -------------------------------------------------------------------------------- /internal/blocko/blocko_test.go: -------------------------------------------------------------------------------- 1 | package blocko_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/carlmjohnson/be" 7 | "github.com/carlmjohnson/be/testfile" 8 | "github.com/spotlightpa/almanack/internal/blocko" 9 | ) 10 | 11 | func TestGoldenFiles(t *testing.T) { 12 | testfile.Run(t, "testdata/*.html", func(t *testing.T, path string) { 13 | in := testfile.Read(t, path) 14 | 15 | got, err := blocko.MinifyAndBlockize(in) 16 | be.NilErr(t, err) 17 | 18 | testfile.Equal(t, testfile.Ext(path, ".md"), got) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /internal/db/option.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.30.0 4 | // source: option.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const getOption = `-- name: GetOption :one 13 | SELECT 14 | "value" 15 | FROM 16 | "option" 17 | WHERE 18 | key = $1 19 | ` 20 | 21 | func (q *Queries) GetOption(ctx context.Context, key string) (string, error) { 22 | row := q.db.QueryRow(ctx, getOption, key) 23 | var value string 24 | err := row.Scan(&value) 25 | return value, err 26 | } 27 | -------------------------------------------------------------------------------- /internal/slicex/unique.go: -------------------------------------------------------------------------------- 1 | // Package slicex has slice helpers not in Go std slices. 2 | package slicex 3 | 4 | import ( 5 | "slices" 6 | ) 7 | 8 | // UniquesFunc deduplicates the slice by removing any items with the same key according to the keyfunc. 9 | func UniquesFunc[S ~[]T, T any, K comparable](s *S, keyfunc func(T) K) { 10 | priorSet := make(map[K]struct{}) 11 | *s = slices.DeleteFunc(*s, func(value T) bool { 12 | key := keyfunc(value) 13 | _, seen := priorSet[key] 14 | priorSet[key] = struct{}{} 15 | return seen 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/video/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "raw", 5 | "value": "
\n

" 6 | } 7 | ] -------------------------------------------------------------------------------- /src/components/ErrorSimple.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | -------------------------------------------------------------------------------- /src/components/ViewError.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | -------------------------------------------------------------------------------- /internal/anf/convert_test.go: -------------------------------------------------------------------------------- 1 | package anf_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/carlmjohnson/be" 7 | "github.com/carlmjohnson/be/testfile" 8 | "github.com/spotlightpa/almanack/internal/anf" 9 | ) 10 | 11 | func TestConvert(t *testing.T) { 12 | testfile.Run(t, "testdata/*/article.html", func(t *testing.T, match string) { 13 | in := testfile.Read(t, match) 14 | art, err := anf.ConvertToAppleNews(in, "http://www.spotlightpa.com") 15 | be.NilErr(t, err) 16 | testfile.EqualJSON(t, testfile.Ext(match, ".json"), art) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/tableaux/testdata/thead.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
onetwo
threefour
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
fivesixseven
eightnineten
28 | -------------------------------------------------------------------------------- /internal/iterx/unique.go: -------------------------------------------------------------------------------- 1 | package iterx 2 | 3 | import ( 4 | "iter" 5 | ) 6 | 7 | // UniquesFunc yields values in sequence but only once according to the keyfunc. 8 | func UniquesFunc[T any, K comparable](seq iter.Seq[T], keyfunc func(T) K) iter.Seq[T] { 9 | return func(yield func(T) bool) { 10 | priorSet := make(map[K]struct{}) 11 | for val := range seq { 12 | key := keyfunc(val) 13 | _, seen := priorSet[key] 14 | if seen { 15 | continue 16 | } 17 | priorSet[key] = struct{}{} 18 | if !yield(val) { 19 | return 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/use-scroll-to.js: -------------------------------------------------------------------------------- 1 | import { ref, nextTick } from "vue"; 2 | 3 | export default function useScrollTo( 4 | querystring = "[data-scroll-to]", 5 | position = -1 6 | ) { 7 | const container = ref(); 8 | 9 | async function trigger() { 10 | await nextTick(); 11 | let el = container.value; 12 | let headings = el.querySelectorAll(querystring); 13 | let newPick = Array.from(headings).at(position); 14 | newPick.scrollIntoView({ 15 | behavior: "smooth", 16 | block: "start", 17 | }); 18 | } 19 | return [container, trigger]; 20 | } 21 | -------------------------------------------------------------------------------- /internal/iterx/iterx.go: -------------------------------------------------------------------------------- 1 | // Package iterx has iteration utilities. 2 | package iterx 3 | 4 | import ( 5 | "iter" 6 | ) 7 | 8 | // Filter returns a sequence of matching items. 9 | func Filter[T any](seq iter.Seq[T], match func(T) bool) iter.Seq[T] { 10 | return func(yield func(T) bool) { 11 | for v := range seq { 12 | if match(v) && !yield(v) { 13 | return 14 | } 15 | } 16 | } 17 | } 18 | 19 | // First returns the first item in a sequence or the zero value. 20 | func First[T any](seq iter.Seq[T]) (v T) { 21 | for v = range seq { 22 | return v 23 | } 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /sql/queries/address-roles.sql: -------------------------------------------------------------------------------- 1 | -- name: GetRolesForAddress :one 2 | SELECT 3 | roles 4 | FROM 5 | address_roles 6 | WHERE 7 | "email_address" ILIKE $1; 8 | 9 | -- name: UpsertRolesForAddress :one 10 | INSERT INTO address_roles ("email_address", roles) 11 | VALUES ($1, $2) 12 | ON CONFLICT (lower("email_address")) 13 | DO UPDATE SET 14 | roles = $2 15 | RETURNING 16 | *; 17 | 18 | -- name: ListAddressesWithRole :many 19 | SELECT 20 | "email_address" 21 | FROM 22 | "address_roles" 23 | WHERE 24 | "roles" @> ARRAY[@role::text] 25 | ORDER BY 26 | "email_address" ASC; 27 | -------------------------------------------------------------------------------- /sql/schema/028_md5_bytes.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "image" 2 | ADD COLUMN "md5" bytea NOT NULL DEFAULT '', 3 | ADD COLUMN "bytes" bigint NOT NULL DEFAULT 0; 4 | 5 | CREATE INDEX "image_md5" ON "image" ("md5"); 6 | 7 | ALTER TABLE "file" 8 | ADD COLUMN "md5" bytea NOT NULL DEFAULT '', 9 | ADD COLUMN "bytes" bigint NOT NULL DEFAULT 0; 10 | 11 | CREATE INDEX "file_md5" ON "file" ("md5"); 12 | 13 | ---- create above / drop below ---- 14 | ALTER TABLE "image" 15 | DROP COLUMN "md5", 16 | DROP COLUMN "bytes"; 17 | 18 | ALTER TABLE "file" 19 | DROP COLUMN "md5", 20 | DROP COLUMN "bytes"; 21 | -------------------------------------------------------------------------------- /src/utils/link.js: -------------------------------------------------------------------------------- 1 | export function toAbs(relurl) { 2 | if (!relurl || !URL.canParse(relurl, "https://www.spotlightpa.org")) { 3 | return relurl; 4 | } 5 | return new URL(relurl, "https://www.spotlightpa.org").href; 6 | } 7 | 8 | export function toRel(url) { 9 | if (!url) { 10 | return ""; 11 | } 12 | let u; 13 | try { 14 | u = new URL(url); 15 | } catch (e) { 16 | return url; 17 | } 18 | if ( 19 | u.hostname === "www.spotlightpa.org" || 20 | u.hostname === "spotlightpa.org" 21 | ) { 22 | return u.pathname; 23 | } 24 | return url; 25 | } 26 | -------------------------------------------------------------------------------- /internal/stringx/count.go: -------------------------------------------------------------------------------- 1 | package stringx 2 | 3 | import ( 4 | "github.com/earthboundkid/bytemap/v2" 5 | ) 6 | 7 | var whitespace = bytemap.Make(" \t\n\r\v\f") 8 | 9 | func WordCount(s string) int { 10 | n := 0 11 | inWord := false 12 | for _, c := range []byte(s) { 13 | wasInWord := inWord 14 | inWord = !whitespace[c] 15 | if !wasInWord && inWord { 16 | n++ 17 | } 18 | } 19 | return n 20 | } 21 | 22 | func ColumnInches(s string) float64 { 23 | return float64(WordCount(s)) / 30 24 | } 25 | 26 | func Lines(s string) float64 { 27 | return float64(WordCount(s)) / 30 * 8 28 | } 29 | -------------------------------------------------------------------------------- /pkg/almanack/convert_test.go: -------------------------------------------------------------------------------- 1 | package almanack 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/carlmjohnson/be" 7 | ) 8 | 9 | func TestSlugFromURL(t *testing.T) { 10 | cases := []struct{ in, out string }{ 11 | {}, 12 | { 13 | in: "/politics/pennsylvania/spl/republicans-pa-house-control-constitional-amendments-abortion-20221216.html", 14 | out: "republicans-pa-house-control-constitional-amendments-abortion", 15 | }, 16 | { 17 | in: "weird", 18 | out: "weird", 19 | }, 20 | } 21 | for _, tc := range cases { 22 | be.Equal(t, tc.out, slugFromURL(tc.in)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/iterx/error.go: -------------------------------------------------------------------------------- 1 | package iterx 2 | 3 | import "iter" 4 | 5 | // ErrorChildren does a shallow unwrapping of multierrors. 6 | func ErrorChildren(err error) iter.Seq[error] { 7 | return func(yield func(error) bool) { 8 | if err == nil { 9 | return 10 | } 11 | type Unwrapper interface { 12 | Unwrap() []error 13 | } 14 | if unwrapper, ok := err.(Unwrapper); ok { 15 | for _, suberr := range unwrapper.Unwrap() { 16 | if suberr != nil && !yield(suberr) { 17 | return 18 | } 19 | } 20 | } else { 21 | if !yield(err) { 22 | return 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sql/schema/029_image_fts.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE image 2 | ADD COLUMN keywords text NOT NULL DEFAULT ''::text; 3 | 4 | ALTER TABLE image 5 | ADD COLUMN fts tsvector GENERATED ALWAYS AS 6 | ((setweight(to_tsvector('english', "description"), 'C')) || 7 | (setweight(to_tsvector('english', "credit"), 'B')) || 8 | (setweight(to_tsvector('english', "keywords"), 'A'))) 9 | STORED; 10 | 11 | CREATE INDEX image_internal_id_fts_idx ON image USING gin (fts); 12 | 13 | ---- create above / drop below ---- 14 | ALTER TABLE image 15 | DROP COLUMN fts; 16 | 17 | ALTER TABLE image 18 | DROP COLUMN keywords; 19 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/H1N4qOxH.req.txt: -------------------------------------------------------------------------------- 1 | GET /v3/signin/identifier?dsh=S856044640%3A1683224180288822&continue=https%3A%2F%2Flh3.google.com%2Fu%2F0%2Fd%2F1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneFr_GIs4LBwBA2fm7lx0I1OV7xnbhfCJ49A_RjrLwgcsJR4T-hFtXm0mIPP0kTtjvKOZkj-cA&flowName=WebLiteSignIn&flowEntry=ServiceLogin HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://accounts.google.com/InteractiveLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneGEW7r2o5YuaxTfS05CwPp_g5VQ_ySwz1jYpHdrQY5zStqyOp8GpmfYSDq_P-eBa-rI4qqR 4 | 5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/SUHw3r_B.req.txt: -------------------------------------------------------------------------------- 1 | GET /v3/signin/identifier?dsh=S-1056199824%3A1683302953465451&continue=https%3A%2F%2Flh3.google.com%2Fu%2F0%2Fd%2F1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneFl1uDOuGE8kFyKJrmJi4ZazrR6CAttAz5Dn44DETF-LbE49Phfw6JDMGQuP9d1smCjM2lNoA&flowName=WebLiteSignIn&flowEntry=ServiceLogin HTTP/0.0 2 | Host: accounts.google.com 3 | Referer: https://accounts.google.com/InteractiveLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en&ifkv=Af_xneGls-BG2uxMs06OxfaHLGuEXg-ofq43KwFayWJXFgN17BJZJN0UbP_AfFIp4818N1rzPlC_Vw 4 | 5 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Demo document/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "My Cool Demo Document", 7 | "description": "Showing off how to do a demo document.", 8 | "lede_image": "cas/pam5-bzc5-asx0-g3p6.jpeg", 9 | "lede_image_credit": "Leise Hook for Spotlight PA", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Demo document", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "My Cool Demo Document", 7 | "description": "Showing off how to do a demo document.", 8 | "lede_image": "cas/pam5-bzc5-asx0-g3p6.jpeg", 9 | "lede_image_credit": "Leise Hook for Spotlight PA", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "Demo document", 4 | "byline": "", 5 | "budget": "", 6 | "hed": "My Cool Demo Document", 7 | "description": "Showing off how to do a demo document.", 8 | "lede_image": "cas/pam5-bzc5-asx0-g3p6.jpeg", 9 | "lede_image_credit": "Leise Hook for Spotlight PA", 10 | "lede_image_description": "", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /sql/schema/018_big-int.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "file" 2 | ALTER COLUMN "id" TYPE bigint; 3 | 4 | ALTER TABLE "image" 5 | ALTER COLUMN "id" TYPE bigint; 6 | 7 | ALTER TABLE "domain_roles" 8 | ALTER COLUMN "id" TYPE bigint; 9 | 10 | ALTER TABLE "address_roles" 11 | ALTER COLUMN "id" TYPE bigint; 12 | 13 | ---- create above / drop below ---- 14 | ALTER TABLE "file" 15 | ALTER COLUMN "id" TYPE integer; 16 | 17 | ALTER TABLE "image" 18 | ALTER COLUMN "id" TYPE integer; 19 | 20 | ALTER TABLE "domain_roles" 21 | ALTER COLUMN "id" TYPE integer; 22 | 23 | ALTER TABLE "address_roles" 24 | ALTER COLUMN "id" TYPE integer; 25 | -------------------------------------------------------------------------------- /sql/schema/033_shared_blurb.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "shared_article" 2 | ADD COLUMN "blurb" text NOT NULL DEFAULT ''; 3 | 4 | ALTER TABLE "shared_article" DISABLE TRIGGER row_updated_at_on_shared_article_trigger_; 5 | 6 | UPDATE 7 | "shared_article" AS sa 8 | SET 9 | blurb = gd."metadata" ->> 'blurb' 10 | FROM 11 | "g_docs_doc" gd 12 | WHERE 13 | sa."source_type" = 'gdocs' 14 | AND cast(sa."raw_data" AS bigint) = gd.id; 15 | 16 | ALTER TABLE shared_article ENABLE TRIGGER row_updated_at_on_shared_article_trigger_; 17 | 18 | ---- create above / drop below ---- 19 | ALTER TABLE "shared_article" 20 | DROP COLUMN "blurb"; 21 | -------------------------------------------------------------------------------- /internal/slicex/unique_test.go: -------------------------------------------------------------------------------- 1 | package slicex_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | "github.com/spotlightpa/almanack/internal/slicex" 9 | ) 10 | 11 | func TestUniqueFunc(t *testing.T) { 12 | for _, tc := range []struct{ have, want string }{ 13 | {"", ""}, 14 | {"1", "1"}, 15 | {"1", "1"}, 16 | {"1 2", "1 2"}, 17 | {"1 2 2 3", "1 2 3"}, 18 | {"1 2 3 2 3 4", "1 2 3 4"}, 19 | } { 20 | in := strings.Fields(tc.have) 21 | slicex.UniquesFunc(&in, func(s string) string { return s }) 22 | got := strings.Join(in, " ") 23 | be.Equal(t, tc.want, got) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP1/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "Blurb", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP2/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "Blurb", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "Blurb", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /sql/schema/010_page.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "page" ( 2 | id bigserial PRIMARY KEY, 3 | path text NOT NULL UNIQUE, 4 | frontmatter jsonb NOT NULL DEFAULT '{}' ::jsonb, 5 | body text NOT NULL DEFAULT '', 6 | schedule_for timestamptz, 7 | last_published timestamptz, 8 | created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | CREATE TRIGGER row_updated_at_on_page_trigger_ 13 | BEFORE UPDATE ON "page" 14 | FOR EACH ROW 15 | EXECUTE PROCEDURE update_row_updated_at_function_ (); 16 | 17 | ---- create above / drop below ---- 18 | DROP TABLE "page"; 19 | -------------------------------------------------------------------------------- /src/utils/sanitize-text.js: -------------------------------------------------------------------------------- 1 | export default function sanitizeText(text) { 2 | let el = document.createElement("div"); 3 | el.innerText = text ?? ""; 4 | text = el.innerHTML; 5 | text = text 6 | .replace(/<strong>/g, "") 7 | .replace(/<\/strong>/g, "") 8 | .replace(/<em>/g, "") 9 | .replace(/<\/em>/g, "") 10 | .replace(/<b>/g, "") 11 | .replace(/<\/b>/g, "") 12 | .replace(/<i>/g, "") 13 | .replace(/<\/i>/g, "") 14 | .replace(/
/g, "\n"); 15 | el.innerHTML = text; 16 | return el.innerHTML; 17 | } 18 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "News", 13 | "url_slug": "", 14 | "blurb": "Blurb", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc byby/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "spltest1", 4 | "byline": "Jane Doe", 5 | "budget": "", 6 | "hed": "The Test Document", 7 | "description": "How creating documents is making us all crazy; and how to quit", 8 | "lede_image": "", 9 | "lede_image_credit": "Daniel Fishel / For Spotlight PA", 10 | "lede_image_description": "President Bendapudi is a cartoon", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc spl/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-spl", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Shortcode", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc table/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-table", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Table table", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc toc/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-toc", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Table of Contents", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /src/components/LinkButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc hash/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-hash", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Hash mark example", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-image-width", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "abc", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /src/utils/throttle.js: -------------------------------------------------------------------------------- 1 | import { computed, ref, toRef, watch } from "vue"; 2 | 3 | export function useThrottleToggle( 4 | watchedRef, 5 | { prop = "", timeout = 1000 } = {} 6 | ) { 7 | const watched = prop ? toRef(watchedRef, prop) : watchedRef; 8 | 9 | const recentlyChanged = ref(false); 10 | watch( 11 | watched, 12 | (val) => { 13 | if (val) { 14 | recentlyChanged.value = true; 15 | window.setTimeout(() => { 16 | recentlyChanged.value = false; 17 | }, timeout); 18 | } 19 | }, 20 | { immediate: true } 21 | ); 22 | return computed(() => watchedRef.value || recentlyChanged.value); 23 | } 24 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/spltest1/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "spltest1", 4 | "byline": "Jane Doe", 5 | "budget": "", 6 | "hed": "The Test Document", 7 | "description": "How creating documents is making us all crazy; and how to quit", 8 | "lede_image": "", 9 | "lede_image_credit": "Daniel Fishel / For Spotlight PA", 10 | "lede_image_description": "President Bendapudi is a cartoon", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /src/assets/img/circle-white-on-trans.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/anf/testdata/req.analytics.signed: -------------------------------------------------------------------------------- 1 | POST /v1beta/properties/1:runReport HTTP/1.1 2 | Host: analyticsdata.googleapis.com 3 | Authorization: HHMAC; key="key"; signature="6OOUdQ+rNm1sCjmBL04OVqXs7P43ByKeMR/ohh9pdYc="; date="2000-01-01T00:00:00Z" 4 | Content-Length: 362 5 | Content-Type: application/json 6 | 7 | {"dateRanges":[{"startDate":"today","endDate":"today"}],"dimensions":[{"name":"pagePath"}],"metrics":[{"name":"screenPageViews"}],"orderBys":[{"metric":{"metricName":"screenPageViews"},"desc":true}],"dimensionFilter":{"filter":{"fieldName":"pagePath","stringFilter":{"matchType":"FULL_REGEXP","value":"^/(news|statecollege|berks)/\\d\\d\\d\\d/.*"}}},"limit":20} 8 | -------------------------------------------------------------------------------- /pkg/almlog/context.go: -------------------------------------------------------------------------------- 1 | package almlog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type contextKey struct{} 9 | 10 | // NewContext returns a context that contains the given Logger. 11 | // Use FromContext to retrieve the Logger. 12 | func NewContext(ctx context.Context, l *slog.Logger) context.Context { 13 | return context.WithValue(ctx, contextKey{}, l) 14 | } 15 | 16 | // FromContext returns the Logger stored in ctx by NewContext, or the default 17 | // Logger if there is none. 18 | func FromContext(ctx context.Context) *slog.Logger { 19 | if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok { 20 | return l 21 | } 22 | return Logger 23 | } 24 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "external/tf4bb5trw4yragh9ecafmvrvm8.jpeg", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /pkg/integration/testdb_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/carlmjohnson/be" 9 | "github.com/jackc/pgx/v5/pgxpool" 10 | "github.com/spotlightpa/almanack/internal/db" 11 | ) 12 | 13 | var ( 14 | once sync.Once 15 | pool *pgxpool.Pool 16 | poolErr error 17 | ) 18 | 19 | func createTestDB(t *testing.T) *pgxpool.Pool { 20 | t.Helper() 21 | dbURL := os.Getenv("ALMANACK_POSTGRES") 22 | if dbURL == "" { 23 | t.Skip("ALMANACK_POSTGRES not set") 24 | } 25 | once.Do(func() { 26 | pool, poolErr = db.CreateTestDatabase(dbURL) 27 | }) 28 | be.NilErr(t, poolErr) 29 | return pool 30 | } 31 | -------------------------------------------------------------------------------- /internal/anf/fromdb_test.go: -------------------------------------------------------------------------------- 1 | package anf_test 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | "github.com/carlmjohnson/be/testfile" 9 | "github.com/spotlightpa/almanack/internal/anf" 10 | "github.com/spotlightpa/almanack/internal/db" 11 | ) 12 | 13 | func TestFromDB(t *testing.T) { 14 | testfile.Run(t, "testdata/*/item.json", func(t *testing.T, match string) { 15 | var item db.NewsFeedItem 16 | testfile.ReadJSON(t, match, &item) 17 | art, err := anf.FromDB(&item) 18 | be.NilErr(t, err) 19 | filename := filepath.Join(filepath.Dir(match), "article.json") 20 | testfile.EqualJSON(t, filename, art) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /internal/netlifyid/context.go: -------------------------------------------------------------------------------- 1 | package netlifyid 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/spotlightpa/almanack/pkg/almlog" 8 | ) 9 | 10 | type netlifyidContextType int 11 | 12 | const netlifyidContextKey netlifyidContextType = iota 13 | 14 | func addJWTToRequest(id *JWT, r *http.Request) *http.Request { 15 | ctx := context.WithValue(r.Context(), netlifyidContextKey, id) 16 | l := almlog.FromContext(ctx). 17 | With("user.email", id.User.Email) 18 | 19 | return r.WithContext(almlog.NewContext(ctx, l)) 20 | } 21 | 22 | func FromContext(ctx context.Context) *JWT { 23 | val, _ := ctx.Value(netlifyidContextKey).(*JWT) 24 | return val 25 | } 26 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc fake heading/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-fake-heading", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Fake heading test", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc na/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-na", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Basic Story / Google to Alm Template", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "", 17 | "description": "", 18 | "lede_image": "", 19 | "lede_image_credit": "", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "OP1", 4 | "byline": "The Scorpions", 5 | "budget": "A CIA op becomes an unexpected smash hit.", 6 | "hed": "The Winds of Change", 7 | "description": "Something is in the air tonight…", 8 | "lede_image": "external/tf4bb5trw4yragh9ecafmvrvm8.jpeg", 9 | "lede_image_credit": "Teenage Engineering", 10 | "lede_image_description": "Synthesizer", 11 | "lede_image_caption": "", 12 | "eyebrow": "", 13 | "url_slug": "", 14 | "blurb": "", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /sql/schema/005_file.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE file ( 2 | id serial PRIMARY KEY, 3 | url text NOT NULL UNIQUE, 4 | filename text NOT NULL DEFAULT '', 5 | mime_type text NOT NULL DEFAULT '', 6 | description text NOT NULL DEFAULT '', 7 | is_uploaded boolean NOT NULL DEFAULT FALSE, 8 | created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | CREATE TRIGGER row_updated_at_on_file_trigger_ 13 | BEFORE UPDATE ON file 14 | FOR EACH ROW 15 | EXECUTE PROCEDURE update_row_updated_at_function_ (); 16 | 17 | ---- create above / drop below ---- 18 | DROP TABLE file; 19 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import pluginVue from "eslint-plugin-vue"; 4 | 5 | import eslintConfigPrettier from "eslint-config-prettier"; 6 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 7 | 8 | export default [ 9 | { files: ["**/*.{js,mjs,cjs,vue}"] }, 10 | { languageOptions: { globals: globals.browser } }, 11 | pluginJs.configs.recommended, 12 | ...pluginVue.configs["flat/essential"], 13 | eslintPluginPrettierRecommended, 14 | eslintConfigPrettier, 15 | { 16 | rules: { 17 | "no-unused-vars": ["error", { caughtErrors: "none" }], 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /sql/schema/007_address_roles.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE address_roles ( 2 | id serial PRIMARY KEY, 3 | email_address text NOT NULL, 4 | roles text[], 5 | created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | CREATE UNIQUE INDEX unique_email_address_on_address_roles ON address_roles 10 | ((lower("email_address")) text_ops); 11 | 12 | CREATE TRIGGER row_updated_at_on_address_roles_trigger_ 13 | BEFORE UPDATE ON address_roles 14 | FOR EACH ROW 15 | EXECUTE PROCEDURE update_row_updated_at_function_ (); 16 | 17 | ---- create above / drop below ---- 18 | DROP TABLE address_roles; 19 | -------------------------------------------------------------------------------- /src/components/LinkRoute.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /pkg/integration/testdata/fm.md: -------------------------------------------------------------------------------- 1 | +++ 2 | arc-id = "123" 3 | authors = ["john", "doe"] 4 | blurb = "blurb" 5 | byline = "menen" 6 | description = "desc2" 7 | extended-kicker = "More News" 8 | image = "xyz.jpeg" 9 | image-caption = "capt" 10 | image-credit = "cred" 11 | image-description = "desc" 12 | image-size = "inline" 13 | internal-budget = "hello" 14 | internal-id = "spl123" 15 | kicker = "kick" 16 | language-code = "es" 17 | layout = "fancy" 18 | linktitle = "lt" 19 | modal-exclude = true 20 | no-index = true 21 | published = 2006-01-01T00:00:00-05:00 22 | slug = "slug" 23 | subtitle = "subtitle" 24 | suppress-featured = true 25 | title = "hed" 26 | url = "/url/" 27 | weight = 2 28 | +++ 29 | 30 | 31 | -------------------------------------------------------------------------------- /internal/db/arc.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.30.0 4 | // source: arc.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const getArcByArcID = `-- name: GetArcByArcID :one 13 | SELECT 14 | id, arc_id, raw_data, last_updated, created_at, updated_at 15 | FROM 16 | arc 17 | WHERE 18 | arc_id = $1 19 | ` 20 | 21 | func (q *Queries) GetArcByArcID(ctx context.Context, arcID string) (Arc, error) { 22 | row := q.db.QueryRow(ctx, getArcByArcID, arcID) 23 | var i Arc 24 | err := row.Scan( 25 | &i.ID, 26 | &i.ArcID, 27 | &i.RawData, 28 | &i.LastUpdated, 29 | &i.CreatedAt, 30 | &i.UpdatedAt, 31 | ) 32 | return i, err 33 | } 34 | -------------------------------------------------------------------------------- /internal/iterx/concat.go: -------------------------------------------------------------------------------- 1 | package iterx 2 | 3 | import "iter" 4 | 5 | // Concat streams each sequence it was passed in order. 6 | func Concat[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 7 | return func(yield func(T) bool) { 8 | for _, seq := range seqs { 9 | for v := range seq { 10 | if !yield(v) { 11 | return 12 | } 13 | } 14 | } 15 | } 16 | } 17 | 18 | // Concat2 streams each sequence it was passed in order. 19 | func Concat2[T1, T2 any](seqs ...iter.Seq2[T1, T2]) iter.Seq2[T1, T2] { 20 | return func(yield func(T1, T2) bool) { 21 | for _, seq := range seqs { 22 | for v1, v2 := range seq { 23 | if !yield(v1, v2) { 24 | return 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/use-data.js: -------------------------------------------------------------------------------- 1 | import { ref, computed } from "vue"; 2 | 3 | export default function useData(getData, props) { 4 | const data = {}; 5 | let innerVals = []; 6 | for (let [prop, [name, wrap = (v) => v, unwrap = (v) => v]] of Object.entries( 7 | props 8 | )) { 9 | const inner = ref(null); 10 | innerVals.push(inner); 11 | data[prop] = computed({ 12 | get: () => inner.value ?? wrap(getData()[name]), 13 | set: (val) => { 14 | inner.value = val; 15 | getData()[name] = unwrap(val); 16 | }, 17 | }); 18 | } 19 | data.resetData = () => { 20 | for (let val of innerVals) { 21 | val.value = null; 22 | } 23 | }; 24 | return data; 25 | } 26 | -------------------------------------------------------------------------------- /internal/db/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.30.0 4 | 5 | package db 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/toc/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "toc", 5 | "value": "

Article contents

" 6 | } 7 | ] -------------------------------------------------------------------------------- /pkg/almlog/httplogger.go: -------------------------------------------------------------------------------- 1 | package almlog 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/carlmjohnson/requests" 8 | ) 9 | 10 | var HTTPTransport http.RoundTripper 11 | 12 | func init() { 13 | HTTPTransport = requests.LogTransport(http.DefaultTransport, logReq) 14 | http.DefaultTransport = HTTPTransport 15 | } 16 | 17 | func logReq(req *http.Request, res *http.Response, err error, duration time.Duration) { 18 | level := LevelThreshold(duration, 500*time.Millisecond, 1*time.Second) 19 | FromContext(req.Context()). 20 | Log(req.Context(), level, "RoundTrip", 21 | "req_method", req.Method, 22 | "req_host", req.Host, 23 | "res_status", res.StatusCode, 24 | "duration", duration, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /functions/schedule.mts: -------------------------------------------------------------------------------- 1 | export const handler = async () => { 2 | try { 3 | console.log("start req of https://almanack.data.spotlightpa.org/api-background/cron"); 4 | let res = await fetch("https://almanack.data.spotlightpa.org/api-background/cron"); 5 | if (res.ok) { 6 | console.log("res ok"); 7 | return { 8 | statusCode: 200, 9 | body: JSON.stringify({ message: "Done" }), 10 | }; 11 | } 12 | console.error("bad res", res.status, res.statusText); 13 | } catch (e) { 14 | console.error("could not connect", e); 15 | } 16 | return { 17 | statusCode: 502, 18 | body: JSON.stringify({ message: "Could not connect" }), 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /internal/aws/gocloud.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/credentials" 6 | "github.com/aws/aws-sdk-go/aws/session" 7 | "gocloud.dev/blob" 8 | _ "gocloud.dev/blob/fileblob" 9 | _ "gocloud.dev/blob/memblob" 10 | "gocloud.dev/blob/s3blob" 11 | ) 12 | 13 | func register(scheme, region, id, secret string) error { 14 | sess, err := session.NewSession(&aws.Config{ 15 | Region: aws.String(region), 16 | Credentials: credentials.NewStaticCredentials(id, secret, ""), 17 | }) 18 | if err != nil { 19 | return err 20 | } 21 | o := s3blob.URLOpener{ 22 | ConfigProvider: sess, 23 | } 24 | blob.DefaultURLMux().RegisterBucket(scheme, &o) 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/stringx/count_test.go: -------------------------------------------------------------------------------- 1 | package stringx_test 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | "github.com/spotlightpa/almanack/internal/stringx" 9 | ) 10 | 11 | //go:embed testdata/article.txt 12 | var article string 13 | 14 | func TestWordCount(t *testing.T) { 15 | cases := []struct { 16 | s string 17 | n int 18 | }{ 19 | {"", 0}, 20 | {" ", 0}, 21 | {"a", 1}, 22 | {"a 'quick' brown fox ", 4}, 23 | {article, 1510}, 24 | } 25 | for _, tc := range cases { 26 | be.Equal(be.Relaxed(t), tc.n, stringx.WordCount(tc.s)) 27 | } 28 | } 29 | 30 | var n int 31 | 32 | func BenchmarkWordCount(b *testing.B) { 33 | for i := 0; i < b.N; i++ { 34 | n = stringx.WordCount(article) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/integration/testdata/fm+body.md: -------------------------------------------------------------------------------- 1 | +++ 2 | arc-id = "123" 3 | authors = ["john", "doe"] 4 | blurb = "blurb" 5 | byline = "menen" 6 | description = "desc2" 7 | extended-kicker = "More News" 8 | image = "xyz.jpeg" 9 | image-caption = "capt" 10 | image-credit = "cred" 11 | image-description = "desc" 12 | image-size = "inline" 13 | internal-budget = "hello" 14 | internal-id = "spl123" 15 | kicker = "kick" 16 | language-code = "es" 17 | layout = "fancy" 18 | linktitle = "lt" 19 | modal-exclude = true 20 | no-index = true 21 | published = 2006-01-01T00:00:00-05:00 22 | slug = "slug" 23 | subtitle = "subtitle" 24 | suppress-featured = true 25 | title = "hed" 26 | url = "/url/" 27 | weight = 2 28 | +++ 29 | 30 | Hello, world! 31 | +++ 32 | more~~ 33 |

34 | -------------------------------------------------------------------------------- /src/components/ThumbnailArc.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /sql/one-time/image-import.sql: -------------------------------------------------------------------------------- 1 | WITH input AS ( 2 | SELECT 3 | jsonb_array_elements( 4 | -- insert JSON here 5 | $$ REPLACEME $$ 6 | -- 7 | ::jsonb) AS data) 8 | INSERT INTO image (path, credit, description, is_uploaded, created_at) 9 | SELECT 10 | data ->> 'image' AS path, 11 | min(data ->> 'image-credit'), 12 | coalesce(min(data ->> 'image-description'), ''), 13 | TRUE, 14 | min(to_timestamp(data ->> 'created-at', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) 15 | FROM 16 | input 17 | GROUP BY 18 | path 19 | ON CONFLICT (path) 20 | DO UPDATE SET 21 | description = coalesce(image.description, excluded.description), 22 | created_at = least (image.created_at, excluded.created_at), 23 | is_uploaded = TRUE 24 | RETURNING 25 | *; 26 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-warning", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Demo document", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "My Cool Demo Document", 17 | "description": "Showing off how to do a demo document.", 18 | "lede_image": "cas/pam5-bzc5-asx0-g3p6.jpeg", 19 | "lede_image_credit": "Leise Hook for Spotlight PA", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /src/components/LinkHref.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-empty-embed", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "Demo document", 14 | "byline": "", 15 | "budget": "", 16 | "hed": "My Cool Demo Document", 17 | "description": "Showing off how to do a demo document.", 18 | "lede_image": "cas/pam5-bzc5-asx0-g3p6.jpeg", 19 | "lede_image_credit": "Leise Hook for Spotlight PA", 20 | "lede_image_description": "", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/article.md: -------------------------------------------------------------------------------- 1 | My name is \*Carlana Johnson\*. I come from Wisconsin. 2 | 3 | This is my \_test document\_. 4 | 5 | \[Citation Needed\] 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 8 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | on: [push] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: yarn install 21 | run: yarn 22 | env: 23 | CI: true 24 | - name: yarn test 25 | run: ./run.sh test:frontend 26 | env: 27 | CI: true 28 | - name: yarn build 29 | run: ./run.sh build:frontend 30 | env: 31 | CI: true 32 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/raw.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*. I come from Wisconsin.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/rich.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*. I come from Wisconsin.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import TheApp from "./components/TheApp.vue"; 3 | const app = createApp(TheApp); 4 | 5 | import * as Sentry from "@sentry/vue"; 6 | 7 | if (import.meta.env.MODE === "production") { 8 | let dsn = 9 | "https://cf41d56053f841ae9625673c3ab8d53f@o361657.ingest.sentry.io/3944373"; 10 | Sentry.init({ app, dsn }); 11 | } 12 | 13 | import { createHead } from "@unhead/vue/legacy"; 14 | const head = createHead(); 15 | app.use(head); 16 | import { Head } from "@unhead/vue/components"; 17 | app.component("MetaHead", Head); 18 | 19 | import fontAwesome from "./plugins/font-awesome.js"; 20 | app.use(fontAwesome); 21 | 22 | import router from "./plugins/router.js"; 23 | app.use(router); 24 | router.app = app; 25 | 26 | app.mount("#app"); 27 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc eyebrow/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-eyebrow", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "OP1", 14 | "byline": "The Scorpions", 15 | "budget": "A CIA op becomes an unexpected smash hit.", 16 | "hed": "The Winds of Change", 17 | "description": "Something is in the air tonight…", 18 | "lede_image": "", 19 | "lede_image_credit": "Teenage Engineering", 20 | "lede_image_description": "Synthesizer", 21 | "lede_image_caption": "", 22 | "blurb": "Blurb" 23 | } -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-paren-na", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "OP1", 14 | "byline": "The Scorpions", 15 | "budget": "A CIA op becomes an unexpected smash hit.", 16 | "hed": "The Winds of Change", 17 | "description": "Something is in the air tonight…", 18 | "lede_image": "", 19 | "lede_image_credit": "Teenage Engineering", 20 | "lede_image_description": "Synthesizer", 21 | "lede_image_caption": "", 22 | "blurb": "Blurb" 23 | } -------------------------------------------------------------------------------- /src/components/SpinnerProgress.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 33 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc byby/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-byby", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "spltest1", 14 | "byline": "Jane Doe", 15 | "budget": "", 16 | "hed": "The Test Document", 17 | "description": "How creating documents is making us all crazy; and how to quit", 18 | "lede_image": "", 19 | "lede_image_credit": "Daniel Fishel / For Spotlight PA", 20 | "lede_image_description": "President Bendapudi is a cartoon", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLHAROLD/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "raw", 5 | "value": "
" 6 | }, 7 | { 8 | "n": 2, 9 | "type": "image", 10 | "value": { 11 | "path": "2023/12/01jr-8vcr-58ns-bgsp.jpeg", 12 | "credit": "Sara Stewart / For Spotlight PA", 13 | "caption": "A plaque inside Millvale \"they bar\" Harold's Haunt profiles the businesses' owners and namesake, a neighborhood ghost.", 14 | "description": "A plaque inside Harold’s Haunt is pictured.", 15 | "width": 0, 16 | "height": 0, 17 | "kind": "all" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /sql/schema/004_site_data.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE site_data ( 2 | id serial PRIMARY KEY, 3 | key text NOT NULL UNIQUE, 4 | data jsonb NOT NULL DEFAULT '{}' ::jsonb, 5 | created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | CREATE TRIGGER row_updated_at_on_site_data_trigger_ 10 | BEFORE UPDATE ON site_data 11 | FOR EACH ROW 12 | EXECUTE PROCEDURE update_row_updated_at_function_ (); 13 | 14 | INSERT INTO site_data (KEY, data) 15 | VALUES ('data/editorsPicks.json', ' 16 | { 17 | "featuredStories": [], 18 | "limitSubfeatures": true, 19 | "subfeatures": [], 20 | "subfeaturesLimit": 2, 21 | "topSlots": [] 22 | }'); 23 | 24 | ---- create above / drop below ---- 25 | DROP TABLE site_data; 26 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-more", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "OP1", 14 | "byline": "The Scorpions", 15 | "budget": "A CIA op becomes an unexpected smash hit.", 16 | "hed": "The Winds of Change", 17 | "description": "Something is in the air tonight…", 18 | "lede_image": "external/tf4bb5trw4yragh9ecafmvrvm8.jpeg", 19 | "lede_image_credit": "Teenage Engineering", 20 | "lede_image_description": "Synthesizer", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /src/components/SiteParamsRailSticky.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /internal/db/map.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type Map map[string]any 10 | 11 | // Value implements the driver.Valuer interface. 12 | func (m Map) Value() (driver.Value, error) { 13 | b, err := json.Marshal(m) 14 | return b, err 15 | } 16 | 17 | // Scan implements the sql.Scanner interface. 18 | func (m *Map) Scan(value any) error { 19 | dbMap := make(map[string]any) 20 | if value == nil { 21 | *m = dbMap 22 | return nil 23 | } 24 | if buf, ok := value.(string); ok { 25 | value = []byte(buf) 26 | } 27 | buf, ok := value.([]byte) 28 | if !ok { 29 | return fmt.Errorf("canot parse %T to bytes", value) 30 | } 31 | if err := json.Unmarshal(buf, &dbMap); err != nil { 32 | return err 33 | } 34 | *m = dbMap 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc simple/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-simple", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "OP1", 14 | "byline": "The Scorpions", 15 | "budget": "A CIA op becomes an unexpected smash hit.", 16 | "hed": "The Winds of Change", 17 | "description": "Something is in the air tonight…", 18 | "lede_image": "external/tf4bb5trw4yragh9ecafmvrvm8.jpeg", 19 | "lede_image_credit": "Teenage Engineering", 20 | "lede_image_description": "Synthesizer", 21 | "lede_image_caption": "", 22 | "blurb": "" 23 | } -------------------------------------------------------------------------------- /internal/google/testdata/vhSTnwyq.res.txt: -------------------------------------------------------------------------------- 1 | HTTP/2.0 200 OK 2 | Cache-Control: no-cache, no-store, max-age=0, must-revalidate 3 | Content-Security-Policy: frame-ancestors 'self' 4 | Content-Type: application/json; charset=UTF-8 5 | Date: Fri, 06 Aug 2021 20:55:23 GMT 6 | Expires: Mon, 01 Jan 1990 00:00:00 GMT 7 | Pragma: no-cache 8 | Server: GSE 9 | Vary: Origin 10 | Vary: X-Origin 11 | X-Content-Type-Options: nosniff 12 | X-Frame-Options: SAMEORIGIN 13 | X-Xss-Protection: 1; mode=block 14 | 15 | { 16 | "files": [ 17 | { 18 | "id": "abcXYZ", 19 | "name": "Doc title here", 20 | "lastModifyingUser": { 21 | "kind": "drive#user", 22 | "displayName": "John Doe", 23 | "photoLink": "https://lh3.googleusercontent.com/a-/ABC=s64", 24 | "me": false, 25 | "permissionId": "1234" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /pkg/almanack/service-gdocs_test.go: -------------------------------------------------------------------------------- 1 | package almanack 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/carlmjohnson/be" 7 | ) 8 | 9 | func TestImageCAS(t *testing.T) { 10 | cases := []struct { 11 | body, ct, want string 12 | }{ 13 | {"", "", "cas/tger-spcf-02s0-9tc0.bin"}, 14 | {"", "image/png", "cas/tger-spcf-02s0-9tc0.png"}, 15 | {"Hello, World!", "image/jpeg", "cas/cpme-4zc8-f4m3-gcdp.jpeg"}, 16 | } 17 | for _, tc := range cases { 18 | t.Run("", func(t *testing.T) { 19 | got := makeCASaddress([]byte(tc.body), tc.ct) 20 | be.Equal(t, tc.want, got) 21 | }) 22 | var s string 23 | body := []byte(tc.body) 24 | allocs := testing.AllocsPerRun(10, func() { 25 | s = makeCASaddress(body, tc.ct) 26 | }) 27 | if allocs > 1 { 28 | t.Errorf("benchmark regression %q: %v", s, allocs) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sql/schema/013_site_data_schedule.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "site_data" 2 | ALTER COLUMN "id" TYPE bigint, 3 | DROP CONSTRAINT "site_data_key_key", 4 | ADD COLUMN "schedule_for" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | ADD COLUMN "published_at" timestamptz, 6 | ADD CONSTRAINT "site_data_key_schedule_for_key" UNIQUE ("key", "schedule_for"); 7 | 8 | UPDATE 9 | "site_data" 10 | SET 11 | "published_at" = CURRENT_TIMESTAMP 12 | WHERE 13 | "published_at" IS NULL; 14 | 15 | ---- create above / drop below ---- 16 | DELETE FROM "site_data" 17 | WHERE "published_at" IS NULL; 18 | 19 | ALTER TABLE "site_data" 20 | ALTER COLUMN "id" TYPE integer, 21 | DROP CONSTRAINT "site_data_key_schedule_for_key", 22 | ADD CONSTRAINT "site_data_key_key" UNIQUE ("key"), 23 | DROP COLUMN "schedule_for", 24 | DROP COLUMN "published_at"; 25 | -------------------------------------------------------------------------------- /src/api/imgproxy-url.js: -------------------------------------------------------------------------------- 1 | export default function imageURL( 2 | filepath, 3 | { 4 | width = 400, 5 | height = 300, 6 | extension = "jpeg", 7 | gravity = "", 8 | quality = 75, 9 | } = {} 10 | ) { 11 | if (!filepath) { 12 | return ""; 13 | } 14 | let baseURL = "https://images.data.spotlightpa.org"; 15 | let signature = "insecure"; 16 | let resizing_type = "fill"; 17 | let enlarge = "1"; 18 | let encoded_source_url = btoa(filepath); 19 | 20 | gravity = gravity || "sm"; 21 | width = Math.round(width); 22 | height = Math.round(height); 23 | quality = Math.round(quality); 24 | let qualityStr = quality ? `/q:${quality}` : ""; 25 | return `${baseURL}/${signature}/rt:${resizing_type}/w:${width}/h:${height}/g:${gravity}/el:${enlarge}${qualityStr}/${encoded_source_url}.${extension}`; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/google-analytics.js: -------------------------------------------------------------------------------- 1 | // Ensure a Google Analytics window func 2 | if (!window.ga) { 3 | window.ga = function () { 4 | (window.ga.q = window.ga.q || []).push(arguments); 5 | }; 6 | window.ga.l = +new Date(); 7 | } 8 | 9 | let dnt = !window.location.host.match(/spotlightpa\.org$/); 10 | 11 | export function callGA(...args) { 12 | if (dnt) { 13 | console.info("GA", args); 14 | return; 15 | } 16 | window.ga(...args); 17 | } 18 | 19 | export function sendGAEvent(ev) { 20 | callGA("send", "event", ev); 21 | } 22 | 23 | export function sendGAPageview(path) { 24 | callGA("send", "pageview", path); 25 | } 26 | 27 | export function setDimensions({ domain, name, role }) { 28 | callGA("set", "dimension1", domain); 29 | callGA("set", "dimension2", name); 30 | callGA("set", "dimension3", role); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/BulmaModal.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | -------------------------------------------------------------------------------- /internal/tableaux/table_test.go: -------------------------------------------------------------------------------- 1 | package tableaux_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/carlmjohnson/be" 9 | "github.com/carlmjohnson/be/testfile" 10 | "github.com/earthboundkid/xhtml" 11 | "github.com/spotlightpa/almanack/internal/tableaux" 12 | "golang.org/x/net/html" 13 | ) 14 | 15 | func TestTable(t *testing.T) { 16 | t.Parallel() 17 | testfile.Run(t, "testdata/*.html", func(t *testing.T, path string) { 18 | in := testfile.Read(t, path) 19 | bareName := testfile.Ext(path, "") 20 | 21 | root, err := html.Parse(strings.NewReader(in)) 22 | be.NilErr(t, err) 23 | i := 0 24 | for _, tbl := range tableaux.Tables(root) { 25 | i++ 26 | rows := tableaux.Map(tbl, xhtml.InnerHTML) 27 | testfile.EqualJSON(t, fmt.Sprintf("%s-%d.json", bareName, i), &rows) 28 | } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /internal/blocko/minify.go: -------------------------------------------------------------------------------- 1 | package blocko 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/earthboundkid/xhtml" 9 | "github.com/tdewolff/minify/v2" 10 | "github.com/tdewolff/minify/v2/html" 11 | nethtml "golang.org/x/net/html" 12 | "golang.org/x/net/html/atom" 13 | ) 14 | 15 | func Minify(r io.Reader) (*nethtml.Node, error) { 16 | m := minify.New() 17 | m.AddFunc("text/html", (&html.Minifier{ 18 | KeepEndTags: true, 19 | }).Minify) 20 | 21 | var out bytes.Buffer 22 | if err := m.Minify("text/html", &out, r); err != nil { 23 | return nil, err 24 | } 25 | doc, err := nethtml.Parse(&out) 26 | if err != nil { 27 | return nil, err 28 | } 29 | body := xhtml.Select(doc, xhtml.WithAtom(atom.Body)) 30 | if body == nil { 31 | return nil, fmt.Errorf("could not find body") 32 | } 33 | 34 | return body, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/google/google-analytics_test.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "cmp" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/carlmjohnson/be" 10 | "github.com/carlmjohnson/requests/reqtest" 11 | "github.com/spotlightpa/almanack/pkg/almlog" 12 | ) 13 | 14 | func TestMostPopularNews(t *testing.T) { 15 | almlog.UseTestLogger(t) 16 | svc := Service{} 17 | svc.viewID = cmp.Or(os.Getenv("ALMANACK_GOOGLE_TEST_VIEW"), "1") 18 | ctx := t.Context() 19 | cl := *http.DefaultClient 20 | cl.Transport = reqtest.Replay("testdata") 21 | if os.Getenv("ALMANACK_GOOGLE_TEST_RECORD_REQUEST") != "" { 22 | gcl, err := svc.GAClient(ctx) 23 | be.NilErr(t, err) 24 | cl.Transport = reqtest.Record(gcl.Transport, "testdata") 25 | } 26 | pages, err := svc.MostPopularNews(ctx, &cl) 27 | be.NilErr(t, err) 28 | be.EqualLength(t, 20, pages) 29 | } 30 | -------------------------------------------------------------------------------- /internal/mailchimp/sendemail_test.go: -------------------------------------------------------------------------------- 1 | package mailchimp_test 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "testing" 7 | 8 | "github.com/carlmjohnson/be" 9 | "github.com/carlmjohnson/requests/reqtest" 10 | "github.com/spotlightpa/almanack/internal/mailchimp" 11 | "github.com/spotlightpa/almanack/pkg/almlog" 12 | ) 13 | 14 | func TestSendEmail(t *testing.T) { 15 | almlog.UseTestLogger(t) 16 | 17 | cl := *http.DefaultClient 18 | cl.Transport = reqtest.Replay("testdata/sendemail") 19 | apiKey := os.Getenv("ALMANACK_MC_TEST_API_KEY") 20 | listID := os.Getenv("ALMANACK_MC_TEST_LISTID") 21 | 22 | if os.Getenv("RECORD") != "" { 23 | cl.Transport = reqtest.Caching(nil, "testdata/sendemail") 24 | } 25 | v3 := mailchimp.NewV3(apiKey, listID, &cl) 26 | err := v3.SendEmail(t.Context(), "Test message", "Hello, World!") 27 | be.NilErr(t, err) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/almanack/site-data.go: -------------------------------------------------------------------------------- 1 | package almanack 2 | 3 | import "fmt" 4 | 5 | const ( 6 | HomepageLoc = "data/editorsPicks.json" 7 | SidebarLoc = "data/sidebar.json" 8 | SiteParamsLoc = "config/_default/params.json" 9 | StateCollegeLoc = "data/stateCollege.json" 10 | BerksLoc = "data/berks.json" 11 | ) 12 | 13 | var messageForLoc = map[string]string{ 14 | HomepageLoc: "Setting homepage configuration", 15 | SidebarLoc: "Setting sidebar configuration", 16 | SiteParamsLoc: "Setting site parameters", 17 | StateCollegeLoc: "Setting State College frontpage configuration", 18 | BerksLoc: "Setting Berks County frontpage configuration", 19 | } 20 | 21 | func MessageForLoc(loc string) string { 22 | msg := messageForLoc[loc] 23 | if msg == "" { 24 | return fmt.Sprintf("Updating %s", loc) 25 | } 26 | return msg 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ArcArticleOEmbed.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | -------------------------------------------------------------------------------- /src/components/SiteParamsRailTop.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 31 | -------------------------------------------------------------------------------- /.github/workflows/govuln.yml: -------------------------------------------------------------------------------- 1 | name: vuln 2 | 3 | permissions: read-all 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | schedule: 9 | - cron: '0 10 * * 1' 10 | 11 | jobs: 12 | run: 13 | name: Vuln 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | strategy: 17 | fail-fast: true 18 | matrix: 19 | go: ['stable'] 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Install Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go }} 29 | check-latest: true 30 | 31 | - name: Install `govulncheck` 32 | run: | 33 | go install golang.org/x/vuln/cmd/govulncheck@latest 34 | govulncheck -version 35 | 36 | - name: Run `govulncheck` 37 | run: "govulncheck ./..." 38 | -------------------------------------------------------------------------------- /src/utils/use-props.js: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | // useProps takes an object and turns select props into refs. 4 | // The mapping is an object whose keys are turned into names on returned data object. 5 | // The values of the mapping are the names of the props on source object 6 | // and then optional transformations to do on deserialize and serialize. 7 | export default function useProps(src, mapping) { 8 | const data = {}; 9 | for (let [prop, [name, deserialize = (v) => v]] of Object.entries(mapping)) { 10 | data[prop] = ref(deserialize(src[name])); 11 | } 12 | function saveData() { 13 | let dst = {}; 14 | for (let [prop, [name, , serialize = (v) => v]] of Object.entries( 15 | mapping 16 | )) { 17 | dst[name] = serialize(data[prop].value); 18 | } 19 | return dst; 20 | } 21 | return [data, saveData]; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/BulmaCharLimit.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 32 | 33 | 38 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/raw.html: -------------------------------------------------------------------------------- 1 |

Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters.

HARRISBURG —

Embed #1

BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results.

-------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/rich.html: -------------------------------------------------------------------------------- 1 |

Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters.

HARRISBURG —

Embed #1

BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results.

-------------------------------------------------------------------------------- /src/components/TagStatus.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/raw.html: -------------------------------------------------------------------------------- 1 |

Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters.

2 |

HARRISBURG —

3 |

Embed #1

4 |

BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results.

5 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/rich.html: -------------------------------------------------------------------------------- 1 |

Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters.

2 |

HARRISBURG —

3 |

Embed #1

4 |

BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results.

5 | -------------------------------------------------------------------------------- /src/components/SiteParamsFeaturedHomepage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | -------------------------------------------------------------------------------- /internal/mailchimp/emailservice.go: -------------------------------------------------------------------------------- 1 | package mailchimp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/spotlightpa/almanack/pkg/almlog" 9 | ) 10 | 11 | type EmailService interface { 12 | SendEmail(ctx context.Context, subject, body string) error 13 | } 14 | 15 | func NewMailService(apiKey, listID string, c *http.Client) EmailService { 16 | if apiKey == "" || listID == "" { 17 | almlog.Logger.Warn("mocking email service") 18 | return MockEmailService{} 19 | } 20 | return V3{apiKey, listID, c} 21 | } 22 | 23 | type MockEmailService struct { 24 | } 25 | 26 | func (mock MockEmailService) SendEmail(ctx context.Context, subject, body string) error { 27 | l := almlog.FromContext(ctx) 28 | l.InfoContext(ctx, "mocking campaign, debug output") 29 | fmt.Println() 30 | fmt.Println(subject) 31 | fmt.Println("----") 32 | fmt.Println(body) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /src/components/HomepageEditorItem.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | 25 | 32 | -------------------------------------------------------------------------------- /src/components/SiteParamsNewsletter.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 34 | -------------------------------------------------------------------------------- /src/components/ThumbnailS3.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc embed newlines/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "SPLEVCLINK", 4 | "byline": "Spotlight PA Staff", 5 | "budget": "", 6 | "hed": "You’re invited! Spotlight PA State College hosts quiz bash and anniversary fundraiser", 7 | "description": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m.", 8 | "lede_image": "2023/06/01j9-kq9m-z11p-e2kj.png", 9 | "lede_image_credit": "Spotlight PA", 10 | "lede_image_description": "Spotlight PA Quizbash Fundraiser", 11 | "lede_image_caption": "", 12 | "eyebrow": "Events", 13 | "url_slug": "pennsylvania-state-college-anniversary-event", 14 | "blurb": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m.", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /src/api/gdocs.js: -------------------------------------------------------------------------------- 1 | import { getGDocsDoc, postGDocsDoc, get, post } from "./client-v2"; 2 | 3 | function wait(milliseconds) { 4 | return new Promise((resolve) => { 5 | window.setTimeout(resolve, milliseconds); 6 | }); 7 | } 8 | 9 | export async function processGDocsDoc(externalGDocsID) { 10 | // Create job 11 | let [dbDoc, err] = await post(postGDocsDoc, { 12 | external_gdocs_id: externalGDocsID, 13 | }); 14 | if (err) { 15 | return [null, err]; 16 | } 17 | // Kick off task runner 18 | try { 19 | await window.fetch("/api-background/images"); 20 | } catch (err) { 21 | return [null, err]; 22 | } 23 | 24 | // Poll while waiting for task to complete 25 | while (!dbDoc.processed_at) { 26 | await wait(250); 27 | [dbDoc, err] = await get(getGDocsDoc, { id: dbDoc.id }); 28 | if (err) { 29 | return [null, err]; 30 | } 31 | } 32 | return [dbDoc, null]; 33 | } 34 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLEVCLINK/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "publication_date": null, 3 | "internal_id": "SPLEVCLINK", 4 | "byline": "Spotlight PA Staff", 5 | "budget": "", 6 | "hed": "You’re invited! Spotlight PA State College hosts quiz bash and anniversary fundraiser", 7 | "description": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m.", 8 | "lede_image": "2023/06/01j9-kq9m-z11p-e2kj.png", 9 | "lede_image_credit": "Spotlight PA", 10 | "lede_image_description": "Spotlight PA Quizbash Fundraiser", 11 | "lede_image_caption": "", 12 | "eyebrow": "Events", 13 | "url_slug": "pennsylvania-state-college-anniversary-event", 14 | "blurb": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m.", 15 | "link_title": "", 16 | "seo_title": "", 17 | "og_title": "", 18 | "twitter_title": "", 19 | "layout": "" 20 | } -------------------------------------------------------------------------------- /internal/db/domain-roles.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/earthboundkid/emailx/v2" 8 | ) 9 | 10 | func GetRolesForEmail(ctx context.Context, q *Queries, email string) (roles []string, err error) { 11 | // not likely to get pass Netlify with an invalid address, but why not check? 12 | if err = emailx.Validate(email); err != nil { 13 | return 14 | } 15 | _, domain := emailx.Split(email) 16 | if domain == "" { 17 | return nil, fmt.Errorf("invalid email: %q", email) 18 | } 19 | roles, err = q.GetRolesForAddress(ctx, email) 20 | if err != nil && !IsNotFound(err) { 21 | return 22 | } 23 | // if user has specific roles, early exit 24 | if err == nil && len(roles) > 0 { 25 | return 26 | } 27 | roles, err = q.GetRolesForDomain(ctx, domain) 28 | if err != nil && !IsNotFound(err) { 29 | return 30 | } 31 | // ignore any not found errors 32 | return roles, nil 33 | } 34 | -------------------------------------------------------------------------------- /sql/one-time/import-newsletters.sql: -------------------------------------------------------------------------------- 1 | WITH raw_json AS ( 2 | SELECT 3 | $$ $$::jsonb AS data 4 | ), 5 | campaign_json AS ( 6 | SELECT 7 | jsonb_array_elements(data -> 'campaigns') AS campaign 8 | FROM 9 | raw_json 10 | ), 11 | campaign AS ( 12 | SELECT 13 | to_timestamp(campaign ->> 'send_time', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS published_at, 14 | campaign ->> 'archive_url' AS archive_url, 15 | campaign -> 'settings' ->> 'subject_line' AS subject 16 | FROM 17 | campaign_json 18 | ), 19 | filtered_campaign AS ( 20 | SELECT 21 | * 22 | FROM 23 | campaign 24 | WHERE 25 | campaign.archive_url NOT IN ( 26 | SELECT 27 | archive_url 28 | FROM 29 | newsletter)) 30 | INSERT INTO newsletter (subject, archive_url, published_at) 31 | SELECT 32 | subject, 33 | archive_url, 34 | published_at 35 | FROM 36 | filtered_campaign 37 | RETURNING 38 | *; 39 | -------------------------------------------------------------------------------- /internal/google/translate_test.go: -------------------------------------------------------------------------------- 1 | package google 2 | 3 | import ( 4 | "cmp" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/carlmjohnson/be" 10 | "github.com/carlmjohnson/requests/reqtest" 11 | "github.com/spotlightpa/almanack/pkg/almlog" 12 | ) 13 | 14 | func TestTranslate(t *testing.T) { 15 | almlog.UseTestLogger(t) 16 | svc := Service{} 17 | svc.projectID = cmp.Or(os.Getenv("ALMANACK_GOOGLE_PROJECT_ID"), "1") 18 | ctx := t.Context() 19 | cl := *http.DefaultClient 20 | cl.Transport = reqtest.Replay("testdata") 21 | if os.Getenv("ALMANACK_GOOGLE_TEST_RECORD_REQUEST") != "" { 22 | gcl, err := svc.TranslateClient(ctx) 23 | be.NilErr(t, err) 24 | cl.Transport = reqtest.Record(gcl.Transport, "testdata") 25 | } 26 | translated, err := svc.Translate(ctx, &cl, "text/plain", "Hello, World!") 27 | be.NilErr(t, err) 28 | be.EqualLength(t, 1, translated) 29 | be.Equal(t, "¡Hola Mundo!", translated[0]) 30 | } 31 | -------------------------------------------------------------------------------- /internal/blocko/testdata/list.html: -------------------------------------------------------------------------------- 1 |
header 5
2 |
    3 |
  • one and one
  • 4 |
  • 5 |

    two

    6 |

    three

    7 |
  • 8 |
  • four

  • 9 |
  • 10 |
      11 |
    • five
    • 12 |
    • 13 |

      six

      14 |

      seven

      15 |
    • 16 |
    • eight
    • 17 |
    18 |
  • 19 |
20 |

21 | Lorem ipsum. 22 |

23 |
    24 |
  1. one

  2. 25 |
  3. 26 |

    two

    27 |

    three

    28 |
  4. 29 |
  5. 30 |

    four

    31 |
      32 |
    1. 33 |

      five

      34 |

      six

      35 |
    2. 36 |
    3. seven

    4. 37 |
    5. eight

    6. 38 |
    39 |
  6. 40 |
41 |

42 | Lorem ipsum. 43 |

44 | -------------------------------------------------------------------------------- /src/components/GDocsDocWarnings.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc warning/W6CL_zwW.res.txt: -------------------------------------------------------------------------------- 1 | HTTP/2.0 302 Found 2 | Content-Length: 333 3 | Access-Control-Allow-Origin: * 4 | Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 5 | Cache-Control: private 6 | Content-Type: text/html; charset=UTF-8 7 | Date: Fri, 05 May 2023 16:09:13 GMT 8 | Location: https://accounts.google.com/ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en 9 | Server: fife 10 | Timing-Allow-Origin: * 11 | Vary: Origin 12 | X-Content-Type-Options: nosniff 13 | X-Xss-Protection: 0 14 | 15 | 16 | 302 Moved 17 |

302 Moved

18 | The document has moved 19 | here. 20 | 21 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc empty embed/W6CL_zwW.res.txt: -------------------------------------------------------------------------------- 1 | HTTP/2.0 302 Found 2 | Content-Length: 333 3 | Access-Control-Allow-Origin: * 4 | Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 5 | Cache-Control: private 6 | Content-Type: text/html; charset=UTF-8 7 | Date: Thu, 04 May 2023 18:16:20 GMT 8 | Location: https://accounts.google.com/ServiceLogin?continue=https://lh3.google.com/u/0/d/1QeCripnoiyu6XlRtsN3IkaHO6kBPOkv7%3Dw2048-h1322&hl=en 9 | Server: fife 10 | Timing-Allow-Origin: * 11 | Vary: Origin 12 | X-Content-Type-Options: nosniff 13 | X-Xss-Protection: 0 14 | 15 | 16 | 302 Moved 17 |

302 Moved

18 | The document has moved 19 | here. 20 | 21 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/SPLEVCLINK/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "raw", 5 | "value": "\n\n\n\n\n\n" 6 | } 7 | ] -------------------------------------------------------------------------------- /sql/queries/domain-roles.sql: -------------------------------------------------------------------------------- 1 | -- name: GetRolesForDomain :one 2 | SELECT 3 | roles 4 | FROM 5 | domain_roles 6 | WHERE 7 | "domain" ILIKE $1; 8 | 9 | -- name: UpsertRolesForDomain :one 10 | INSERT INTO domain_roles ("domain", roles) 11 | VALUES ($1, $2) 12 | ON CONFLICT (lower("domain")) 13 | DO UPDATE SET 14 | roles = $2 15 | RETURNING 16 | *; 17 | 18 | -- name: AppendRoleToDomain :one 19 | INSERT INTO domain_roles ("domain", roles) 20 | VALUES (@domain, ARRAY[@role::text]) 21 | ON CONFLICT (lower("domain")) 22 | DO UPDATE SET 23 | roles = CASE WHEN NOT (domain_roles.roles::text[] @> ARRAY[@role]) THEN 24 | domain_roles.roles::text[] || ARRAY[@role] 25 | ELSE 26 | domain_roles.roles 27 | END 28 | RETURNING 29 | *; 30 | 31 | -- name: ListDomainsWithRole :many 32 | SELECT 33 | "domain" 34 | FROM 35 | "domain_roles" 36 | WHERE 37 | "roles" @> ARRAY[@role::text] 38 | ORDER BY 39 | "domain" ASC; 40 | -------------------------------------------------------------------------------- /internal/jwthook/testdata/signup-umd.txt: -------------------------------------------------------------------------------- 1 | POST /hook HTTP/1.1 2 | Host: 3.131.97.98:50039 3 | Accept-Encoding: gzip 4 | Content-Length: 447 5 | Content-Type: application/json 6 | User-Agent: Go-http-client/1.1 7 | X-Webhook-Signature: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjAyNDkyMTUsImlzcyI6ImdvdHJ1ZSIsInN1YiI6ImQ0Y2NlNmYyLTZiNDYtNGJiYS1iMTI2LWNmYjhmNDY5ZTNjNSIsInNoYTI1NiI6IjM2Yzk3ZWZmOTA2ZDFhYmY5MDllYmVhNjFmMzkwZjA5MGI1NGNmZTdmNmRjZGY0NWEwNDFlYTBjMzk2ODE3ZmMifQ.qLBWQIlL9i05oA8kvD2HNDIvwjbMC5TiB_erwxbNf_4 8 | 9 | {"event":"signup","instance_id":"d4cce6f2-6b46-4bba-b126-cfb8f469e3c5","user":{"id":"94adbdd1-710f-4e49-bb20-7fc09be02a73","aud":"","role":"","email":"carlmj@umd.edu","app_metadata":{"provider":"google"},"user_metadata":{"avatar_url":"https://lh3.googleusercontent.com/a-/AFdZucpoZA9xL-jam3fBVzCgDnOqMfkUBiYpV2wNzVGf=s96-c","full_name":"Carl Matthew Johnson"},"created_at":"2022-08-11T20:20:15.056544Z","updated_at":"2022-08-11T20:20:15.060258Z"}} -------------------------------------------------------------------------------- /internal/jwthook/testdata/validate-umd.txt: -------------------------------------------------------------------------------- 1 | POST /hook HTTP/1.1 2 | Host: 3.131.97.98:50039 3 | Accept-Encoding: gzip 4 | Content-Length: 449 5 | Content-Type: application/json 6 | User-Agent: Go-http-client/1.1 7 | X-Webhook-Signature: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjAyNDkyMTUsImlzcyI6ImdvdHJ1ZSIsInN1YiI6ImQ0Y2NlNmYyLTZiNDYtNGJiYS1iMTI2LWNmYjhmNDY5ZTNjNSIsInNoYTI1NiI6IjZkYTI3YWYwODVjNTE2NDI5ODQ2OWNjYzcxMzJjZGY3ZDc4ZDgxZTlhMzhkZmRkZGI1ZWY2M2E4NDI0MGIyOTUifQ.Yk5CVlxr43LdgtGlJg37gBsl_KPBz3_tRDDoEb0EaM4 8 | 9 | {"event":"validate","instance_id":"d4cce6f2-6b46-4bba-b126-cfb8f469e3c5","user":{"id":"94adbdd1-710f-4e49-bb20-7fc09be02a73","aud":"","role":"","email":"carlmj@umd.edu","app_metadata":{"provider":"google"},"user_metadata":{"avatar_url":"https://lh3.googleusercontent.com/a-/AFdZucpoZA9xL-jam3fBVzCgDnOqMfkUBiYpV2wNzVGf=s96-c","full_name":"Carl Matthew Johnson"},"created_at":"2022-08-11T20:20:15.056544Z","updated_at":"2022-08-11T20:20:15.060258Z"}} -------------------------------------------------------------------------------- /sql/schema/019_page_source.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "page" 2 | ADD COLUMN "source_type" text NOT NULL DEFAULT '', 3 | ADD COLUMN "source_id" text NOT NULL DEFAULT ''; 4 | 5 | UPDATE 6 | page 7 | SET 8 | source_type = 'arc', 9 | source_id = frontmatter ->> 'arc-id' 10 | WHERE 11 | frontmatter ->> 'arc-id' IS NOT NULL; 12 | 13 | WITH nl_to_page AS ( 14 | SELECT 15 | newsletter.id AS nl_id, 16 | page.id AS p_id 17 | FROM 18 | newsletter 19 | LEFT JOIN page ON newsletter.spotlightpa_path = page.file_path 20 | WHERE 21 | newsletter.spotlightpa_path != '') 22 | UPDATE 23 | page 24 | SET 25 | source_type = 'mailchimp', 26 | source_id = nl_id 27 | FROM 28 | nl_to_page 29 | WHERE 30 | id = p_id; 31 | 32 | UPDATE 33 | page 34 | SET 35 | source_type = 'manual' 36 | WHERE 37 | source_type = ''; 38 | 39 | ---- create above / drop below ---- 40 | ALTER TABLE "page" 41 | DROP COLUMN "source_type", 42 | DROP COLUMN "source_id"; 43 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP2/embeds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "n": 1, 4 | "type": "raw", 5 | "value": "" 6 | }, 7 | { 8 | "n": 2, 9 | "type": "image", 10 | "value": { 11 | "path": "external/cwp3y3z5x8svbtsfv7t68hggc0.png", 12 | "credit": "CarlCo", 13 | "caption": "Here is a caption", 14 | "description": "Blah blah blah.", 15 | "width": 0, 16 | "height": 0, 17 | "kind": "partner" 18 | } 19 | }, 20 | { 21 | "n": 3, 22 | "type": "image", 23 | "value": { 24 | "path": "external/08v6tmkadg07fyknwy3pb8sm3m.jpeg", 25 | "credit": "Amanda Berg / For Spotlight PA", 26 | "caption": "", 27 | "description": "The House floor in the Pa. Capitol. The chamber is controlled by Democrats for the first session in more than a \"decade.\"", 28 | "width": 0, 29 | "height": 0, 30 | "kind": "all" 31 | } 32 | } 33 | ] -------------------------------------------------------------------------------- /sql/schema/014_citext.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS citext; 2 | 3 | ALTER TABLE "page" 4 | ALTER COLUMN "file_path" TYPE citext, 5 | ALTER COLUMN "url_path" TYPE citext; 6 | 7 | ALTER TABLE "domain_roles" 8 | ALTER COLUMN "domain" TYPE citext; 9 | 10 | ALTER TABLE "address_roles" 11 | ALTER COLUMN "email_address" TYPE citext; 12 | 13 | ALTER TABLE "newsletter" 14 | ALTER COLUMN "spotlightpa_path" TYPE citext; 15 | 16 | ALTER TABLE "site_data" 17 | ALTER COLUMN "key" TYPE citext; 18 | 19 | ---- create above / drop below ---- 20 | ALTER TABLE "page" 21 | ALTER COLUMN "file_path" TYPE text, 22 | ALTER COLUMN "url_path" TYPE text; 23 | 24 | ALTER TABLE "domain_roles" 25 | ALTER COLUMN "domain" TYPE text; 26 | 27 | ALTER TABLE "address_roles" 28 | ALTER COLUMN "email_address" TYPE text; 29 | 30 | ALTER TABLE "newsletter" 31 | ALTER COLUMN "spotlightpa_path" TYPE text; 32 | 33 | ALTER TABLE "site_data" 34 | ALTER COLUMN "key" TYPE text; 35 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/abc/article.md: -------------------------------------------------------------------------------- 1 | Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters. 2 | 3 | HARRISBURG — 4 | 5 | {{}} 6 | 7 | BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results. 8 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc image width/article.md: -------------------------------------------------------------------------------- 1 | Spotlight PA is an independent, nonpartisan, and nonprofit newsroom producing investigative and public-service journalism that holds power to account and drives positive change in Pennsylvania. Sign up for our free newsletters. 2 | 3 | HARRISBURG — 4 | 5 | {{}} 6 | 7 | BEFORE YOU GO… If you learned something from this article, pay it forward and contribute to Spotlight PA at spotlightpa.org/donate. Spotlight PA is funded by foundations and readers like you who are committed to accountability journalism that gets results. 8 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP2/rich.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

This is my _test document_.

[Citation Needed]

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Some blocks

Embed #1

Embed #2

Here is some text. Lorem ipsum.

And here’s another image:

Embed #3

-------------------------------------------------------------------------------- /src/components/BulmaPaste.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 40 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/OP2/raw.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

This is my _test document_.

[Citation Needed]

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Some blocks

Embed #2

Here is some text. Lorem ipsum.

And here’s another image:

Embed #3

-------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/rich.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 |

Some blocks

6 |

Embed #1

7 |

Embed #2

8 |

Here is some text. Lorem ipsum.

9 |

Embed #3

10 | -------------------------------------------------------------------------------- /pkg/almanack/testdata/processDocHTML/Shortcode/intermediate.html: -------------------------------------------------------------------------------- 1 |

Blah blah blah

Lorem ipsum dolor

-------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc more/raw.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 |

Some blocks

6 | 7 |

Embed #2

8 |

Here is some text. Lorem ipsum.

9 |

Embed #3

10 | -------------------------------------------------------------------------------- /src/components/BulmaBreadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /internal/netlifyid/mock.go: -------------------------------------------------------------------------------- 1 | package netlifyid 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/spotlightpa/almanack/pkg/almlog" 7 | ) 8 | 9 | type MockAuthService struct{} 10 | 11 | var _ AuthService = MockAuthService{} 12 | 13 | func (mas MockAuthService) AuthFromHeader(r *http.Request) (*http.Request, error) { 14 | r = addJWTToRequest(&JWT{User: User{Email: "mock"}}, r) 15 | return r, nil 16 | } 17 | 18 | func (mas MockAuthService) AuthFromCookie(r *http.Request) (*http.Request, error) { 19 | return r, nil 20 | } 21 | 22 | func (mas MockAuthService) HasRole(r *http.Request, role string) error { 23 | l := almlog.FromContext(r.Context()) 24 | l.DebugContext(r.Context(), "mock permission middleware", 25 | "requires-role", role, 26 | "has-role", true) 27 | 28 | if r.Header.Get("Authorization") != "" { 29 | return nil 30 | } 31 | if _, err := r.Cookie("nf_jwt"); err == nil { 32 | return nil 33 | } 34 | l.WarnContext(r.Context(), "missing Authorization header/cookie") 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc embed newlines/shared-article.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 123, 3 | "status": "U", 4 | "embargo_until": null, 5 | "note": "", 6 | "source_type": "gdocs", 7 | "source_id": "abc123_testdata-gdoc-embed-newlines", 8 | "raw_data": null, 9 | "page_id": null, 10 | "created_at": "2020-03-15T20:00:00Z", 11 | "updated_at": "2020-03-15T20:00:00Z", 12 | "publication_date": null, 13 | "internal_id": "SPLEVCLINK", 14 | "byline": "Spotlight PA Staff", 15 | "budget": "", 16 | "hed": "You’re invited! Spotlight PA State College hosts quiz bash and anniversary fundraiser", 17 | "description": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m.", 18 | "lede_image": "2023/06/01j9-kq9m-z11p-e2kj.png", 19 | "lede_image_credit": "Spotlight PA", 20 | "lede_image_description": "Spotlight PA Quizbash Fundraiser", 21 | "lede_image_caption": "", 22 | "blurb": "Join Spotlight PA for a \"Clink and Think\" quiz bash on Sunday, Aug. 6, from 4:30-7:30 p.m." 23 | } -------------------------------------------------------------------------------- /src/components/BulmaFieldCheckbox.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 44 | -------------------------------------------------------------------------------- /internal/aws/md5_test.go: -------------------------------------------------------------------------------- 1 | package aws_test 2 | 3 | import ( 4 | "crypto/md5" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/carlmjohnson/be" 10 | "github.com/spotlightpa/almanack/internal/aws" 11 | "github.com/spotlightpa/almanack/pkg/almlog" 12 | ) 13 | 14 | func TestMD5(t *testing.T) { 15 | almlog.UseTestLogger(t) 16 | dir := t.TempDir() 17 | const teststr = "Hello, World!" 18 | wantMD5 := md5.Sum([]byte(teststr)) 19 | 20 | ctx := t.Context() 21 | bucket := aws.NewBlobStore("file://" + dir + "/") 22 | err := bucket.WriteFile(ctx, "hello.txt", nil, []byte(teststr)) 23 | be.NilErr(t, err) 24 | 25 | hash, size, err := bucket.ReadMD5(ctx, "hello.txt") 26 | be.NilErr(t, err) 27 | be.AllEqual(t, wantMD5[:], hash) 28 | be.Equal(t, int64(len(teststr)), size) 29 | 30 | be.NilErr(t, os.Remove(filepath.Join(dir, "hello.txt.attrs"))) 31 | 32 | hash, size, err = bucket.ReadMD5(ctx, "hello.txt") 33 | be.NilErr(t, err) 34 | be.AllEqual(t, wantMD5[:], hash) 35 | be.EqualLength(t, int(size), teststr) 36 | } 37 | -------------------------------------------------------------------------------- /internal/jwthook/testdata/login-spotlight.txt: -------------------------------------------------------------------------------- 1 | POST /hook HTTP/1.1 2 | Host: 3.131.97.98:50039 3 | Accept-Encoding: gzip 4 | Content-Length: 525 5 | Content-Type: application/json 6 | User-Agent: Go-http-client/1.1 7 | X-Webhook-Signature: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjAyNDkxOTIsImlzcyI6ImdvdHJ1ZSIsInN1YiI6ImQ0Y2NlNmYyLTZiNDYtNGJiYS1iMTI2LWNmYjhmNDY5ZTNjNSIsInNoYTI1NiI6IjhkNWU3Yzk5ZGQ4NGIwNTMxNjExMGM3ZGEzZDI1Nzg0NTc2MzVkYzFkZjYyM2ZhMDZiZTBlOTI1YjMxOGI1OWEifQ.TYEHB0EMVt3UUhov6n2W_6ytzdyMYg6i3ql-BysIKxc 8 | 9 | {"event":"login","instance_id":"d4cce6f2-6b46-4bba-b126-cfb8f469e3c5","user":{"id":"2264d747-1043-4c77-a6a3-3667065cedd8","aud":"","role":"","email":"cjohnson@spotlightpa.org","confirmed_at":"2022-08-10T22:51:28Z","app_metadata":{"provider":"google","roles":["Spotlight PA","admin","arc user","editor"]},"user_metadata":{"avatar_url":"https://lh3.googleusercontent.com/a-/AFdZucpFY3yFmlhtPakipAVix45JI5MEkLw7UTHCPuRq=s96-c","full_name":"Carl Johnson"},"created_at":"2022-08-10T22:51:28Z","updated_at":"2022-08-10T22:51:28Z"}} -------------------------------------------------------------------------------- /sql/queries/file.sql: -------------------------------------------------------------------------------- 1 | -- name: ListFiles :many 2 | SELECT 3 | * 4 | FROM 5 | file 6 | WHERE 7 | is_uploaded = TRUE 8 | AND deleted_at IS NULL 9 | ORDER BY 10 | created_at DESC 11 | LIMIT $1 OFFSET $2; 12 | 13 | -- name: CreateFilePlaceholder :execrows 14 | INSERT INTO file ("filename", "url", "mime_type") 15 | VALUES (@filename, @url, @type) 16 | ON CONFLICT (url) 17 | DO NOTHING; 18 | 19 | -- name: UpdateFile :one 20 | UPDATE 21 | file 22 | SET 23 | description = CASE WHEN @set_description::boolean THEN 24 | @description 25 | ELSE 26 | description 27 | END, 28 | is_uploaded = TRUE 29 | WHERE 30 | url = @url 31 | RETURNING 32 | *; 33 | 34 | -- name: ListFilesWhereNoMD5 :many 35 | SELECT 36 | * 37 | FROM 38 | file 39 | WHERE 40 | md5 = '' 41 | AND is_uploaded 42 | AND deleted_at IS NULL 43 | ORDER BY 44 | created_at ASC 45 | LIMIT $1; 46 | 47 | -- name: UpdateFileMD5Size :one 48 | UPDATE 49 | file 50 | SET 51 | md5 = @md5, 52 | bytes = @bytes 53 | WHERE 54 | id = @id 55 | RETURNING 56 | *; 57 | -------------------------------------------------------------------------------- /internal/mailchimp/v3.go: -------------------------------------------------------------------------------- 1 | package mailchimp 2 | 3 | import ( 4 | "flag" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/carlmjohnson/requests" 9 | ) 10 | 11 | func AddFlags(fs *flag.FlagSet) func(c *http.Client) V3 { 12 | apiKey := fs.String("mcnl-api-key", "", "`API key` for MailChimp newsletter archive") 13 | listID := fs.String("mcnl-list-id", "", "List `ID` for MailChimp newsletter archive") 14 | 15 | return func(c *http.Client) V3 { 16 | return NewV3(*apiKey, *listID, c) 17 | } 18 | } 19 | 20 | type V3 struct { 21 | apiKey string 22 | listID string 23 | cl *http.Client 24 | } 25 | 26 | func NewV3(apiKey, listID string, cl *http.Client) V3 { 27 | return V3{apiKey, listID, cl} 28 | } 29 | 30 | func (v3 V3) config(rb *requests.Builder) { 31 | // API keys end with 123XYZ-us1, where us1 is the datacenter 32 | _, datacenter, _ := strings.Cut(v3.apiKey, "-") 33 | rb. 34 | BaseURL("https://dc.api.mailchimp.com/3.0/"). 35 | Client(v3.cl). 36 | BasicAuth("", v3.apiKey). 37 | Hostf("%s.api.mailchimp.com", datacenter) 38 | } 39 | -------------------------------------------------------------------------------- /internal/jsonfeed/feed.go: -------------------------------------------------------------------------------- 1 | package jsonfeed 2 | 3 | type Feed struct { 4 | Description string `json:"description"` 5 | FeedURL string `json:"feed_url"` 6 | HomePageURL string `json:"home_page_url"` 7 | Items []Item `json:"items"` 8 | Title string `json:"title"` 9 | Version string `json:"version"` 10 | } 11 | 12 | type Item struct { 13 | Author string `json:"author"` 14 | Authors []string `json:"authors"` 15 | Blurb string `json:"blurb"` 16 | Category string `json:"category"` 17 | ContentHTML string `json:"content_html"` 18 | DateModified string `json:"date_modified"` 19 | DatePublished string `json:"date_published"` 20 | ID string `json:"id"` 21 | Image string `json:"image"` 22 | ImageCredit string `json:"image_credit"` 23 | ImageDescription string `json:"image_description"` 24 | Language string `json:"language"` 25 | Title string `json:"title"` 26 | URL string `json:"url"` 27 | } 28 | -------------------------------------------------------------------------------- /internal/jwthook/testdata/login-spotlight-tampered.txt: -------------------------------------------------------------------------------- 1 | POST /hook HTTP/1.1 2 | Host: 3.131.97.98:50039 3 | Accept-Encoding: gzip 4 | Content-Type: application/json 5 | User-Agent: Go-http-client/1.1 6 | X-Webhook-Signature: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjAyNDkxOTIsImlzcyI6ImdvdHJ1ZSIsInN1YiI6ImQ0Y2NlNmYyLTZiNDYtNGJiYS1iMTI2LWNmYjhmNDY5ZTNjNSIsInNoYTI1NiI6IjhkNWU3Yzk5ZGQ4NGIwNTMxNjExMGM3ZGEzZDI1Nzg0NTc2MzVkYzFkZjYyM2ZhMDZiZTBlOTI1YjMxOGI1OWEifQ.TYEHB0EMVt3UUhov6n2W_6ytzdyMYg6i3ql-BysIKxc 7 | 8 | {"event":"login","instance_id":"d4cce6f2-6b46-4bba-b126-cfb8f469e3c5","user":{"id":"2264d747-1043-4c77-a6a3-3667065cedd8","aud":"","role":"","email":"cjohnson@spotlightpa.org","confirmed_at":"2022-08-10T22:51:28Z","app_metadata":{"provider":"google","roles":["Spotlight PA","admin","arc user","editor"]},"user_metadata":{"avatar_url":"https://lh3.googleusercontent.com/a-/AFdZucpFY3yFmlhtPakipAVix45JI5MEkLw7UTHCPuRq=s96-c","full_name":"Carl Johnson"},"created_at":"2022-08-10T22:51:28Z","updated_at":"2022-08-10T22:51:28Z"}} 9 | 10 | Hello! 11 | -------------------------------------------------------------------------------- /pkg/almanack/imagestore_test.go: -------------------------------------------------------------------------------- 1 | package almanack 2 | 3 | import ( 4 | "path" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | ) 9 | 10 | func TestMakeImageName(t *testing.T) { 11 | cases := map[string]struct { 12 | ct string 13 | want string 14 | }{ 15 | "none": {"", ".bin"}, 16 | "slash": {"/", ".bin"}, 17 | "no slash": {"hello", ".bin"}, 18 | "malformed": {"image/", ".bin"}, 19 | "png": {"image/png", ".png"}, 20 | "jpeg": {"image/jpeg", ".jpeg"}, 21 | "tiff": {"image/tiff", ".tiff"}, 22 | "json": {"application/json", ".json"}, 23 | "text": {"text/plain", ".plain"}, 24 | } 25 | for name, tc := range cases { 26 | t.Run(name, func(t *testing.T) { 27 | got := makeImageName(tc.ct) 28 | be.Equal(t, tc.want, path.Ext(got)) 29 | be.NotIn(t, "..", got) 30 | }) 31 | var s string 32 | allocs := testing.AllocsPerRun(10, func() { 33 | s = makeImageName(tc.ct) 34 | }) 35 | if allocs > 3 { 36 | t.Errorf("benchmark regression %q: %v", s, allocs) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ArcArticleImage.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 39 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/rich.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 |

Some blocks

6 |

Embed #1

7 |

Embed #2

8 |

Here is some text. Lorem ipsum.

9 |

Embed #3

10 |

And here’s another image:

11 |

Embed #4

12 | -------------------------------------------------------------------------------- /pkg/integration/testdata/gdoc paren na/raw.html: -------------------------------------------------------------------------------- 1 |

My name is *Carlana Johnson*.

2 |

This is my _test document_.

3 |

[Citation Needed]

4 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

5 |

Some blocks

6 | 7 |

Embed #2

8 |

Here is some text. Lorem ipsum.

9 |

Embed #3

10 |

And here’s another image:

11 |

Embed #4

12 | -------------------------------------------------------------------------------- /internal/anf/testdata/sample/article.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Sample Article 4 | 5 | 6 |

Welcome to Apple News

7 |

8 | This is a 9 | sample article 10 | that demonstrates how to convert HTML to Apple News Format. 11 |

12 |

13 | You can include 14 | italic text 15 | and 16 | links 17 | in your articles. 18 |

19 |

Features

20 |
    21 |
  • Support for headings
  • 22 |
  • Paragraph text with formatting
  • 23 |
  • Links and emphasis
  • 24 |
  • Lists and quotes
  • 25 |
26 |
27 | This is a sample quote that will be styled appropriately in Apple News. 28 |
29 |

Images can also be included:

30 |
31 | Sample image 32 |
image caption here
33 |
34 | 35 | 36 | --------------------------------------------------------------------------------