mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Fix document set rules (#4768)
This commit is contained in:
parent
feb0c91395
commit
d97d71948e
@ -44,12 +44,12 @@ pub fn write_catalog(
|
|||||||
let info_ref = alloc.bump();
|
let info_ref = alloc.bump();
|
||||||
let mut info = pdf.document_info(info_ref);
|
let mut info = pdf.document_info(info_ref);
|
||||||
let mut xmp = XmpWriter::new();
|
let mut xmp = XmpWriter::new();
|
||||||
if let Some(title) = &ctx.document.title {
|
if let Some(title) = &ctx.document.info.title {
|
||||||
info.title(TextStr(title));
|
info.title(TextStr(title));
|
||||||
xmp.title([(None, title.as_str())]);
|
xmp.title([(None, title.as_str())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let authors = &ctx.document.author;
|
let authors = &ctx.document.info.author;
|
||||||
if !authors.is_empty() {
|
if !authors.is_empty() {
|
||||||
// Turns out that if the authors are given in both the document
|
// Turns out that if the authors are given in both the document
|
||||||
// information dictionary and the XMP metadata, Acrobat takes a little
|
// information dictionary and the XMP metadata, Acrobat takes a little
|
||||||
@ -76,15 +76,15 @@ pub fn write_catalog(
|
|||||||
info.creator(TextStr(&creator));
|
info.creator(TextStr(&creator));
|
||||||
xmp.creator_tool(&creator);
|
xmp.creator_tool(&creator);
|
||||||
|
|
||||||
let keywords = &ctx.document.keywords;
|
let keywords = &ctx.document.info.keywords;
|
||||||
if !keywords.is_empty() {
|
if !keywords.is_empty() {
|
||||||
let joined = keywords.join(", ");
|
let joined = keywords.join(", ");
|
||||||
info.keywords(TextStr(&joined));
|
info.keywords(TextStr(&joined));
|
||||||
xmp.pdf_keywords(&joined);
|
xmp.pdf_keywords(&joined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(date) = ctx.document.date.unwrap_or(timestamp) {
|
if let Some(date) = ctx.document.info.date.unwrap_or(timestamp) {
|
||||||
let tz = ctx.document.date.is_auto();
|
let tz = ctx.document.info.date.is_auto();
|
||||||
if let Some(pdf_date) = pdf_date(date, tz) {
|
if let Some(pdf_date) = pdf_date(date, tz) {
|
||||||
info.creation_date(pdf_date);
|
info.creation_date(pdf_date);
|
||||||
info.modified_date(pdf_date);
|
info.modified_date(pdf_date);
|
||||||
@ -109,10 +109,10 @@ pub fn write_catalog(
|
|||||||
let doc_id = if let Smart::Custom(ident) = ident {
|
let doc_id = if let Smart::Custom(ident) = ident {
|
||||||
// We were provided with a stable ID. Yay!
|
// We were provided with a stable ID. Yay!
|
||||||
hash_base64(&(PDF_VERSION, ident))
|
hash_base64(&(PDF_VERSION, ident))
|
||||||
} else if ctx.document.title.is_some() && !ctx.document.author.is_empty() {
|
} else if ctx.document.info.title.is_some() && !ctx.document.info.author.is_empty() {
|
||||||
// If not provided from the outside, but title and author were given, we
|
// If not provided from the outside, but title and author were given, we
|
||||||
// compute a hash of them, which should be reasonably stable and unique.
|
// compute a hash of them, which should be reasonably stable and unique.
|
||||||
hash_base64(&(PDF_VERSION, &ctx.document.title, &ctx.document.author))
|
hash_base64(&(PDF_VERSION, &ctx.document.info.title, &ctx.document.info.author))
|
||||||
} else {
|
} else {
|
||||||
// The user provided no usable metadata which we can use as an `/ID`.
|
// The user provided no usable metadata which we can use as an `/ID`.
|
||||||
instance_id.clone()
|
instance_id.clone()
|
||||||
|
@ -133,6 +133,15 @@ impl Styles {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether there is a style for the given field of the given element.
|
||||||
|
pub fn has<T: NativeElement>(&self, field: u8) -> bool {
|
||||||
|
let elem = T::elem();
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.filter_map(|style| style.property())
|
||||||
|
.any(|property| property.is_of(elem) && property.id == field)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `Some(_)` with an optional span if this list contains
|
/// Returns `Some(_)` with an optional span if this list contains
|
||||||
/// styles for the given element.
|
/// styles for the given element.
|
||||||
pub fn interruption<T: NativeElement>(&self) -> Option<Option<Span>> {
|
pub fn interruption<T: NativeElement>(&self) -> Option<Option<Span>> {
|
||||||
|
@ -149,9 +149,9 @@ impl Content {
|
|||||||
route: Route::extend(route).unnested(),
|
route: Route::extend(route).unnested(),
|
||||||
};
|
};
|
||||||
let arenas = Arenas::default();
|
let arenas = Arenas::default();
|
||||||
let (document, styles) =
|
let (document, styles, info) =
|
||||||
realize_doc(&mut engine, locator.next(&()), &arenas, content, styles)?;
|
realize_doc(&mut engine, locator.next(&()), &arenas, content, styles)?;
|
||||||
document.layout(&mut engine, locator.next(&()), styles)
|
document.layout(&mut engine, locator.next(&()), styles, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
cached(
|
cached(
|
||||||
|
@ -3,8 +3,8 @@ use ecow::EcoString;
|
|||||||
use crate::diag::{bail, HintedStrResult, SourceResult};
|
use crate::diag::{bail, HintedStrResult, SourceResult};
|
||||||
use crate::engine::Engine;
|
use crate::engine::Engine;
|
||||||
use crate::foundations::{
|
use crate::foundations::{
|
||||||
cast, elem, Args, Array, Construct, Content, Datetime, Packed, Smart, StyleChain,
|
cast, elem, Args, Array, Construct, Content, Datetime, Fields, Packed, Smart,
|
||||||
Value,
|
StyleChain, Styles, Value,
|
||||||
};
|
};
|
||||||
use crate::introspection::{Introspector, Locator, ManualPageCounter};
|
use crate::introspection::{Introspector, Locator, ManualPageCounter};
|
||||||
use crate::layout::{Page, PageElem};
|
use crate::layout::{Page, PageElem};
|
||||||
@ -78,6 +78,7 @@ impl Packed<DocumentElem> {
|
|||||||
engine: &mut Engine,
|
engine: &mut Engine,
|
||||||
locator: Locator,
|
locator: Locator,
|
||||||
styles: StyleChain,
|
styles: StyleChain,
|
||||||
|
info: DocumentInfo,
|
||||||
) -> SourceResult<Document> {
|
) -> SourceResult<Document> {
|
||||||
let children = self.children();
|
let children = self.children();
|
||||||
let mut peekable = children.chain(&styles).peekable();
|
let mut peekable = children.chain(&styles).peekable();
|
||||||
@ -107,14 +108,7 @@ impl Packed<DocumentElem> {
|
|||||||
pages.extend(result?.finalize(engine, &mut page_counter)?);
|
pages.extend(result?.finalize(engine, &mut page_counter)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Document {
|
Ok(Document { pages, info, introspector: Introspector::default() })
|
||||||
pages,
|
|
||||||
title: DocumentElem::title_in(styles).map(|content| content.plain_text()),
|
|
||||||
author: DocumentElem::author_in(styles).0,
|
|
||||||
keywords: DocumentElem::keywords_in(styles).0,
|
|
||||||
date: DocumentElem::date_in(styles),
|
|
||||||
introspector: Introspector::default(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,6 +139,15 @@ cast! {
|
|||||||
pub struct Document {
|
pub struct Document {
|
||||||
/// The document's finished pages.
|
/// The document's finished pages.
|
||||||
pub pages: Vec<Page>,
|
pub pages: Vec<Page>,
|
||||||
|
/// Details about the document.
|
||||||
|
pub info: DocumentInfo,
|
||||||
|
/// Provides the ability to execute queries on the document.
|
||||||
|
pub introspector: Introspector,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Details about the document.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Hash)]
|
||||||
|
pub struct DocumentInfo {
|
||||||
/// The document's title.
|
/// The document's title.
|
||||||
pub title: Option<EcoString>,
|
pub title: Option<EcoString>,
|
||||||
/// The document's author.
|
/// The document's author.
|
||||||
@ -153,8 +156,29 @@ pub struct Document {
|
|||||||
pub keywords: Vec<EcoString>,
|
pub keywords: Vec<EcoString>,
|
||||||
/// The document's creation date.
|
/// The document's creation date.
|
||||||
pub date: Smart<Option<Datetime>>,
|
pub date: Smart<Option<Datetime>>,
|
||||||
/// Provides the ability to execute queries on the document.
|
}
|
||||||
pub introspector: Introspector,
|
|
||||||
|
impl DocumentInfo {
|
||||||
|
/// Populate this document info with details from the given styles.
|
||||||
|
///
|
||||||
|
/// Document set rules are a bit special, so we need to do this manually.
|
||||||
|
pub fn populate(&mut self, styles: &Styles) {
|
||||||
|
let chain = StyleChain::new(styles);
|
||||||
|
let has = |field| styles.has::<DocumentElem>(field as _);
|
||||||
|
if has(<DocumentElem as Fields>::Enum::Title) {
|
||||||
|
self.title =
|
||||||
|
DocumentElem::title_in(chain).map(|content| content.plain_text());
|
||||||
|
}
|
||||||
|
if has(<DocumentElem as Fields>::Enum::Author) {
|
||||||
|
self.author = DocumentElem::author_in(chain).0;
|
||||||
|
}
|
||||||
|
if has(<DocumentElem as Fields>::Enum::Keywords) {
|
||||||
|
self.keywords = DocumentElem::keywords_in(chain).0;
|
||||||
|
}
|
||||||
|
if has(<DocumentElem as Fields>::Enum::Date) {
|
||||||
|
self.date = DocumentElem::date_in(chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -30,8 +30,8 @@ use crate::layout::{
|
|||||||
};
|
};
|
||||||
use crate::math::{EquationElem, LayoutMath};
|
use crate::math::{EquationElem, LayoutMath};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
CiteElem, CiteGroup, DocumentElem, EnumElem, EnumItem, ListElem, ListItem, ParElem,
|
CiteElem, CiteGroup, DocumentElem, DocumentInfo, EnumElem, EnumItem, ListElem,
|
||||||
ParbreakElem, TermItem, TermsElem,
|
ListItem, ParElem, ParbreakElem, TermItem, TermsElem,
|
||||||
};
|
};
|
||||||
use crate::syntax::Span;
|
use crate::syntax::Span;
|
||||||
use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
|
use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
|
||||||
@ -45,7 +45,7 @@ pub fn realize_doc<'a>(
|
|||||||
arenas: &'a Arenas<'a>,
|
arenas: &'a Arenas<'a>,
|
||||||
content: &'a Content,
|
content: &'a Content,
|
||||||
styles: StyleChain<'a>,
|
styles: StyleChain<'a>,
|
||||||
) -> SourceResult<(Packed<DocumentElem>, StyleChain<'a>)> {
|
) -> SourceResult<(Packed<DocumentElem>, StyleChain<'a>, DocumentInfo)> {
|
||||||
let mut builder = Builder::new(engine, locator, arenas, true);
|
let mut builder = Builder::new(engine, locator, arenas, true);
|
||||||
builder.accept(content, styles)?;
|
builder.accept(content, styles)?;
|
||||||
builder.interrupt_page(Some(styles), true)?;
|
builder.interrupt_page(Some(styles), true)?;
|
||||||
@ -198,11 +198,21 @@ impl<'a, 'v, 't> Builder<'a, 'v, 't> {
|
|||||||
styled: &'a StyledElem,
|
styled: &'a StyledElem,
|
||||||
styles: StyleChain<'a>,
|
styles: StyleChain<'a>,
|
||||||
) -> SourceResult<()> {
|
) -> SourceResult<()> {
|
||||||
|
let local = &styled.styles;
|
||||||
let stored = self.arenas.store(styles);
|
let stored = self.arenas.store(styles);
|
||||||
let styles = stored.chain(&styled.styles);
|
let styles = stored.chain(local);
|
||||||
self.interrupt_style(&styled.styles, None)?;
|
|
||||||
|
if let Some(Some(span)) = local.interruption::<DocumentElem>() {
|
||||||
|
let Some(doc) = &mut self.doc else {
|
||||||
|
bail!(span, "document set rules are not allowed inside of containers");
|
||||||
|
};
|
||||||
|
doc.info.populate(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interrupt_style(local, None)?;
|
||||||
self.accept(&styled.child, styles)?;
|
self.accept(&styled.child, styles)?;
|
||||||
self.interrupt_style(&styled.styles, Some(styles))?;
|
self.interrupt_style(local, Some(styles))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,20 +221,6 @@ impl<'a, 'v, 't> Builder<'a, 'v, 't> {
|
|||||||
local: &Styles,
|
local: &Styles,
|
||||||
outer: Option<StyleChain<'a>>,
|
outer: Option<StyleChain<'a>>,
|
||||||
) -> SourceResult<()> {
|
) -> SourceResult<()> {
|
||||||
if let Some(Some(span)) = local.interruption::<DocumentElem>() {
|
|
||||||
let Some(doc) = &self.doc else {
|
|
||||||
bail!(span, "document set rules are not allowed inside of containers");
|
|
||||||
};
|
|
||||||
if outer.is_none()
|
|
||||||
&& (!doc.pages.is_empty()
|
|
||||||
|| !self.flow.0.is_empty()
|
|
||||||
|| !self.par.0.is_empty()
|
|
||||||
|| !self.list.items.is_empty()
|
|
||||||
|| !self.cites.items.is_empty())
|
|
||||||
{
|
|
||||||
bail!(span, "document set rules must appear before any content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Some(span)) = local.interruption::<PageElem>() {
|
if let Some(Some(span)) = local.interruption::<PageElem>() {
|
||||||
if self.doc.is_none() {
|
if self.doc.is_none() {
|
||||||
bail!(span, "page configuration is not allowed inside of containers");
|
bail!(span, "page configuration is not allowed inside of containers");
|
||||||
@ -314,6 +310,8 @@ struct DocBuilder<'a> {
|
|||||||
keep_next: bool,
|
keep_next: bool,
|
||||||
/// Whether the next page should be cleared to an even or odd number.
|
/// Whether the next page should be cleared to an even or odd number.
|
||||||
clear_next: Option<Parity>,
|
clear_next: Option<Parity>,
|
||||||
|
/// Details about the document.
|
||||||
|
info: DocumentInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> DocBuilder<'a> {
|
impl<'a> DocBuilder<'a> {
|
||||||
@ -354,9 +352,9 @@ impl<'a> DocBuilder<'a> {
|
|||||||
|
|
||||||
/// Turns this builder into the resulting document, along with
|
/// Turns this builder into the resulting document, along with
|
||||||
/// its [style chain][StyleChain].
|
/// its [style chain][StyleChain].
|
||||||
fn finish(self) -> (Packed<DocumentElem>, StyleChain<'a>) {
|
fn finish(self) -> (Packed<DocumentElem>, StyleChain<'a>, DocumentInfo) {
|
||||||
let (children, trunk, span) = self.pages.finish();
|
let (children, trunk, span) = self.pages.finish();
|
||||||
(Packed::new(DocumentElem::new(children)).spanned(span), trunk)
|
(Packed::new(DocumentElem::new(children)).spanned(span), trunk, self.info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,6 +364,7 @@ impl Default for DocBuilder<'_> {
|
|||||||
pages: BehavedBuilder::new(),
|
pages: BehavedBuilder::new(),
|
||||||
keep_next: true,
|
keep_next: true,
|
||||||
clear_next: None,
|
clear_next: None,
|
||||||
|
info: DocumentInfo::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
tests/ref/document-set-after-content.png
Normal file
BIN
tests/ref/document-set-after-content.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 245 B |
46
tests/src/custom.rs
Normal file
46
tests/src/custom.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use typst::foundations::Smart;
|
||||||
|
use typst::model::{Document, DocumentInfo};
|
||||||
|
use typst::World;
|
||||||
|
|
||||||
|
use crate::collect::Test;
|
||||||
|
use crate::world::TestWorld;
|
||||||
|
|
||||||
|
/// We don't want to panic when there is a failure.
|
||||||
|
macro_rules! test_eq {
|
||||||
|
($sink:expr, $lhs:expr, $rhs:expr) => {
|
||||||
|
if $lhs != $rhs {
|
||||||
|
writeln!(&mut $sink, "{:?} != {:?}", $lhs, $rhs).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run special checks for specific tests for which it is not worth it to create
|
||||||
|
/// custom annotations.
|
||||||
|
pub fn check(test: &Test, world: &TestWorld, doc: Option<&Document>) -> String {
|
||||||
|
let mut sink = String::new();
|
||||||
|
match test.name.as_str() {
|
||||||
|
"document-set-author-date" => {
|
||||||
|
let info = info(doc);
|
||||||
|
test_eq!(sink, info.author, ["A", "B"]);
|
||||||
|
test_eq!(sink, info.date, Smart::Custom(world.today(None)));
|
||||||
|
}
|
||||||
|
"issue-4065-document-context" => {
|
||||||
|
let info = info(doc);
|
||||||
|
test_eq!(sink, info.title.as_deref(), Some("Top level"));
|
||||||
|
}
|
||||||
|
"issue-4769-document-context-conditional" => {
|
||||||
|
let info = info(doc);
|
||||||
|
test_eq!(sink, info.author, ["Changed"]);
|
||||||
|
test_eq!(sink, info.title.as_deref(), Some("Alternative"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
sink
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the document information.
|
||||||
|
fn info(doc: Option<&Document>) -> DocumentInfo {
|
||||||
|
doc.map(|doc| doc.info.clone()).unwrap_or_default()
|
||||||
|
}
|
@ -89,6 +89,7 @@ impl<'a> Runner<'a> {
|
|||||||
log!(self, "no document, but also no errors");
|
log!(self, "no document, but also no errors");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.check_custom(doc.as_ref());
|
||||||
self.check_document(doc.as_ref());
|
self.check_document(doc.as_ref());
|
||||||
|
|
||||||
for error in &errors {
|
for error in &errors {
|
||||||
@ -129,6 +130,18 @@ impl<'a> Runner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run custom checks for which it is not worth to create special
|
||||||
|
/// annotations.
|
||||||
|
fn check_custom(&mut self, doc: Option<&Document>) {
|
||||||
|
let errors = crate::custom::check(self.test, &self.world, doc);
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log!(self, "custom check failed");
|
||||||
|
for line in errors.lines() {
|
||||||
|
log!(self, " {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check that the document output is correct.
|
/// Check that the document output is correct.
|
||||||
fn check_document(&mut self, document: Option<&Document>) {
|
fn check_document(&mut self, document: Option<&Document>) {
|
||||||
let live_path = format!("{}/render/{}.png", crate::STORE_PATH, self.test.name);
|
let live_path = format!("{}/render/{}.png", crate::STORE_PATH, self.test.name);
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
mod collect;
|
mod collect;
|
||||||
|
mod custom;
|
||||||
mod logger;
|
mod logger;
|
||||||
mod run;
|
mod run;
|
||||||
mod world;
|
mod world;
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
// Test document and page-level styles.
|
// Test document and page-level styles.
|
||||||
|
|
||||||
--- document-set-title ---
|
--- document-set-title ---
|
||||||
// This is okay.
|
|
||||||
#set document(title: [Hello])
|
#set document(title: [Hello])
|
||||||
What's up?
|
What's up?
|
||||||
|
|
||||||
--- document-set-author-date ---
|
--- document-set-author-date ---
|
||||||
// This, too.
|
|
||||||
#set document(author: ("A", "B"), date: datetime.today())
|
#set document(author: ("A", "B"), date: datetime.today())
|
||||||
|
|
||||||
--- document-date-bad ---
|
--- document-date-bad ---
|
||||||
@ -14,15 +12,14 @@ What's up?
|
|||||||
#set document(date: "today")
|
#set document(date: "today")
|
||||||
|
|
||||||
--- document-author-bad ---
|
--- document-author-bad ---
|
||||||
// This, too.
|
|
||||||
// Error: 23-29 expected string, found integer
|
// Error: 23-29 expected string, found integer
|
||||||
#set document(author: (123,))
|
#set document(author: (123,))
|
||||||
What's up?
|
What's up?
|
||||||
|
|
||||||
--- document-set-after-content ---
|
--- document-set-after-content ---
|
||||||
|
// Document set rules can appear anywhere in top-level realization, also after
|
||||||
|
// content.
|
||||||
Hello
|
Hello
|
||||||
|
|
||||||
// Error: 2-30 document set rules must appear before any content
|
|
||||||
#set document(title: [Hello])
|
#set document(title: [Hello])
|
||||||
|
|
||||||
--- document-constructor ---
|
--- document-constructor ---
|
||||||
@ -34,3 +31,24 @@ Hello
|
|||||||
// Error: 4-32 document set rules are not allowed inside of containers
|
// Error: 4-32 document set rules are not allowed inside of containers
|
||||||
#set document(title: [Hello])
|
#set document(title: [Hello])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
--- issue-4065-document-context ---
|
||||||
|
// Test that we can set document properties based on context.
|
||||||
|
#show: body => context {
|
||||||
|
let all = query(heading)
|
||||||
|
let title = if all.len() > 0 { all.first().body }
|
||||||
|
set document(title: title)
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
#show heading: none
|
||||||
|
= Top level
|
||||||
|
|
||||||
|
--- issue-4769-document-context-conditional ---
|
||||||
|
// Test that document set rule can be conditional on document information
|
||||||
|
// itself.
|
||||||
|
#set document(author: "Normal", title: "Alternative")
|
||||||
|
#context {
|
||||||
|
set document(author: "Changed") if "Normal" in document.author
|
||||||
|
set document(title: "Changed") if document.title == "Normal"
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user