Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ renovate.json
spec/
tmp/
work/
frontend/dist/
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,15 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: ".tool-versions"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies for OpenAPI client verification
run: npm ci
working-directory: frontend

- name: Verify generated OpenAPI is up to date
run: bundle exec rake openapi:verify
- name: Verify generated OpenAPI spec and client are up to date
run: make openapi-verify

- name: Lint OpenAPI contract
run: make openapi-lint
Expand All @@ -83,9 +89,6 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Verify generated OpenAPI client is up to date
run: npm run openapi:verify

- name: Typecheck frontend
run: npm run typecheck

Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
# Ignore frontend build output and tooling caches
/frontend/dist/
/frontend/node_modules/
/public/frontend
/frontend/playwright-report/
/frontend/test-results/

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,6 @@ COPY --chown=$USER:$USER Gemfile Gemfile.lock app.rb config.ru ./
COPY --chown=$USER:$USER app ./app
COPY --chown=$USER:$USER config ./config
COPY --chown=$USER:$USER public ./public
COPY --from=frontend-builder --chown=$USER:$USER /app/public/frontend ./public/frontend
COPY --from=frontend-builder --chown=$USER:$USER /app/frontend/dist ./frontend/dist

CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"]
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,26 @@ ready: ## Pre-commit gate (quick checks + RSpec)
yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/
bundle exec rake yard:verify_public_docs

openapi: ## Regenerate docs/api/v1/openapi.yaml from request specs
openapi: ## Regenerate public/openapi.yaml from request specs
bundle exec rake openapi:generate

openapi-verify: ## Regenerate OpenAPI and fail if docs/api/v1/openapi.yaml or frontend client is stale
openapi-verify: ## Regenerate OpenAPI and fail if public/openapi.yaml or frontend client is stale
bundle exec rake openapi:verify
$(MAKE) openapi-client-verify

openapi-client: ## Generate frontend OpenAPI client/types from docs/api/v1/openapi.yaml
openapi-client: ## Generate frontend OpenAPI client/types from public/openapi.yaml
@cd frontend && npm run openapi:generate

openapi-client-verify: ## Generate frontend OpenAPI client and fail if generated files are stale
@cd frontend && npm run openapi:verify

openapi-lint: openapi-lint-redocly openapi-lint-spectral ## Lint docs/api/v1/openapi.yaml with Redocly and Spectral
openapi-lint: openapi-lint-redocly openapi-lint-spectral ## Lint public/openapi.yaml with Redocly and Spectral

openapi-lint-redocly: ## Lint OpenAPI using Redocly recommended rules
npx --yes @redocly/cli lint --config .redocly.yaml docs/api/v1/openapi.yaml
npx --yes @redocly/cli lint --config .redocly.yaml public/openapi.yaml

openapi-lint-spectral: ## Lint OpenAPI using Spectral OAS rules
npx --yes @stoplight/spectral-cli lint --ruleset .spectral.yaml docs/api/v1/openapi.yaml
npx --yes @stoplight/spectral-cli lint --ruleset .spectral.yaml public/openapi.yaml

openai-lint-spectral: openapi-lint-spectral ## Alias for openapi-lint-spectral

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ html2rss-web converts arbitrary websites into RSS 2.0 feeds with a slim Ruby bac
## Architecture

- **Backend:** Ruby + Roda, backed by the `html2rss` gem for extraction.
- **Frontend:** Preact app built with Vite into `public/frontend`.
- **Frontend:** Preact app built with Vite into `frontend/dist` and served at `/`.
- **Distribution:** Docker Compose by default; other deployments require manual wiring.
- [Project notes](docs/README.md)

