Repository navigation
Staging banner linking to prod, and noindex until launch #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ed4974a
d286b76
805eeef
659210a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,8 @@ | |
| // - Managed lines in hand-written files end with `# app:sync <key>`; only the value | ||
| // before that comment is replaced, with the derived value named by <key>. Every | ||
| // tracked file containing such a tag is managed (see managedFiles). | ||
| // - The <title> in src/index.html, so the raw page names the app before it boots. | ||
| // - The <title> and robots meta in src/index.html, so the raw page names the app and | ||
| // stays out of search results until app.yaml says it has launched. | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { existsSync, readFileSync, writeFileSync } from 'node:fs'; | ||
| import { dirname, join } from 'node:path'; | ||
|
|
@@ -77,6 +78,7 @@ export function validate(app) { | |
| errors.push(`family must be one of: ${Object.keys(FAMILIES).join(', ')}`); | ||
| } | ||
| if (!Number.isInteger(app?.matomo_site_id)) errors.push('matomo_site_id must be an integer'); | ||
| if (typeof app?.launched !== 'boolean') errors.push('launched must be true or false'); | ||
| return errors; | ||
| } | ||
|
|
||
|
|
@@ -96,15 +98,19 @@ export function derive(app) { | |
| }; | ||
| } | ||
|
|
||
| function oauthConfig(env, host, backend) { | ||
| // `environment` drives the internal-testing banner: anything but prod shows it, | ||
| // linking to `prodUrl` | ||
| function runtimeConfig(auth, environment, host, backend, prodHost) { | ||
| const rd = encodeURIComponent(`https://${host}/configuration`); | ||
| return { | ||
| hostname: backend, | ||
| basePath: '', | ||
| signInUrl: `${AUTH[env]}/start?rd=${rd}`, | ||
| signOutUrl: `${AUTH[env]}/sign_out?rd=${rd}`, | ||
| userInfoUrl: `${AUTH[env]}/userinfo`, | ||
| signInUrl: `${AUTH[auth]}/start?rd=${rd}`, | ||
| signOutUrl: `${AUTH[auth]}/sign_out?rd=${rd}`, | ||
| userInfoUrl: `${AUTH[auth]}/userinfo`, | ||
| frontendOnly: 'false', | ||
| environment, | ||
| prodUrl: `https://${prodHost}`, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -145,14 +151,14 @@ export function render(app) { | |
| 'chart/values.yaml': toYaml({ | ||
| ingress: { hostname: d.local_host, tls: false, annotations: {} }, | ||
| controller: { image: `${d.image}:main` }, | ||
| config: oauthConfig('staging', d.local_host, BACKEND.staging), | ||
| config: runtimeConfig('staging', 'local', d.local_host, BACKEND.staging, d.prod_host), | ||
| extraDeploy: [], | ||
| }), | ||
|
|
||
| 'chart/values.staging.yaml': toYaml({ | ||
| ingress: { hostname: d.staging_host, tls: true, annotations: TLS_ANNOTATIONS }, | ||
| controller: { image: `${d.image}:staging` }, | ||
| config: oauthConfig('staging', d.staging_host, BACKEND.staging), | ||
| config: runtimeConfig('staging', 'staging', d.staging_host, BACKEND.staging, d.prod_host), | ||
| }), | ||
|
|
||
| 'chart/values.prod.yaml': toYaml({ | ||
|
|
@@ -166,7 +172,7 @@ export function render(app) { | |
| }, | ||
| }, | ||
| controller: { image: `${d.image}:main` }, | ||
| config: oauthConfig('prod', d.prod_host, BACKEND.prod), | ||
| config: runtimeConfig('prod', 'prod', d.prod_host, BACKEND.prod, d.prod_host), | ||
| extraDeploy: [{ | ||
| apiVersion: 'traefik.io/v1alpha1', | ||
| kind: 'Middleware', | ||
|
|
@@ -202,11 +208,23 @@ export function updateManagedLines(text, derived, file) { | |
| }).join('\n'); | ||
| } | ||
|
|
||
| export function updateTitle(html, name) { | ||
| const escaped = name.replace(/&/g, '&').replace(/</g, '<'); | ||
| if (!/<title>[^<]*<\/title>/.test(html)) throw new Error('src/index.html has no <title>'); | ||
| // A function, so `$&` and friends in the name are inserted literally | ||
| return html.replace(/<title>[^<]*<\/title>/, () => `<title>${escaped}</title>`); | ||
| const NOINDEX = /\n[ \t]*<meta name="robots" content="noindex" \/>/; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A hand edit like adding Minor, same regex: if two copies of the exact tag exist, re-syncing keeps both (stable at 2). That's harmless.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reproduced with your three variants, then fixed in d286b76. After removing its own tag, |
||
|
|
||
| export function updateIndexHtml(html, app) { | ||
| const escaped = app.name.replace(/&/g, '&').replace(/</g, '<'); | ||
| const title = /([ \t]*)<title>[^<]*<\/title>/; | ||
| if (!title.test(html)) throw new Error('src/index.html has no <title>'); | ||
| // Functions, so `$&` and friends in the name are inserted literally | ||
| let out = html.replace(NOINDEX, () => ''); | ||
| // Any other robots or noindex tag (any crawler, quoted or not) would survive launch | ||
| // and keep the site out of search | ||
| if (/<meta\b[^>]*\b(name=["']?robots\b|content=["']?[^"'>]*noindex)/i.test(out)) { | ||
| throw new Error('src/index.html has a robots meta tag app:sync did not write; remove it ' | ||
| + '(indexing is controlled by `launched` in app.yaml)'); | ||
| } | ||
| out = out.replace(title, (_, indent) => `${indent}<title>${escaped}</title>` | ||
| + (app.launched ? '' : `\n${indent}<meta name="robots" content="noindex" />`)); | ||
| return out; | ||
| } | ||
|
|
||
| export function outputs(app, readFile) { | ||
|
|
@@ -215,7 +233,7 @@ export function outputs(app, readFile) { | |
| for (const file of managedFiles()) { | ||
| files[file] = updateManagedLines(readFile(file), derived, file); | ||
| } | ||
| files['src/index.html'] = updateTitle(readFile('src/index.html'), app.name); | ||
| files['src/index.html'] = updateIndexHtml(readFile('src/index.html'), app); | ||
| return files; | ||
| } | ||
|
|
||
|
|
@@ -243,7 +261,7 @@ function main(argv) { | |
| for (const file of Object.keys(render(app))) console.log(` ${file}`); | ||
| console.log('\nFiles with managed lines (# app:sync <key>):'); | ||
| for (const file of managedFiles()) console.log(` ${file}`); | ||
| console.log(' src/index.html (<title>)'); | ||
| console.log(' src/index.html (<title>, robots meta)'); | ||
| return 0; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { TestBed } from '@angular/core/testing'; | ||
| import { Meta } from '@angular/platform-browser'; | ||
|
|
||
| import { EnvironmentService } from '~/app/services/environment.service'; | ||
| import { StagingBannerComponent } from './staging-banner.component'; | ||
|
|
||
| function render(config: { environment?: string; prodUrl?: string } | undefined) { | ||
| TestBed.configureTestingModule({ | ||
| imports: [StagingBannerComponent], | ||
| providers: [{ provide: EnvironmentService, useValue: { getEnvConfig: () => config } }], | ||
| }); | ||
| const fixture = TestBed.createComponent(StagingBannerComponent); | ||
| fixture.detectChanges(); | ||
| return { el: fixture.nativeElement as HTMLElement, meta: TestBed.inject(Meta) }; | ||
| } | ||
|
|
||
| describe('StagingBannerComponent', () => { | ||
| afterEach(() => document.querySelector('meta[name="robots"]')?.remove()); | ||
|
|
||
| it('on staging, says so, links the released tool and asks not to be indexed', () => { | ||
| const { el, meta } = render({ environment: 'staging', prodUrl: 'https://x.platform.moleculemaker.org' }); | ||
|
|
||
| expect(el.textContent).toContain('internal testing build'); | ||
| expect(el.querySelector('a')?.getAttribute('href')).toBe('https://x.platform.moleculemaker.org'); | ||
| expect(meta.getTag('name="robots"')?.content).toBe('noindex'); | ||
| }); | ||
|
|
||
| it('locally, shows the banner without a link when there is no prod URL', () => { | ||
| const { el } = render({ environment: 'local', prodUrl: '' }); | ||
|
|
||
| expect(el.textContent).toContain('internal testing build'); | ||
| expect(el.querySelector('a')).toBeNull(); | ||
| }); | ||
|
|
||
| it('on prod, shows nothing and adds no robots tag', () => { | ||
| const { el, meta } = render({ environment: 'prod', prodUrl: 'https://x.platform.moleculemaker.org' }); | ||
|
|
||
| expect(el.textContent?.trim()).toBe(''); | ||
| expect(meta.getTag('name="robots"')).toBeNull(); | ||
| }); | ||
|
|
||
| it('with no environment configured, shows nothing', () => { | ||
| expect(render({}).el.textContent?.trim()).toBe(''); | ||
| }); | ||
|
|
||
| it('before any config has loaded, shows nothing', () => { | ||
| expect(render(undefined).el.textContent?.trim()).toBe(''); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note (not reproduced; I have no cluster or
helmhere). TheseconfigMapKeyRefs aren'toptional. If the new chart template is deployed with values that lackenvironmentorprodUrl, for example an existing app's own values or a separate deploy repo, the pod fails to start (CreateContainerConfigError). It wouldn't fall back to "unset = no banner". In this repo the template and the generated values always change together, and the existing keys follow the same pattern, so I'm not raising it as a defect.optional: trueon the two new keys would make the "unset shows nothing" design hold for the chart as well as for the image.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Both refs are
optional: truein d286b76. An unset key becomes""throughenvsubst, which shows no banner.