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
8 changes: 8 additions & 0 deletions apps/studio/src/ui/problemText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ describe("problemMessage", () => {
expect(say({ code: "missingLanguage", params: { language: "en" } }, `${DCTERMS}title`)).toBe(
"Title: no text in English.",
);
expect(
say(
{ code: "missingImageDescription", params: { language: "sv" } },
SM.frontImageDescription,
),
).toBe(
"Description of the front picture: no description in Swedish. Without one, a screen reader says only that there is a picture.",
);
expect(
say({
code: "missingTheme",
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/ui/problemText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const FIELDS: Readonly<Record<string, string>> = {
[SM.backLabel]: "backLabel",
[SM.frontNote]: "frontNote",
[SM.backNote]: "backNote",
[SM.frontImageDescription]: "frontImageDescription",
[SM.backImageDescription]: "backImageDescription",
[SM.distractor]: "distractor",
[SM.distractorText]: "distractorText",
[SM.distractorNote]: "distractorNote",
Expand Down Expand Up @@ -214,6 +216,7 @@ export function problemMessage(
return t(key, { field, counts: counts.join(", ") });
}
case "missingLanguage":
case "missingImageDescription":
return t(key, { field, language: language(problem.params.language) });
case "missingTheme":
return t(key, {
Expand Down
2,842 changes: 2,842 additions & 0 deletions decks/famous-paintings/v4.ttl

Large diffs are not rendered by default.

170 changes: 122 additions & 48 deletions decks/index.ttl

Large diffs are not rendered by default.

1,887 changes: 1,887 additions & 0 deletions decks/rijksmuseum-collection/v2.ttl

Large diffs are not rendered by default.

2,634 changes: 2,634 additions & 0 deletions decks/world-flags/v3.ttl

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion docs/deck-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ publisher still in the index:
`dcterms:publisher <…/decks/index.ttl#solid-memo>`, a
`dcat:distribution <#turtle>` pointing at the file itself, and from
version 2 `dcat:prev` and `dcat:previousVersion` `<…/<name>/v<N-1>.ttl>`.
- **A picture on a card is described** (`sm:frontImageDescription`,
`sm:backImageDescription`) in English and in every other language the
card's text is in, so a screen reader can say what it shows; without
one it says only that there is a picture, and where the picture is the
question the card cannot be studied without sight. The description
says what a sighted learner sees and never gives the answer away: "Three
vertical stripes, green, white and red", not the country.
`npm run library:check` asks it of each deck's newest version
([Checks](#checks)).
- **A published card is never removed, but retired**: keep it and add
`owl:deprecated true` (card format 3 and later). Copies of the deck
keep it and its review history, but no longer study it; a later
Expand Down Expand Up @@ -444,6 +453,13 @@ what the versions make. Both check:
it: when the app gains a language, every deck fails until a new
version adds it, while the versions before stay frozen. The Studio's
check for the library asks the same of a draft (`repoPolicy`);
- that each picture on a card of a deck's newest version is described
in English and in every other language the card's text is in, by its
primary subtag (`missingImageDescription`, by `LIBRARY_LANGUAGES`
too): text in `sv-se` asks a description in `sv`, and untagged text
asks none. It is a library rule for the same reason, held to the
newest version alone, and the Studio's check for the library asks it
of a draft too;
- a course's outline, by the [course rules](#course-rules);
- the index's course for newcomers (`newcomerProblems`): at most one, an
IRI, a deck of the library (one of the catalog's `dcat:dataset`s),
Expand Down Expand Up @@ -474,7 +490,7 @@ the Studio can run them in the browser as the command does:
| `continuityRules.ts` | A version against the one before it. |
| `courseRules.ts` | The [course rules](#course-rules); and what a release needs that its draft may lack (`readinessProblems`), which the Studio's check adds. |
| `markdownFields.ts` | Which fields are Markdown, and by which rule; the check itself is passed in, since the domain reads no Markdown. |
| `curationRules.ts` | A `LibraryPolicy`: what a library asks beyond the data. `repoPolicy` asks a title, description and keywords in every language the app speaks (`LIBRARY_LANGUAGES`, from `LOCALES`) and the theme `EDUC`; `podPolicy` asks nothing. |
| `curationRules.ts` | A `LibraryPolicy`: what a library asks beyond the data. `repoPolicy` asks a title, description and keywords in every language the app speaks and each picture on a card described (`LIBRARY_LANGUAGES`, from `LOCALES`) and the theme `EDUC`; `podPolicy` asks nothing. |
| `seriesEntry.ts` | What the index says of a deck. |
| `releaseCheck.ts` | The Studio's release check: the rules a policy runs, where a library publishes a release (`libraryReleaseUrl`, `librarySeriesUrl`, which this command uses too), and where each problem is fixed. |
| `draftModel.ts` | A Studio draft as a release model, at its own address or another. |
Expand Down
2 changes: 1 addition & 1 deletion docs/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ read, and live in this repository beside the code that applies them.
| The shape of every document in a pod and in the library | SHACL shapes in [`ns/shapes/`](../ns/shapes/) ([shapes.md](shapes.md)) | the app in the browser and `npm run library:check` (rdf-validate-shacl), and pySHACL in CI ([validation.md](validation.md)) |
| The meaning of every Solid Memo term | the vocabulary in [`ns/vocab/`](../ns/vocab/) ([vocab.md](vocab.md)) | the constants generated from it, kept in step by `npm run generate:check` |
| What a library deck version must state, beyond the shapes: its place, its series, its publisher, the version before it and its license | [`libraryRules.ts`](../packages/domain/src/release/libraryRules.ts) ([deck-library.md](deck-library.md#checks)) | `npm run library:check`, and the Studio's check for the library |
| What the repository's library asks of a deck: a title, description and keywords in every language the app speaks (`LIBRARY_LANGUAGES`, from `LOCALES` in [`locale.ts`](../packages/domain/src/locale.ts)), the theme `EDUC` | `repoPolicy` in [`curationRules.ts`](../packages/domain/src/release/curationRules.ts) | the Studio's check for the library; `npm run library:check` for the languages of each deck's newest version, and the library deck shapes for the rest |
| What the repository's library asks of a deck: a title, description and keywords in every language the app speaks (`LIBRARY_LANGUAGES`, from `LOCALES` in [`locale.ts`](../packages/domain/src/locale.ts)), a description of each picture on a card in English and the card's other languages, the theme `EDUC` | `repoPolicy` in [`curationRules.ts`](../packages/domain/src/release/curationRules.ts) | the Studio's check for the library; `npm run library:check` for the languages and picture descriptions of each deck's newest version, and the library deck shapes for the rest |
| What a page may load and run | the Content Security Policy in [`apps/web/index.html`](../apps/web/index.html) ([markdown.md](markdown.md)) | the browser; [`build.test.ts`](../apps/web/src/build.test.ts) checks it |

Every published version of a shape or of a deck is frozen: a change is
Expand Down
3 changes: 2 additions & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -1819,7 +1819,8 @@ each part per draft. The release a draft follows is read once.
The policy is a pod's by default: a release in a pod is held to its
data's rules only. `policy=library` adds the Solid Memo library's
curation (`repoPolicy`: a title, description and keywords in every
language the app speaks, the theme EDUC,
language the app speaks, each picture on a card described in English
and the card's other languages, the theme EDUC,
and the library's contact in place of an email, as
`npm run library:check` asks of a new version:
[deck-library.md](deck-library.md#contact))
Expand Down
43 changes: 42 additions & 1 deletion packages/domain/src/release/curationRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

import { expect, it } from "vitest";
import { LOCALES } from "../locale";
import { at, iri, model, RELEASE, text } from "../testing/releaseModel";
import { at, card, iri, model, RELEASE, text } from "../testing/releaseModel";
import { curationProblems, LIBRARY_CONTACT, podPolicy, repoPolicy } from "./curationRules";

const SM = "https://solid-memo.com/ns/vocab/v1.ttl#";
const EDUC = "http://publications.europa.eu/resource/authority/data-theme/EDUC";
const KIND = "http://www.w3.org/2006/vcard/ns#Kind";
const contact = (url: string, types = [KIND]) => ({
Expand Down Expand Up @@ -104,6 +105,46 @@ it("asks the library's contact, a vcard:Kind, and no one's email", () => {
);
});

it("asks a description of each picture in English and every language its card's text is in", () => {
const picture = "https://example.com/flag.svg";
const pictured = {
...curated,
cards: [
card("described", {
frontImage: [picture],
frontImageDescription: [text("Three stripes", "en-gb"), text("Tre ränder", "sv")],
back: [text("Answer"), text("Svar", "sv-se")],
}),
card("bare", {
frontLabel: [text("Fråga", "sv")],
backNote: [text("Note", "")],
backImage: [picture],
backImageDescription: [text("Ränder", "sv"), text("Stripes", "")],
}),
card("unpictured", {
frontImageDescription: [],
back: [text("답", "ko")],
}),
],
};
expect(
curationProblems(pictured, repoPolicy).map(({ code, subject, field, params }) => ({
code,
subject,
field,
params,
})),
).toEqual([
{
code: "missingImageDescription",
subject: at("bare"),
field: `${SM}backImageDescription`,
params: { language: "en" },
},
]);
expect(curationProblems(pictured, podPolicy)).toEqual([]);
});

it("asks nothing of a release in a pod, an email included", () => {
const emailed = model({
emails: [{ subject: at("ann"), object: iri("mailto:ann@example.com") }],
Expand Down
66 changes: 59 additions & 7 deletions packages/domain/src/release/curationRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

import { EDUCATION_THEME } from "../dcat.ts";
import { LOCALES } from "../locale.ts";
import { SM } from "@solid-memo/vocab/vocab.generated";
import { problem, type ReleaseProblem } from "./problems.ts";
import {
DCAT_NS,
DCTERMS_NS,
FOAF_MBOX,
VCARD_NS,
type ReleaseCard,
type ReleaseModel,
type ReleaseTerm,
} from "./releaseModel.ts";
Expand All @@ -19,7 +21,8 @@ import {
* (docs/deck-library.md, A version): a value the caller passes in,
* never a rule of a series, author or host. The repository's library
* asks a title, a description and keywords in every language the app
* speaks (LIBRARY_LANGUAGES), the EU data theme EDUC, and its contact,
* speaks and a description of every picture on a card
* (LIBRARY_LANGUAGES), the EU data theme EDUC, and its contact,
* the project's issue tracker, in place of anyone's email; a release in
* a pod asks nothing.
*/
Expand All @@ -29,6 +32,12 @@ export interface LibraryPolicy {
descriptionLanguages: readonly string[];
/** Languages the keywords have at least one keyword in. */
keywordLanguages: readonly string[];
/**
* Whether every picture on a card has a description in English and
* in every other language the card's text is in, so a screen reader
* can say what it shows.
*/
describedPictures: boolean;
/** Themes (dcat:theme) among the release's. */
themes: readonly string[];
/**
Expand All @@ -46,12 +55,14 @@ export const LIBRARY_CONTACT = "https://github.com/solid-memo/website/issues";
* The languages this repository's library asks of a deck's title,
* description and keywords: every language the app speaks (LOCALES), so
* a language the app gains is asked of every deck, which a new version
* then adds (docs/deck-library.md, A version).
* then adds (docs/deck-library.md, A version). Of a picture on a card,
* a description in English and in every language the card's text is in.
*/
export const LIBRARY_LANGUAGES = {
titleLanguages: LOCALES,
descriptionLanguages: LOCALES,
keywordLanguages: LOCALES,
describedPictures: true,
} satisfies Partial<LibraryPolicy>;

/** The policy of this repository's decks/. */
Expand All @@ -66,6 +77,7 @@ export const podPolicy: LibraryPolicy = {
titleLanguages: [],
descriptionLanguages: [],
keywordLanguages: [],
describedPictures: false,
themes: [],
contact: null,
};
Expand All @@ -74,11 +86,7 @@ export const podPolicy: LibraryPolicy = {
export function curationProblems(model: ReleaseModel, policy: LibraryPolicy): ReleaseProblem[] {
const problems: ReleaseProblem[] = [];
const languages = (field: string, terms: readonly ReleaseTerm[], wanted: readonly string[]) => {
const stated = terms.flatMap((term) => (term.kind === "literal" ? [term.language] : []));
// A language range, as sh:languageIn reads it: "en" is met by "en-gb" too.
const has = (language: string) =>
stated.some((tag) => tag === language || tag.startsWith(`${language}-`));
for (const language of wanted.filter((language) => !has(language))) {
for (const language of lacking(terms, wanted)) {
problems.push(
problem(model.url, { code: "missingLanguage", params: { language } }, { field }),
);
Expand All @@ -87,6 +95,7 @@ export function curationProblems(model: ReleaseModel, policy: LibraryPolicy): Re
languages(`${DCTERMS_NS}title`, model.title, policy.titleLanguages);
languages(`${DCTERMS_NS}description`, model.description, policy.descriptionLanguages);
languages(`${DCAT_NS}keyword`, model.keywords, policy.keywordLanguages);
if (policy.describedPictures) problems.push(...model.cards.flatMap(pictureProblems));
const themes = new Set(model.themes.map((theme) => theme.value));
for (const theme of policy.themes.filter((theme) => !themes.has(theme))) {
problems.push(
Expand All @@ -97,6 +106,49 @@ export function curationProblems(model: ReleaseModel, policy: LibraryPolicy): Re
return problems;
}

/** The languages of `wanted` that no literal of `terms` is in. */
function lacking(terms: readonly ReleaseTerm[], wanted: readonly string[]): string[] {
const stated = terms.flatMap((term) => (term.kind === "literal" ? [term.language] : []));
// A language range, as sh:languageIn reads it: "en" is met by "en-gb" too.
const has = (language: string) =>
stated.some((tag) => tag === language || tag.startsWith(`${language}-`));
return wanted.filter((language) => !has(language));
}

/**
* Each language a card's picture has no description in: English, and
* every other language the card's text is in, by its primary subtag
* ("en-gb" asks "en"). Without one, a screen reader says only that there
* is a picture, and where the picture is the question, the card cannot
* be studied without sight.
*/
function pictureProblems(card: ReleaseCard): ReleaseProblem[] {
const text = [
card.front,
card.back,
card.frontLabel,
card.backLabel,
card.frontNote,
card.backNote,
].flat();
const wanted = [
...new Set([
"en",
...text.flatMap(({ language }) => (language === "" ? [] : [language.split("-")[0]!])),
]),
];
const side = (images: readonly string[], descriptions: readonly ReleaseTerm[], field: string) =>
images.length === 0
? []
: lacking(descriptions, wanted).map((language) =>
problem(card.iri, { code: "missingImageDescription", params: { language } }, { field }),
);
return [
...side(card.frontImage, card.frontImageDescription, SM.frontImageDescription),
...side(card.backImage, card.backImageDescription, SM.backImageDescription),
];
}

/** A release's contact, unless it names `contact`, and each email it names. */
function contactProblems(model: ReleaseModel, contact: string): ReleaseProblem[] {
const named = model.contacts.some(
Expand Down
17 changes: 17 additions & 0 deletions packages/domain/src/release/draftModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,23 @@ describe("draftReleaseModel", () => {
expect(model.activities).toEqual([{ iri: of("old"), generating: false }]);
});

it("reads a card's pictures and their descriptions", () => {
const deck = deckDraft();
const data = {
...deck.cards[0]!.data,
frontImage: "https://img.example/map.svg",
backImage: "https://img.example/flag.svg",
backImageDescription: { en: "Three stripes" },
};
const [card] = draftReleaseModel({ ...deck, cards: [{ id: "w1", data }] }).cards;
expect(card).toMatchObject({
frontImage: ["https://img.example/map.svg"],
backImage: ["https://img.example/flag.svg"],
frontImageDescription: [],
backImageDescription: [{ value: "Three stripes", language: "en" }],
});
});

it("reads a chapter or step without a place, chapter or course as having none", () => {
const draft = courseDraft();
const loose = {
Expand Down
4 changes: 4 additions & 0 deletions packages/domain/src/release/draftModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export function draftReleaseModel(draft: ReleaseDraft, url: string = draft.url):
backLabel: texts(node.data.backLabel),
frontNote: texts(node.data.frontNote),
backNote: texts(node.data.backNote),
frontImage: one(node.data.frontImage).map((term) => term.value),
backImage: one(node.data.backImage).map((term) => term.value),
frontImageDescription: texts(node.data.frontImageDescription),
backImageDescription: texts(node.data.backImageDescription),
distractors: iris(node.data.distractor),
}));
const distractors: ReleaseDistractor[] = draft.distractors.map((node) => ({
Expand Down
2 changes: 2 additions & 0 deletions packages/domain/src/release/problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface MarkdownFinding {
export type CurationProblem =
/** No text in a language the policy asks of the title, description or keywords (`field`). */
| { code: "missingLanguage"; params: { language: string } }
/** A card's picture without a description (`field`) in a language the policy asks of it. */
| { code: "missingImageDescription"; params: { language: string } }
| { code: "missingTheme"; params: { theme: string } }
/** No contact point (dcat:contactPoint) that is a vcard:Kind naming the policy's `contact` (vcard:hasURL). */
| { code: "missingContact"; params: { contact: string } }
Expand Down
4 changes: 4 additions & 0 deletions packages/domain/src/release/provenanceRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ describe("provenanceProblems", () => {
backLabel: [],
frontNote: [],
backNote: [],
frontImage: [],
backImage: [],
frontImageDescription: [],
backImageDescription: [],
distractors: [],
})),
making: [making(comments, { carried })],
Expand Down
5 changes: 5 additions & 0 deletions packages/domain/src/release/releaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export interface ReleaseCard extends ReleaseSubject {
backLabel: ReleaseText[];
frontNote: ReleaseText[];
backNote: ReleaseText[];
/** The pictures it shows (solid-memo:frontImage, backImage), by IRI. */
frontImage: string[];
backImage: string[];
frontImageDescription: ReleaseText[];
backImageDescription: ReleaseText[];
/** What it names by solid-memo:distractor, whatever each is. */
distractors: ReleaseTerm[];
}
Expand Down
4 changes: 4 additions & 0 deletions packages/domain/src/testing/releaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export function card(id: string, extra: Partial<ReleaseCard> = {}): ReleaseCard
backLabel: [],
frontNote: [],
backNote: [],
frontImage: [],
backImage: [],
frontImageDescription: [],
backImageDescription: [],
distractors: [],
...extra,
};
Expand Down
19 changes: 19 additions & 0 deletions packages/shacl/node/deckLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,25 @@ describe("languageProblems", () => {
]);
});

it("names each language a card's picture has no description in", () => {
const turtle = `${releaseText("capitals", 2, NORWAY)}
<#flag> a <${SM}Card> ;
<${SM}formatVersion> 4 ;
<${SM}frontImage> <https://example.org/flag.svg> ;
<${SM}frontImageDescription> "A red flag with a blue cross"@en ;
<${SM}back> "Norway"@en , "Norge"@sv .
`;
const pictured: DeckRelease = {
deck: "capitals",
version: 2,
turtle,
quads: parseTurtle(turtle, `${DECKS}capitals/v2.ttl`),
};
expect(languageProblems(pictured)).toEqual([
`${L}: <#flag> has a picture without solid-memo:frontImageDescription in sv: a library deck describes each picture in English and in every language its card's text is in, without giving the answer away, so a screen reader can say what it shows.`,
]);
});

it("is asked of each deck's newest version, not of the frozen ones before it", async () => {
const languages = ["en", "sv"];
const older = [release("capitals", 1, { languages }), release("capitals", 2, NORWAY)];
Expand Down
Loading
Loading