Expand Down Expand Up @@ -85,7 +85,7 @@ For contributors and AI agents changing backend structure, follow the rules in [
| `make lint` | Run all linters. |
| `make lintfix` | Auto-fix lint warnings where possible. |
| `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. |
| `make openapi` | Regenerate `docs/api/v1/openapi.yaml` from request specs. |
| `make openapi` | Regenerate `public/openapi.yaml` from request specs. |
| `make openapi-verify` | Regenerate + fail if OpenAPI file is stale. |
| `make clean` | Remove build artefacts. |

Expand All @@ -101,7 +101,7 @@ The OpenAPI file is generated from Ruby request specs only.
| Command | Purpose |
| ----------------------- | -------------------------------------------- |
| `npm run dev` | Vite dev server with hot reload (port 4001). |
| `npm run build` | Build static assets into `public/frontend`. |
| `npm run build` | Build static assets into `frontend/dist/`. |
| `npm run test:run` | Unit tests (Vitest). |
| `npm run test:contract` | Contract tests with MSW. |

Expand Down
6 changes: 3 additions & 3 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ end
namespace :openapi do
desc 'Generate OpenAPI YAML from request specs'
task :generate do
FileUtils.mkdir_p('docs/api/v1')
FileUtils.rm_f('docs/api/v1/openapi.yaml')
FileUtils.mkdir_p('public')
FileUtils.rm_f('public/openapi.yaml')
sh({ 'OPENAPI' => '1' }, 'bundle exec rspec spec/html2rss/web/api/v1_spec.rb --order defined')
end

desc 'Verify generated OpenAPI YAML is up to date'
task verify: :generate do
sh 'git diff --exit-code -- docs/api/v1/openapi.yaml'
sh 'git diff --exit-code -- public/openapi.yaml'
end
end

Expand Down
9 changes: 7 additions & 2 deletions app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class App < Roda
</body>
</html>
HTML
FRONTEND_DIST_PATH = 'frontend/dist'
FRONTEND_INDEX_PATH = File.join(FRONTEND_DIST_PATH, 'index.html')
def self.development? = EnvironmentValidator.development?

def development? = self.class.development?
Expand Down Expand Up @@ -80,6 +82,10 @@ def development? = self.class.development?
}

plugin :json_parser
plugin :static,
['/assets'],
root: FRONTEND_DIST_PATH,
headers: { 'Cache-Control' => 'public, max-age=31536000, immutable' }
Comment on lines +85 to +88
plugin :public
plugin :head
plugin :not_allowed
Expand All @@ -100,9 +106,8 @@ def development? = self.class.development?
private

def render_index_page(router)
index_path = 'public/frontend/index.html'
router.response['Content-Type'] = 'text/html'
File.exist?(index_path) ? File.read(index_path) : FALLBACK_HTML
File.exist?(FRONTEND_INDEX_PATH) ? File.read(FRONTEND_INDEX_PATH) : FALLBACK_HTML
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/web/api/v1/root_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def build(router)
api: {
name: 'html2rss-web API',
description: 'RESTful API for converting websites to RSS feeds',
openapi_url: "#{router.base_url}/api/v1/openapi.yaml"
openapi_url: "#{router.base_url}/openapi.yaml"
},
instance: instance_payload(router)
}
Expand Down
21 changes: 1 addition & 20 deletions app/web/routes/api_v1/metadata_routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ def call(router)
def mount_openapi_spec(router)
router.on 'openapi.yaml' do
router.get do
router.response['Content-Type'] = 'application/yaml'
openapi_spec_contents
router.redirect '/openapi.yaml', 301
end
end
end
Expand Down Expand Up @@ -55,29 +54,11 @@ def mount_root(router)
end
end

# @return [String]
def openapi_spec_path
File.expand_path('../../../../docs/api/v1/openapi.yaml', __dir__)
end

# @param router [Roda::RodaRequest]
# @return [String]
def render_root_metadata(router)
JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router)))
end

# @return [String]
def openapi_spec_contents
return File.read(openapi_spec_path) if File.exist?(openapi_spec_path)

<<~YAML
openapi: 3.0.3
info:
title: html2rss-web API
version: 1.0.0
paths: {}
YAML
end
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ This is the only hand-written project document in `docs/`.

Keep this file short, current, and operational. Do not add planning docs, migration diaries, redesign notes, or parallel architecture narratives back into this directory.

The only other file that belongs in `docs/` is the generated OpenAPI contract at [`docs/api/v1/openapi.yaml`](/Users/gil/versioned/html2rss/html2rss-web/docs/api/v1/openapi.yaml).
The only generated artifact intentionally exposed by the app is [`public/openapi.yaml`](/Users/gil/versioned/html2rss/html2rss-web/public/openapi.yaml).

## System Snapshot

- Backend: Ruby + Roda under the `Html2rss::Web` namespace.
- Frontend: Preact + Vite, built into `public/frontend`.
- Frontend: Preact + Vite, built into `frontend/dist` and served at `/`.
- Feed extraction: delegated to the `html2rss` gem.
- Distribution: Docker Compose / Dev Container first.

Expand Down Expand Up @@ -55,7 +55,7 @@ Frontend verification lives at `http://127.0.0.1:4001/` while the dev container

## API Contract Rules

- `docs/api/v1/openapi.yaml` is generated output, not hand-edited design prose.
- `public/openapi.yaml` is generated output, not hand-edited design prose.
- Backend behavior and request specs define the contract.
- Regenerate with `make openapi`.
- Drift must fail with `make openapi-verify`.
Expand Down
5 changes: 1 addition & 4 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
name="description"
content="html2rss converts fixed demo pages or operator-submitted URLs into feed endpoints."
/>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%230b0c0d'/%3E%3Cpath d='M14 20h36v6H14zm0 12h25v6H14zm0 12h16v6H14z' fill='%23f3f4f6'/%3E%3C/svg%3E"
/>
<link rel="icon" href="/favicon.ico" />
<title>html2rss</title>
</head>
<body>
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"openapi:generate": "openapi-ts -i ../docs/api/v1/openapi.yaml -o src/api/generated -c @hey-api/client-fetch",
"openapi:verify": "openapi-ts -i ../docs/api/v1/openapi.yaml -o src/api/generated -c @hey-api/client-fetch && git diff --exit-code -- src/api/generated",
"openapi:generate": "openapi-ts -i ../public/openapi.yaml -o src/api/generated -c @hey-api/client-fetch",
"openapi:verify": "openapi-ts -i ../public/openapi.yaml -o src/api/generated -c @hey-api/client-fetch && git diff --exit-code -- src/api/generated",
"test": "vitest",
"test:run": "vitest run",
"test:unit": "vitest run --reporter=verbose --config vitest.unit.config.ts",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/App.contract.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('App contract', () => {
api: {
name: 'html2rss-web API',
description: 'RESTful API for converting websites to RSS feeds',
openapi_url: 'http://example.test/api/v1/openapi.yaml',
openapi_url: 'http://example.test/openapi.yaml',
},
instance: {
feed_creation: {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('App', () => {
api: {
name: 'html2rss-web API',
description: 'RESTful API for converting websites to RSS feeds',
openapi_url: 'http://example.test/api/v1/openapi.yaml',
openapi_url: 'http://example.test/openapi.yaml',
},
instance: {
feed_creation: {
Expand Down Expand Up @@ -272,13 +272,13 @@ describe('App', () => {
});
});

it('builds a bookmarklet that returns to the current frontend entry', () => {
window.history.replaceState({}, '', 'http://localhost:3000/frontend/index.html');
it('builds a bookmarklet that returns to the root app entry', () => {
window.history.replaceState({}, '', 'http://localhost:3000/');
render(<App />);

fireEvent.click(screen.getByRole('button', { name: 'More' }));
const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' });
expect(bookmarklet.getAttribute('href')).toContain('/frontend/index.html?url=');
expect(bookmarklet.getAttribute('href')).toContain('/?url=');
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
});
});
2 changes: 1 addition & 1 deletion frontend/src/__tests__/mocks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const server = setupServer(
api: {
name: 'html2rss-web API',
description: 'RESTful API for converting websites to RSS feeds',
openapi_url: 'http://example.test/api/v1/openapi.yaml',
openapi_url: 'http://example.test/openapi.yaml',
},
instance: {
feed_creation: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/generated/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts

export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getOpenApiSpec, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen';
export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponse, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen';
export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen';
export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen';
9 changes: 1 addition & 8 deletions frontend/src/api/generated/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen';
import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen';

export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
Expand Down Expand Up @@ -72,13 +72,6 @@ export const getLivenessProbe = <ThrowOnError extends boolean = false>(options?:
*/
export const getReadinessProbe = <ThrowOnError extends boolean = false>(options?: Options<GetReadinessProbeData, ThrowOnError>) => (options?.client ?? client).get<GetReadinessProbeResponses, unknown, ThrowOnError>({ url: '/health/ready', ...options });

/**
* OpenAPI specification
*
* OpenAPI specification
*/
export const getOpenApiSpec = <ThrowOnError extends boolean = false>(options?: Options<GetOpenApiSpecData, ThrowOnError>) => (options?.client ?? client).get<GetOpenApiSpecResponses, unknown, ThrowOnError>({ url: '/openapi.yaml', ...options });

/**
* List extraction strategies
*
Expand Down
16 changes: 0 additions & 16 deletions frontend/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,22 +245,6 @@ export type GetReadinessProbeResponses = {

export type GetReadinessProbeResponse = GetReadinessProbeResponses[keyof GetReadinessProbeResponses];

export type GetOpenApiSpecData = {
body?: never;
path?: never;
query?: never;
url: '/openapi.yaml';
};

export type GetOpenApiSpecResponses = {
/**
* serves the OpenAPI document as YAML
*/
200: string;
};

export type GetOpenApiSpecResponse = GetOpenApiSpecResponses[keyof GetOpenApiSpecResponses];

export type ListStrategiesData = {
body?: never;
path?: never;
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/Bookmarklet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ export function Bookmarklet() {
appUrl.search = '';
appUrl.hash = '';

const targetPath = appUrl.pathname.endsWith('/frontend/index.html') ? appUrl.pathname : '/';
const targetPrefix = `${appUrl.origin}${targetPath}?url=`;
const targetPrefix = `${appUrl.origin}/?url=`;

return `javascript:window.location.href=${JSON.stringify(targetPrefix)}+encodeURIComponent(window.location.href);`;
})();
Expand Down
3 changes: 2 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
base: '/',
plugins: [preact()],
server: {
host: true,
Expand All @@ -19,7 +20,7 @@ export default defineConfig({
exclude: ['msw/node'],
},
build: {
outDir: '../public/frontend',
outDir: './dist',
emptyOutDir: true,
},
});
15 changes: 0 additions & 15 deletions docs/api/v1/openapi.yaml → public/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -449,21 +449,6 @@ paths:
summary: Readiness probe
tags:
- Health
"/openapi.yaml":
get:
description: OpenAPI specification
operationId: getOpenApiSpec
responses:
'200':
content:
application/yaml:
schema:
type: string
description: serves the OpenAPI document as YAML
security: []
summary: OpenAPI specification
tags:
- Root
"/strategies":
get:
description: List extraction strategies
Expand Down
Loading
Loading